返回 DeepSeek-Reasonix
recovery_gc.go
根目录 / desktop / recovery_gc.go
1 package main
2
3 import (
4 "log/slog"
5 "os"
6 "time"
7
8 "reasonix/internal/agent"
9 "reasonix/internal/sessioncatalog"
10 )
11
12 // startRecoveryGC is intentionally a no-op for physical moves.
13 //
14 // Catalog v4 folds recovery lineages into one ordinary list row. Automatic
15 // startup/upgrade/timer GC must not move JSONL/meta into trash — only explicit
16 // CleanRecoveryLineage (UI, with preview) and `reasonix sessions cleanup
17 // --apply` may reclaim covered copies. reclaimRecoveryBranchesIn remains for
18 // those explicit entry points and focused tests.
19 func (a *App) startRecoveryGC() {}
20
21 func waitRecoveryGCStartup(done <-chan struct{}, elapsed <-chan time.Time) bool {
22 select {
23 case <-elapsed:
24 return true
25 case <-done:
26 return false
27 }
28 }
29
30 func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time, grace time.Duration) int {
31 if grace <= 0 {
32 grace = agent.RecoveryGCGracePeriod
33 }
34 reclaimed := 0
35 for _, dir := range dirs {
36 removed := map[string]bool{}
37 if catalog := a.sessionCatalog.Load(); catalog != nil {
38 groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
39 if err != nil {
40 slog.Warn("desktop: list recovery lineages for GC", "dir", dir, "err", err)
41 } else {
42 for _, group := range groups {
43 for _, path := range a.reclaimAdoptedRecoveryGroup(group, now, grace) {
44 removed[sessionRuntimeKey(path)] = true
45 reclaimed++
46 }
47 }
48 }
49 }
50 reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, grace)
51 if err != nil {
52 slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
53 continue
54 }
55 for _, path := range reclaimable {
56 if removed[sessionRuntimeKey(path)] {
57 continue
58 }
59 // Re-check liveness right before disposal: the scan is a snapshot,
60 // and the user may have opened the branch since.
61 if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
62 continue
63 }
64 // DeleteRecoveryCopy re-proves real parent coverage under removal
65 // guards. A concurrent continue-edit, missing parent, or busy lease
66 // skips without moving or permanently deleting anything.
67 if err := a.DeleteRecoveryCopy(path); err != nil {
68 slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err)
69 continue
70 }
71 reclaimed++
72 }
73 }
74 if reclaimed > 0 {
75 slog.Info("desktop: moved redundant recovery branches to the session trash",
76 "count", reclaimed, "grace", grace.String())
77 }
78 return reclaimed
79 }
80
81 // reclaimAdoptedRecoveryGroup compacts an entire legacy recovery chain once a
82 // canonical leaf has proved it covers every member. Every candidate is still
83 // revalidated under removal guards immediately before it is moved to trash.
84 func (a *App) reclaimAdoptedRecoveryGroup(group sessioncatalog.RecoveryGroup, now time.Time, grace time.Duration) []string {
85 if group.State != "adopted" || group.CanonicalPath == "" || group.ID == "" {
86 return nil
87 }
88 candidates := []string{}
89 for _, member := range group.Members {
90 if member.Path == group.CanonicalPath || member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
91 continue
92 }
93 info, err := os.Stat(member.Path)
94 if err != nil || now.Sub(info.ModTime()) < grace {
95 continue
96 }
97 candidates = append(candidates, member.Path)
98 }
99 if len(candidates) == 0 {
100 return nil
101 }
102 defer a.lockRuntimeMutation("gc-recovery-lineage")()
103 a.sessionRemovalMu.Lock()
104 defer a.sessionRemovalMu.Unlock()
105 if a.sessionOpenInAnyTab(group.CanonicalPath) || agent.SessionLeaseHeld(group.CanonicalPath) {
106 return nil
107 }
108 if err := agent.ReparentRecoveryCanonical(group.CanonicalPath, group.ID, group.Directory); err != nil {
109 return nil
110 }
111 moved := []string{}
112 for _, path := range candidates {
113 if a.sessionOpenInAnyTab(path) || agent.SessionLeaseHeld(path) {
114 continue
115 }
116 if err := agent.TrashRecoveryBranchCoveredBy(path, group.CanonicalPath, group.Directory); err != nil {
117 continue
118 }
119 moved = append(moved, path)
120 a.removeSessionCatalogPath(path, "recovery_lineage_gc")
121 }
122 return moved
123 }
124
125 // sessionOpenInAnyTab reports whether any tab's current session is path.
126 // Lease checks cover live runtimes; this additionally covers tabs that hold a
127 // session without a lease (read-only channel views).
128 func (a *App) sessionOpenInAnyTab(path string) bool {
129 key := sessionRuntimeKey(path)
130 if key == "" {
131 return false
132 }
133 a.mu.RLock()
134 defer a.mu.RUnlock()
135 for _, tab := range a.tabs {
136 if tab == nil {
137 continue
138 }
139 if sessionRuntimeKey(tab.currentSessionPath()) == key {
140 return true
141 }
142 }
143 return false
144 }
145
145 lines GO