detached 77dae25

fix(subagents): report queued spawn waits

commit
77dae25f7a42512310b954e5b38a34f7c46dec9b
author
brice · · 2026-08-18 14:18 UTC
parent
073fe61
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +124 -12
M docs/subagents.md +10 -6
@@ -102,15 +102,19 @@ The model-facing tools retain their logical names:
102102 `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
103103 `worktree`). `wait` is an explicit whole-second value from 1 through 300;
104104 when omitted, spawning returns immediately. A timed-out wait leaves the
105- child running. It returns a logical `subagent://<id>` reference.
106-- `subagent_status` returns bounded state for one child or the current set.
105+ accepted child active, whether it is queued or running. Do not retry that
106+ task until it reaches a terminal failure, cancellation, or interruption. It
107+ returns a logical `subagent://<id>` reference.
108+- `subagent_status` returns bounded current state for one child or the current
109+ set immediately.
107110 - `subagent_stop` stops one live child.
108111 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
109112
110-These calls are asynchronous unless `subagent_spawn` receives an explicit
111-`wait` value. Otherwise completion arrives through the host’s typed completion
112-update, not process polling. Do not use sleep loops, repeated status calls,
113-journal files, or terminal UI inspection as a completion signal.
113+Child execution started by `subagent_spawn` is asynchronous unless it receives
114+an explicit `wait` value. For an unwaited spawn, completion arrives through the
115+host’s typed completion update; `subagent_status` only returns the current
116+state immediately. Do not use sleep loops, repeated status calls, journal
117+files, or terminal UI inspection as a completion signal.
114118 Successful completions include the final visible assistant summary, capped at
115119 256 KiB; open the child session for the complete durable transcript.
116120
@@ -102,15 +102,19 @@ The model-facing tools retain their logical names:
102 `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or102 `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
103 `worktree`). `wait` is an explicit whole-second value from 1 through 300;103 `worktree`). `wait` is an explicit whole-second value from 1 through 300;
104 when omitted, spawning returns immediately. A timed-out wait leaves the104 when omitted, spawning returns immediately. A timed-out wait leaves the
105- child running. It returns a logical `subagent://<id>` reference.105+ accepted child active, whether it is queued or running. Do not retry that
106-- `subagent_status` returns bounded state for one child or the current set.106+ task until it reaches a terminal failure, cancellation, or interruption. It
107+ returns a logical `subagent://<id>` reference.
108+- `subagent_status` returns bounded current state for one child or the current
109+ set immediately.
107 - `subagent_stop` stops one live child.110 - `subagent_stop` stops one live child.
108 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.111 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
109 112
110-These calls are asynchronous unless `subagent_spawn` receives an explicit113+Child execution started by `subagent_spawn` is asynchronous unless it receives
111-`wait` value. Otherwise completion arrives through the host’s typed completion114+an explicit `wait` value. For an unwaited spawn, completion arrives through the
112-update, not process polling. Do not use sleep loops, repeated status calls,115+host’s typed completion update; `subagent_status` only returns the current
113-journal files, or terminal UI inspection as a completion signal.116+state immediately. Do not use sleep loops, repeated status calls, journal
117+files, or terminal UI inspection as a completion signal.
114 Successful completions include the final visible assistant summary, capped at118 Successful completions include the final visible assistant summary, capped at
115 256 KiB; open the child session for the complete durable transcript.119 256 KiB; open the child session for the complete durable transcript.
116 120
M packages/agent/settings_store.go +3 -3
@@ -258,15 +258,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro
258258
259259 Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.
260260
261-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
261+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, canceled, or interrupted required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
262262
263-Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update.
263+Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. An expired wait leaves the accepted child active, whether queued or running; do not retry it until terminal failure, cancellation, or interruption. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update.
264264
265265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
266266
267267 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
268268 // available without enabling proactive primary-agent orchestration.
269-const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; a timed-out wait leaves the child running. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
269+const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; an expired wait leaves the accepted child active, whether queued or running. Do not retry it until terminal failure, cancellation, or interruption. Failed, canceled, or interrupted required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
270270
271271 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
272272 // orchestrator contract still applies when launch-time policy withholds the
@@ -258,15 +258,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro
258 258
259 Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.259 Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.
260 260
261-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.261+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, canceled, or interrupted required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
262 262
263-Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update.263+Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. An expired wait leaves the accepted child active, whether queued or running; do not retry it until terminal failure, cancellation, or interruption. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update.
264 264
265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
266 266
267 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools267 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
268 // available without enabling proactive primary-agent orchestration.268 // available without enabling proactive primary-agent orchestration.
269-const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; a timed-out wait leaves the child running. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`269+const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; an expired wait leaves the accepted child active, whether queued or running. Do not retry it until terminal failure, cancellation, or interruption. Failed, canceled, or interrupted required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
270 270
271 // AutoSubagentsDelegationUnavailableAddendum explains why the strict271 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
272 // orchestrator contract still applies when launch-time policy withholds the272 // orchestrator contract still applies when launch-time policy withholds the
M packages/agent/subagents/resident_manager.go +15 -0
@@ -582,6 +582,21 @@ func (m *ResidentManager) Get(childID string) *ResidentChild {
582582 return m.children[childID]
583583 }
584584
585+// State reports a live child's current execution state. A child with an
586+// accepted turn awaiting global scheduler admission is queued, even though its
587+// control loop has started preparing that turn.
588+func (m *ResidentManager) State(childID string) (ResidentState, bool) {
589+ child := m.Get(childID)
590+ if child == nil {
591+ return "", false
592+ }
593+ state := child.State()
594+ if state == ResidentRunning && m.scheduler.Pending(childID) {
595+ return ResidentQueued, true
596+ }
597+ return state, true
598+}
599+
585600 // Live returns an immutable unfinished-turn snapshot for one live child. It
586601 // never holds the manager lock while copying the child projection.
587602 func (m *ResidentManager) Live(childID string) (ResidentLiveSnapshot, bool) {
@@ -582,6 +582,21 @@ func (m *ResidentManager) Get(childID string) *ResidentChild {
582 return m.children[childID]582 return m.children[childID]
583 }583 }
584 584
585+// State reports a live child's current execution state. A child with an
586+// accepted turn awaiting global scheduler admission is queued, even though its
587+// control loop has started preparing that turn.
588+func (m *ResidentManager) State(childID string) (ResidentState, bool) {
589+ child := m.Get(childID)
590+ if child == nil {
591+ return "", false
592+ }
593+ state := child.State()
594+ if state == ResidentRunning && m.scheduler.Pending(childID) {
595+ return ResidentQueued, true
596+ }
597+ return state, true
598+}
599+
585 // Live returns an immutable unfinished-turn snapshot for one live child. It600 // Live returns an immutable unfinished-turn snapshot for one live child. It
586 // never holds the manager lock while copying the child projection.601 // never holds the manager lock while copying the child projection.
587 func (m *ResidentManager) Live(childID string) (ResidentLiveSnapshot, bool) {602 func (m *ResidentManager) Live(childID string) (ResidentLiveSnapshot, bool) {
M packages/agent/subagents/resident_scheduler.go +16 -0
@@ -64,6 +64,22 @@ func (s *ResidentScheduler) Admit() (ResidentTicket, bool) {
6464 return ResidentTicket{}, false
6565 }
6666
67+// Pending reports whether a child's accepted turn is waiting for scheduler
68+// admission rather than running provider work.
69+func (s *ResidentScheduler) Pending(childID string) bool {
70+ if s == nil {
71+ return false
72+ }
73+ s.mu.Lock()
74+ defer s.mu.Unlock()
75+ for _, ticket := range s.pending {
76+ if ticket.ChildID == childID {
77+ return true
78+ }
79+ }
80+ return false
81+}
82+
6783 // Cancel removes an admitted-waiting ticket. Once admitted, the caller owns
6884 // the active reservation and must Release it instead.
6985 func (s *ResidentScheduler) Cancel(sequence uint64) bool {
@@ -64,6 +64,22 @@ func (s *ResidentScheduler) Admit() (ResidentTicket, bool) {
64 return ResidentTicket{}, false64 return ResidentTicket{}, false
65 }65 }
66 66
67+// Pending reports whether a child's accepted turn is waiting for scheduler
68+// admission rather than running provider work.
69+func (s *ResidentScheduler) Pending(childID string) bool {
70+ if s == nil {
71+ return false
72+ }
73+ s.mu.Lock()
74+ defer s.mu.Unlock()
75+ for _, ticket := range s.pending {
76+ if ticket.ChildID == childID {
77+ return true
78+ }
79+ }
80+ return false
81+}
82+
67 // Cancel removes an admitted-waiting ticket. Once admitted, the caller owns83 // Cancel removes an admitted-waiting ticket. Once admitted, the caller owns
68 // the active reservation and must Release it instead.84 // the active reservation and must Release it instead.
69 func (s *ResidentScheduler) Cancel(sequence uint64) bool {85 func (s *ResidentScheduler) Cancel(sequence uint64) bool {
M packages/agent/tools/resident_tools_test.go +67 -0
@@ -4,7 +4,9 @@ import (
44 "context"
55 "encoding/json"
66 "strings"
7+ "sync"
78 "testing"
9+ "time"
810
911 "github.com/bnema/zut/packages/agent/subagents"
1012 "github.com/bnema/zut/packages/core"
@@ -76,6 +78,71 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
7678 }
7779 }
7880
81+func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
82+ started := make(chan struct{}, 1)
83+ release := make(chan struct{})
84+ var releaseOnce sync.Once
85+ releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
86+ finished := make(chan string, 1)
87+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
88+ return func(ctx context.Context, prompt string) error {
89+ switch prompt {
90+ case "blocker":
91+ started <- struct{}{}
92+ select {
93+ case <-release:
94+ return nil
95+ case <-ctx.Done():
96+ return ctx.Err()
97+ }
98+ case "waiter":
99+ finished <- prompt
100+ }
101+ return nil
102+ }, nil
103+ })
104+ t.Cleanup(func() {
105+ releaseBlocker()
106+ _ = manager.Close(context.Background())
107+ })
108+ spawn := &SubagentSpawnTool{
109+ ResidentManager: manager,
110+ Enabled: func() bool { return true },
111+ DefaultProvider: func() string { return "openai" },
112+ DefaultModel: func() string { return "gpt-5" },
113+ BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
114+ return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
115+ },
116+ }
117+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
118+ t.Fatalf("spawn blocker: %v", err)
119+ }
120+ select {
121+ case <-started:
122+ case <-time.After(5 * time.Second):
123+ t.Fatal("blocker did not start")
124+ }
125+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
126+ if err != nil || result.IsError {
127+ t.Fatalf("spawn waiter = (%#v, %v)", result, err)
128+ }
129+ if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
130+ t.Fatalf("wait expiry result = %q, want queued timeout", got)
131+ }
132+ if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
133+ t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
134+ }
135+ releaseBlocker()
136+ select {
137+ case got := <-finished:
138+ if got != "waiter" {
139+ t.Fatalf("finished task = %q, want waiter", got)
140+ }
141+ case <-time.After(5 * time.Second):
142+ t.Fatal("queued waiter did not execute")
143+ }
144+}
145+
79146 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
80147 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
81148 return func(context.Context, string) error { return nil }, nil
@@ -4,7 +4,9 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "strings"6 "strings"
7+ "sync"
7 "testing"8 "testing"
9+ "time"
8 10
9 "github.com/bnema/zut/packages/agent/subagents"11 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
@@ -76,6 +78,71 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
76 }78 }
77 }79 }
78 80
81+func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
82+ started := make(chan struct{}, 1)
83+ release := make(chan struct{})
84+ var releaseOnce sync.Once
85+ releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
86+ finished := make(chan string, 1)
87+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
88+ return func(ctx context.Context, prompt string) error {
89+ switch prompt {
90+ case "blocker":
91+ started <- struct{}{}
92+ select {
93+ case <-release:
94+ return nil
95+ case <-ctx.Done():
96+ return ctx.Err()
97+ }
98+ case "waiter":
99+ finished <- prompt
100+ }
101+ return nil
102+ }, nil
103+ })
104+ t.Cleanup(func() {
105+ releaseBlocker()
106+ _ = manager.Close(context.Background())
107+ })
108+ spawn := &SubagentSpawnTool{
109+ ResidentManager: manager,
110+ Enabled: func() bool { return true },
111+ DefaultProvider: func() string { return "openai" },
112+ DefaultModel: func() string { return "gpt-5" },
113+ BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
114+ return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
115+ },
116+ }
117+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
118+ t.Fatalf("spawn blocker: %v", err)
119+ }
120+ select {
121+ case <-started:
122+ case <-time.After(5 * time.Second):
123+ t.Fatal("blocker did not start")
124+ }
125+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
126+ if err != nil || result.IsError {
127+ t.Fatalf("spawn waiter = (%#v, %v)", result, err)
128+ }
129+ if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
130+ t.Fatalf("wait expiry result = %q, want queued timeout", got)
131+ }
132+ if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
133+ t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
134+ }
135+ releaseBlocker()
136+ select {
137+ case got := <-finished:
138+ if got != "waiter" {
139+ t.Fatalf("finished task = %q, want waiter", got)
140+ }
141+ case <-time.After(5 * time.Second):
142+ t.Fatal("queued waiter did not execute")
143+ }
144+}
145+
79 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {146 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
80 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {147 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
81 return func(context.Context, string) error { return nil }, nil148 return func(context.Context, string) error { return nil }, nil
M packages/agent/tools/subagent_spawn.go +13 -3
@@ -252,7 +252,15 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
252252 if ctx.Err() != nil {
253253 return core.ToolResult{}, ctx.Err()
254254 }
255- waitTimedOut = true
255+ select {
256+ case result, ok := <-completionResult:
257+ if !ok {
258+ return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
259+ }
260+ completion = result
261+ default:
262+ waitTimedOut = true
263+ }
256264 }
257265 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
258266 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
@@ -260,7 +268,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
260268 state := "queued"
261269 if a.Wait != nil {
262270 if waitTimedOut {
263- state = "running"
271+ if current, ok := t.ResidentManager.State(spec.ID); ok {
272+ state = string(current)
273+ }
264274 } else if completion.Err != nil {
265275 state = "failed"
266276 } else {
@@ -282,7 +292,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
282292 if a.Wait != nil {
283293 if waitTimedOut {
284294 fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
285- sb.WriteString("\nThe sub-agent is still running in the background. Completion is host-event-driven through [auto-subagents update].")
295+ sb.WriteString("\nThe accepted sub-agent remains active in the background. Completion is host-event-driven through [auto-subagents update].")
286296 } else if completion.Err != nil {
287297 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
288298 if completion.Summary != "" {
@@ -252,7 +252,15 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
252 if ctx.Err() != nil {252 if ctx.Err() != nil {
253 return core.ToolResult{}, ctx.Err()253 return core.ToolResult{}, ctx.Err()
254 }254 }
255- waitTimedOut = true255+ select {
256+ case result, ok := <-completionResult:
257+ if !ok {
258+ return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
259+ }
260+ completion = result
261+ default:
262+ waitTimedOut = true
263+ }
256 }264 }
257 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {265 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
258 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)266 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
@@ -260,7 +268,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
260 state := "queued"268 state := "queued"
261 if a.Wait != nil {269 if a.Wait != nil {
262 if waitTimedOut {270 if waitTimedOut {
263- state = "running"271+ if current, ok := t.ResidentManager.State(spec.ID); ok {
272+ state = string(current)
273+ }
264 } else if completion.Err != nil {274 } else if completion.Err != nil {
265 state = "failed"275 state = "failed"
266 } else {276 } else {
@@ -282,7 +292,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
282 if a.Wait != nil {292 if a.Wait != nil {
283 if waitTimedOut {293 if waitTimedOut {
284 fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)294 fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
285- sb.WriteString("\nThe sub-agent is still running in the background. Completion is host-event-driven through [auto-subagents update].")295+ sb.WriteString("\nThe accepted sub-agent remains active in the background. Completion is host-event-driven through [auto-subagents update].")
286 } else if completion.Err != nil {296 } else if completion.Err != nil {
287 fmt.Fprintf(&sb, "error: %s\n", completion.Err)297 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
288 if completion.Summary != "" {298 if completion.Summary != "" {

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