1package subagents
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "sync/atomic"
14 "testing"
15 "time"
16
17 "github.com/bnema/zut/packages/core"
18 "github.com/bnema/zut/packages/provider"
19)
20
21func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
22 run := make(chan string, 1)
23 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
24 return ResidentTurnRunner(func(_ context.Context, prompt string) error { run <- prompt; return nil }), nil
25 })
26 defer manager.Close(context.Background())
27
28 child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "child-manager", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "inspect this")
29 if err != nil {
30 t.Fatalf("Spawn: %v", err)
31 }
32 if child == nil {
33 t.Fatal("Spawn returned nil child")
34 }
35 select {
36 case got := <-run:
37 if got != "inspect this" {
38 t.Fatalf("runner prompt = %q", got)
39 }
40 case <-time.After(time.Second):
41 t.Fatal("resident runner did not execute accepted task")
42 }
43 deadline := time.Now().Add(time.Second)
44 for {
45 result, readErr := manager.Result("child-manager")
46 if readErr == nil {
47 if result.State != ResidentIdle || result.TurnID == "" {
48 t.Fatalf("result = %#v", result)
49 }
50 break
51 }
52 if time.Now().After(deadline) {
53 t.Fatalf("result projection was not written: %v", readErr)
54 }
55 time.Sleep(time.Millisecond)
56 }
57}
58
59func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
60 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
61 return ResidentTurnRunner(func(context.Context, string) error {
62 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
63 }), nil
64 })
65 t.Cleanup(func() { _ = manager.Close(context.Background()) })
66 spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
67 completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
68 defer cancelWait()
69 if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil {
70 t.Fatal(err)
71 }
72 result := <-completion
73 if result.Err != nil {
74 t.Fatalf("initial completion error = %v", result.Err)
75 }
76 snapshot, ok := manager.SnapshotFor(spec.ID)
77 if !ok || snapshot.State != ResidentIdle {
78 t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)
79 }
80 if err := manager.Resume(context.Background(), spec.ID, "continue"); err != nil {
81 t.Fatalf("resume error = %v", err)
82 }
83}
84
85// resumeWithTurn lets a caller subscribe to the exact follow-up turn before it
86// can complete.
87func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
88 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
89 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
90 })
91 t.Cleanup(func() { _ = manager.Close(context.Background()) })
92 initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn")
93 defer cancelInitial()
94 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "turn-child", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
95 t.Fatal(err)
96 }
97 select {
98 case <-initial:
99 case <-time.After(time.Second):
100 t.Fatal("initial turn did not complete")
101 }
102 followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn")
103 defer cancelFollowUp()
104 if err := manager.resumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {
105 t.Fatalf("resumeWithTurn: %v", err)
106 }
107 select {
108 case completion := <-followUp:
109 if completion.TurnID != "follow-up-turn" {
110 t.Fatalf("completion turn ID = %q", completion.TurnID)
111 }
112 if completion.Err != nil {
113 t.Fatalf("completion error = %v", completion.Err)
114 }
115 case <-time.After(time.Second):
116 t.Fatal("no completion for the caller-owned turn")
117 }
118}
119
120// A blank or whitespace caller turn ID falls back to a generated unique ID, so
121// the completion is still addressable and the journal stays replayable.
122func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
123 completions := make(chan ResidentCompletion, 4)
124 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
125 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
126 })
127 t.Cleanup(func() { _ = manager.Close(context.Background()) })
128 initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn")
129 defer cancelInitial()
130 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "blank-turn-child", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
131 t.Fatal(err)
132 }
133 select {
134 case <-initial:
135 case <-time.After(5 * time.Second):
136 t.Fatal("initial turn did not complete")
137 }
138 manager.SetCompletionObserver(func(completion ResidentCompletion) bool { completions <- completion; return true })
139 if err := manager.resumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {
140 t.Fatalf("resumeWithTurn: %v", err)
141 }
142 select {
143 case completion := <-completions:
144 if completion.TurnID == "" || strings.TrimSpace(completion.TurnID) == "" {
145 t.Fatalf("completion turn ID = %q, want a generated non-blank ID", completion.TurnID)
146 }
147 case <-time.After(5 * time.Second):
148 t.Fatal("no completion for the blank caller turn ID")
149 }
150}
151
152func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
153 completed := make(chan ResidentCompletion, 1)
154 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
155 return ResidentTurnRunner(func(context.Context, string) error {
156 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}})
157 }), nil
158 })
159 manager.SetCompletionObserver(func(completion ResidentCompletion) bool { completed <- completion; return true })
160 t.Cleanup(func() { _ = manager.Close(context.Background()) })
161 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "summary-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "task"); err != nil {
162 t.Fatal(err)
163 }
164 select {
165 case completion := <-completed:
166 if completion.TurnID == "" {
167 t.Fatal("completion turn ID is empty")
168 }
169 if completion.Summary != "the actual child answer" {
170 t.Fatalf("completion summary = %q", completion.Summary)
171 }
172 case <-time.After(time.Second):
173 t.Fatal("resident child did not complete")
174 }
175}
176
177func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testing.T) {
178 started := make(chan struct{})
179 release := make(chan struct{})
180 completed := make(chan ResidentCompletion, 2)
181 var journal *ResidentJournal
182 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentRuntime, error) {
183 journal = childJournal
184 return ResidentTurnRunner(func(context.Context, string) error {
185 close(started)
186 <-release
187 return nil
188 }), nil
189 })
190 manager.SetCompletionObserver(func(completion ResidentCompletion) bool { completed <- completion; return true })
191 t.Cleanup(func() { _ = manager.Close(context.Background()) })
192
193 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "persistence-failure-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "initial task"); err != nil {
194 t.Fatal(err)
195 }
196 select {
197 case <-started:
198 case <-time.After(time.Second):
199 t.Fatal("resident child did not start")
200 }
201 if err := manager.Resume(context.Background(), "persistence-failure-child", "queued follow-up"); err != nil {
202 t.Fatalf("Resume: %v", err)
203 }
204 if err := journal.Close(); err != nil {
205 t.Fatalf("Close journal: %v", err)
206 }
207 close(release)
208
209 seen := make(map[string]ResidentCompletion, 2)
210 for range 2 {
211 select {
212 case completion := <-completed:
213 seen[completion.Task] = completion
214 case <-time.After(time.Second):
215 t.Fatalf("terminal completions = %#v, want initial and queued turns", seen)
216 }
217 }
218 for _, task := range []string{"initial task", "queued follow-up"} {
219 completion, ok := seen[task]
220 if !ok || completion.TurnID == "" || completion.Err == nil || !strings.Contains(completion.Err.Error(), "persist resident child terminal state") {
221 t.Fatalf("completion for %q = %#v", task, completion)
222 }
223 }
224}
225
226func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
227 started := make(chan struct{})
228 completed := make(chan ResidentCompletion, 1)
229 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
230 return ResidentTurnRunner(func(ctx context.Context, _ string) error {
231 close(started)
232 <-ctx.Done()
233 return ctx.Err()
234 }), nil
235 })
236 manager.SetCompletionObserver(func(completion ResidentCompletion) bool { completed <- completion; return true })
237 t.Cleanup(func() { _ = manager.Close(context.Background()) })
238
239 child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "interrupted-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "task")
240 if err != nil {
241 t.Fatal(err)
242 }
243 select {
244 case <-started:
245 case <-time.After(time.Second):
246 t.Fatal("resident child did not start")
247 }
248 if err := child.Close(context.Background()); err != nil {
249 t.Fatalf("Close: %v", err)
250 }
251 select {
252 case completion := <-completed:
253 if completion.ChildID != "interrupted-child" || completion.TurnID == "" || !errors.Is(completion.Err, context.Canceled) {
254 t.Fatalf("completion = %#v", completion)
255 }
256 case <-time.After(time.Second):
257 t.Fatal("interrupted turn did not report completion")
258 }
259}
260
261func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) {
262 started := make(chan struct{})
263 release := make(chan struct{})
264 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
265 return ResidentTurnRunner(func(context.Context, string) error {
266 close(started)
267 <-release
268 return nil
269 }), nil
270 })
271 defer manager.Close(context.Background())
272 defer close(release)
273 _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "live-manager", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "task")
274 if err != nil {
275 t.Fatal(err)
276 }
277 select {
278 case <-started:
279 case <-time.After(time.Second):
280 t.Fatal("resident child did not start")
281 }
282 live, ok := manager.Live("live-manager")
283 if !ok || live.TurnID == "" || live.State != ResidentRunning {
284 t.Fatalf("Live = (%#v, %t)", live, ok)
285 }
286 if _, ok := manager.Live("missing"); ok {
287 t.Fatal("Live returned missing child")
288 }
289}
290
291func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T) {
292 started := make(chan struct{})
293 publishedDelta := make(chan struct{})
294 persist := make(chan struct{})
295 release := make(chan struct{})
296 historyUpdates := make(chan string, 1)
297 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
298 return ResidentTurnRunner(func(context.Context, string) error {
299 close(started)
300 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil {
301 return err
302 }
303 close(publishedDelta)
304 <-persist
305 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "finalized"}}}}); err != nil {
306 return err
307 }
308 <-release
309 return nil
310 }), nil
311 })
312 manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID })
313 t.Cleanup(func() {
314 close(release)
315 if err := manager.Close(context.Background()); err != nil {
316 t.Error(err)
317 }
318 })
319 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "history-observer", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "task"); err != nil {
320 t.Fatal(err)
321 }
322 <-started
323 <-publishedDelta
324 select {
325 case got := <-historyUpdates:
326 t.Fatalf("transient stream event requested history reload for %q", got)
327 default:
328 }
329 close(persist)
330 select {
331 case got := <-historyUpdates:
332 if got != "history-observer" {
333 t.Fatalf("history update child = %q", got)
334 }
335 case <-time.After(time.Second):
336 t.Fatal("finalized event did not request history reload")
337 }
338}
339
340func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *testing.T) {
341 started := make(chan struct{})
342 release := make(chan struct{})
343 activity := make(chan bool, 2)
344 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
345 return ResidentTurnRunner(func(context.Context, string) error {
346 close(started)
347 <-release
348 return nil
349 }), nil
350 })
351 manager.SetActivityObserver(func(active bool) { activity <- active })
352 t.Cleanup(func() {
353 if err := manager.Close(context.Background()); err != nil {
354 t.Error(err)
355 }
356 })
357 select {
358 case got := <-activity:
359 if got {
360 t.Fatal("new manager reported active resident")
361 }
362 default:
363 t.Fatal("activity observer did not receive its initial state")
364 }
365 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "activity-observer", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "task"); err != nil {
366 t.Fatal(err)
367 }
368 select {
369 case got := <-activity:
370 if !got {
371 t.Fatal("spawn marked resident activity inactive")
372 }
373 case <-time.After(time.Second):
374 t.Fatal("spawn did not report resident activity")
375 }
376 <-started
377 close(release)
378 select {
379 case got := <-activity:
380 if got {
381 t.Fatal("completed resident remained active")
382 }
383 case <-time.After(time.Second):
384 t.Fatal("completion did not clear resident activity")
385 }
386}
387
388func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) {
389 root := t.TempDir()
390 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
391 return nil, errors.New("synthetic factory failure")
392 })
393 spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
394 if _, err := manager.Spawn(context.Background(), spec, "task"); err == nil {
395 t.Fatal("Spawn succeeded")
396 }
397 metadata, err := ReadResidentMetadata(filepath.Join(root, spec.ID, residentMetadataName))
398 if err != nil {
399 t.Fatal(err)
400 }
401 if metadata.State != ResidentFailed {
402 t.Fatalf("state = %q, want failed", metadata.State)
403 }
404}
405
406func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) {
407 root := t.TempDir()
408 legacyChild := filepath.Join(root, "agents", "old-child")
409 if err := os.MkdirAll(legacyChild, 0o700); err != nil {
410 t.Fatal(err)
411 }
412 if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil {
413 t.Fatal(err)
414 }
415 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
416 return nil, nil
417 })
418 if errs := manager.Reconcile(); len(errs) != 0 {
419 t.Fatalf("Reconcile errors = %v", errs)
420 }
421 if snapshots := manager.Snapshot(); len(snapshots) != 0 {
422 t.Fatalf("Reconcile recovered legacy child: %#v", snapshots)
423 }
424}
425
426func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) {
427 runs := make(chan string, 2)
428 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
429 return ResidentTurnRunner(func(ctx context.Context, prompt string) error {
430 runs <- prompt
431 if prompt == "initial" {
432 <-ctx.Done()
433 return ctx.Err()
434 }
435 return nil
436 }), nil
437 })
438 defer manager.Close(context.Background())
439 spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true}
440 if _, err := manager.Spawn(context.Background(), spec, "initial"); err != nil {
441 t.Fatal(err)
442 }
443 if got := <-runs; got != "initial" {
444 t.Fatalf("initial prompt = %q", got)
445 }
446 if err := manager.Stop(context.Background(), spec.ID); err != nil {
447 t.Fatal(err)
448 }
449 if err := manager.Resume(context.Background(), spec.ID, "follow up"); err != nil {
450 t.Fatalf("Resume stopped child: %v", err)
451 }
452 if got := <-runs; got != "follow up" {
453 t.Fatalf("follow-up prompt = %q", got)
454 }
455 deadline := time.Now().Add(time.Second)
456 for len(manager.UnmetRequired()) != 0 {
457 if time.Now().After(deadline) {
458 t.Fatalf("required child remained unmet: %#v", manager.UnmetRequired())
459 }
460 time.Sleep(time.Millisecond)
461 }
462}
463
464func TestResidentManagerPreparesWorkspaceBeforeAcceptance(t *testing.T) {
465 root := t.TempDir()
466 prepared := false
467 manager := NewResidentManagerWithWorkspace(root, 0, func(_ context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
468 prepared = true
469 if req.AgentID != "workspace-child" || req.StateDir != filepath.Join(root, "workspace-child") {
470 t.Fatalf("workspace request = %#v", req)
471 }
472 return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req)
473 }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
474 if !prepared || spec.Workspace != "/repo" {
475 t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared)
476 }
477 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
478 })
479 defer manager.Close(context.Background())
480 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil {
481 t.Fatal(err)
482 }
483}
484
485func TestResidentManagerRejectsWorkspaceOutsideAllowedRootsBeforeAcceptance(t *testing.T) {
486 prepared := false
487 manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
488 prepared = true
489 return nil, nil
490 }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
491 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
492 })
493 outside := t.TempDir()
494 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil {
495 t.Fatal("Spawn succeeded outside allowed roots")
496 }
497 if prepared {
498 t.Fatal("workspace preparation ran outside allowed roots")
499 }
500}
501
502func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
503 root := t.TempDir()
504 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}}
505 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
506 return workspace, nil
507 }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
508 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
509 })
510 defer manager.Close(context.Background())
511 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
512 t.Fatal(err)
513 }
514 deadline := time.Now().Add(time.Second)
515 for manager.Snapshot()[0].State != ResidentIdle {
516 if time.Now().After(deadline) {
517 t.Fatal("turn did not finish")
518 }
519 time.Sleep(time.Millisecond)
520 }
521 if workspace.Cleaned() {
522 t.Fatal("idle child worktree was removed before explicit close")
523 }
524 result, err := manager.Result("capture-child")
525 if err != nil {
526 t.Fatal(err)
527 }
528 if result.PatchRef != PatchRef("capture-child") || len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "changed.go" {
529 t.Fatalf("result = %#v", result)
530 }
531 patch, err := os.ReadFile(filepath.Join(root, "capture-child", residentPatchName))
532 if err != nil || string(patch) != "patch" {
533 t.Fatalf("patch = %q, err=%v", patch, err)
534 }
535 if err := manager.Stop(context.Background(), "capture-child"); err != nil {
536 t.Fatal(err)
537 }
538 if !workspace.Cleaned() {
539 t.Fatal("successful worktree was not cleaned on stop")
540 }
541}
542
543func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
544 root := t.TempDir()
545 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}}
546 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
547 return workspace, nil
548 }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
549 return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil
550 })
551 defer manager.Close(context.Background())
552 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
553 t.Fatal(err)
554 }
555 deadline := time.Now().Add(time.Second)
556 for manager.Snapshot()[0].State == ResidentRunning || manager.Snapshot()[0].State == ResidentQueued {
557 if time.Now().After(deadline) {
558 t.Fatal("turn did not finish")
559 }
560 time.Sleep(time.Millisecond)
561 }
562 // A non-canceled failure still captures partial work, but the turn
563 // stays failed: capture never converts failure to success.
564 if !workspace.Captured() {
565 t.Fatal("failed worktree was not captured")
566 }
567 snapshot := manager.Snapshot()[0]
568 if snapshot.State != ResidentFailed {
569 t.Fatalf("snapshot state = %q, want failed", snapshot.State)
570 }
571 result, err := manager.Result("failed-capture")
572 if err != nil {
573 t.Fatal(err)
574 }
575 if result.PatchRef != PatchRef("failed-capture") || len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "changed.go" {
576 t.Fatalf("result = %#v", result)
577 }
578 if result.Handoff != "" {
579 t.Fatalf("new failure result carries handoff %q, want empty", result.Handoff)
580 }
581}
582
583func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
584 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}
585 manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
586 return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil
587 })
588 defer manager.Close(context.Background())
589 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
590 t.Fatal(err)
591 }
592 deadline := time.Now().Add(time.Second)
593 for manager.Snapshot()[0].State == ResidentRunning || manager.Snapshot()[0].State == ResidentQueued {
594 if time.Now().After(deadline) {
595 t.Fatal("turn did not finish")
596 }
597 time.Sleep(time.Millisecond)
598 }
599 // Non-canceled failures capture partial work for recovery, but the
600 // worktree is retained (never cleaned) until an explicit stop or close.
601 if !workspace.Captured() || workspace.Cleaned() {
602 t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.Captured(), workspace.Cleaned())
603 }
604}
605
606func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
607 if _, err := exec.LookPath("git"); err != nil {
608 t.Skip("git is required")
609 }
610 root, repo := t.TempDir(), initTestRepo(t)
611 manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
612 return ResidentTurnRunner(func(context.Context, string) error {
613 return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600)
614 }), nil
615 })
616 defer manager.Close(context.Background())
617 child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task")
618 if err != nil {
619 t.Fatal(err)
620 }
621 // Creating and diffing a real Git worktree can exceed a second on Windows,
622 // especially under filesystem scanning. Keep a bounded wait while allowing
623 // the actual asynchronous work to complete.
624 deadline := time.Now().Add(5 * time.Second)
625 for child.State() != ResidentIdle {
626 if time.Now().After(deadline) {
627 t.Fatal("worktree turn did not finish")
628 }
629 time.Sleep(time.Millisecond)
630 }
631 result, err := manager.Result("git-worktree")
632 if err != nil {
633 t.Fatal(err)
634 }
635 if result.PatchRef != PatchRef("git-worktree") || len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "README.md" {
636 t.Fatalf("result = %#v", result)
637 }
638 patch, err := os.ReadFile(filepath.Join(root, "git-worktree", residentPatchName))
639 if err != nil || !strings.Contains(string(patch), "resident change") {
640 t.Fatalf("patch = %q, err=%v", patch, err)
641 }
642 if err := manager.Stop(context.Background(), "git-worktree"); err != nil {
643 t.Fatal(err)
644 }
645 if _, err := os.Stat(child.workspace.Dir()); !os.IsNotExist(err) {
646 t.Fatalf("worktree remains after stop: %v", err)
647 }
648}
649
650// testResidentWorkspace flags are atomic because Capture/Cleanup run on
651// the resident child's goroutine while tests read them after polling
652// manager state (no happens-before edge the race detector can see).
653type testResidentWorkspace struct {
654 dir string
655 mode WorkspaceMode
656 capture WorkspaceCapture
657
658 captured atomic.Bool
659 cleaned atomic.Bool
660}
661
662func (w *testResidentWorkspace) Captured() bool { return w.captured.Load() }
663func (w *testResidentWorkspace) Cleaned() bool { return w.cleaned.Load() }
664
665func (w *testResidentWorkspace) Dir() string { return w.dir }
666func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" }
667func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode }
668func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) {
669 w.captured.Store(true)
670 return w.capture, nil
671}
672func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store(true); return nil }
673
674func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {
675 root := t.TempDir()
676 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
677 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
678 })
679 defer manager.Close(context.Background())
680 spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
681 if _, err := manager.Spawn(context.Background(), spec, "first"); err != nil {
682 t.Fatal(err)
683 }
684 if _, err := manager.Spawn(context.Background(), spec, "second"); err == nil {
685 t.Fatal("duplicate Spawn succeeded")
686 }
687 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
688 if err != nil {
689 t.Fatal(err)
690 }
691 accepted := 0
692 for _, record := range records {
693 if record.Type == residentRecordAccepted {
694 accepted++
695 if record.Prompt != "first" {
696 t.Fatalf("accepted prompt = %q, want first", record.Prompt)
697 }
698 }
699 }
700 if accepted != 1 {
701 t.Fatalf("records = %#v, want one accepted spawn", records)
702 }
703}
704
705func TestResidentManagerReconcileSkipsIncompatibleJournal(t *testing.T) {
706 root := t.TempDir()
707 journal, err := OpenResidentJournal(root, "legacy-version")
708 if err != nil {
709 t.Fatal(err)
710 }
711 spec := ResidentChildSpec{ID: "legacy-version", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
712 if err := journal.Accept(spec, "review"); err != nil {
713 t.Fatal(err)
714 }
715 if err := journal.Close(); err != nil {
716 t.Fatal(err)
717 }
718 transcript := filepath.Join(root, spec.ID, residentTranscriptName)
719 data, err := os.ReadFile(transcript)
720 if err != nil {
721 t.Fatal(err)
722 }
723 legacy := strings.Replace(string(data), `"version":3`, `"version":1`, 1)
724 if legacy == string(data) {
725 t.Fatal("accepted record has no journal version")
726 }
727 if err := os.WriteFile(transcript, []byte(legacy), 0o600); err != nil {
728 t.Fatal(err)
729 }
730 manager := NewResidentManager(root, nil)
731 t.Cleanup(func() { _ = manager.Close(context.Background()) })
732 for range 2 {
733 if errs := manager.Reconcile(); len(errs) != 0 {
734 t.Fatalf("Reconcile errors = %v", errs)
735 }
736 }
737 if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentJournal) {
738 t.Fatalf("explicit reconciliation error = %v, want incompatible version", err)
739 }
740 if snapshots := manager.Snapshot(); len(snapshots) != 0 {
741 t.Fatalf("snapshots = %#v", snapshots)
742 }
743 unchanged, err := os.ReadFile(transcript)
744 if err != nil {
745 t.Fatal(err)
746 }
747 if string(unchanged) != legacy {
748 t.Fatal("Reconcile modified incompatible journal")
749 }
750
751 valid, err := OpenResidentJournal(root, "valid")
752 if err != nil {
753 t.Fatal(err)
754 }
755 spec.ID = "valid"
756 if err := valid.Accept(spec, "keep discoverable"); err != nil {
757 t.Fatal(err)
758 }
759 if err := valid.Close(); err != nil {
760 t.Fatal(err)
761 }
762 corrupt, err := OpenResidentJournal(root, "corrupt")
763 if err != nil {
764 t.Fatal(err)
765 }
766 if err := corrupt.Close(); err != nil {
767 t.Fatal(err)
768 }
769 for range 2 {
770 errs := manager.Reconcile()
771 if len(errs) != 1 || !strings.Contains(errs[0].Error(), "resident child corrupt:") {
772 t.Fatalf("Reconcile errors = %v, want corrupt child error only", errs)
773 }
774 if snapshots := manager.Snapshot(); len(snapshots) != 1 || snapshots[0].ID != "valid" {
775 t.Fatalf("snapshots = %#v, want valid sibling only", snapshots)
776 }
777 }
778}
779
780func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) {
781 root := t.TempDir()
782 journal, err := OpenResidentJournal(root, "recovered")
783 if err != nil {
784 t.Fatal(err)
785 }
786 spec := ResidentChildSpec{ID: "recovered", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", Required: true}
787 if err := journal.Accept(spec, "do not replay"); err != nil {
788 t.Fatal(err)
789 }
790 if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
791 t.Fatal(err)
792 }
793 if err := journal.Close(); err != nil {
794 t.Fatal(err)
795 }
796 runs := 0
797 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
798 runs++
799 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
800 })
801 if errs := manager.Reconcile(); len(errs) != 0 {
802 t.Fatalf("Reconcile errors = %v", errs)
803 }
804 if runs != 0 {
805 t.Fatalf("factory ran %d times during reconciliation", runs)
806 }
807 snapshots := manager.Snapshot()
808 if len(snapshots) != 1 || snapshots[0].State != ResidentInterrupted || !snapshots[0].Required {
809 t.Fatalf("snapshots = %#v", snapshots)
810 }
811 if _, err := manager.Spawn(context.Background(), spec, "overwrite"); err == nil {
812 t.Fatal("Spawn overwrote recovered journal")
813 }
814}
815
816func TestResidentManagerReconcileDoesNotMutateForeignProcessJournal(t *testing.T) {
817 if os.Getenv("ZUT_RESIDENT_MANAGER_HELPER") == "1" {
818 root := os.Getenv("ZUT_RESIDENT_MANAGER_ROOT")
819 journal, err := OpenResidentJournal(root, "foreign-process")
820 if err != nil {
821 t.Fatal(err)
822 }
823 spec := ResidentChildSpec{ID: "foreign-process", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
824 if err := journal.Accept(spec, "active task"); err != nil {
825 t.Fatal(err)
826 }
827 if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
828 t.Fatal(err)
829 }
830 if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordToolCall, Time: time.Now().UTC(), ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
831 t.Fatal(err)
832 }
833 fmt.Fprintln(os.Stdout, "ready")
834 if _, err := bufio.NewReader(os.Stdin).ReadString('\n'); err != nil {
835 t.Fatal(err)
836 }
837 if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordToolResult, Time: time.Now().UTC(), ToolID: "call-1", ToolResult: json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)}); err != nil {
838 t.Fatal(err)
839 }
840 if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
841 t.Fatal(err)
842 }
843 if err := journal.Close(); err != nil {
844 t.Fatal(err)
845 }
846 return
847 }
848
849 root := t.TempDir()
850 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
851 defer cancel()
852 cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestResidentManagerReconcileDoesNotMutateForeignProcessJournal$")
853 defer func() {
854 if cmd.Process != nil {
855 _ = cmd.Process.Kill()
856 }
857 _ = cmd.Wait()
858 }()
859 cmd.Env = append(os.Environ(), "ZUT_RESIDENT_MANAGER_HELPER=1", "ZUT_RESIDENT_MANAGER_ROOT="+root)
860 stdout, err := cmd.StdoutPipe()
861 if err != nil {
862 t.Fatal(err)
863 }
864 stdin, err := cmd.StdinPipe()
865 if err != nil {
866 t.Fatal(err)
867 }
868 if err := cmd.Start(); err != nil {
869 t.Fatal(err)
870 }
871 ready, err := bufio.NewReader(stdout).ReadString('\n')
872 if err != nil {
873 t.Fatal(err)
874 }
875 if ready != "ready\n" {
876 t.Fatalf("helper readiness = %q", ready)
877 }
878 transcript := filepath.Join(root, "foreign-process", residentTranscriptName)
879 before, err := os.ReadFile(transcript)
880 if err != nil {
881 t.Fatal(err)
882 }
883
884 observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
885 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
886 })
887 if errs := observer.Reconcile(); len(errs) != 0 {
888 t.Fatalf("Reconcile errors = %v", errs)
889 }
890 after, err := os.ReadFile(transcript)
891 if err != nil {
892 t.Fatal(err)
893 }
894 if string(after) != string(before) {
895 t.Fatal("foreign reconciliation modified the transcript")
896 }
897 snapshot, ok := observer.SnapshotFor("foreign-process")
898 if !ok || !snapshot.OwnedElsewhere || snapshot.State != ResidentRunning {
899 t.Fatalf("foreign snapshot = %#v, found=%v", snapshot, ok)
900 }
901 if err := observer.Resume(context.Background(), "foreign-process", "follow up"); err == nil || !strings.Contains(err.Error(), "owned by another") {
902 t.Fatalf("foreign Resume error = %v", err)
903 }
904 if _, err := observer.History("foreign-process", 1); err == nil || !strings.Contains(err.Error(), "owned by another") {
905 t.Fatalf("foreign History error = %v", err)
906 }
907 if _, err := observer.HistoryPage("foreign-process", "", 1); err == nil || !strings.Contains(err.Error(), "owned by another") {
908 t.Fatalf("foreign HistoryPage error = %v", err)
909 }
910 if _, err := observer.Result("foreign-process"); err == nil || !strings.Contains(err.Error(), "owned by another") {
911 t.Fatalf("foreign Result error = %v", err)
912 }
913 if _, err := fmt.Fprintln(stdin); err != nil {
914 t.Fatal(err)
915 }
916 if err := stdin.Close(); err != nil {
917 t.Fatal(err)
918 }
919 if err := cmd.Wait(); err != nil {
920 t.Fatal(err)
921 }
922 if err := observer.Resume(context.Background(), "foreign-process", "follow up"); err != nil {
923 t.Fatalf("Resume after owner exit: %v", err)
924 }
925 if err := observer.Stop(context.Background(), "foreign-process"); err != nil {
926 t.Fatalf("Stop rebuilt child: %v", err)
927 }
928}
929
930func TestResidentManagerExplicitlyResumesRecoveredChildWithoutReplayingTask(t *testing.T) {
931 root := t.TempDir()
932 journal, err := OpenResidentJournal(root, "resume-recovered")
933 if err != nil {
934 t.Fatal(err)
935 }
936 spec := ResidentChildSpec{ID: "resume-recovered", SessionID: "child-session", InitialTurnID: "old-turn", Provider: "openai", Model: "gpt-5"}
937 if err := journal.Accept(spec, "old task must not replay"); err != nil {
938 t.Fatal(err)
939 }
940 if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
941 t.Fatal(err)
942 }
943 if err := journal.Close(); err != nil {
944 t.Fatal(err)
945 }
946 runs := make(chan string, 1)
947 manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
948 if got.SessionID != spec.SessionID {
949 t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID)
950 }
951 return ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil
952 })
953 defer manager.Close(context.Background())
954 if errs := manager.Reconcile(); len(errs) != 0 {
955 t.Fatalf("Reconcile = %v", errs)
956 }
957 if err := manager.Resume(context.Background(), spec.ID, "new explicit prompt"); err != nil {
958 t.Fatal(err)
959 }
960 select {
961 case got := <-runs:
962 if got != "new explicit prompt" {
963 t.Fatalf("runner prompt = %q", got)
964 }
965 case <-time.After(time.Second):
966 t.Fatal("explicit resume did not run")
967 }
968}
969
970func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) {
971 root := t.TempDir()
972 journal, err := OpenResidentJournal(root, "resume-worktree")
973 if err != nil {
974 t.Fatal(err)
975 }
976 spec := ResidentChildSpec{ID: "resume-worktree", SessionID: "child-session", InitialTurnID: "old-turn", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", Workspace: "/prior-worktree", WorkspaceMode: WorkspaceWorktree}
977 if err := journal.Accept(spec, "old task"); err != nil {
978 t.Fatal(err)
979 }
980 if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
981 t.Fatal(err)
982 }
983 if err := journal.Close(); err != nil {
984 t.Fatal(err)
985 }
986 workspace := &testResidentWorkspace{dir: "/prior-worktree", mode: WorkspaceWorktree}
987 manager := NewResidentManagerWithWorkspace(root, 0, func(_ context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
988 if req.ExistingPath != "/prior-worktree" {
989 t.Fatalf("resume workspace request = %#v", req)
990 }
991 return workspace, nil
992 }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
993 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
994 })
995 defer manager.Close(context.Background())
996 if errs := manager.Reconcile(); len(errs) != 0 {
997 t.Fatal(errs)
998 }
999 if err := manager.Resume(context.Background(), spec.ID, "resume"); err != nil {
1000 t.Fatal(err)
1001 }
1002}
1003
1004func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
1005 started := make(chan string, 2)
1006 release := make(chan struct{})
1007 manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
1008 return ResidentTurnRunner(func(_ context.Context, _ string) error {
1009 started <- spec.ID
1010 if spec.ID == "first" {
1011 <-release
1012 }
1013 return nil
1014 }), nil
1015 })
1016 defer manager.Close(context.Background())
1017 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil {
1018 t.Fatalf("Spawn(first): %v", err)
1019 }
1020 select {
1021 case got := <-started:
1022 if got != "first" {
1023 t.Fatalf("first runner = %q, want first", got)
1024 }
1025 case <-time.After(time.Second):
1026 t.Fatal("first runner did not start")
1027 }
1028 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "second", SessionID: "second", Provider: "openai", Model: "gpt-5"}, "second"); err != nil {
1029 t.Fatalf("Spawn(second): %v", err)
1030 }
1031 deadline := time.Now().Add(time.Second)
1032 for {
1033 result, err := manager.Result("second")
1034 if err == nil && result.State == ResidentFailed {
1035 break
1036 }
1037 if time.Now().After(deadline) {
1038 t.Fatalf("queued child did not record timeout: result=%#v err=%v", result, err)
1039 }
1040 time.Sleep(time.Millisecond)
1041 }
1042 select {
1043 case got := <-started:
1044 t.Fatalf("timed out child ran as %q", got)
1045 default:
1046 }
1047 close(release)
1048}
1049
1050func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) {
1051 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
1052 return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
1053 })
1054 defer manager.Close(context.Background())
1055 accepted := make(chan string, 2)
1056 manager.SetAcceptedObserver(func(_ ResidentChildSpec, _ string, prompt string) { accepted <- prompt })
1057 spec := ResidentChildSpec{ID: "observer", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1058 if _, err := manager.Spawn(context.Background(), spec, "initial"); err != nil {
1059 t.Fatal(err)
1060 }
1061 if got := <-accepted; got != "initial" {
1062 t.Fatalf("initial observer prompt = %q", got)
1063 }
1064 if err := manager.Resume(context.Background(), spec.ID, "follow up"); err != nil {
1065 t.Fatal(err)
1066 }
1067 if got := <-accepted; got != "follow up" {
1068 t.Fatalf("follow-up observer prompt = %q", got)
1069 }
1070}
1071
1072func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) {
1073 root := t.TempDir()
1074 journal, err := OpenResidentJournal(root, "stale")
1075 if err != nil {
1076 t.Fatal(err)
1077 }
1078 spec := ResidentChildSpec{ID: "stale", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
1079 if err := journal.Accept(spec, "task"); err != nil {
1080 t.Fatal(err)
1081 }
1082 if err := journal.Close(); err != nil {
1083 t.Fatal(err)
1084 }
1085 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return nil, nil })
1086 if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 {
1087 t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot())
1088 }
1089 if err := os.RemoveAll(filepath.Join(root, "stale")); err != nil {
1090 t.Fatal(err)
1091 }
1092 if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 0 {
1093 t.Fatalf("second Reconcile = %v, snapshots=%#v", errs, manager.Snapshot())
1094 }
1095}
1096
1097func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) {
1098 started := make(chan string, DefaultResidentConcurrency+1)
1099 release := make(chan struct{})
1100 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
1101 return ResidentTurnRunner(func(ctx context.Context, prompt string) error {
1102 started <- prompt
1103 select {
1104 case <-release:
1105 return nil
1106 case <-ctx.Done():
1107 return ctx.Err()
1108 }
1109 }), nil
1110 })
1111 defer manager.Close(context.Background())
1112
1113 for i := 0; i < DefaultResidentConcurrency+1; i++ {
1114 spec := ResidentChildSpec{ID: fmt.Sprintf("child-%d", i), SessionID: fmt.Sprintf("session-%d", i), Provider: "openai", Model: "gpt-5"}
1115 if _, err := manager.Spawn(context.Background(), spec, spec.ID); err != nil {
1116 t.Fatalf("Spawn(%d): %v", i, err)
1117 }
1118 }
1119 for i := 0; i < DefaultResidentConcurrency; i++ {
1120 select {
1121 case <-started:
1122 case <-time.After(time.Second):
1123 t.Fatal("did not fill the default six slots")
1124 }
1125 }
1126 select {
1127 case extra := <-started:
1128 t.Fatalf("seventh turn started before a slot released: %q", extra)
1129 default:
1130 }
1131 close(release)
1132 select {
1133 case <-started:
1134 case <-time.After(time.Second):
1135 t.Fatal("queued seventh turn did not start after a slot released")
1136 }
1137}