返回 DeepSeek-Reasonix
recovery_gc_test.go
根目录 / internal / agent / recovery_gc_test.go
1 package agent
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/provider"
11 "reasonix/internal/store"
12 )
13
14 // forkRecoveryBranch builds a real conflict-recovery branch: a diverged disk
15 // parent plus a stale in-memory session, forked through SaveRecoveryBranch —
16 // the exact artifacts GC will meet in the field.
17 func forkRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string, branchMsgs []provider.Message) {
18 t.Helper()
19 parentPath = filepath.Join(dir, name+".jsonl")
20 disk := NewSession("sys")
21 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
22 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
23 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
24 if err := disk.Save(parentPath); err != nil {
25 t.Fatalf("Save parent: %v", err)
26 }
27 stale := NewSession("sys")
28 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
29 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
30 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
31 info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: parentPath})
32 if err != nil {
33 t.Fatalf("SaveRecoveryBranch: %v", err)
34 }
35 return parentPath, info.Path, stale.Snapshot()
36 }
37
38 // coverBranchInParent rewrites the parent so it contains the branch's content
39 // plus later turns — the "original session went on and kept everything the
40 // fork preserved" shape that makes the fork redundant.
41 func coverBranchInParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
42 t.Helper()
43 merged := NewSession("")
44 merged.Messages = append([]provider.Message(nil), branchMsgs...)
45 merged.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
46 if err := merged.Save(parentPath); err != nil {
47 t.Fatalf("Save covering parent: %v", err)
48 }
49 }
50
51 func TestReclaimableRecoveryBranchesCollectsOnlyCoveredIdleForks(t *testing.T) {
52 dir := t.TempDir()
53 later := time.Now().Add(48 * time.Hour)
54
55 // Covered + idle + unleased: reclaimable.
56 _, covered, coveredMsgs := forkRecoveryBranch(t, dir, "covered")
57 coverBranchInParent(t, filepath.Join(dir, "covered.jsonl"), coveredMsgs)
58
59 // Diverged parent: the fork holds turns that exist nowhere else — kept.
60 forkRecoveryBranch(t, dir, "diverged")
61
62 // Continued on: one follow-up turn on the branch disqualifies it forever.
63 continuedParent, continuedBranch, continuedMsgs := forkRecoveryBranch(t, dir, "continued")
64 coverBranchInParent(t, continuedParent, continuedMsgs)
65 cont, err := LoadSession(continuedBranch)
66 if err != nil {
67 t.Fatalf("LoadSession continued branch: %v", err)
68 }
69 cont.Add(provider.Message{Role: provider.RoleAssistant, Content: "user kept chatting here"})
70 if err := cont.Save(continuedBranch); err != nil {
71 t.Fatalf("Save continued branch: %v", err)
72 }
73
74 got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod)
75 if err != nil {
76 t.Fatalf("ReclaimableRecoveryBranches: %v", err)
77 }
78 if len(got) != 1 || got[0] != covered {
79 t.Fatalf("reclaimable = %v, want only %q", got, covered)
80 }
81 }
82
83 func TestReclaimableRecoveryBranchesRespectsGraceLeaseAndMissingParent(t *testing.T) {
84 dir := t.TempDir()
85 later := time.Now().Add(48 * time.Hour)
86
87 parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "guarded")
88 coverBranchInParent(t, parentPath, branchMsgs)
89
90 // Fresh fork inside the grace window: kept.
91 if got, err := ReclaimableRecoveryBranches(dir, time.Now(), RecoveryGCGracePeriod); err != nil || len(got) != 0 {
92 t.Fatalf("within grace = %v err=%v, want none", got, err)
93 }
94
95 // Lease held (by this very process): kept.
96 lease, err := TryAcquireSessionLease(branchPath)
97 if err != nil {
98 t.Fatalf("TryAcquireSessionLease: %v", err)
99 }
100 if !SessionLeaseHeld(branchPath) {
101 t.Fatal("SessionLeaseHeld = false while this process holds the lease")
102 }
103 if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
104 t.Fatalf("with lease held = %v err=%v, want none", got, err)
105 }
106 lease.Release()
107 if SessionLeaseHeld(branchPath) {
108 t.Fatal("SessionLeaseHeld = true after release")
109 }
110
111 // Released + idle: reclaimable now.
112 if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 1 || got[0] != branchPath {
113 t.Fatalf("after release = %v err=%v, want %q", got, err, branchPath)
114 }
115
116 // Parent gone: content is no longer covered anywhere — kept.
117 for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
118 if err := os.Remove(parentPath + suffix); err != nil && !os.IsNotExist(err) {
119 t.Fatalf("remove parent artifact %s: %v", suffix, err)
120 }
121 }
122 if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
123 t.Fatalf("parent missing = %v err=%v, want none", got, err)
124 }
125 }
126
127 func TestRecoveryBranchCoveredByParentReadsActualContent(t *testing.T) {
128 dir := t.TempDir()
129
130 _, divergedBranch, _ := forkRecoveryBranch(t, dir, "diverged-proof")
131 if RecoveryBranchCoveredByParent(divergedBranch, dir) {
132 t.Fatal("diverged parent reported as covering its recovery branch")
133 }
134
135 coveredParent, coveredBranch, coveredMsgs := forkRecoveryBranch(t, dir, "covered-proof")
136 coverBranchInParent(t, coveredParent, coveredMsgs)
137 if !RecoveryBranchCoveredByParent(coveredBranch, dir) {
138 t.Fatal("parent containing the recovery transcript did not cover the branch")
139 }
140
141 // Restore the fork metadata after continuing the transcript. This models a
142 // stale sidecar that still claims content_digest == recovery_digest even
143 // though the actual branch has changed.
144 staleMeta, ok, err := LoadBranchMeta(coveredBranch)
145 if err != nil || !ok {
146 t.Fatalf("LoadBranchMeta stale branch: ok=%v err=%v", ok, err)
147 }
148 continued, err := LoadSession(coveredBranch)
149 if err != nil {
150 t.Fatalf("LoadSession stale branch: %v", err)
151 }
152 continued.Add(provider.Message{Role: provider.RoleAssistant, Content: "continued after sidecar snapshot"})
153 if err := continued.Save(coveredBranch); err != nil {
154 t.Fatalf("Save continued branch: %v", err)
155 }
156 if err := SaveBranchMetaPreserveUpdated(coveredBranch, staleMeta); err != nil {
157 t.Fatalf("restore stale branch meta: %v", err)
158 }
159 if RecoveryBranchCoveredByParent(coveredBranch, dir) {
160 t.Fatal("stale sidecar authorized cleanup of changed branch content")
161 }
162
163 missingParent, missingBranch, missingMsgs := forkRecoveryBranch(t, dir, "missing-proof")
164 coverBranchInParent(t, missingParent, missingMsgs)
165 if !RecoveryBranchCoveredByParent(missingBranch, dir) {
166 t.Fatal("missing-parent fixture was not covered before parent removal")
167 }
168 for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
169 if err := os.Remove(missingParent + suffix); err != nil && !os.IsNotExist(err) {
170 t.Fatalf("remove parent artifact %s: %v", suffix, err)
171 }
172 }
173 if RecoveryBranchCoveredByParent(missingBranch, dir) {
174 t.Fatal("missing parent reported as covering its recovery branch")
175 }
176 }
177
178 func TestRecoveryParentGuardBlocksRewindAfterValidation(t *testing.T) {
179 dir := t.TempDir()
180 parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "rewind-race")
181 coverBranchInParent(t, parentPath, branchMsgs)
182
183 guard, err := TryAcquireRecoveryParentGuard(branchPath, dir)
184 if err != nil {
185 t.Fatalf("TryAcquireRecoveryParentGuard: %v", err)
186 }
187 // Force the dangerous ordering: coverage validation has completed, then a
188 // concurrent rewind tries to take the parent's save lock before purge. The
189 // guard must keep that lock unavailable until the caller finishes deleting
190 // the redundant branch.
191 if lock, err := tryTakeSessionLockFile(store.SessionLockFile(parentPath)); !errors.Is(err, ErrSessionFileLockHeld) {
192 if lock != nil {
193 lock.Unlock()
194 }
195 guard.Release()
196 t.Fatalf("parent save lock after coverage validation = %v, want ErrSessionFileLockHeld", err)
197 }
198 guard.Release()
199
200 parent, err := LoadSession(parentPath)
201 if err != nil {
202 t.Fatalf("LoadSession parent: %v", err)
203 }
204 parent.Messages = append([]provider.Message(nil), parent.Messages[:1]...)
205 if err := parent.SaveRewrite(parentPath); err != nil {
206 t.Fatalf("SaveRewrite after guard release: %v", err)
207 }
208 if RecoveryBranchCoveredByParent(branchPath, dir) {
209 t.Fatal("rewound parent still reported as covering the recovery branch")
210 }
211 }
212
213 func TestRecoveryParentGuardRefusesInFlightParentRewrite(t *testing.T) {
214 dir := t.TempDir()
215 parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "rewrite-busy")
216 coverBranchInParent(t, parentPath, branchMsgs)
217
218 unlock, err := lockSessionFile(parentPath)
219 if err != nil {
220 t.Fatalf("lockSessionFile: %v", err)
221 }
222 defer unlock()
223 if guard, err := TryAcquireRecoveryParentGuard(branchPath, dir); !errors.Is(err, ErrSessionLeaseHeld) {
224 if guard != nil {
225 guard.Release()
226 }
227 t.Fatalf("guard during parent rewrite err = %v, want ErrSessionLeaseHeld", err)
228 }
229 }
230
231 func ageRecoveryBranchForGC(t *testing.T, path string) {
232 t.Helper()
233 meta, ok, err := LoadBranchMeta(path)
234 if err != nil || !ok {
235 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
236 }
237 meta.UpdatedAt = time.Now().Add(-2 * RecoveryGCGracePeriod)
238 if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
239 t.Fatalf("age recovery meta: %v", err)
240 }
241 }
242
243 func TestTrashReclaimableRecoveryBranchUsesRecoverableDesktopLayout(t *testing.T) {
244 dir := t.TempDir()
245 parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "trash-covered")
246 coverBranchInParent(t, parentPath, branchMsgs)
247 ageRecoveryBranchForGC(t, branchPath)
248
249 if err := TrashReclaimableRecoveryBranch(branchPath, dir); err != nil {
250 t.Fatalf("TrashReclaimableRecoveryBranch: %v", err)
251 }
252 if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
253 t.Fatalf("live recovery transcript still exists: %v", err)
254 }
255 if IsCleanupPending(branchPath) {
256 t.Fatal("cleanup-pending marker remained after completed trash move")
257 }
258 itemDir := filepath.Join(dir, recoveryTrashDir, filepath.Base(branchPath))
259 for _, path := range []string{
260 filepath.Join(itemDir, filepath.Base(branchPath)),
261 filepath.Join(itemDir, filepath.Base(BranchMetaPath(branchPath))),
262 filepath.Join(itemDir, filepath.Base(store.SessionEventLog(branchPath))),
263 filepath.Join(itemDir, recoveryTrashMetaFile),
264 } {
265 if _, err := os.Stat(path); err != nil {
266 t.Fatalf("recoverable trash artifact %s: %v", path, err)
267 }
268 }
269 if _, err := os.Stat(parentPath); err != nil {
270 t.Fatalf("parent session changed by recovery trash: %v", err)
271 }
272 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashPendingFile)); !os.IsNotExist(err) {
273 t.Fatalf("completed trash entry retained pending marker: %v", err)
274 }
275 }
276
277 func TestTrashReclaimableRecoveryBranchEnforcesGraceAtFinalGuard(t *testing.T) {
278 dir := t.TempDir()
279 parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "trash-fresh")
280 coverBranchInParent(t, parentPath, branchMsgs)
281
282 if err := TrashReclaimableRecoveryBranch(branchPath, dir); !errors.Is(err, ErrRecoveryBranchNotIdle) {
283 t.Fatalf("fresh recovery trash err = %v, want ErrRecoveryBranchNotIdle", err)
284 }
285 if _, err := os.Stat(branchPath); err != nil {
286 t.Fatalf("fresh recovery branch was not preserved: %v", err)
287 }
288 }
289
290 func TestInterruptedRecoveryTrashStageReconcilesBeforePublication(t *testing.T) {
291 dir := t.TempDir()
292 _, branchPath, _ := forkRecoveryBranch(t, dir, "staged-crash")
293 key := filepath.Base(branchPath)
294 stageDir, err := reserveRecoveryTrashStage(dir)
295 if err != nil {
296 t.Fatalf("reserveRecoveryTrashStage: %v", err)
297 }
298 if err := prepareRecoveryTrashStage(branchPath, key, stageDir); err != nil {
299 t.Fatalf("prepareRecoveryTrashStage: %v", err)
300 }
301 if IsCleanupPending(branchPath) {
302 t.Fatal("live cleanup marker should not be used by the staging protocol")
303 }
304 if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
305 t.Fatalf("live transcript still exists after staging: %v", err)
306 }
307 if store.IsSessionTranscriptName(filepath.Base(stageDir)) {
308 t.Fatalf("staging directory is Desktop-visible by name: %s", stageDir)
309 }
310 for _, target := range []string{
311 filepath.Join(stageDir, key),
312 filepath.Join(stageDir, recoveryTrashPendingFile),
313 } {
314 if _, err := os.Stat(target); err != nil {
315 t.Fatalf("staged recovery artifact %s: %v", target, err)
316 }
317 }
318 if _, err := os.Stat(filepath.Join(stageDir, recoveryTrashMetaFile)); !os.IsNotExist(err) {
319 t.Fatalf("incomplete stage became Desktop-visible through trash metadata: %v", err)
320 }
321
322 // Simulate a process crash after the transcript rename but before any
323 // sidecars moved. Startup reconciliation must finish the hidden stage and
324 // publish one complete Desktop trash item without the hard-delete callback.
325 calledFallback := false
326 if err := ReconcileCleanupPending(dir, func(CleanupPendingInfo) error {
327 calledFallback = true
328 return errors.New("hard-delete fallback must not run")
329 }); err != nil {
330 t.Fatalf("ReconcileCleanupPending: %v", err)
331 }
332 if calledFallback {
333 t.Fatal("recovery trash stage reached hard-delete fallback")
334 }
335 if _, err := os.Stat(stageDir); !os.IsNotExist(err) {
336 t.Fatalf("staging directory remained after publication: %v", err)
337 }
338 itemDir := filepath.Join(dir, recoveryTrashDir, key)
339 for _, target := range []string{
340 filepath.Join(itemDir, key),
341 filepath.Join(itemDir, filepath.Base(BranchMetaPath(branchPath))),
342 filepath.Join(itemDir, filepath.Base(store.SessionEventLog(branchPath))),
343 filepath.Join(itemDir, recoveryTrashMetaFile),
344 } {
345 if _, err := os.Stat(target); err != nil {
346 t.Fatalf("reconciled recovery artifact %s: %v", target, err)
347 }
348 }
349 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashPendingFile)); !os.IsNotExist(err) {
350 t.Fatalf("published trash entry retained pending marker: %v", err)
351 }
352 }
353
354 func TestReconcileCleanupPendingFinishesRecoveryTrashWithoutHardDeleteCallback(t *testing.T) {
355 dir := t.TempDir()
356 _, branchPath, _ := forkRecoveryBranch(t, dir, "trash-interrupted")
357 key := filepath.Base(branchPath)
358 itemName, itemDir, err := reserveRecoveryTrashItemDir(dir, key)
359 if err != nil {
360 t.Fatalf("reserveRecoveryTrashItemDir: %v", err)
361 }
362 if err := prepareRecoveryTrashEntry(branchPath, key, itemDir); err != nil {
363 t.Fatalf("prepare trash entry before simulated crash: %v", err)
364 }
365 if err := MarkCleanupPending(branchPath, recoveryTrashOperationPrefix+itemName); err != nil {
366 t.Fatalf("MarkCleanupPending: %v", err)
367 }
368
369 calledFallback := false
370 if err := ReconcileCleanupPending(dir, func(CleanupPendingInfo) error {
371 calledFallback = true
372 return errors.New("hard-delete fallback must not run")
373 }); err != nil {
374 t.Fatalf("ReconcileCleanupPending: %v", err)
375 }
376 if calledFallback {
377 t.Fatal("recovery-trash marker reached hard-delete fallback")
378 }
379 if IsCleanupPending(branchPath) {
380 t.Fatal("cleanup-pending marker remained after reconciliation")
381 }
382 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashMetaFile)); err != nil {
383 t.Fatalf("reconciled trash metadata: %v", err)
384 }
385 }
386
386 lines GO