返回 DeepSeek-Reasonix
recovery_gc_test.go
根目录 / desktop / recovery_gc_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/provider"
12 )
13
14 // forkCoveredRecoveryBranch builds the reclaimable shape in dir: a conflict
15 // fork whose parent went on to contain everything the fork preserved.
16 func forkCoveredRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string) {
17 t.Helper()
18 parentPath = filepath.Join(dir, name+".jsonl")
19 disk := agent.NewSession("sys")
20 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
21 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
22 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
23 if err := disk.Save(parentPath); err != nil {
24 t.Fatalf("Save parent: %v", err)
25 }
26 stale := agent.NewSession("sys")
27 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
28 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
29 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
30 info, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
31 if err != nil {
32 t.Fatalf("SaveRecoveryBranch: %v", err)
33 }
34 covering := agent.NewSession("")
35 covering.Messages = append([]provider.Message(nil), stale.Snapshot()...)
36 covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
37 if err := covering.Save(parentPath); err != nil {
38 t.Fatalf("Save covering parent: %v", err)
39 }
40 return parentPath, info.Path
41 }
42
43 func TestRecoveryGCTrashesCoveredForkAndKeepsParent(t *testing.T) {
44 isolateDesktopUserDirs(t)
45 root := globalTabWorkspaceRoot()
46 dir := desktopSessionDir(root)
47 if err := os.MkdirAll(dir, 0o755); err != nil {
48 t.Fatalf("mkdir sessions: %v", err)
49 }
50 parentPath, branchPath := forkCoveredRecoveryBranch(t, dir, "session")
51
52 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
53 if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 1 {
54 t.Fatalf("reclaimed = %d, want 1", got)
55 }
56
57 if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
58 t.Fatalf("reclaimed branch still present at %s (err=%v)", branchPath, err)
59 }
60 key := filepath.Base(branchPath)
61 trashPath := filepath.Join(dir, sessionTrashDir, key, key)
62 if _, err := os.Stat(trashPath); err != nil {
63 t.Fatalf("reclaimed branch should be in trash: %v", err)
64 }
65 if _, err := os.Stat(parentPath); err != nil {
66 t.Fatalf("parent session must be untouched: %v", err)
67 }
68
69 // A second sweep is a no-op: nothing left to reclaim.
70 if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
71 t.Fatalf("second sweep reclaimed = %d, want 0", got)
72 }
73 }
74
75 func TestRecoveryGCSkipsBranchOpenInTab(t *testing.T) {
76 isolateDesktopUserDirs(t)
77 root := globalTabWorkspaceRoot()
78 dir := desktopSessionDir(root)
79 if err := os.MkdirAll(dir, 0o755); err != nil {
80 t.Fatalf("mkdir sessions: %v", err)
81 }
82 _, branchPath := forkCoveredRecoveryBranch(t, dir, "open")
83
84 tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
85 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
86 if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
87 t.Fatalf("reclaimed = %d, want 0 while the branch is open in a tab", got)
88 }
89 if _, err := os.Stat(branchPath); err != nil {
90 t.Fatalf("open branch must be untouched: %v", err)
91 }
92 }
93
94 // TestRecoveryGCFirstSweepWaitsForTabRestore forces the startup race the
95 // review caught: a saved recovery tab exists in desktop-tabs.json but a.tabs
96 // has not been populated yet. The GC's first sweep must wait for the restore
97 // gate — sweeping early would judge the branch "not open in any tab" and
98 // DeleteSession would persist the pre-restore (empty) tab list over the
99 // user's saved one.
100 func TestRecoveryGCFirstSweepWaitsForTabRestore(t *testing.T) {
101 isolateDesktopUserDirs(t)
102 root := globalTabWorkspaceRoot()
103 dir := desktopSessionDir(root)
104 if err := os.MkdirAll(dir, 0o755); err != nil {
105 t.Fatalf("mkdir sessions: %v", err)
106 }
107 _, branchPath := forkCoveredRecoveryBranch(t, dir, "startup")
108
109 ctx, cancel := context.WithCancel(context.Background())
110 defer cancel()
111 app := &App{
112 ctx: ctx,
113 tabs: map[string]*WorkspaceTab{},
114 tabsRestored: make(chan struct{}),
115 }
116
117 swept := make(chan int, 1)
118 go func() {
119 select {
120 case <-app.tabsRestoredSignal():
121 case <-ctx.Done():
122 swept <- -1
123 return
124 }
125 swept <- app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
126 }()
127
128 // Gate still closed: the sweep must not have run — the branch is intact.
129 select {
130 case n := <-swept:
131 t.Fatalf("sweep ran before tab restore completed (reclaimed=%d)", n)
132 case <-time.After(100 * time.Millisecond):
133 }
134 if _, err := os.Stat(branchPath); err != nil {
135 t.Fatalf("branch touched before restore completed: %v", err)
136 }
137
138 // Restore lands the saved tab holding the branch, then opens the gate:
139 // the sweep runs and must skip the now-open branch.
140 tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
141 app.mu.Lock()
142 app.tabs["tab"] = tab
143 app.mu.Unlock()
144 app.markTabsRestored()
145
146 if n := <-swept; n != 0 {
147 t.Fatalf("post-restore sweep reclaimed = %d, want 0 (branch is open in a restored tab)", n)
148 }
149 if _, err := os.Stat(branchPath); err != nil {
150 t.Fatalf("restored tab's branch must be untouched: %v", err)
151 }
152
153 // markTabsRestored is idempotent (restore + recover paths may both fire).
154 app.markTabsRestored()
155 }
156
157 func TestRecoveryGCRunsDespiteSafeModeEnv(t *testing.T) {
158 // v1.20+: GC is no longer suppressed by REASONIX_SAFE_MODE.
159 isolateDesktopUserDirs(t)
160 t.Setenv("REASONIX_SAFE_MODE", "1")
161 root := globalTabWorkspaceRoot()
162 dir := desktopSessionDir(root)
163 if err := os.MkdirAll(dir, 0o755); err != nil {
164 t.Fatalf("mkdir sessions: %v", err)
165 }
166 _, branchPath := forkCoveredRecoveryBranch(t, dir, "safe")
167
168 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
169 _ = app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
170 // Branch may or may not be reclaimed depending on age/coverage; the
171 // important contract is that Safe Mode env does not force a no-op panic-free path.
172 _ = branchPath
173 }
174
174 lines GO