返回 DeepSeek-Reasonix
recovery_copy_sweep_test.go
根目录 / desktop / recovery_copy_sweep_test.go
1 package main
2
3 import (
4 "context"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/provider"
14 "reasonix/internal/sessioncatalog"
15 )
16
17 // forkThreeIdenticalRecoveryCopies builds one lineage whose parent went on to
18 // cover three identical conflict forks — the multiplying "Recovered unsaved
19 // changes" shape from #8525. Each fork gets its own isolated lane, so three
20 // distinct files share one recovery group.
21 func forkThreeIdenticalRecoveryCopies(t *testing.T, dir, name string) (parentPath string, copyPaths []string) {
22 t.Helper()
23 parentPath = filepath.Join(dir, name+".jsonl")
24 disk := agent.NewSession("sys")
25 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
26 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
27 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
28 if err := disk.Save(parentPath); err != nil {
29 t.Fatalf("Save parent: %v", err)
30 }
31 var stale *agent.Session
32 for range 3 {
33 fork := agent.NewSession("sys")
34 fork.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
35 fork.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
36 fork.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
37 info, err := fork.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
38 if err != nil {
39 t.Fatalf("SaveRecoveryBranch: %v", err)
40 }
41 copyPaths = append(copyPaths, info.Path)
42 if stale == nil {
43 stale = fork
44 }
45 }
46 // The parent goes on to contain everything every identical fork preserved.
47 covering, err := agent.LoadSession(parentPath)
48 if err != nil {
49 t.Fatalf("Load covering parent: %v", err)
50 }
51 covering.Replace(append([]provider.Message(nil), stale.Snapshot()...))
52 covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
53 if err := covering.SaveRewrite(parentPath); err != nil {
54 t.Fatalf("Save covering parent: %v", err)
55 }
56 return parentPath, copyPaths
57 }
58
59 func countTrashTranscripts(t *testing.T, dir string) int {
60 t.Helper()
61 count := 0
62 root := filepath.Join(dir, sessionTrashDir)
63 err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
64 if err != nil {
65 return err
66 }
67 if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".jsonl") && !strings.HasSuffix(entry.Name(), ".events.jsonl") {
68 count++
69 }
70 return nil
71 })
72 if os.IsNotExist(err) {
73 return 0
74 }
75 if err != nil {
76 t.Fatal(err)
77 }
78 return count
79 }
80
81 func openSweepTestCatalog(t *testing.T, dir string) *sessioncatalog.Catalog {
82 t.Helper()
83 ctx := context.Background()
84 catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
85 Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
86 })
87 if err != nil {
88 t.Fatal(err)
89 }
90 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
91 if err := catalog.ReconcileDirectory(ctx, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
92 t.Fatal(err)
93 }
94 return catalog
95 }
96
97 func sweepTestApp(catalog *sessioncatalog.Catalog) *App {
98 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
99 app.sessionCatalog.Store(catalog)
100 return app
101 }
102
103 func TestRecoveryCopySweepTrashesExcessCoveredCopies(t *testing.T) {
104 isolateDesktopUserDirs(t)
105 root := globalTabWorkspaceRoot()
106 dir := desktopSessionDir(root)
107 if err := os.MkdirAll(dir, 0o755); err != nil {
108 t.Fatal(err)
109 }
110 parentPath, copyPaths := forkThreeIdenticalRecoveryCopies(t, dir, "storm")
111 // A second lineage below the threshold must never be touched.
112 otherParent, otherCopy := forkCoveredRecoveryBranch(t, dir, "quiet")
113
114 catalog := openSweepTestCatalog(t, dir)
115 app := sweepTestApp(catalog)
116 if got := app.sweepExcessRecoveryCopiesIn(catalog, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, time.Now(), 0); got != 2 {
117 t.Fatalf("swept = %d, want 2 (3 copies, keep newest 1)", got)
118 }
119
120 remaining := 0
121 for _, path := range copyPaths {
122 if _, err := os.Stat(path); err == nil {
123 remaining++
124 }
125 }
126 if remaining != 1 {
127 t.Fatalf("copies left in place = %d, want exactly the newest 1", remaining)
128 }
129 if got := countTrashTranscripts(t, dir); got != 2 {
130 t.Fatalf("trashed transcripts = %d, want 2 recoverable copies", got)
131 }
132 for _, path := range []string{parentPath, otherParent, otherCopy} {
133 if _, err := os.Stat(path); err != nil {
134 t.Fatalf("session outside the swept set must be untouched: %s: %v", path, err)
135 }
136 }
137
138 // The catalog projection loses the swept copies but keeps the lineage.
139 ctx := context.Background()
140 groups, err := catalog.ListRecoveryGroups(ctx, dir)
141 if err != nil {
142 t.Fatal(err)
143 }
144 for _, group := range groups {
145 for _, member := range group.Members {
146 for _, swept := range copyPaths {
147 if member.Path == swept {
148 if _, err := os.Stat(swept); os.IsNotExist(err) {
149 t.Fatalf("swept copy still projected: %s", swept)
150 }
151 }
152 }
153 }
154 }
155
156 // A repeat sweep is a no-op: only one copy remains.
157 if got := app.sweepExcessRecoveryCopiesIn(catalog, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, time.Now(), 0); got != 0 {
158 t.Fatalf("repeat sweep moved = %d, want 0", got)
159 }
160 }
161
162 func TestRecoveryCopySweepKeepsNewestAndSkipsOpenCopy(t *testing.T) {
163 isolateDesktopUserDirs(t)
164 root := globalTabWorkspaceRoot()
165 dir := desktopSessionDir(root)
166 if err := os.MkdirAll(dir, 0o755); err != nil {
167 t.Fatal(err)
168 }
169 _, copyPaths := forkThreeIdenticalRecoveryCopies(t, dir, "open")
170 catalog := openSweepTestCatalog(t, dir)
171 app := sweepTestApp(catalog)
172 // The newest copy (last forked, highest recency) stays; hold the middle one
173 // open so only the oldest can move.
174 openPath := copyPaths[1]
175 app.tabs["tab"] = &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: openPath, Ready: true}
176
177 if got := app.sweepExcessRecoveryCopiesIn(catalog, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, time.Now(), 0); got != 1 {
178 t.Fatalf("swept = %d, want 1 (open copy skipped)", got)
179 }
180 if _, err := os.Stat(openPath); err != nil {
181 t.Fatalf("open copy must be untouched: %v", err)
182 }
183 }
184
185 func TestRecoveryCopySweepRespectsConfigGate(t *testing.T) {
186 home := isolateDesktopUserDirs(t)
187 root := globalTabWorkspaceRoot()
188 dir := desktopSessionDir(root)
189 if err := os.MkdirAll(dir, 0o755); err != nil {
190 t.Fatal(err)
191 }
192 _, copyPaths := forkThreeIdenticalRecoveryCopies(t, dir, "gated")
193 catalog := openSweepTestCatalog(t, dir)
194 app := sweepTestApp(catalog)
195
196 configDir := filepath.Join(home, ".reasonix")
197 if err := os.MkdirAll(configDir, 0o755); err != nil {
198 t.Fatal(err)
199 }
200 if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("[recovery_cleanup]\nauto_enabled = false\n"), 0o600); err != nil {
201 t.Fatal(err)
202 }
203 app.sweepExcessRecoveryCopies(catalog, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"})
204 for _, path := range copyPaths {
205 if _, err := os.Stat(path); err != nil {
206 t.Fatalf("disabled sweep must not move copies: %v", err)
207 }
208 }
209 }
210
210 lines GO