返回 DeepSeek-Reasonix
recovery_gc.go
根目录 / desktop / recovery_gc.go
1 package main
2
3 import (
4 "log/slog"
5 "time"
6
7 "reasonix/internal/agent"
8 "reasonix/internal/config"
9 )
10
11 // recoveryGCInterval bounds how often the background sweep repeats after the
12 // startup run. Recovery branches accumulate slowly (only on a save conflict),
13 // so a low frequency is plenty and keeps the disk scan off the hot path.
14 const recoveryGCInterval = 6 * time.Hour
15
16 // startRecoveryGC waits for tab restore to complete, runs one sweep, then
17 // repeats on an interval until the app context is cancelled. The wait is
18 // load-bearing: restoreOrBuildTabs populates a.tabs asynchronously, and a
19 // sweep against the pre-restore empty tab map would see every saved tab's
20 // session as closed — and DeleteSession's tab-list persistence would then
21 // overwrite desktop-tabs.json with that empty snapshot.
22 func (a *App) startRecoveryGC() {
23 a.goSafe("recoveryGC", func() {
24 select {
25 case <-a.tabsRestoredSignal():
26 case <-a.ctx.Done():
27 return
28 }
29 a.sweepReclaimableRecoveryBranches()
30 ticker := time.NewTicker(recoveryGCInterval)
31 defer ticker.Stop()
32 for {
33 select {
34 case <-a.ctx.Done():
35 return
36 case <-ticker.C:
37 a.sweepReclaimableRecoveryBranches()
38 }
39 }
40 })
41 }
42
43 // sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
44 // preserve nothing unique (their content is covered by a still-present parent
45 // session), were never continued on, sat idle past the grace period, and are
46 // not held by any runtime. Trashing — never hard deletion — keeps every swept
47 // branch recoverable from the session trash. Returns how many were reclaimed.
48 func (a *App) sweepReclaimableRecoveryBranches() int {
49 return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now())
50 }
51
52 func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time) int {
53 reclaimed := 0
54 for _, dir := range dirs {
55 reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, agent.RecoveryGCGracePeriod)
56 if err != nil {
57 slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
58 continue
59 }
60 for _, path := range reclaimable {
61 // Re-check liveness right before disposal: the scan is a snapshot,
62 // and the user may have opened the branch since. DeleteSession then
63 // runs the full removal path (removal guard, runtime unbinding,
64 // trash move), so even a miss here lands in the recoverable trash.
65 if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
66 continue
67 }
68 if err := a.DeleteSession(path); err != nil {
69 slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err)
70 continue
71 }
72 reclaimed++
73 }
74 }
75 if reclaimed > 0 {
76 slog.Info("desktop: moved redundant recovery branches to the session trash", "count", reclaimed)
77 }
78 return reclaimed
79 }
80
81 // recoveryGCDirs returns every session directory the desktop lists sessions
82 // from: the global desktop and legacy shared dirs plus each saved project's
83 // session dirs, deduplicated.
84 func recoveryGCDirs() []string {
85 seen := map[string]bool{}
86 var dirs []string
87 add := func(dir string) {
88 key := projectRootKey(dir)
89 if dir == "" || seen[key] {
90 return
91 }
92 seen[key] = true
93 dirs = append(dirs, dir)
94 }
95 add(desktopSessionDir(globalWorkspaceRoot()))
96 add(config.SessionDir())
97 for _, project := range loadProjectsFile().Projects {
98 if root := normalizeProjectRoot(project.Root); root != "" {
99 add(desktopSessionDir(root))
100 add(config.ProjectSessionDir(root))
101 }
102 }
103 return dirs
104 }
105
106 // sessionOpenInAnyTab reports whether any tab's current session is path.
107 // Lease checks cover live runtimes; this additionally covers tabs that hold a
108 // session without a lease (read-only channel views).
109 func (a *App) sessionOpenInAnyTab(path string) bool {
110 key := sessionRuntimeKey(path)
111 if key == "" {
112 return false
113 }
114 a.mu.RLock()
115 defer a.mu.RUnlock()
116 for _, tab := range a.tabs {
117 if tab == nil {
118 continue
119 }
120 if sessionRuntimeKey(tab.currentSessionPath()) == key {
121 return true
122 }
123 }
124 return false
125 }
126
126 lines GO