返回 DeepSeek-Reasonix
subagent_store_test.go
根目录 / internal / agent / subagent_store_test.go
1 package agent
2
3 import (
4 "bufio"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 func TestSubagentStoreContinueLoadsSavedTranscript(t *testing.T) {
20 store := NewSubagentStore(t.TempDir())
21 spec := testSubagentSpec(t, "review")
22 run, err := store.PrepareFresh(spec)
23 if err != nil {
24 t.Fatalf("PrepareFresh: %v", err)
25 }
26 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
27 run.Session.Add(provider.Message{Role: provider.RoleAssistant, Content: "finding A"})
28 if err := store.SaveCompleted(run); err != nil {
29 t.Fatalf("SaveCompleted: %v", err)
30 }
31 run.Release()
32
33 continued, err := store.PrepareContinue(run.Ref, spec)
34 if err != nil {
35 t.Fatalf("PrepareContinue: %v", err)
36 }
37 defer continued.Release()
38 if continued.Ref != run.Ref {
39 t.Fatalf("continued ref = %q, want %q", continued.Ref, run.Ref)
40 }
41 if got := continued.Session.Snapshot(); len(got) != 3 || got[2].Content != "finding A" {
42 t.Fatalf("continued transcript = %+v, want saved messages", got)
43 }
44 }
45
46 // TestSubagentStoreTerminalSaveKeepsBranchStartAndActivityTimes guards #7298:
47 // CreatedAt must remain the subagent start time while LastActivityAt reflects
48 // the later terminal save used for recency ordering.
49 func TestSubagentStoreTerminalSaveKeepsBranchStartAndActivityTimes(t *testing.T) {
50 for _, tc := range []struct {
51 name string
52 save func(*SubagentStore, *SubagentRun) error
53 }{
54 {name: "completed", save: (*SubagentStore).SaveCompleted},
55 {name: "failed", save: (*SubagentStore).SaveFailed},
56 } {
57 t.Run(tc.name, func(t *testing.T) {
58 store := NewSubagentStore(t.TempDir())
59 run, err := store.PrepareFresh(testSubagentSpec(t, "explore"))
60 if err != nil {
61 t.Fatalf("PrepareFresh: %v", err)
62 }
63 defer run.Release()
64
65 created := time.Now().UTC().Add(-2 * time.Hour)
66 run.Meta.CreatedAt = created
67 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "explore repo"})
68 run.Session.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
69 beforeTerminalSave := time.Now().UTC()
70 if err := tc.save(store, run); err != nil {
71 t.Fatalf("terminal save: %v", err)
72 }
73
74 path := filepath.Join(store.dir, run.Ref+".jsonl")
75 branch, ok, err := LoadBranchMeta(path)
76 if err != nil || !ok {
77 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
78 }
79 if !branch.CreatedAt.Equal(created) {
80 t.Fatalf("branch CreatedAt = %v, want subagent start %v", branch.CreatedAt, created)
81 }
82 if branch.UpdatedAt.Before(beforeTerminalSave) || branch.UpdatedAt.After(run.Meta.UpdatedAt) {
83 t.Fatalf("branch UpdatedAt = %v, want terminal save in [%v, %v]", branch.UpdatedAt, beforeTerminalSave, run.Meta.UpdatedAt)
84 }
85
86 peerPath := filepath.Join(store.dir, "older-peer.jsonl")
87 peer := NewSession("system")
88 peer.Add(provider.Message{Role: provider.RoleUser, Content: "older work"})
89 if err := peer.Save(peerPath); err != nil {
90 t.Fatalf("save peer: %v", err)
91 }
92 if err := SaveBranchMetaPreserveUpdated(peerPath, BranchMeta{
93 ID: BranchID(peerPath),
94 CreatedAt: created.Add(-time.Hour),
95 UpdatedAt: created.Add(time.Hour),
96 }); err != nil {
97 t.Fatalf("save peer meta: %v", err)
98 }
99
100 ordered, err := ListSessionOrder(store.dir)
101 if err != nil {
102 t.Fatalf("ListSessionOrder: %v", err)
103 }
104 if len(ordered) != 2 || ordered[0].Path != path {
105 t.Fatalf("session order = %+v, want terminally saved subagent first", ordered)
106 }
107 if !ordered[0].CreatedAt.Equal(created) || !ordered[0].LastActivityAt.Equal(branch.UpdatedAt) {
108 t.Fatalf("listed times = created %v activity %v, want %v / %v", ordered[0].CreatedAt, ordered[0].LastActivityAt, created, branch.UpdatedAt)
109 }
110 })
111 }
112 }
113
114 func TestSubagentStoreSaveFailedPersistsTerminalMetaWhenBranchMetaIsCorrupt(t *testing.T) {
115 store := NewSubagentStore(t.TempDir())
116 run, err := store.PrepareFresh(testSubagentSpec(t, "explore"))
117 if err != nil {
118 t.Fatalf("PrepareFresh: %v", err)
119 }
120 defer run.Release()
121 if err := store.MarkRunning(run); err != nil {
122 t.Fatalf("MarkRunning: %v", err)
123 }
124 if err := os.WriteFile(BranchMetaPath(store.sessionPath(run.Ref)), []byte("{"), 0o600); err != nil {
125 t.Fatalf("corrupt branch meta: %v", err)
126 }
127
128 if err := store.SaveFailed(run); err == nil {
129 t.Fatal("SaveFailed unexpectedly succeeded with corrupt branch meta")
130 }
131 meta, err := store.LoadMeta(run.Ref)
132 if err != nil {
133 t.Fatalf("LoadMeta: %v", err)
134 }
135 if meta.Status != SubagentFailed {
136 t.Fatalf("persisted status = %q, want %q", meta.Status, SubagentFailed)
137 }
138 }
139
140 func TestSubagentStoreForkCreatesIndependentReference(t *testing.T) {
141 store := NewSubagentStore(t.TempDir())
142 spec := testSubagentSpec(t, "review")
143 run, err := store.PrepareFresh(spec)
144 if err != nil {
145 t.Fatalf("PrepareFresh: %v", err)
146 }
147 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
148 if err := store.SaveCompleted(run); err != nil {
149 t.Fatalf("SaveCompleted: %v", err)
150 }
151 run.Release()
152
153 forked, err := store.prepareFork(run.Ref, spec)
154 if err != nil {
155 t.Fatalf("PrepareFork: %v", err)
156 }
157 defer forked.Release()
158 if forked.Ref == run.Ref {
159 t.Fatalf("fork ref should be new, got %q", forked.Ref)
160 }
161 if got := forked.Session.Snapshot(); len(got) != 2 || got[1].Content != "review diff" {
162 t.Fatalf("fork transcript = %+v, want copied messages", got)
163 }
164 if forked.Meta.ParentSession != spec.ParentSession {
165 t.Fatalf("fork parent session = %q, want %q", forked.Meta.ParentSession, spec.ParentSession)
166 }
167 }
168
169 func TestSubagentStoreRejectsContinueFromSiblingSession(t *testing.T) {
170 sessionDir := t.TempDir()
171 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
172 spec := testSubagentSpec(t, "review")
173 spec.ParentSession = "left"
174 run, err := store.PrepareFresh(spec)
175 if err != nil {
176 t.Fatalf("PrepareFresh: %v", err)
177 }
178 if err := store.SaveCompleted(run); err != nil {
179 t.Fatalf("SaveCompleted: %v", err)
180 }
181 run.Release()
182
183 saveTestBranchMeta(t, sessionDir, "root", "")
184 saveTestBranchMeta(t, sessionDir, "left", "root")
185 saveTestBranchMeta(t, sessionDir, "right", "root")
186 other := spec
187 other.ParentSession = "right"
188 if _, err := store.PrepareContinue(run.Ref, other); err == nil || !strings.Contains(err.Error(), "not in current parent session") {
189 t.Fatalf("PrepareContinue error = %v, want lineage rejection", err)
190 }
191 }
192
193 func TestSubagentStoreContinueFromAncestorCopiesIntoCurrentSession(t *testing.T) {
194 sessionDir := t.TempDir()
195 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
196 spec := testSubagentSpec(t, "review")
197 spec.ParentSession = "root"
198 run, err := store.PrepareFresh(spec)
199 if err != nil {
200 t.Fatalf("PrepareFresh: %v", err)
201 }
202 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
203 if err := store.SaveCompleted(run); err != nil {
204 t.Fatalf("SaveCompleted: %v", err)
205 }
206 run.Release()
207
208 saveTestBranchMeta(t, sessionDir, "root", "")
209 saveTestBranchMeta(t, sessionDir, "child", "root")
210 child := spec
211 child.ParentSession = "child"
212 continued, err := store.PrepareContinue(run.Ref, child)
213 if err != nil {
214 t.Fatalf("PrepareContinue: %v", err)
215 }
216 defer continued.Release()
217 if continued.Ref == run.Ref {
218 t.Fatalf("continued ref should be copied into child session, got source ref %q", continued.Ref)
219 }
220 if continued.Meta.ParentSession != "child" {
221 t.Fatalf("continued parent session = %q, want child", continued.Meta.ParentSession)
222 }
223 if continued.Meta.ForkedFrom != run.Ref {
224 t.Fatalf("forkedFrom = %q, want %q", continued.Meta.ForkedFrom, run.Ref)
225 }
226 if got := continued.Session.Snapshot(); len(got) != 2 || got[1].Content != "review diff" {
227 t.Fatalf("continued transcript = %+v, want copied source transcript", got)
228 }
229 sourceMeta, err := store.LoadMeta(run.Ref)
230 if err != nil {
231 t.Fatalf("LoadMeta source: %v", err)
232 }
233 if sourceMeta.ParentSession != "root" {
234 t.Fatalf("source parent session = %q, want root", sourceMeta.ParentSession)
235 }
236 }
237
238 func TestSubagentStoreLegacyForkFromAncestorConvertsToContinueCopy(t *testing.T) {
239 sessionDir := t.TempDir()
240 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
241 spec := testSubagentSpec(t, "review")
242 spec.ParentSession = "root"
243 run, err := store.PrepareFresh(spec)
244 if err != nil {
245 t.Fatalf("PrepareFresh: %v", err)
246 }
247 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
248 if err := store.SaveCompleted(run); err != nil {
249 t.Fatalf("SaveCompleted: %v", err)
250 }
251 run.Release()
252
253 saveTestBranchMeta(t, sessionDir, "root", "")
254 saveTestBranchMeta(t, sessionDir, "child", "root")
255 child := spec
256 child.ParentSession = "child"
257 continued, err := store.PrepareLegacyForkFrom(run.Ref, child)
258 if err != nil {
259 t.Fatalf("PrepareLegacyForkFrom: %v", err)
260 }
261 defer continued.Release()
262 if continued.Ref == run.Ref {
263 t.Fatalf("legacy fork ref should be copied into child session, got source ref %q", continued.Ref)
264 }
265 if continued.Meta.ParentSession != "child" {
266 t.Fatalf("continued parent session = %q, want child", continued.Meta.ParentSession)
267 }
268 if continued.Meta.ForkedFrom != run.Ref {
269 t.Fatalf("forkedFrom = %q, want %q", continued.Meta.ForkedFrom, run.Ref)
270 }
271 if got := continued.Session.Snapshot(); len(got) != 2 || got[1].Content != "review diff" {
272 t.Fatalf("continued transcript = %+v, want copied source transcript", got)
273 }
274 }
275
276 func TestSubagentStoreRejectsLegacyForkFromCurrentSession(t *testing.T) {
277 store := NewSubagentStore(t.TempDir())
278 spec := testSubagentSpec(t, "review")
279 run, err := store.PrepareFresh(spec)
280 if err != nil {
281 t.Fatalf("PrepareFresh: %v", err)
282 }
283 if err := store.SaveCompleted(run); err != nil {
284 t.Fatalf("SaveCompleted: %v", err)
285 }
286 run.Release()
287
288 if _, err := store.PrepareLegacyForkFrom(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "cannot be safely converted") {
289 t.Fatalf("PrepareLegacyForkFrom error = %v, want unsafe conversion rejection", err)
290 }
291 }
292
293 func TestSubagentStoreContinueFromAncestorReusesCurrentSessionCopy(t *testing.T) {
294 sessionDir := t.TempDir()
295 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
296 spec := testSubagentSpec(t, "review")
297 spec.ParentSession = "root"
298 run, err := store.PrepareFresh(spec)
299 if err != nil {
300 t.Fatalf("PrepareFresh: %v", err)
301 }
302 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
303 if err := store.SaveCompleted(run); err != nil {
304 t.Fatalf("SaveCompleted: %v", err)
305 }
306 run.Release()
307
308 saveTestBranchMeta(t, sessionDir, "root", "")
309 saveTestBranchMeta(t, sessionDir, "child", "root")
310 child := spec
311 child.ParentSession = "child"
312 first, err := store.PrepareContinue(run.Ref, child)
313 if err != nil {
314 t.Fatalf("first PrepareContinue: %v", err)
315 }
316 firstRef := first.Ref
317 if err := store.SaveCompleted(first); err != nil {
318 t.Fatalf("SaveCompleted first: %v", err)
319 }
320 first.Release()
321
322 second, err := store.PrepareContinue(run.Ref, child)
323 if err != nil {
324 t.Fatalf("second PrepareContinue: %v", err)
325 }
326 defer second.Release()
327 if second.Ref != firstRef {
328 t.Fatalf("second continuation ref = %q, want existing child copy %q", second.Ref, firstRef)
329 }
330 }
331
332 func TestSubagentStoreContinueFromOlderAncestorUsesNearestLineageCopy(t *testing.T) {
333 sessionDir := t.TempDir()
334 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
335 spec := testSubagentSpec(t, "review")
336 spec.ParentSession = "root"
337 rootRun, err := store.PrepareFresh(spec)
338 if err != nil {
339 t.Fatalf("PrepareFresh root: %v", err)
340 }
341 rootRun.Session.Add(provider.Message{Role: provider.RoleUser, Content: "root task"})
342 if err := store.SaveCompleted(rootRun); err != nil {
343 t.Fatalf("SaveCompleted root: %v", err)
344 }
345 rootRun.Release()
346
347 saveTestBranchMeta(t, sessionDir, "root", "")
348 saveTestBranchMeta(t, sessionDir, "child", "root")
349 saveTestBranchMeta(t, sessionDir, "grandchild", "child")
350
351 child := spec
352 child.ParentSession = "child"
353 childRun, err := store.PrepareContinue(rootRun.Ref, child)
354 if err != nil {
355 t.Fatalf("PrepareContinue child: %v", err)
356 }
357 childRun.Session.Add(provider.Message{Role: provider.RoleAssistant, Content: "child finding"})
358 childRef := childRun.Ref
359 if err := store.SaveCompleted(childRun); err != nil {
360 t.Fatalf("SaveCompleted child: %v", err)
361 }
362 childRun.Release()
363
364 grandchild := spec
365 grandchild.ParentSession = "grandchild"
366 fromRoot, err := store.PrepareContinue(rootRun.Ref, grandchild)
367 if err != nil {
368 t.Fatalf("PrepareContinue grandchild from root: %v", err)
369 }
370 grandchildRef := fromRoot.Ref
371 if fromRoot.Meta.ForkedFrom != childRef {
372 t.Fatalf("grandchild forkedFrom = %q, want nearest child copy %q", fromRoot.Meta.ForkedFrom, childRef)
373 }
374 if got := fromRoot.Session.Snapshot(); len(got) != 3 || got[2].Content != "child finding" {
375 t.Fatalf("grandchild transcript = %+v, want child copy transcript", got)
376 }
377 if err := store.SaveCompleted(fromRoot); err != nil {
378 t.Fatalf("SaveCompleted grandchild: %v", err)
379 }
380 fromRoot.Release()
381
382 fromChild, err := store.PrepareContinue(childRef, grandchild)
383 if err != nil {
384 t.Fatalf("PrepareContinue grandchild from child: %v", err)
385 }
386 defer fromChild.Release()
387 if fromChild.Ref != grandchildRef {
388 t.Fatalf("grandchild ref from child copy = %q, want existing copy %q", fromChild.Ref, grandchildRef)
389 }
390 }
391
392 func TestSubagentStoreRejectsAncestorContinuationWhenCurrentCopyFailed(t *testing.T) {
393 sessionDir := t.TempDir()
394 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
395 spec := testSubagentSpec(t, "review")
396 spec.ParentSession = "root"
397 run, err := store.PrepareFresh(spec)
398 if err != nil {
399 t.Fatalf("PrepareFresh: %v", err)
400 }
401 if err := store.SaveCompleted(run); err != nil {
402 t.Fatalf("SaveCompleted root: %v", err)
403 }
404 run.Release()
405
406 saveTestBranchMeta(t, sessionDir, "root", "")
407 saveTestBranchMeta(t, sessionDir, "child", "root")
408 child := spec
409 child.ParentSession = "child"
410 copyRun, err := store.PrepareContinue(run.Ref, child)
411 if err != nil {
412 t.Fatalf("PrepareContinue child: %v", err)
413 }
414 if err := store.SaveFailed(copyRun); err != nil {
415 t.Fatalf("SaveFailed child copy: %v", err)
416 }
417 copyRun.Release()
418
419 if _, err := store.PrepareContinue(run.Ref, child); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
420 t.Fatalf("PrepareContinue error = %v, want failed current copy rejection", err)
421 }
422 }
423
424 func TestSubagentStoreRejectsAncestorContinuationWithMultipleCurrentCopies(t *testing.T) {
425 sessionDir := t.TempDir()
426 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
427 spec := testSubagentSpec(t, "review")
428 spec.ParentSession = "root"
429 run, err := store.PrepareFresh(spec)
430 if err != nil {
431 t.Fatalf("PrepareFresh: %v", err)
432 }
433 if err := store.SaveCompleted(run); err != nil {
434 t.Fatalf("SaveCompleted root: %v", err)
435 }
436 run.Release()
437
438 saveTestBranchMeta(t, sessionDir, "root", "")
439 saveTestBranchMeta(t, sessionDir, "child", "root")
440 child := spec
441 child.ParentSession = "child"
442 first, err := store.PrepareContinue(run.Ref, child)
443 if err != nil {
444 t.Fatalf("PrepareContinue first: %v", err)
445 }
446 if err := store.SaveCompleted(first); err != nil {
447 t.Fatalf("SaveCompleted first: %v", err)
448 }
449 first.Release()
450
451 second, err := store.PrepareFresh(child)
452 if err != nil {
453 t.Fatalf("PrepareFresh second: %v", err)
454 }
455 second.Meta.ForkedFrom = run.Ref
456 if err := store.SaveCompleted(second); err != nil {
457 t.Fatalf("SaveCompleted second: %v", err)
458 }
459 second.Release()
460
461 if _, err := store.PrepareContinue(run.Ref, child); err == nil || !strings.Contains(err.Error(), "multiple copied transcripts") {
462 t.Fatalf("PrepareContinue error = %v, want multiple-copy rejection", err)
463 }
464 }
465
466 func TestSubagentStoreForkFromAncestorSessionCreatesCurrentOwner(t *testing.T) {
467 sessionDir := t.TempDir()
468 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
469 spec := testSubagentSpec(t, "review")
470 spec.ParentSession = "root"
471 run, err := store.PrepareFresh(spec)
472 if err != nil {
473 t.Fatalf("PrepareFresh: %v", err)
474 }
475 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
476 if err := store.SaveCompleted(run); err != nil {
477 t.Fatalf("SaveCompleted: %v", err)
478 }
479 run.Release()
480
481 saveTestBranchMeta(t, sessionDir, "root", "")
482 saveTestBranchMeta(t, sessionDir, "child", "root")
483 other := spec
484 other.ParentSession = "child"
485 forked, err := store.prepareFork(run.Ref, other)
486 if err != nil {
487 t.Fatalf("PrepareFork: %v", err)
488 }
489 defer forked.Release()
490 if forked.Ref == run.Ref {
491 t.Fatalf("fork ref should be new, got %q", forked.Ref)
492 }
493 if forked.Meta.ParentSession != "child" {
494 t.Fatalf("fork parent session = %q, want child", forked.Meta.ParentSession)
495 }
496 sourceMeta, err := store.LoadMeta(run.Ref)
497 if err != nil {
498 t.Fatalf("LoadMeta source: %v", err)
499 }
500 if sourceMeta.ParentSession != spec.ParentSession {
501 t.Fatalf("source parent session = %q, want %q", sourceMeta.ParentSession, spec.ParentSession)
502 }
503 }
504
505 func TestSubagentStoreRejectsForkWhenSourceOwnerMetaMissing(t *testing.T) {
506 sessionDir, store, ref, spec := prepareCompletedSubagentForLineageTest(t, "root")
507 saveTestBranchMeta(t, sessionDir, "child", "root")
508
509 other := spec
510 other.ParentSession = "child"
511 if _, err := store.prepareFork(ref, other); err == nil || !strings.Contains(err.Error(), "lineage could not be verified") {
512 t.Fatalf("PrepareFork error = %v, want unverified lineage rejection", err)
513 }
514 }
515
516 func TestSubagentStoreRejectsForkWhenSourceOwnerMetaCorrupt(t *testing.T) {
517 sessionDir, store, ref, spec := prepareCompletedSubagentForLineageTest(t, "root")
518 saveTestBranchMeta(t, sessionDir, "child", "root")
519 if err := os.WriteFile(filepath.Join(sessionDir, "root.jsonl.meta"), []byte("{"), 0o644); err != nil {
520 t.Fatalf("write corrupt branch meta: %v", err)
521 }
522
523 other := spec
524 other.ParentSession = "child"
525 if _, err := store.prepareFork(ref, other); err == nil || !strings.Contains(err.Error(), "lineage could not be verified") {
526 t.Fatalf("PrepareFork error = %v, want unverified lineage rejection", err)
527 }
528 }
529
530 func TestSubagentStoreRejectsForkWhenSourceOwnerMetaIDDiffers(t *testing.T) {
531 sessionDir, store, ref, spec := prepareCompletedSubagentForLineageTest(t, "root")
532 saveTestBranchMeta(t, sessionDir, "child", "root")
533 if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{ID: "other-root"}); err != nil {
534 t.Fatalf("SaveBranchMeta(root): %v", err)
535 }
536
537 other := spec
538 other.ParentSession = "child"
539 if _, err := store.prepareFork(ref, other); err == nil || !strings.Contains(err.Error(), "lineage could not be verified") {
540 t.Fatalf("PrepareFork error = %v, want unverified lineage rejection", err)
541 }
542 }
543
544 func TestSubagentStoreRejectsForkFromSiblingSession(t *testing.T) {
545 sessionDir := t.TempDir()
546 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
547 spec := testSubagentSpec(t, "review")
548 spec.ParentSession = "left"
549 run, err := store.PrepareFresh(spec)
550 if err != nil {
551 t.Fatalf("PrepareFresh: %v", err)
552 }
553 if err := store.SaveCompleted(run); err != nil {
554 t.Fatalf("SaveCompleted: %v", err)
555 }
556 run.Release()
557
558 saveTestBranchMeta(t, sessionDir, "root", "")
559 saveTestBranchMeta(t, sessionDir, "left", "root")
560 saveTestBranchMeta(t, sessionDir, "right", "root")
561 other := spec
562 other.ParentSession = "right"
563 if _, err := store.prepareFork(run.Ref, other); err == nil || !strings.Contains(err.Error(), "not in current parent session") {
564 t.Fatalf("PrepareFork error = %v, want lineage rejection", err)
565 }
566 }
567
568 func TestSubagentStoreRejectsForkFromUnrelatedSession(t *testing.T) {
569 sessionDir := t.TempDir()
570 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
571 spec := testSubagentSpec(t, "review")
572 spec.ParentSession = "source"
573 run, err := store.PrepareFresh(spec)
574 if err != nil {
575 t.Fatalf("PrepareFresh: %v", err)
576 }
577 if err := store.SaveCompleted(run); err != nil {
578 t.Fatalf("SaveCompleted: %v", err)
579 }
580 run.Release()
581
582 saveTestBranchMeta(t, sessionDir, "root", "")
583 saveTestBranchMeta(t, sessionDir, "current", "root")
584 other := spec
585 other.ParentSession = "current"
586 if _, err := store.prepareFork(run.Ref, other); err == nil || !strings.Contains(err.Error(), "not in current parent session") {
587 t.Fatalf("PrepareFork error = %v, want unrelated session rejection", err)
588 }
589 }
590
591 func TestSubagentStoreRejectsForkWhenLineageCannotBeProven(t *testing.T) {
592 sessionDir := t.TempDir()
593 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
594 spec := testSubagentSpec(t, "review")
595 spec.ParentSession = "root"
596 run, err := store.PrepareFresh(spec)
597 if err != nil {
598 t.Fatalf("PrepareFresh: %v", err)
599 }
600 if err := store.SaveCompleted(run); err != nil {
601 t.Fatalf("SaveCompleted: %v", err)
602 }
603 run.Release()
604
605 other := spec
606 other.ParentSession = "child"
607 if _, err := store.prepareFork(run.Ref, other); err == nil || !strings.Contains(err.Error(), "lineage could not be verified") {
608 t.Fatalf("PrepareFork error = %v, want unverified lineage rejection", err)
609 }
610 }
611
612 func TestSubagentStoreForkReleasesSourceLockAfterCopy(t *testing.T) {
613 store := NewSubagentStore(t.TempDir())
614 spec := testSubagentSpec(t, "review")
615 run, err := store.PrepareFresh(spec)
616 if err != nil {
617 t.Fatalf("PrepareFresh: %v", err)
618 }
619 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"})
620 if err := store.SaveCompleted(run); err != nil {
621 t.Fatalf("SaveCompleted: %v", err)
622 }
623 run.Release()
624
625 forked, err := store.prepareFork(run.Ref, spec)
626 if err != nil {
627 t.Fatalf("PrepareFork: %v", err)
628 }
629 defer forked.Release()
630 continued, err := store.PrepareContinue(run.Ref, spec)
631 if err != nil {
632 t.Fatalf("source should not stay locked by fork run: %v", err)
633 }
634 continued.Release()
635 }
636
637 func TestSubagentStoreRejectsIncompatibleTranscript(t *testing.T) {
638 store := NewSubagentStore(t.TempDir())
639 spec := testSubagentSpec(t, "review")
640 run, err := store.PrepareFresh(spec)
641 if err != nil {
642 t.Fatalf("PrepareFresh: %v", err)
643 }
644 if err := store.SaveCompleted(run); err != nil {
645 t.Fatalf("SaveCompleted: %v", err)
646 }
647 run.Release()
648
649 other := spec
650 other.Name = "security-review"
651 if _, err := store.PrepareContinue(run.Ref, other); err == nil || !strings.Contains(err.Error(), "name") {
652 t.Fatalf("PrepareContinue error = %v, want incompatible name", err)
653 }
654 }
655
656 func TestSubagentStoreRejectsConcurrentContinue(t *testing.T) {
657 store := NewSubagentStore(t.TempDir())
658 spec := testSubagentSpec(t, "review")
659 run, err := store.PrepareFresh(spec)
660 if err != nil {
661 t.Fatalf("PrepareFresh: %v", err)
662 }
663 if err := store.SaveCompleted(run); err != nil {
664 t.Fatalf("SaveCompleted: %v", err)
665 }
666 run.Release()
667
668 first, err := store.PrepareContinue(run.Ref, spec)
669 if err != nil {
670 t.Fatalf("first PrepareContinue: %v", err)
671 }
672 defer first.Release()
673 if _, err := store.PrepareContinue(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "already running") {
674 t.Fatalf("second PrepareContinue error = %v, want lock error", err)
675 }
676 }
677
678 func TestSubagentStoreSaveFailedPersistsTranscriptAndRejectsReuse(t *testing.T) {
679 store := NewSubagentStore(t.TempDir())
680 spec := testSubagentSpec(t, "review")
681 run, err := store.PrepareFresh(spec)
682 if err != nil {
683 t.Fatalf("PrepareFresh: %v", err)
684 }
685 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "failed continuation"})
686 if err := store.SaveFailed(run); err != nil {
687 t.Fatalf("SaveFailed: %v", err)
688 }
689 run.Release()
690
691 loaded, err := LoadSession(store.sessionPath(run.Ref))
692 if err != nil {
693 t.Fatalf("LoadSession: %v", err)
694 }
695 if got := loaded.Snapshot(); len(got) != 2 || got[1].Content != "failed continuation" {
696 t.Fatalf("failed transcript = %+v, want persisted failed prompt", got)
697 }
698 meta, err := store.LoadMeta(run.Ref)
699 if err != nil {
700 t.Fatalf("LoadMeta: %v", err)
701 }
702 if meta.Status != SubagentFailed {
703 t.Fatalf("status = %q, want failed", meta.Status)
704 }
705 if _, err := store.PrepareContinue(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
706 t.Fatalf("PrepareContinue error = %v, want failed ref rejection", err)
707 }
708 if _, err := store.prepareFork(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
709 t.Fatalf("PrepareFork error = %v, want failed ref rejection", err)
710 }
711 }
712
713 func TestSubagentStoreCleanupStaleRunningMarksInterrupted(t *testing.T) {
714 store := NewSubagentStore(t.TempDir())
715 spec := testSubagentSpec(t, "review")
716 run, err := store.PrepareFresh(spec)
717 if err != nil {
718 t.Fatalf("PrepareFresh: %v", err)
719 }
720 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "interrupted prompt"})
721 if err := store.MarkRunning(run); err != nil {
722 t.Fatalf("MarkRunning: %v", err)
723 }
724 ref := run.Ref
725 run.Release()
726
727 cleaned, err := store.CleanupStaleRunning()
728 if err != nil {
729 t.Fatalf("CleanupStaleRunning: %v", err)
730 }
731 if cleaned != 1 {
732 t.Fatalf("cleaned = %d, want 1", cleaned)
733 }
734 meta, err := store.LoadMeta(ref)
735 if err != nil {
736 t.Fatalf("LoadMeta: %v", err)
737 }
738 if meta.Status != SubagentInterrupted {
739 t.Fatalf("status = %q, want interrupted", meta.Status)
740 }
741 if _, err := store.PrepareContinue(ref, spec); err == nil || !strings.Contains(err.Error(), "interrupted by a previous shutdown or crash") {
742 t.Fatalf("PrepareContinue error = %v, want interrupted rejection", err)
743 }
744 if _, err := store.prepareFork(ref, spec); err == nil || !strings.Contains(err.Error(), "cannot be continued or forked") {
745 t.Fatalf("PrepareFork error = %v, want interrupted fork rejection", err)
746 }
747 }
748
749 func TestSubagentStoreCleanupStaleRunningSkipsMissingParentProof(t *testing.T) {
750 store := NewSubagentStore(t.TempDir())
751 spec := testSubagentSpec(t, "review")
752 run, err := store.PrepareFresh(spec)
753 if err != nil {
754 t.Fatalf("PrepareFresh: %v", err)
755 }
756 if err := store.MarkRunning(run); err != nil {
757 t.Fatalf("MarkRunning: %v", err)
758 }
759 ref := run.Ref
760 run.Release()
761 meta, err := store.LoadMeta(ref)
762 if err != nil {
763 t.Fatalf("LoadMeta: %v", err)
764 }
765 meta.ParentSession = ""
766 if err := store.saveMeta(meta); err != nil {
767 t.Fatalf("saveMeta without parent: %v", err)
768 }
769
770 cleaned, err := store.CleanupStaleRunning()
771 if err != nil {
772 t.Fatalf("CleanupStaleRunning: %v", err)
773 }
774 if cleaned != 0 {
775 t.Fatalf("cleaned = %d without parent proof, want 0", cleaned)
776 }
777 meta, err = store.LoadMeta(ref)
778 if err != nil {
779 t.Fatalf("LoadMeta after cleanup: %v", err)
780 }
781 if meta.Status != SubagentRunning {
782 t.Fatalf("status = %q without parent proof, want running", meta.Status)
783 }
784 }
785
786 func TestSubagentStoreCleanupStaleRunningSkipsCorruptMeta(t *testing.T) {
787 store := NewSubagentStore(t.TempDir())
788 spec := testSubagentSpec(t, "review")
789 run, err := store.PrepareFresh(spec)
790 if err != nil {
791 t.Fatalf("PrepareFresh: %v", err)
792 }
793 if err := store.MarkRunning(run); err != nil {
794 t.Fatalf("MarkRunning: %v", err)
795 }
796 ref := run.Ref
797 run.Release()
798
799 // Corrupt metadata files (truncated JSON, empty, and invalid custom field
800 // values) must be skipped, not abort the whole startup cleanup.
801 for i, corrupt := range []string{
802 `{"status":"running"`,
803 "",
804 `{"createdAt":"not-a-time"}`,
805 } {
806 corruptRef := fmt.Sprintf("sa_corrupt_%d", i)
807 if err := os.WriteFile(filepath.Join(store.dir, corruptRef+".meta.json"), []byte(corrupt), 0o600); err != nil {
808 t.Fatalf("write corrupt meta %d: %v", i, err)
809 }
810 }
811
812 cleaned, err := store.CleanupStaleRunning()
813 if err != nil {
814 t.Fatalf("CleanupStaleRunning should skip corrupt meta: %v", err)
815 }
816 if cleaned != 1 {
817 t.Fatalf("cleaned = %d, want 1 (corrupt metas skipped, running meta interrupted)", cleaned)
818 }
819 meta, err := store.LoadMeta(ref)
820 if err != nil {
821 t.Fatalf("LoadMeta: %v", err)
822 }
823 if meta.Status != SubagentInterrupted {
824 t.Fatalf("status = %q, want interrupted", meta.Status)
825 }
826 }
827
828 func TestSubagentStoreCleanupStaleRunningKeepsParentLeaseAfterCorruptReread(t *testing.T) {
829 sessionDir := t.TempDir()
830 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
831 spec := testSubagentSpec(t, "review")
832 spec.ParentSession = "lease-parent"
833
834 refs := make([]string, 0, 2)
835 for range 2 {
836 run, err := store.PrepareFresh(spec)
837 if err != nil {
838 t.Fatalf("PrepareFresh: %v", err)
839 }
840 if err := store.MarkRunning(run); err != nil {
841 t.Fatalf("MarkRunning: %v", err)
842 }
843 refs = append(refs, run.Ref)
844 run.Release()
845 }
846 sort.Strings(refs)
847
848 var probeErr error
849 store.cleanupBeforeReread = func(parentSession, ref string) {
850 switch ref {
851 case refs[0]:
852 if err := os.WriteFile(store.metaPath(ref), []byte(`{"createdAt":"not-a-time"}`), 0o600); err != nil {
853 t.Fatalf("corrupt first metadata reread: %v", err)
854 }
855 case refs[1]:
856 probe, err := TryAcquireSessionLease(filepath.Join(sessionDir, parentSession+".jsonl"))
857 probeErr = err
858 if probe != nil {
859 probe.Release()
860 }
861 }
862 }
863
864 cleaned, err := store.CleanupStaleRunning()
865 if err != nil {
866 t.Fatalf("CleanupStaleRunning: %v", err)
867 }
868 if !errors.Is(probeErr, ErrSessionLeaseHeld) {
869 t.Fatalf("parent lease probe before second reread = %v, want ErrSessionLeaseHeld", probeErr)
870 }
871 if cleaned != 1 {
872 t.Fatalf("cleaned = %d, want 1", cleaned)
873 }
874 meta, err := store.LoadMeta(refs[1])
875 if err != nil {
876 t.Fatalf("LoadMeta second ref: %v", err)
877 }
878 if meta.Status != SubagentInterrupted {
879 t.Fatalf("second ref status = %q, want interrupted", meta.Status)
880 }
881 }
882
883 func TestSubagentStoreCleanupStaleRunningSkipsForeignLiveParent(t *testing.T) {
884 sessionDir := t.TempDir()
885 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
886 spec := testSubagentSpec(t, "review")
887 spec.ParentSession = "live-parent"
888 run, err := store.PrepareFresh(spec)
889 if err != nil {
890 t.Fatalf("PrepareFresh: %v", err)
891 }
892 if err := store.MarkRunning(run); err != nil {
893 t.Fatalf("MarkRunning: %v", err)
894 }
895 ref := run.Ref
896 run.Release()
897
898 parentPath := filepath.Join(sessionDir, spec.ParentSession+".jsonl")
899 cmd := exec.Command(os.Args[0], "-test.run=^TestSubagentStoreForeignLeaseHelper$")
900 cmd.Env = append(os.Environ(),
901 "REASONIX_SUBAGENT_LEASE_HELPER=1",
902 "REASONIX_SUBAGENT_LEASE_PATH="+parentPath,
903 )
904 stdin, err := cmd.StdinPipe()
905 if err != nil {
906 t.Fatalf("StdinPipe: %v", err)
907 }
908 stdout, err := cmd.StdoutPipe()
909 if err != nil {
910 t.Fatalf("StdoutPipe: %v", err)
911 }
912 cmd.Stderr = os.Stderr
913 if err := cmd.Start(); err != nil {
914 t.Fatalf("start lease holder: %v", err)
915 }
916 if line, err := bufio.NewReader(stdout).ReadString('\n'); err != nil || line != "ready\n" {
917 _ = stdin.Close()
918 _ = cmd.Wait()
919 t.Fatalf("lease holder readiness = %q, err = %v", line, err)
920 }
921
922 cleaned, err := store.CleanupStaleRunning()
923 if err != nil {
924 _ = stdin.Close()
925 _ = cmd.Wait()
926 t.Fatalf("CleanupStaleRunning with foreign holder: %v", err)
927 }
928 if cleaned != 0 {
929 _ = stdin.Close()
930 _ = cmd.Wait()
931 t.Fatalf("cleaned = %d while foreign parent lease was live, want 0", cleaned)
932 }
933 meta, err := store.LoadMeta(ref)
934 if err != nil {
935 _ = stdin.Close()
936 _ = cmd.Wait()
937 t.Fatalf("LoadMeta with foreign holder: %v", err)
938 }
939 if meta.Status != SubagentRunning {
940 _ = stdin.Close()
941 _ = cmd.Wait()
942 t.Fatalf("status = %q while foreign parent lease was live, want running", meta.Status)
943 }
944
945 if err := stdin.Close(); err != nil {
946 t.Fatalf("release lease holder stdin: %v", err)
947 }
948 if err := cmd.Wait(); err != nil {
949 t.Fatalf("lease holder exit: %v", err)
950 }
951 cleaned, err = store.CleanupStaleRunning()
952 if err != nil {
953 t.Fatalf("CleanupStaleRunning after foreign release: %v", err)
954 }
955 if cleaned != 1 {
956 t.Fatalf("cleaned = %d after foreign release, want 1", cleaned)
957 }
958 meta, err = store.LoadMeta(ref)
959 if err != nil {
960 t.Fatalf("LoadMeta after foreign release: %v", err)
961 }
962 if meta.Status != SubagentInterrupted {
963 t.Fatalf("status = %q after foreign release, want interrupted", meta.Status)
964 }
965 }
966
967 func TestSubagentStoreForeignLeaseHelper(t *testing.T) {
968 if os.Getenv("REASONIX_SUBAGENT_LEASE_HELPER") != "1" {
969 return
970 }
971 lease, err := TryAcquireSessionLease(os.Getenv("REASONIX_SUBAGENT_LEASE_PATH"))
972 if err != nil {
973 t.Fatalf("TryAcquireSessionLease: %v", err)
974 }
975 if _, err := os.Stdout.WriteString("ready\n"); err != nil {
976 lease.Release()
977 t.Fatalf("write readiness: %v", err)
978 }
979 var release [1]byte
980 _, _ = os.Stdin.Read(release[:])
981 lease.Release()
982 }
983
984 func TestSubagentStoreSkipsSaveForDestroyedParent(t *testing.T) {
985 store := NewSubagentStore(t.TempDir()).WithDestroyedChecker(func(parentSession string) bool {
986 return parentSession == "parent-session"
987 })
988 spec := testSubagentSpec(t, "review")
989 run, err := store.PrepareFresh(spec)
990 if err != nil {
991 t.Fatalf("PrepareFresh: %v", err)
992 }
993 run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "answer after destroy"})
994 if err := store.SaveCompleted(run); err != nil {
995 t.Fatalf("SaveCompleted: %v", err)
996 }
997 if _, err := os.Stat(store.sessionPath(run.Ref)); !os.IsNotExist(err) {
998 t.Fatalf("destroyed parent should not save session, stat err = %v", err)
999 }
1000 if _, err := os.Stat(store.metaPath(run.Ref)); !os.IsNotExist(err) {
1001 t.Fatalf("destroyed parent should not save meta, stat err = %v", err)
1002 }
1003 if err := store.SaveFailed(run); err != nil {
1004 t.Fatalf("SaveFailed: %v", err)
1005 }
1006 if _, err := os.Stat(store.sessionPath(run.Ref)); !os.IsNotExist(err) {
1007 t.Fatalf("destroyed parent should still not save session, stat err = %v", err)
1008 }
1009 run.Release()
1010 }
1011
1012 func testSubagentSpec(t *testing.T, name string) SubagentSpec {
1013 t.Helper()
1014 reg := tool.NewRegistry()
1015 reg.Add(fakeTool{name: "read_file", readOnly: true})
1016 return SubagentSpec{
1017 Kind: "skill",
1018 Name: name,
1019 WorkspaceRoot: t.TempDir(),
1020 ParentSession: "parent-session",
1021 SystemPrompt: "review persona",
1022 Registry: reg,
1023 Model: "deepseek",
1024 Effort: "max",
1025 }
1026 }
1027
1028 func saveTestBranchMeta(t *testing.T, sessionDir, id, parent string) {
1029 t.Helper()
1030 if err := SaveBranchMeta(filepath.Join(sessionDir, id+".jsonl"), BranchMeta{ParentID: parent}); err != nil {
1031 t.Fatalf("SaveBranchMeta(%s): %v", id, err)
1032 }
1033 }
1034
1035 func prepareCompletedSubagentForLineageTest(t *testing.T, parentSession string) (string, *SubagentStore, string, SubagentSpec) {
1036 t.Helper()
1037 sessionDir := t.TempDir()
1038 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
1039 spec := testSubagentSpec(t, "review")
1040 spec.ParentSession = parentSession
1041 run, err := store.PrepareFresh(spec)
1042 if err != nil {
1043 t.Fatalf("PrepareFresh: %v", err)
1044 }
1045 if err := store.SaveCompleted(run); err != nil {
1046 t.Fatalf("SaveCompleted: %v", err)
1047 }
1048 run.Release()
1049 return sessionDir, store, run.Ref, spec
1050 }
1051
1051 lines GO