返回 DeepSeek-Reasonix
recovery_gc.go
根目录 / internal / agent / recovery_gc.go
1 package agent
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "reasonix/internal/fileutil"
15 "reasonix/internal/provider"
16 "reasonix/internal/store"
17 )
18
19 // Recovery-branch garbage collection. Conflict recovery forks a copy of the
20 // in-memory transcript whenever a save conflicts (#5993); the triggers are
21 // fixed, but every fork that ever happened still sits in the session list
22 // until the user trashes it by hand. Most of them preserve nothing: the
23 // original session went on to contain everything the fork saved. Those — and
24 // only those — are safe to reclaim automatically.
25
26 // RecoveryGCGracePeriod is how long a reclaimable recovery branch must sit
27 // idle before the periodic GC may collect it. A fresh fork is part of an
28 // active conflict flow — the user may be comparing it against the original.
29 const RecoveryGCGracePeriod = 24 * time.Hour
30
31 // RecoveryGCStartupGracePeriod is used for the first post-restore sweep so
32 // upgrade storms of covered copies are cleared within minutes rather than a
33 // full day, while still protecting a conflict the user is actively inspecting.
34 const RecoveryGCStartupGracePeriod = 15 * time.Minute
35
36 const (
37 recoveryTrashDir = ".trash"
38 recoveryTrashMetaFile = ".trash-meta.json"
39 recoveryTrashOperationPrefix = "recovery-trash:"
40 recoveryTrashPendingFile = ".recovery-trash-pending.json"
41 recoveryTrashStagingPrefix = ".recovery-trash-staging-"
42 )
43
44 // ErrRecoveryBranchNotCovered means the branch cannot currently be proven
45 // redundant with its parent. Destructive callers must preserve it.
46 var ErrRecoveryBranchNotCovered = errors.New("recovery branch is not covered by its parent")
47
48 // ErrRecoveryBranchNotIdle means the branch has not yet passed the safety
49 // grace period. It remains visible and may be retried by a later GC pass.
50 var ErrRecoveryBranchNotIdle = errors.New("recovery branch is still inside its safety grace period")
51
52 type recoveryTrashMeta struct {
53 Key string `json:"key"`
54 DeletedAt int64 `json:"deletedAt"`
55 }
56
57 type recoveryTrashPendingMeta struct {
58 Key string `json:"key"`
59 }
60
61 // SessionLeaseHeld reports whether ANY live runtime — this process included —
62 // holds the session's write lease. SessionLeaseHeldByOtherRuntime deliberately
63 // answers false for the current process; GC needs the stricter question, since
64 // a branch open in one of our own tabs is just as much in use.
65 func SessionLeaseHeld(path string) bool {
66 if strings.TrimSpace(path) == "" {
67 return false
68 }
69 if _, ok := sessionLeaseOwners.Load(CanonicalSessionPath(path)); ok {
70 return true
71 }
72 return SessionLeaseHeldByOtherRuntime(path)
73 }
74
75 // RecoveryBranchCoveredByParent reports whether a conflict-recovery branch
76 // preserves no content that is absent from its parent. It deliberately reads
77 // both transcripts instead of trusting listing sidecars: stale metadata must
78 // never authorize hiding, migration skipping, bulk trash, or permanent purge.
79 // Missing/corrupt metadata, a changed branch, or a missing/diverged parent are
80 // all treated conservatively as not covered.
81 func RecoveryBranchCoveredByParent(path, parentDir string) bool {
82 meta, ok, err := LoadBranchMeta(path)
83 if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
84 return false
85 }
86 return recoveryBranchCoveredByParent(path, parentDir, meta)
87 }
88
89 // SessionContentCovers reports whether covering contains the complete message
90 // history stored in covered. It is intentionally conservative for catalog
91 // canonical promotion: repaired or damaged loads cannot authorize a redirect.
92 func SessionContentCovers(coveringPath, coveredPath string) bool {
93 covering, ok := LoadSessionContentSnapshot(coveringPath)
94 if !ok {
95 return false
96 }
97 covered, ok := LoadSessionContentSnapshot(coveredPath)
98 return ok && covering.Covers(covered)
99 }
100
101 // RecoveryPreferenceCurrent proves that the branch still has the exact content
102 // the user selected. Continued or externally edited branches fall back to an
103 // unresolved lineage until the user chooses again.
104 func RecoveryPreferenceCurrent(path string, meta BranchMeta) bool {
105 if !meta.RecoveryPreferred || strings.TrimSpace(meta.RecoveryPreferredDigest) == "" {
106 return false
107 }
108 session, err := LoadSession(path)
109 if err != nil || session == nil || session.normalizedDirty || session.eventLogDamaged {
110 return false
111 }
112 digest, err := digestSessionMessages(session.Snapshot())
113 return err == nil && digestString(digest) == strings.TrimSpace(meta.RecoveryPreferredDigest)
114 }
115
116 // SessionContentSnapshot is an immutable, validated transcript projection used
117 // by the catalog. Its messages stay private so callers cannot accidentally use
118 // a classification read as a mutable Session.
119 type SessionContentSnapshot struct {
120 messages []provider.Message
121 digest string
122 }
123
124 // LoadSessionContentSnapshot loads one transcript once for lineage analysis.
125 // Dirty normalization and damaged event logs fail closed: neither may prove a
126 // canonical branch or authorize cleanup.
127 func LoadSessionContentSnapshot(path string) (SessionContentSnapshot, bool) {
128 session, err := LoadSession(path)
129 if err != nil || session == nil || session.normalizedDirty || session.eventLogDamaged {
130 return SessionContentSnapshot{}, false
131 }
132 messages := session.Snapshot()
133 digest, err := digestSessionMessages(messages)
134 if err != nil {
135 return SessionContentSnapshot{}, false
136 }
137 return SessionContentSnapshot{messages: messages, digest: digestString(digest)}, true
138 }
139
140 // Len is used only to discard candidates that cannot cover the longest member.
141 func (s SessionContentSnapshot) Len() int { return len(s.messages) }
142
143 // MatchesDigest binds catalog lineage decisions to the recovery ledger.
144 func (s SessionContentSnapshot) MatchesDigest(digest string) bool {
145 return strings.TrimSpace(digest) != "" && s.digest == strings.TrimSpace(digest)
146 }
147
148 // Covers reports whether s contains all content in covered as a compatible
149 // prefix. Both snapshots have already passed the conservative load checks.
150 func (s SessionContentSnapshot) Covers(covered SessionContentSnapshot) bool {
151 return messagesHavePrefix(s.messages, covered.messages) ||
152 messagesHavePrefixWithCompatibleSystem(s.messages, covered.messages)
153 }
154
155 // TryAcquireRecoveryParentGuard verifies that a recovery branch is covered by
156 // its parent while holding the parent's save and lease locks. The caller must
157 // keep the returned guard until permanent deletion finishes, then Release it.
158 // If the parent is open or being rewritten, acquisition fails without waiting
159 // so bulk cleanup preserves the branch and can be retried later.
160 func TryAcquireRecoveryParentGuard(path, parentDir string) (*SessionRemovalGuard, error) {
161 meta, ok, err := LoadBranchMeta(path)
162 if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
163 return nil, ErrRecoveryBranchNotCovered
164 }
165 parentID := strings.TrimSpace(meta.ParentID)
166 if parentID == "" {
167 return nil, ErrRecoveryBranchNotCovered
168 }
169 parentDir = strings.TrimSpace(parentDir)
170 if parentDir == "" {
171 parentDir = filepath.Dir(path)
172 }
173 parentPath := filepath.Join(parentDir, parentID+".jsonl")
174 if parentPath == path || !IsVisibleSession(parentPath) {
175 return nil, ErrRecoveryBranchNotCovered
176 }
177 guard, err := TryAcquireSessionRemovalGuard(parentPath)
178 if err != nil {
179 return nil, err
180 }
181 if !recoveryBranchCoveredByParent(path, parentDir, meta) {
182 guard.Release()
183 return nil, ErrRecoveryBranchNotCovered
184 }
185 return guard, nil
186 }
187
188 func recoveryBranchCoveredByParent(path, parentDir string, meta BranchMeta) bool {
189 parentID := strings.TrimSpace(meta.ParentID)
190 if parentID == "" {
191 return false
192 }
193 branch, err := LoadSession(path)
194 if err != nil || branch == nil {
195 return false
196 }
197 branchMsgs := branch.Snapshot()
198 branchDigest, err := digestSessionMessages(branchMsgs)
199 if err != nil || digestString(branchDigest) != strings.TrimSpace(meta.RecoveryDigest) {
200 // Continued on (or undigestable): this is someone's conversation now.
201 return false
202 }
203 parentDir = strings.TrimSpace(parentDir)
204 if parentDir == "" {
205 parentDir = filepath.Dir(path)
206 }
207 parentPath := filepath.Join(parentDir, parentID+".jsonl")
208 if parentPath == path || !IsVisibleSession(parentPath) {
209 return false
210 }
211 parent, err := LoadSession(parentPath)
212 if err != nil || parent == nil {
213 return false
214 }
215 parentMsgs := parent.Snapshot()
216 parentDigest, err := digestSessionMessages(parentMsgs)
217 if err != nil {
218 return false
219 }
220 return bytes.Equal(parentDigest[:], branchDigest[:]) ||
221 messagesHavePrefix(parentMsgs, branchMsgs) ||
222 messagesHavePrefixWithCompatibleSystem(parentMsgs, branchMsgs)
223 }
224
225 // ReclaimableRecoveryBranches scans dir for conflict-recovery branches that
226 // are safe to dispose of. Every condition must hold — when in doubt the branch
227 // stays, because a recovery branch exists precisely to prevent data loss:
228 //
229 // 1. The branch meta says Recovered and records the fork digest.
230 // 2. The transcript still matches that fork digest: the branch was never
231 // continued on. A single follow-up turn disqualifies it permanently.
232 // 3. The parent transcript (meta.ParentID, same directory) exists and covers
233 // the branch content — equal digest, or the branch is a strict prefix
234 // (allowing a compatible leading-system swap). These are the same checks
235 // SaveRecoveryBranch uses to declare a recovery not needed in the first
236 // place, so "covered" here means the fork preserves nothing unique.
237 // 4. No live runtime holds the branch's session lease.
238 // 5. The branch has been idle for at least grace.
239 //
240 // It returns candidate paths only; disposal (trash, delete) is caller policy.
241 func ReclaimableRecoveryBranches(dir string, now time.Time, grace time.Duration) ([]string, error) {
242 dir = strings.TrimSpace(dir)
243 if dir == "" {
244 return nil, nil
245 }
246 entries, err := os.ReadDir(dir)
247 if err != nil {
248 if os.IsNotExist(err) {
249 return nil, nil
250 }
251 return nil, err
252 }
253 var out []string
254 for _, e := range entries {
255 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") || strings.HasSuffix(e.Name(), ".events.jsonl") {
256 continue
257 }
258 path := filepath.Join(dir, e.Name())
259 if !IsVisibleSession(path) {
260 continue
261 }
262 meta, ok, err := LoadBranchMeta(path)
263 if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
264 continue
265 }
266 if strings.TrimSpace(meta.ParentID) == "" {
267 continue
268 }
269 if !recoveryBranchIdle(path, meta, now, grace) {
270 continue
271 }
272 if SessionLeaseHeld(path) {
273 continue
274 }
275 if !recoveryBranchCoveredByParent(path, dir, meta) {
276 continue
277 }
278 out = append(out, path)
279 }
280 return out, nil
281 }
282
283 // TrashCoveredRecoveryBranch moves a redundant recovery branch into the same
284 // recoverable .trash layout used by Desktop. This is the explicit/manual cleanup
285 // path, so it does not require the background GC idle grace period. Parent
286 // coverage is rechecked while both parent and branch removal guards are held.
287 func TrashCoveredRecoveryBranch(path, parentDir string) error {
288 return trashCoveredRecoveryBranch(path, parentDir, false)
289 }
290
291 // TrashRecoveryBranchCoveredBy moves path to recoverable trash when canonical
292 // contains its complete transcript. Unlike TrashCoveredRecoveryBranch this is
293 // lineage-aware: legacy recovery storms form chains, so an adopted leaf may
294 // cover an ancestor even though that ancestor's immediate parent does not cover
295 // it. Both transcripts are held behind removal guards while coverage is proved.
296 func TrashRecoveryBranchCoveredBy(path, canonicalPath, parentDir string) error {
297 path = filepath.Clean(strings.TrimSpace(path))
298 canonicalPath = filepath.Clean(strings.TrimSpace(canonicalPath))
299 parentDir = filepath.Clean(strings.TrimSpace(parentDir))
300 if path == "." || canonicalPath == "." || parentDir == "." || path == canonicalPath ||
301 filepath.Dir(path) != parentDir || filepath.Dir(canonicalPath) != parentDir {
302 return ErrRecoveryBranchNotCovered
303 }
304 meta, ok, err := LoadBranchMeta(path)
305 if err != nil || !ok || !meta.Recovered {
306 return ErrRecoveryBranchNotCovered
307 }
308 paths := []string{path, canonicalPath}
309 sort.Strings(paths)
310 guards := make(map[string]*SessionRemovalGuard, len(paths))
311 for _, guardedPath := range paths {
312 guard, guardErr := TryAcquireSessionRemovalGuard(guardedPath)
313 if guardErr != nil {
314 for _, held := range guards {
315 held.Release()
316 }
317 return guardErr
318 }
319 guards[guardedPath] = guard
320 }
321 defer func() {
322 for _, guard := range guards {
323 guard.Release()
324 }
325 }()
326 if !SessionContentCovers(canonicalPath, path) {
327 return ErrRecoveryBranchNotCovered
328 }
329 key := filepath.Base(path)
330 if !validRecoveryTrashKey(key) {
331 return fmt.Errorf("invalid recovery session path")
332 }
333 stageDir, err := reserveRecoveryTrashStage(parentDir)
334 if err != nil {
335 return err
336 }
337 if err := prepareRecoveryTrashStage(path, key, stageDir); err != nil {
338 return err
339 }
340 return finishRecoveryTrashStage(parentDir, path, key, stageDir, guards[path])
341 }
342
343 // ReparentRecoveryCanonical shortens a proved recovery chain to root ->
344 // canonical without rewriting either transcript. This preserves discovery for
345 // older versions after covered intermediate branches are moved to trash.
346 func ReparentRecoveryCanonical(canonicalPath, rootID, parentDir string) error {
347 canonicalPath = filepath.Clean(strings.TrimSpace(canonicalPath))
348 parentDir = filepath.Clean(strings.TrimSpace(parentDir))
349 rootID = strings.TrimSpace(rootID)
350 rootPath := filepath.Join(parentDir, rootID+".jsonl")
351 if canonicalPath == "." || parentDir == "." || rootID == "" || filepath.Base(rootID) != rootID ||
352 filepath.Dir(canonicalPath) != parentDir || canonicalPath == rootPath {
353 return ErrRecoveryBranchNotCovered
354 }
355 paths := []string{canonicalPath, rootPath}
356 sort.Strings(paths)
357 guards := make([]*SessionRemovalGuard, 0, len(paths))
358 for _, path := range paths {
359 guard, err := TryAcquireSessionRemovalGuard(path)
360 if err != nil {
361 for _, held := range guards {
362 held.Release()
363 }
364 return err
365 }
366 guards = append(guards, guard)
367 }
368 defer func() {
369 for _, guard := range guards {
370 guard.Release()
371 }
372 }()
373 if !SessionContentCovers(canonicalPath, rootPath) {
374 return ErrRecoveryBranchNotCovered
375 }
376 return UpdateBranchMeta(canonicalPath, false, func(meta *BranchMeta) error {
377 if !meta.Recovered {
378 return ErrRecoveryBranchNotCovered
379 }
380 meta.ParentID = rootID
381 meta.RecoveryDepth = 1
382 return nil
383 })
384 }
385
386 // TrashReclaimableRecoveryBranch is the background-GC variant. In addition to
387 // the same atomic coverage proof, it requires the branch to remain idle for the
388 // full grace period.
389 func TrashReclaimableRecoveryBranch(path, parentDir string) error {
390 return trashCoveredRecoveryBranch(path, parentDir, true)
391 }
392
393 func trashCoveredRecoveryBranch(path, parentDir string, requireIdle bool) error {
394 path = filepath.Clean(strings.TrimSpace(path))
395 parentDir = filepath.Clean(strings.TrimSpace(parentDir))
396 if path == "." || parentDir == "." || filepath.Dir(path) != parentDir {
397 return fmt.Errorf("recovery branch must be a direct child of its session directory")
398 }
399 key := filepath.Base(path)
400 if !strings.HasSuffix(key, ".jsonl") || strings.HasSuffix(key, ".events.jsonl") {
401 return fmt.Errorf("invalid recovery session path")
402 }
403
404 parentGuard, err := TryAcquireRecoveryParentGuard(path, parentDir)
405 if err != nil {
406 return err
407 }
408 defer parentGuard.Release()
409
410 branchGuard, err := TryAcquireSessionRemovalGuard(path)
411 if err != nil {
412 return err
413 }
414 defer branchGuard.Release()
415 if requireIdle {
416 meta, ok, err := LoadBranchMeta(path)
417 if err != nil || !ok || !recoveryBranchIdle(path, meta, time.Now(), RecoveryGCGracePeriod) {
418 return ErrRecoveryBranchNotIdle
419 }
420 }
421 if !RecoveryBranchCoveredByParent(path, parentDir) {
422 return ErrRecoveryBranchNotCovered
423 }
424
425 stageDir, err := reserveRecoveryTrashStage(parentDir)
426 if err != nil {
427 return err
428 }
429 // Keep the move invisible until every artifact is staged. Older Reasonix
430 // versions ignore the non-session staging directory, while new versions can
431 // finish it from the durable in-directory marker after a crash. Publishing is
432 // one same-filesystem rename, so Desktop can never restore or purge a split
433 // transcript/sidecar set.
434 if err := prepareRecoveryTrashStage(path, key, stageDir); err != nil {
435 return err
436 }
437 return finishRecoveryTrashStage(parentDir, path, key, stageDir, branchGuard)
438 }
439
440 func recoveryBranchIdle(path string, meta BranchMeta, now time.Time, grace time.Duration) bool {
441 idleSince := meta.UpdatedAt
442 if idleSince.IsZero() {
443 info, err := os.Stat(path)
444 if err != nil {
445 return false
446 }
447 idleSince = info.ModTime()
448 }
449 return now.Sub(idleSince) >= grace
450 }
451
452 // reconcileRecoveryTrashPending completes an interrupted move left by the
453 // pre-staging recovery-trash protocol. Keep this compatibility path so users
454 // upgrading from an intermediate build do not strand its typed marker.
455 func reconcileRecoveryTrashPending(item CleanupPendingInfo) (bool, error) {
456 operation := strings.TrimSpace(item.Meta.Operation)
457 if !strings.HasPrefix(operation, recoveryTrashOperationPrefix) {
458 return false, nil
459 }
460 itemName := strings.TrimPrefix(operation, recoveryTrashOperationPrefix)
461 if itemName == "" || filepath.Base(itemName) != itemName || itemName == "." || itemName == ".." {
462 return true, fmt.Errorf("invalid recovery trash target")
463 }
464 path := filepath.Clean(item.SessionPath)
465 dir := filepath.Dir(path)
466 key := filepath.Base(path)
467 guard, err := TryAcquireSessionRemovalGuard(path)
468 if err != nil {
469 return true, err
470 }
471 defer guard.Release()
472 return true, finishRecoveryTrashMove(dir, path, key, filepath.Join(dir, recoveryTrashDir, itemName), guard)
473 }
474
475 // reconcileRecoveryTrashStages completes the atomic staging protocol used by
476 // new runtimes. A staging directory is intentionally not a valid Desktop trash
477 // item: it has neither a session-shaped directory name nor .trash-meta.json.
478 // Once complete, the whole directory is renamed into place atomically.
479 func reconcileRecoveryTrashStages(dir string) error {
480 dir = strings.TrimSpace(dir)
481 if dir == "" {
482 return nil
483 }
484 root := filepath.Join(dir, recoveryTrashDir)
485 entries, err := os.ReadDir(root)
486 if err != nil {
487 if os.IsNotExist(err) {
488 return nil
489 }
490 return err
491 }
492 var errs []error
493 for _, entry := range entries {
494 if !entry.IsDir() {
495 continue
496 }
497 itemDir := filepath.Join(root, entry.Name())
498 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashPendingFile)); err != nil {
499 if os.IsNotExist(err) {
500 continue
501 }
502 errs = append(errs, fmt.Errorf("inspect recovery trash stage %s: %w", itemDir, err))
503 continue
504 }
505 if err := reconcileRecoveryTrashStage(dir, itemDir); err != nil {
506 errs = append(errs, fmt.Errorf("reconcile recovery trash stage %s: %w", itemDir, err))
507 }
508 }
509 return errors.Join(errs...)
510 }
511
512 func reconcileRecoveryTrashStage(dir, itemDir string) error {
513 pending, err := readRecoveryTrashPending(itemDir)
514 if err != nil {
515 return err
516 }
517 key := strings.TrimSpace(pending.Key)
518 if !validRecoveryTrashKey(key) {
519 return fmt.Errorf("invalid recovery trash key")
520 }
521 stagedPath := filepath.Join(itemDir, key)
522 staged, err := regularRecoveryTrashPath(stagedPath)
523 if err != nil {
524 return err
525 }
526
527 // A non-staging name means the atomic directory rename already succeeded;
528 // only visibility metadata/final marker cleanup may remain.
529 if !strings.HasPrefix(filepath.Base(itemDir), recoveryTrashStagingPrefix) {
530 if !staged {
531 return fmt.Errorf("published recovery trash item is missing transcript")
532 }
533 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashMetaFile)); err == nil {
534 return clearRecoveryTrashPending(itemDir)
535 } else if !os.IsNotExist(err) {
536 return err
537 }
538 if err := writeRecoveryTrashMetaExisting(itemDir, key); err != nil {
539 if os.IsNotExist(err) {
540 return nil // restored or purged after complete publication
541 }
542 return err
543 }
544 return clearRecoveryTrashPending(itemDir)
545 }
546
547 livePath := filepath.Join(dir, key)
548 live, err := regularRecoveryTrashPath(livePath)
549 if err != nil {
550 return err
551 }
552 switch {
553 case live && !staged:
554 // The durable marker landed but the first rename did not. No session
555 // artifact has moved, so discard the empty stage and let a later GC pass
556 // revalidate coverage before trying again.
557 return removeEmptyRecoveryTrashStage(itemDir)
558 case live && staged:
559 return fmt.Errorf("live and staged recovery transcripts both exist")
560 case !live && !staged:
561 return fmt.Errorf("recovery trash stage is missing transcript")
562 }
563
564 guard, err := TryAcquireSessionRemovalGuard(livePath)
565 if err != nil {
566 return err
567 }
568 defer guard.Release()
569 return finishRecoveryTrashStage(dir, livePath, key, itemDir, guard)
570 }
571
572 func regularRecoveryTrashPath(path string) (bool, error) {
573 info, err := os.Lstat(path)
574 if os.IsNotExist(err) {
575 return false, nil
576 }
577 if err != nil {
578 return false, err
579 }
580 if !info.Mode().IsRegular() {
581 return false, fmt.Errorf("recovery trash path is not a regular file: %s", path)
582 }
583 return true, nil
584 }
585
586 func removeEmptyRecoveryTrashStage(itemDir string) error {
587 entries, err := os.ReadDir(itemDir)
588 if err != nil {
589 return err
590 }
591 if len(entries) != 1 || entries[0].Name() != recoveryTrashPendingFile || entries[0].IsDir() {
592 return fmt.Errorf("recovery trash stage contains artifacts without a transcript")
593 }
594 return os.RemoveAll(itemDir)
595 }
596
597 func reserveRecoveryTrashStage(dir string) (string, error) {
598 root := filepath.Join(dir, recoveryTrashDir)
599 if err := os.MkdirAll(root, 0o755); err != nil {
600 return "", err
601 }
602 return os.MkdirTemp(root, recoveryTrashStagingPrefix)
603 }
604
605 func prepareRecoveryTrashStage(path, key, stageDir string) error {
606 if err := writeRecoveryTrashPending(stageDir, key); err != nil {
607 return err
608 }
609 return moveRecoveryTrashPath(path, filepath.Join(stageDir, key))
610 }
611
612 func finishRecoveryTrashStage(dir, path, key, stageDir string, guard *SessionRemovalGuard) error {
613 if err := moveRecoveryTrashArtifacts(dir, path, stageDir); err != nil {
614 return err
615 }
616 itemDir, err := publishRecoveryTrashStage(dir, key, stageDir)
617 if err != nil {
618 return err
619 }
620 if err := writeRecoveryTrashMetaExisting(itemDir, key); err != nil {
621 if !os.IsNotExist(err) {
622 return err
623 }
624 // A complete entry may be restored or purged as soon as it is published.
625 // In that case there is nothing left for this producer to finalize.
626 return guard.RemoveSidecarsAndRelease()
627 }
628 if err := clearRecoveryTrashPending(itemDir); err != nil {
629 return err
630 }
631 return guard.RemoveSidecarsAndRelease()
632 }
633
634 func publishRecoveryTrashStage(dir, key, stageDir string) (string, error) {
635 root := filepath.Join(dir, recoveryTrashDir)
636 stem := strings.TrimSuffix(key, filepath.Ext(key))
637 stamp := time.Now().UTC().UnixMilli()
638 for i := range 1000 {
639 name := key
640 if i > 0 {
641 name = fmt.Sprintf("%s-recovery-%d-%d", stem, stamp, i)
642 }
643 itemDir := filepath.Join(root, name)
644 if _, err := os.Lstat(itemDir); err == nil {
645 continue
646 } else if !os.IsNotExist(err) {
647 return "", err
648 }
649 if err := os.Rename(stageDir, itemDir); err == nil {
650 return itemDir, nil
651 } else if _, statErr := os.Lstat(itemDir); statErr == nil {
652 continue // another producer won this candidate
653 } else if !os.IsNotExist(statErr) {
654 return "", statErr
655 } else {
656 return "", err
657 }
658 }
659 return "", fmt.Errorf("could not publish recovery trash target")
660 }
661
662 func writeRecoveryTrashPending(itemDir, key string) error {
663 if !validRecoveryTrashKey(key) {
664 return fmt.Errorf("invalid recovery trash key")
665 }
666 b, err := json.MarshalIndent(recoveryTrashPendingMeta{Key: key}, "", " ")
667 if err != nil {
668 return err
669 }
670 return fileutil.AtomicWriteFileStrict(filepath.Join(itemDir, recoveryTrashPendingFile), b, 0o644)
671 }
672
673 func readRecoveryTrashPending(itemDir string) (recoveryTrashPendingMeta, error) {
674 b, err := os.ReadFile(filepath.Join(itemDir, recoveryTrashPendingFile))
675 if err != nil {
676 return recoveryTrashPendingMeta{}, err
677 }
678 var meta recoveryTrashPendingMeta
679 if err := json.Unmarshal(b, &meta); err != nil {
680 return recoveryTrashPendingMeta{}, err
681 }
682 return meta, nil
683 }
684
685 func clearRecoveryTrashPending(itemDir string) error {
686 err := os.Remove(filepath.Join(itemDir, recoveryTrashPendingFile))
687 if os.IsNotExist(err) {
688 return nil
689 }
690 return err
691 }
692
693 func validRecoveryTrashKey(key string) bool {
694 return key != "" && filepath.Base(key) == key && key != "." && key != ".." &&
695 strings.HasSuffix(key, ".jsonl") && !strings.HasSuffix(key, ".events.jsonl")
696 }
697
698 func reserveRecoveryTrashItemDir(dir, key string) (string, string, error) {
699 root := filepath.Join(dir, recoveryTrashDir)
700 if err := os.MkdirAll(root, 0o755); err != nil {
701 return "", "", err
702 }
703 stem := strings.TrimSuffix(key, filepath.Ext(key))
704 for i := range 1000 {
705 name := key
706 if i > 0 {
707 name = fmt.Sprintf("%s-recovery-%d-%d", stem, time.Now().UTC().UnixMilli(), i)
708 }
709 itemDir := filepath.Join(root, name)
710 if err := os.Mkdir(itemDir, 0o755); err == nil {
711 return name, itemDir, nil
712 } else if !os.IsExist(err) {
713 return "", "", err
714 }
715 }
716 return "", "", fmt.Errorf("could not reserve recovery trash target")
717 }
718
719 func finishRecoveryTrashMove(dir, path, key, itemDir string, guard *SessionRemovalGuard) error {
720 if err := os.MkdirAll(itemDir, 0o755); err != nil {
721 return err
722 }
723 if err := moveRecoveryTrashArtifacts(dir, path, itemDir); err != nil {
724 return err
725 }
726 if err := writeRecoveryTrashMeta(itemDir, key); err != nil {
727 return err
728 }
729 // Keep the branch guard until the trash entry is complete and the hidden
730 // marker is cleared. No runtime can bind the now-vacant live path in the
731 // middle and inherit an incomplete cleanup state.
732 if err := ClearCleanupPending(path); err != nil {
733 return err
734 }
735 return guard.RemoveSidecarsAndRelease()
736 }
737
738 func moveRecoveryTrashArtifacts(dir, path, itemDir string) error {
739 for _, src := range recoveryTrashSidecars(path) {
740 if err := moveRecoveryTrashPath(src, filepath.Join(itemDir, filepath.Base(src))); err != nil {
741 return err
742 }
743 }
744 return moveRecoverySubagentArtifacts(dir, path, itemDir)
745 }
746
747 func prepareRecoveryTrashEntry(path, key, itemDir string) error {
748 if err := writeRecoveryTrashMeta(itemDir, key); err != nil {
749 return err
750 }
751 return moveRecoveryTrashPath(path, filepath.Join(itemDir, key))
752 }
753
754 func writeRecoveryTrashMeta(itemDir, key string) error {
755 meta := recoveryTrashMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
756 b, err := json.MarshalIndent(meta, "", " ")
757 if err != nil {
758 return err
759 }
760 if err := os.MkdirAll(itemDir, 0o755); err != nil {
761 return err
762 }
763 return os.WriteFile(filepath.Join(itemDir, recoveryTrashMetaFile), b, 0o644)
764 }
765
766 func writeRecoveryTrashMetaExisting(itemDir, key string) error {
767 meta := recoveryTrashMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
768 b, err := json.MarshalIndent(meta, "", " ")
769 if err != nil {
770 return err
771 }
772 return os.WriteFile(filepath.Join(itemDir, recoveryTrashMetaFile), b, 0o644)
773 }
774
775 func recoveryTrashSidecars(path string) []string {
776 artifacts := append([]string(nil), store.SessionSidecarFiles(path)...)
777 artifacts = append(artifacts,
778 path+".telemetry.json",
779 store.SessionCheckpointDir(path),
780 store.SessionJobsDir(path),
781 store.SessionInboxDir(path),
782 )
783 return artifacts
784 }
785
786 func moveRecoverySubagentArtifacts(dir, path, itemDir string) error {
787 artifacts, err := ListSubagentsByParent(dir, BranchID(path))
788 if err != nil {
789 return err
790 }
791 targetDir := filepath.Join(itemDir, "subagents")
792 for _, artifact := range artifacts {
793 paths := []string{artifact.SessionPath, artifact.MetaPath}
794 paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
795 for _, src := range paths {
796 if err := moveRecoveryTrashPath(src, filepath.Join(targetDir, filepath.Base(src))); err != nil {
797 return err
798 }
799 }
800 }
801 return nil
802 }
803
804 func moveRecoveryTrashPath(src, dst string) error {
805 if strings.TrimSpace(src) == "" {
806 return nil
807 }
808 if _, err := os.Lstat(src); os.IsNotExist(err) {
809 return nil
810 } else if err != nil {
811 return err
812 }
813 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
814 return err
815 }
816 return os.Rename(src, dst)
817 }
818
818 lines GO