detached 642e3ef

fix(subagents): report queued persistence failures

commit
642e3ef8b4412e429a9315e4375149d65c5dd54a
author
brice · · 2026-08-14 06:55 UTC
parent
31b43ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +57 -0
M packages/agent/subagents/resident_child.go +8 -0
@@ -349,6 +349,9 @@ func (c *ResidentChild) run() {
349349 c.cancel()
350350 if c.onCompletion != nil {
351351 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: active.turnID, Task: active.prompt, Err: terminalErr})
352+ for _, pending := range queue {
353+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr})
354+ }
352355 }
353356 return
354357 }
@@ -438,6 +441,11 @@ func (c *ResidentChild) run() {
438441 }
439442 }
440443 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary})
444+ if persistenceFailed {
445+ for _, pending := range queue {
446+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr})
447+ }
448+ }
441449 }
442450 if persistenceFailed {
443451 return
@@ -349,6 +349,9 @@ func (c *ResidentChild) run() {
349 c.cancel()349 c.cancel()
350 if c.onCompletion != nil {350 if c.onCompletion != nil {
351 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: active.turnID, Task: active.prompt, Err: terminalErr})351 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: active.turnID, Task: active.prompt, Err: terminalErr})
352+ for _, pending := range queue {
353+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr})
354+ }
352 }355 }
353 return356 return
354 }357 }
@@ -438,6 +441,11 @@ func (c *ResidentChild) run() {
438 }441 }
439 }442 }
440 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary})443 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary})
444+ if persistenceFailed {
445+ for _, pending := range queue {
446+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr})
447+ }
448+ }
441 }449 }
442 if persistenceFailed {450 if persistenceFailed {
443 return451 return
M packages/agent/subagents/resident_manager_test.go +49 -0
@@ -78,6 +78,55 @@ func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
7878 }
7979 }
8080
81+func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testing.T) {
82+ started := make(chan struct{})
83+ release := make(chan struct{})
84+ completed := make(chan ResidentCompletion, 2)
85+ var journal *ResidentJournal
86+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentTurnRunner, error) {
87+ journal = childJournal
88+ return func(context.Context, string) error {
89+ close(started)
90+ <-release
91+ return nil
92+ }, nil
93+ })
94+ manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
95+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
96+
97+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "persistence-failure-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "initial task"); err != nil {
98+ t.Fatal(err)
99+ }
100+ select {
101+ case <-started:
102+ case <-time.After(time.Second):
103+ t.Fatal("resident child did not start")
104+ }
105+ if err := manager.Resume(context.Background(), "persistence-failure-child", "queued follow-up"); err != nil {
106+ t.Fatalf("Resume: %v", err)
107+ }
108+ if err := journal.Close(); err != nil {
109+ t.Fatalf("Close journal: %v", err)
110+ }
111+ close(release)
112+
113+ seen := make(map[string]ResidentCompletion, 2)
114+ for range 2 {
115+ select {
116+ case completion := <-completed:
117+ seen[completion.Task] = completion
118+ case <-time.After(time.Second):
119+ t.Fatalf("terminal completions = %#v, want initial and queued turns", seen)
120+ }
121+ }
122+ for _, task := range []string{"initial task", "queued follow-up"} {
123+ completion, ok := seen[task]
124+ if !ok || completion.TurnID == "" || completion.Err == nil || !strings.Contains(completion.Err.Error(), "persist resident child terminal state") {
125+ t.Fatalf("completion for %q = %#v", task, completion)
126+ }
127+ }
128+}
129+
81130 func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
82131 started := make(chan struct{})
83132 completed := make(chan ResidentCompletion, 1)
@@ -78,6 +78,55 @@ func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testing.T) {
82+ started := make(chan struct{})
83+ release := make(chan struct{})
84+ completed := make(chan ResidentCompletion, 2)
85+ var journal *ResidentJournal
86+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentTurnRunner, error) {
87+ journal = childJournal
88+ return func(context.Context, string) error {
89+ close(started)
90+ <-release
91+ return nil
92+ }, nil
93+ })
94+ manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
95+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
96+
97+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "persistence-failure-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "initial task"); err != nil {
98+ t.Fatal(err)
99+ }
100+ select {
101+ case <-started:
102+ case <-time.After(time.Second):
103+ t.Fatal("resident child did not start")
104+ }
105+ if err := manager.Resume(context.Background(), "persistence-failure-child", "queued follow-up"); err != nil {
106+ t.Fatalf("Resume: %v", err)
107+ }
108+ if err := journal.Close(); err != nil {
109+ t.Fatalf("Close journal: %v", err)
110+ }
111+ close(release)
112+
113+ seen := make(map[string]ResidentCompletion, 2)
114+ for range 2 {
115+ select {
116+ case completion := <-completed:
117+ seen[completion.Task] = completion
118+ case <-time.After(time.Second):
119+ t.Fatalf("terminal completions = %#v, want initial and queued turns", seen)
120+ }
121+ }
122+ for _, task := range []string{"initial task", "queued follow-up"} {
123+ completion, ok := seen[task]
124+ if !ok || completion.TurnID == "" || completion.Err == nil || !strings.Contains(completion.Err.Error(), "persist resident child terminal state") {
125+ t.Fatalf("completion for %q = %#v", task, completion)
126+ }
127+ }
128+}
129+
81 func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {130 func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
82 started := make(chan struct{})131 started := make(chan struct{})
83 completed := make(chan ResidentCompletion, 1)132 completed := make(chan ResidentCompletion, 1)

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