返回 DeepSeek-Reasonix
session_meta_ledger_test.go
根目录 / internal / agent / session_meta_ledger_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/provider"
15 )
16
17 // shrinkMetaReadBackoffs keeps corrupt-sidecar tests fast. Only the pacing of
18 // the torn-read retries changes; the retry-then-fail semantics stay intact.
19 func shrinkMetaReadBackoffs(t *testing.T) {
20 t.Helper()
21 old := branchMetaReadBackoffs
22 branchMetaReadBackoffs = []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond}
23 t.Cleanup(func() { branchMetaReadBackoffs = old })
24 }
25
26 func assertNoRecoveryBranches(t *testing.T, sessionPath string) {
27 t.Helper()
28 matches, err := filepath.Glob(filepath.Join(filepath.Dir(sessionPath), "*-recovery-*.jsonl"))
29 if err != nil {
30 t.Fatalf("glob recovery branches: %v", err)
31 }
32 if len(matches) != 0 {
33 t.Fatalf("unexpected recovery branches: %v", matches)
34 }
35 }
36
37 // A meta sidecar that exists but cannot be parsed must fail the save instead
38 // of being silently treated as revision 0 — the desync that used to fork
39 // bogus recovery branches. The unreadable ledger must also survive untouched
40 // so a healed read can pick up the real revision.
41 func TestSaveFailsClosedOnCorruptMetaLedger(t *testing.T) {
42 shrinkMetaReadBackoffs(t)
43 path := filepath.Join(t.TempDir(), "session.jsonl")
44 metaPath := BranchMetaPath(path)
45 s := NewSession("sys")
46 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
47 if err := s.SaveSnapshot(path); err != nil {
48 t.Fatalf("SaveSnapshot base: %v", err)
49 }
50 good, err := os.ReadFile(metaPath)
51 if err != nil {
52 t.Fatalf("read good meta: %v", err)
53 }
54 corrupt := []byte("{ torn json")
55 if err := os.WriteFile(metaPath, corrupt, 0o644); err != nil {
56 t.Fatalf("corrupt meta: %v", err)
57 }
58
59 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
60 saveErr := s.SaveSnapshot(path)
61 if saveErr == nil {
62 t.Fatal("SaveSnapshot with corrupt meta ledger succeeded, want error")
63 }
64 if errors.Is(saveErr, ErrSessionSnapshotConflict) {
65 t.Fatalf("SaveSnapshot with corrupt meta misread as conflict: %v", saveErr)
66 }
67 assertNoRecoveryBranches(t, path)
68 if b, err := os.ReadFile(metaPath); err != nil || string(b) != string(corrupt) {
69 t.Fatalf("failed save rewrote the unreadable ledger: err=%v content=%q", err, b)
70 }
71 onDisk, err := LoadSession(path)
72 if err != nil {
73 t.Fatalf("LoadSession after failed save: %v", err)
74 }
75 if got := len(onDisk.Messages); got != 2 {
76 t.Fatalf("failed save changed transcript: %d messages, want 2", got)
77 }
78
79 // Once the sidecar reads cleanly again the same session saves normally.
80 if err := os.WriteFile(metaPath, good, 0o644); err != nil {
81 t.Fatalf("restore meta: %v", err)
82 }
83 if err := s.SaveSnapshot(path); err != nil {
84 t.Fatalf("SaveSnapshot after meta repair: %v", err)
85 }
86 meta, ok, err := LoadBranchMeta(path)
87 if err != nil || !ok {
88 t.Fatalf("LoadBranchMeta after repair ok=%v err=%v", ok, err)
89 }
90 if meta.Revision != 2 {
91 t.Fatalf("revision after repaired save = %d, want 2", meta.Revision)
92 }
93 }
94
95 // An unreadable meta sidecar at load time must not stop the session from
96 // opening, and the revision-0 placeholder must not arm the CAS check: once
97 // the sidecar reads cleanly again, appends save without a bogus conflict.
98 func TestLoadSessionWithUnreadableMetaStillOpensAndAppends(t *testing.T) {
99 shrinkMetaReadBackoffs(t)
100 path := filepath.Join(t.TempDir(), "session.jsonl")
101 metaPath := BranchMetaPath(path)
102 seed := NewSession("sys")
103 seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
104 seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
105 if err := seed.SaveSnapshot(path); err != nil {
106 t.Fatalf("SaveSnapshot seed: %v", err)
107 }
108 good, err := os.ReadFile(metaPath)
109 if err != nil {
110 t.Fatalf("read good meta: %v", err)
111 }
112 if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
113 t.Fatalf("corrupt meta: %v", err)
114 }
115
116 loaded, err := LoadSession(path)
117 if err != nil {
118 t.Fatalf("LoadSession with unreadable meta: %v", err)
119 }
120 if got := len(loaded.Messages); got != 3 {
121 t.Fatalf("loaded %d messages, want 3", got)
122 }
123 if !loaded.persisted.ok {
124 t.Fatal("load must still establish a persistence baseline")
125 }
126 if loaded.persisted.revisionKnown {
127 t.Fatal("baseline built without a readable ledger must be revision-unknown")
128 }
129
130 // The tear heals (another runtime's write completes / the corruption was
131 // transient): the on-disk revision is an honest 1 against this runtime's
132 // unknown baseline. The append must not be misread as a stale runtime.
133 if err := os.WriteFile(metaPath, good, 0o644); err != nil {
134 t.Fatalf("restore meta: %v", err)
135 }
136 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
137 if err := loaded.SaveSnapshot(path); err != nil {
138 t.Fatalf("SaveSnapshot append after unreadable-meta load: %v", err)
139 }
140 assertNoRecoveryBranches(t, path)
141 meta, ok, err := LoadBranchMeta(path)
142 if err != nil || !ok {
143 t.Fatalf("LoadBranchMeta after append ok=%v err=%v", ok, err)
144 }
145 if meta.Revision != 2 {
146 t.Fatalf("revision after append = %d, want 2", meta.Revision)
147 }
148 if !loaded.persisted.revisionKnown {
149 t.Fatal("successful save must re-learn the revision baseline")
150 }
151
152 // With the baseline re-learned, revision CAS is armed again.
153 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
154 if err := loaded.SaveSnapshot(path); err != nil {
155 t.Fatalf("SaveSnapshot second append: %v", err)
156 }
157 }
158
159 // Compaction-style rewrites from a revision-unknown baseline must fall back to
160 // digest+version ownership instead of failing the revision equality check.
161 func TestSaveRewriteWithUnreadableMetaBaselineOwnsByDigest(t *testing.T) {
162 shrinkMetaReadBackoffs(t)
163 path := filepath.Join(t.TempDir(), "session.jsonl")
164 metaPath := BranchMetaPath(path)
165 seed := NewSession("sys")
166 seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
167 seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "a long detailed answer"})
168 if err := seed.SaveSnapshot(path); err != nil {
169 t.Fatalf("SaveSnapshot seed: %v", err)
170 }
171 good, err := os.ReadFile(metaPath)
172 if err != nil {
173 t.Fatalf("read good meta: %v", err)
174 }
175 if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
176 t.Fatalf("corrupt meta: %v", err)
177 }
178 loaded, err := LoadSession(path)
179 if err != nil {
180 t.Fatalf("LoadSession with unreadable meta: %v", err)
181 }
182 if err := os.WriteFile(metaPath, good, 0o644); err != nil {
183 t.Fatalf("restore meta: %v", err)
184 }
185
186 rewritten := loaded.Snapshot()
187 rewritten[len(rewritten)-1].Content = "compacted"
188 loaded.Replace(rewritten)
189 if err := loaded.SaveRewrite(path); err != nil {
190 t.Fatalf("SaveRewrite from unknown-revision baseline: %v", err)
191 }
192 assertNoRecoveryBranches(t, path)
193 meta, ok, err := LoadBranchMeta(path)
194 if err != nil || !ok {
195 t.Fatalf("LoadBranchMeta after rewrite ok=%v err=%v", ok, err)
196 }
197 if meta.Revision != 2 {
198 t.Fatalf("revision after rewrite = %d, want 2", meta.Revision)
199 }
200 reloaded, err := LoadSession(path)
201 if err != nil {
202 t.Fatalf("LoadSession after rewrite: %v", err)
203 }
204 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "compacted" {
205 t.Fatalf("rewrite tail = %q, want compacted", got)
206 }
207 }
208
209 // A missing sidecar is not damage: it is the legitimate revision-0 state of a
210 // session that never recorded one, and must keep arming the CAS baseline.
211 func TestMissingMetaRemainsKnownZeroRevisionBaseline(t *testing.T) {
212 path := filepath.Join(t.TempDir(), "session.jsonl")
213 s := NewSession("sys")
214 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
215 if err := s.SaveSnapshot(path); err != nil {
216 t.Fatalf("SaveSnapshot base: %v", err)
217 }
218 if err := os.Remove(BranchMetaPath(path)); err != nil {
219 t.Fatalf("remove meta: %v", err)
220 }
221
222 loaded, err := LoadSession(path)
223 if err != nil {
224 t.Fatalf("LoadSession without meta: %v", err)
225 }
226 if !loaded.persisted.ok || !loaded.persisted.revisionKnown || loaded.persisted.revision != 0 {
227 t.Fatalf("missing meta baseline = %+v, want known revision 0", loaded.persisted)
228 }
229 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
230 if err := loaded.SaveSnapshot(path); err != nil {
231 t.Fatalf("SaveSnapshot append without meta: %v", err)
232 }
233 assertNoRecoveryBranches(t, path)
234 meta, ok, err := LoadBranchMeta(path)
235 if err != nil || !ok {
236 t.Fatalf("LoadBranchMeta recreated ok=%v err=%v", ok, err)
237 }
238 if meta.Revision != 1 {
239 t.Fatalf("recreated revision = %d, want 1", meta.Revision)
240 }
241 }
242
243 // Losing the event index (a listing accelerator, never read by LoadSession)
244 // must not fail a save whose transcript and revision already landed, and must
245 // not leave the baseline behind disk where the next save reads as a conflict.
246 func TestSaveSnapshotToleratesEventIndexWriteFailure(t *testing.T) {
247 path := filepath.Join(t.TempDir(), "session.jsonl")
248 // Squat a directory on the index path so every index write must fail.
249 if err := os.MkdirAll(SessionEventIndexPath(path), 0o755); err != nil {
250 t.Fatalf("pre-create index dir: %v", err)
251 }
252 s := NewSession("sys")
253 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
254 if err := s.SaveSnapshot(path); err != nil {
255 t.Fatalf("SaveSnapshot with failing event index = %v, want nil", err)
256 }
257 first, ok, err := LoadBranchMeta(path)
258 if err != nil || !ok {
259 t.Fatalf("LoadBranchMeta first ok=%v err=%v", ok, err)
260 }
261 if first.Revision != 1 {
262 t.Fatalf("first revision = %d, want 1", first.Revision)
263 }
264
265 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
266 if err := s.SaveSnapshot(path); err != nil {
267 t.Fatalf("second SaveSnapshot = %v, want nil (baseline must advance despite index failure)", err)
268 }
269 second, ok, err := LoadBranchMeta(path)
270 if err != nil || !ok {
271 t.Fatalf("LoadBranchMeta second ok=%v err=%v", ok, err)
272 }
273 if second.Revision != 2 {
274 t.Fatalf("second revision = %d, want 2", second.Revision)
275 }
276 assertNoRecoveryBranches(t, path)
277 loaded, err := LoadSession(path)
278 if err != nil {
279 t.Fatalf("LoadSession: %v", err)
280 }
281 if got := len(loaded.Messages); got != 3 {
282 t.Fatalf("loaded %d messages, want 3", got)
283 }
284 if info, err := os.Stat(SessionEventIndexPath(path)); err != nil || !info.IsDir() {
285 t.Fatalf("fixture broke: index path no longer a directory (err=%v)", err)
286 }
287 }
288
289 // Meta-only writers (rename, model stamp) racing content saves must never
290 // roll the revision ledger backwards or manufacture conflicts.
291 func TestSessionMetaConcurrentWritersKeepRevisionMonotonic(t *testing.T) {
292 path := filepath.Join(t.TempDir(), "session.jsonl")
293 s := NewSession("sys")
294 s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
295 if err := s.SaveSnapshot(path); err != nil {
296 t.Fatalf("SaveSnapshot seed: %v", err)
297 }
298 if err := RenameSession(path, "seed-title"); err != nil {
299 t.Fatalf("RenameSession seed: %v", err)
300 }
301 if err := SetBranchModelPreserveUpdated(path, "prov/model-seed"); err != nil {
302 t.Fatalf("SetBranchModelPreserveUpdated seed: %v", err)
303 }
304
305 const saves = 25
306 stop := make(chan struct{})
307 var stopOnce sync.Once
308 stopAll := func() { stopOnce.Do(func() { close(stop) }) }
309 var wg sync.WaitGroup
310 defer func() {
311 stopAll()
312 wg.Wait()
313 }()
314 metaErrCh := make(chan error, 2)
315 var regressed atomic.Bool
316
317 wg.Go(func() {
318 for i := 0; ; i++ {
319 select {
320 case <-stop:
321 return
322 default:
323 }
324 if err := RenameSession(path, fmt.Sprintf("title-%d", i)); err != nil {
325 select {
326 case metaErrCh <- err:
327 default:
328 }
329 return
330 }
331 if err := SetBranchModelPreserveUpdated(path, fmt.Sprintf("prov/model-%d", i)); err != nil {
332 select {
333 case metaErrCh <- err:
334 default:
335 }
336 return
337 }
338 }
339 })
340 wg.Go(func() {
341 last := int64(0)
342 for {
343 select {
344 case <-stop:
345 return
346 default:
347 }
348 if meta, ok, err := LoadBranchMeta(path); err == nil && ok {
349 if meta.Revision < last {
350 regressed.Store(true)
351 return
352 }
353 last = meta.Revision
354 }
355 }
356 })
357
358 for i := 1; i <= saves; i++ {
359 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
360 if err := s.SaveSnapshot(path); err != nil {
361 t.Fatalf("SaveSnapshot %d under meta-writer hammer: %v", i, err)
362 }
363 }
364 stopAll()
365 wg.Wait()
366 close(metaErrCh)
367 for err := range metaErrCh {
368 t.Fatalf("meta writer error: %v", err)
369 }
370 if regressed.Load() {
371 t.Fatal("meta revision regressed while meta-only writers raced saves")
372 }
373
374 meta, ok, err := LoadBranchMeta(path)
375 if err != nil || !ok {
376 t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
377 }
378 if want := int64(saves + 1); meta.Revision != want {
379 t.Fatalf("final revision = %d, want %d", meta.Revision, want)
380 }
381 digest, err := digestSessionMessages(s.Snapshot())
382 if err != nil {
383 t.Fatalf("digest final content: %v", err)
384 }
385 if meta.ContentDigest != digestString(digest) {
386 t.Fatalf("final content digest = %q, want %q", meta.ContentDigest, digestString(digest))
387 }
388 if meta.CustomTitle == "" || meta.Model == "" {
389 t.Fatalf("meta-only fields lost under hammer: %+v", meta)
390 }
391 assertNoRecoveryBranches(t, path)
392 }
393
394 func TestRenameSessionIfTitleRevisionPreservesNewerTitleAndRejectsABA(t *testing.T) {
395 path := filepath.Join(t.TempDir(), "session.jsonl")
396 if err := RenameSession(path, "original"); err != nil {
397 t.Fatal(err)
398 }
399 _, originalRevision, err := SessionTitleSnapshot(path)
400 if err != nil {
401 t.Fatal(err)
402 }
403 if err := RenameSessionIfTitleRevision(path, originalRevision, "first AI title"); err != nil {
404 t.Fatalf("compare-and-rename: %v", err)
405 }
406 if err := RenameSession(path, "newer manual title"); err != nil {
407 t.Fatal(err)
408 }
409 if err := RenameSession(path, "original"); err != nil {
410 t.Fatal(err)
411 }
412 if err := RenameSessionIfTitleRevision(path, originalRevision, "stale AI title"); !errors.Is(err, ErrSessionTitleChanged) {
413 t.Fatalf("stale compare-and-rename error = %v", err)
414 }
415 meta, ok, err := LoadBranchMeta(path)
416 if err != nil || !ok || meta.CustomTitle != "original" || meta.TitleRevision == originalRevision {
417 t.Fatalf("meta = %+v, ok=%v, err=%v", meta, ok, err)
418 }
419 }
420
421 // A saver that blocks on the save lock must persist whatever the session
422 // holds when it finally enters the critical section, not a stale capture from
423 // when it was scheduled — the out-of-order landing that used to surface as
424 // "session changed on disk" adoptions between the turn-end snapshot, periodic
425 // autosave, and shutdown snapshot.
426 func TestSaveSnapshotCapturesContentUnderSaveLock(t *testing.T) {
427 path := filepath.Join(t.TempDir(), "session.jsonl")
428 s := NewSession("sys")
429 s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
430 if err := s.SaveSnapshot(path); err != nil {
431 t.Fatalf("SaveSnapshot seed: %v", err)
432 }
433
434 unlock := lockSessionSavePath(path)
435 done := make(chan error, 1)
436 go func() { done <- s.SaveSnapshot(path) }()
437 // Let the saver reach the save lock, then grow the session while it waits.
438 time.Sleep(50 * time.Millisecond)
439 s.Add(provider.Message{Role: provider.RoleUser, Content: "added while saver waited"})
440 unlock()
441 if err := <-done; err != nil {
442 t.Fatalf("SaveSnapshot after lock release: %v", err)
443 }
444
445 loaded, err := LoadSession(path)
446 if err != nil {
447 t.Fatalf("LoadSession: %v", err)
448 }
449 if got := len(loaded.Messages); got != 3 {
450 t.Fatalf("saved %d messages, want 3 (snapshot must be captured under the save lock)", got)
451 }
452 assertNoRecoveryBranches(t, path)
453 }
454
455 // Multiple in-process savers of one session (turn-end snapshot, periodic
456 // autosave, shutdown snapshot) must never conflict with each other: the
457 // snapshot is captured under the save lock, so a stale pre-lock capture can
458 // no longer land after a newer one.
459 func TestConcurrentSnapshotSaversNeverConflict(t *testing.T) {
460 path := filepath.Join(t.TempDir(), "session.jsonl")
461 s := NewSession("sys")
462 s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
463 if err := s.SaveSnapshot(path); err != nil {
464 t.Fatalf("SaveSnapshot seed: %v", err)
465 }
466
467 const adds = 30
468 stop := make(chan struct{})
469 errCh := make(chan error, 64)
470 var wg sync.WaitGroup
471 for range 2 {
472 wg.Go(func() {
473 for {
474 if err := s.SaveSnapshot(path); err != nil {
475 select {
476 case errCh <- err:
477 default:
478 }
479 }
480 select {
481 case <-stop:
482 return
483 default:
484 }
485 }
486 })
487 }
488 for i := 1; i <= adds; i++ {
489 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
490 time.Sleep(time.Millisecond)
491 }
492 close(stop)
493 wg.Wait()
494 close(errCh)
495 for err := range errCh {
496 if errors.Is(err, ErrSessionSnapshotConflict) {
497 t.Fatalf("concurrent snapshot savers conflicted: %v", err)
498 }
499 t.Errorf("concurrent snapshot saver error: %v", err)
500 }
501
502 if err := s.SaveSnapshot(path); err != nil {
503 t.Fatalf("final SaveSnapshot: %v", err)
504 }
505 loaded, err := LoadSession(path)
506 if err != nil {
507 t.Fatalf("LoadSession: %v", err)
508 }
509 if got, want := len(loaded.Messages), adds+2; got != want {
510 t.Fatalf("final message count = %d, want %d", got, want)
511 }
512 assertNoRecoveryBranches(t, path)
513 }
514
515 // A save that lands its transcript bytes and then fails to record the
516 // revision (fail-closed record, or a crash between the two writes) leaves the
517 // ledger describing older content. The next save of the same snapshot takes
518 // the up-to-date path and must heal the ledger — record the revision and
519 // digest the interrupted save deferred — instead of skipping it forever.
520 func TestSameContentSaveHealsStaleLedgerDigest(t *testing.T) {
521 path := schemaOneSessionPath(t, "session.jsonl")
522 metaPath := BranchMetaPath(path)
523 s := NewSession("sys")
524 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
525 if err := s.SaveSnapshot(path); err != nil {
526 t.Fatalf("SaveSnapshot base: %v", err)
527 }
528 staleMeta, err := os.ReadFile(metaPath)
529 if err != nil {
530 t.Fatalf("read base meta: %v", err)
531 }
532
533 // Land new content, then rewind the sidecar to the pre-append ledger:
534 // the exact on-disk aftermath of an append whose revision record failed.
535 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
536 if err := s.SaveSnapshot(path); err != nil {
537 t.Fatalf("SaveSnapshot append: %v", err)
538 }
539 if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
540 t.Fatalf("rewind meta: %v", err)
541 }
542
543 // Any runtime resuming this file now pairs the landed transcript with the
544 // stale revision — the post-crash shape.
545 loaded, err := LoadSession(path)
546 if err != nil {
547 t.Fatalf("LoadSession on stale ledger: %v", err)
548 }
549 if err := loaded.SaveSnapshot(path); err != nil {
550 t.Fatalf("same-content SaveSnapshot: %v", err)
551 }
552 meta, ok, err := LoadBranchMeta(path)
553 if err != nil || !ok {
554 t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
555 }
556 if meta.Revision != 2 {
557 t.Fatalf("healed revision = %d, want 2", meta.Revision)
558 }
559 digest, err := digestSessionMessages(loaded.Snapshot())
560 if err != nil {
561 t.Fatalf("digest current messages: %v", err)
562 }
563 if meta.ContentDigest != digestString(digest) {
564 t.Fatalf("healed digest = %s, want %s", meta.ContentDigest, digestString(digest))
565 }
566 assertNoRecoveryBranches(t, path)
567
568 // The healed baseline keeps working: the next append saves cleanly.
569 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
570 if err := loaded.SaveSnapshot(path); err != nil {
571 t.Fatalf("append after heal: %v", err)
572 }
573 after, _, err := LoadBranchMeta(path)
574 if err != nil {
575 t.Fatalf("LoadBranchMeta after append: %v", err)
576 }
577 if after.Revision != 3 {
578 t.Fatalf("revision after post-heal append = %d, want 3", after.Revision)
579 }
580 assertNoRecoveryBranches(t, path)
581 }
582
583 // The surviving in-process saver — whose baseline never advanced because the
584 // failed save returned before markPersisted — heals through the same
585 // up-to-date path on its autosave retry of the identical snapshot.
586 func TestSameContentRetryHealsLedgerForSurvivingSaver(t *testing.T) {
587 path := schemaOneSessionPath(t, "session.jsonl")
588 metaPath := BranchMetaPath(path)
589 s := NewSession("sys")
590 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
591 if err := s.SaveSnapshot(path); err != nil {
592 t.Fatalf("SaveSnapshot base: %v", err)
593 }
594 staleMeta, err := os.ReadFile(metaPath)
595 if err != nil {
596 t.Fatalf("read base meta: %v", err)
597 }
598 baseline := s.persistState(path)
599
600 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
601 if err := s.SaveSnapshot(path); err != nil {
602 t.Fatalf("SaveSnapshot append: %v", err)
603 }
604 // Rewind the sidecar and the in-memory baseline to the mid-save failure
605 // state: bytes landed, record failed, markPersisted never ran.
606 if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
607 t.Fatalf("rewind meta: %v", err)
608 }
609 s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true, true, 0, nil)
610
611 if err := s.SaveSnapshot(path); err != nil {
612 t.Fatalf("autosave retry: %v", err)
613 }
614 meta, ok, err := LoadBranchMeta(path)
615 if err != nil || !ok {
616 t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
617 }
618 if meta.Revision != 2 {
619 t.Fatalf("healed revision = %d, want 2", meta.Revision)
620 }
621 assertNoRecoveryBranches(t, path)
622 }
623
624 // A legacy sidecar that predates content digests is not a stale ledger: the
625 // up-to-date path must leave it untouched rather than bump a revision other
626 // runtimes still hold as their baseline.
627 func TestUpToDateSaveLeavesDigestlessLegacyMetaAlone(t *testing.T) {
628 path := filepath.Join(t.TempDir(), "session.jsonl")
629 metaPath := BranchMetaPath(path)
630 s := NewSession("sys")
631 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
632 if err := s.SaveSnapshot(path); err != nil {
633 t.Fatalf("SaveSnapshot base: %v", err)
634 }
635 // Strip the digest by editing the raw sidecar: the save helpers back-fill
636 // an empty digest from the existing meta, exactly like real legacy files
637 // acquired one only through a content-bearing save.
638 raw, err := os.ReadFile(metaPath)
639 if err != nil {
640 t.Fatalf("read meta: %v", err)
641 }
642 var fields map[string]any
643 if err := json.Unmarshal(raw, &fields); err != nil {
644 t.Fatalf("decode meta: %v", err)
645 }
646 delete(fields, "content_digest")
647 stripped, err := json.Marshal(fields)
648 if err != nil {
649 t.Fatalf("encode meta: %v", err)
650 }
651 if err := os.WriteFile(metaPath, stripped, 0o644); err != nil {
652 t.Fatalf("write legacy meta: %v", err)
653 }
654
655 loaded, err := LoadSession(path)
656 if err != nil {
657 t.Fatalf("LoadSession legacy meta: %v", err)
658 }
659 if err := loaded.SaveSnapshot(path); err != nil {
660 t.Fatalf("same-content SaveSnapshot: %v", err)
661 }
662 meta, ok, err := LoadBranchMeta(path)
663 if err != nil || !ok {
664 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
665 }
666 if meta.Revision != 1 {
667 t.Fatalf("legacy revision = %d, want 1 (no gratuitous bump)", meta.Revision)
668 }
669 if meta.ContentDigest != "" {
670 t.Fatalf("legacy digest = %q, want empty (untouched)", meta.ContentDigest)
671 }
672 assertNoRecoveryBranches(t, path)
673 }
674
674 lines GO