v0.51.0 a9d674e

resident_manager_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close