fix(subagents): report queued spawn waits
- commit
- 77dae25f7a42512310b954e5b38a34f7c46dec9b
- author
- brice · · 2026-08-18 14:18 UTC
- parent
- 073fe61
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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: | |||
| 102 | 102 | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | |
| 103 | 103 | `worktree`). `wait` is an explicit whole-second value from 1 through 300; | |
| 104 | 104 | 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. | |
| 107 | 110 | - `subagent_stop` stops one live child. | |
| 108 | 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 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. | |
| 114 | 118 | Successful completions include the final visible assistant summary, capped at | |
| 115 | 119 | 256 KiB; open the child session for the complete durable transcript. | |
| 116 | 120 | ||
| @@ -102,15 +102,19 @@ The model-facing tools retain their logical names: | |||||
| 102 | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | 102 | `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 the | 104 | 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 explicit | 113 | + | Child execution started by `subagent_spawn` is asynchronous unless it receives |
| 111 | - | `wait` value. Otherwise completion arrives through the host’s typed completion | 114 | + | 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 at | 118 | 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 | |||
| 258 | 258 | ||
| 259 | 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 | 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 | 267 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | |
| 268 | 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 | 271 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | |
| 272 | 272 | // 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 tools | 267 | // 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 strict | 271 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | ||
| 272 | // orchestrator contract still applies when launch-time policy withholds the | 272 | // 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 { | |||
| 582 | 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 | 600 | // Live returns an immutable unfinished-turn snapshot for one live child. It | |
| 586 | 601 | // never holds the manager lock while copying the child projection. | |
| 587 | 602 | 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. It | 600 | // 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) { | |||
| 64 | 64 | 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 | 83 | // Cancel removes an admitted-waiting ticket. Once admitted, the caller owns | |
| 68 | 84 | // the active reservation and must Release it instead. | |
| 69 | 85 | func (s *ResidentScheduler) Cancel(sequence uint64) bool { | |
| @@ -64,6 +64,22 @@ func (s *ResidentScheduler) Admit() (ResidentTicket, bool) { | |||||
| 64 | return ResidentTicket{}, false | 64 | 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 owns | 83 | // 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "strings" | |
| 7 | + | "sync" | |
| 7 | 8 | "testing" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | 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 | 146 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |
| 80 | 147 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 81 | 148 | 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 }, nil | 148 | 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, _ | |||
| 252 | 252 | if ctx.Err() != nil { | |
| 253 | 253 | return core.ToolResult{}, ctx.Err() | |
| 254 | 254 | } | |
| 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 | + | } | |
| 256 | 264 | } | |
| 257 | 265 | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 258 | 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 | 268 | state := "queued" | |
| 261 | 269 | if a.Wait != nil { | |
| 262 | 270 | if waitTimedOut { | |
| 263 | - | state = "running" | |
| 271 | + | if current, ok := t.ResidentManager.State(spec.ID); ok { | |
| 272 | + | state = string(current) | |
| 273 | + | } | |
| 264 | 274 | } else if completion.Err != nil { | |
| 265 | 275 | state = "failed" | |
| 266 | 276 | } else { | |
| @@ -282,7 +292,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 282 | 292 | if a.Wait != nil { | |
| 283 | 293 | if waitTimedOut { | |
| 284 | 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 | 296 | } else if completion.Err != nil { | |
| 287 | 297 | fmt.Fprintf(&sb, "error: %s\n", completion.Err) | |
| 288 | 298 | 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 = 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 | + | } | |||
| 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 != "" { | ||