detached 5f6ff8a

Merge pull request #101 from bnema/fix/nonblocking-required-subagents

fix(subagents): keep required work asynchronous
commit
5f6ff8a7dfbf5c95810c6563810d555c21f9b844
author
bnema · · 2026-08-10 05:39 UTC
committer
GitHub · · 2026-08-10 05:39 UTC
merge of
befd698 + 16557d5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +153 -279
M README.md +2 -2
@@ -410,7 +410,7 @@ Background subagents that run alongside your main session. Each one is a separat
410410
411411 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
412412
413-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool returns the child id and initial state immediately for optional work. Set `required:true` when the parent or workflow depends on the result: the tool waits event-driven in the current turn, persists the required outcome, blocks other finalization tools, and fails closed on failure, timeout, or cancellation until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Optional completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only optional completion signal. Required completion returns through the manager tool or a high-priority `[required-subagents update]` after cancellation or restart. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
413+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
414414
415415 ### Named subagent profiles
416416
@@ -424,7 +424,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
424424 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
425425 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
426426 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
427-- **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional background or blocking required sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Optional completion is host-event-driven through `[auto-subagents update]`; required work waits in the manager call, persists its outcome, and prevents finalization until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
427+- **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
428428 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.
429429 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
430430 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
@@ -410,7 +410,7 @@ Background subagents that run alongside your main session. Each one is a separat
410 410
411 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.411 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
412 412
413-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool returns the child id and initial state immediately for optional work. Set `required:true` when the parent or workflow depends on the result: the tool waits event-driven in the current turn, persists the required outcome, blocks other finalization tools, and fails closed on failure, timeout, or cancellation until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Optional completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only optional completion signal. Required completion returns through the manager tool or a high-priority `[required-subagents update]` after cancellation or restart. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.413+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
414 414
415 ### Named subagent profiles415 ### Named subagent profiles
416 416
@@ -424,7 +424,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
424 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.424 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
425 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.425 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
426 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.426 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
427-- **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional background or blocking required sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Optional completion is host-event-driven through `[auto-subagents update]`; required work waits in the manager call, persists its outcome, and prevents finalization until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.427+- **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
428 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.428 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.
429 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.429 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
430 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.430 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
M docs/subagents.md +2 -2
@@ -141,7 +141,7 @@ During an active delegated turn, interactive mode shows compact rows beside the
141141
142142 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
143143
144-A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Optional work is delivered through `[auto-subagents update]`. Required work is delivered directly as the blocking manager tool result, or through a high-priority `[required-subagents update]` after cancellation or restart. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:
144+A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:
145145
146146 If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
147147
@@ -160,7 +160,7 @@ Use `/subagents resume-session <id>` to continue the existing session without re
160160
161161 When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
162162
163-Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
163+Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
164164
165165 ## Resource policy
166166
@@ -141,7 +141,7 @@ During an active delegated turn, interactive mode shows compact rows beside the
141 141
142 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.142 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
143 143
144-A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Optional work is delivered through `[auto-subagents update]`. Required work is delivered directly as the blocking manager tool result, or through a high-priority `[required-subagents update]` after cancellation or restart. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:144+A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:
145 145
146 If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.146 If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
147 147
@@ -160,7 +160,7 @@ Use `/subagents resume-session <id>` to continue the existing session without re
160 160
161 When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.161 When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
162 162
163-Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.163+Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
164 164
165 ## Resource policy165 ## Resource policy
166 166
M packages/agent/modes/auto_subagents_test.go +26 -0
@@ -125,6 +125,32 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
125125 }
126126 }
127127
128+func TestTrackRequiredSubagentWorkerReportsFailureAsynchronously(t *testing.T) {
129+ mgr := subagents.New(subagents.Config{
130+ Root: t.TempDir(),
131+ RepoRoot: t.TempDir(),
132+ NewRunner: func(*subagents.Agent) subagents.Runner {
133+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
134+ return errors.New("required reviewer failed")
135+ })
136+ },
137+ })
138+ a, err := mgr.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "review the release", Required: true})
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+
143+ iv := newQueuedAutoSubagentsInteractive()
144+ iv.TrackSubagentWorker(a, a.Task, true)
145+ update := waitForQueuedPrompt(t, iv)
146+ if !strings.Contains(update, "status: failed") || !strings.Contains(update, "required reviewer failed") {
147+ t.Fatalf("required worker failure was not delivered: %q", update)
148+ }
149+ if !a.Snapshot().Requirement.Unmet() {
150+ t.Fatal("delivered failure did not remain recoverable")
151+ }
152+}
153+
128154 func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
129155 started := make(chan struct{})
130156 mgr := subagents.New(subagents.Config{
@@ -125,6 +125,32 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
125 }125 }
126 }126 }
127 127
128+func TestTrackRequiredSubagentWorkerReportsFailureAsynchronously(t *testing.T) {
129+ mgr := subagents.New(subagents.Config{
130+ Root: t.TempDir(),
131+ RepoRoot: t.TempDir(),
132+ NewRunner: func(*subagents.Agent) subagents.Runner {
133+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
134+ return errors.New("required reviewer failed")
135+ })
136+ },
137+ })
138+ a, err := mgr.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "review the release", Required: true})
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+
143+ iv := newQueuedAutoSubagentsInteractive()
144+ iv.TrackSubagentWorker(a, a.Task, true)
145+ update := waitForQueuedPrompt(t, iv)
146+ if !strings.Contains(update, "status: failed") || !strings.Contains(update, "required reviewer failed") {
147+ t.Fatalf("required worker failure was not delivered: %q", update)
148+ }
149+ if !a.Snapshot().Requirement.Unmet() {
150+ t.Fatal("delivered failure did not remain recoverable")
151+ }
152+}
153+
128 func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {154 func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
129 started := make(chan struct{})155 started := make(chan struct{})
130 mgr := subagents.New(subagents.Config{156 mgr := subagents.New(subagents.Config{
M packages/agent/modes/interactive.go +3 -21
@@ -8743,26 +8743,14 @@ func (a telegramSenderAdapter) Active() bool {
87438743
87448744 // TrackSubagentWorker is the exported entry point used by the cli to
87458745 // hand a freshly-spawned auto-subagents agent off to the shared tracker.
8746-func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required bool) {
8747- if required {
8748- if i != nil {
8749- i.invalidate()
8750- }
8751- return
8752- }
8746+func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, _ bool) {
87538747 i.trackSubagentWorker(a, task, false, false)
87548748 }
87558749
87568750 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
87578751 // worker's original task. A long-lived daemon can complete several manager
87588752 // turns, each of which must produce its own automatic delivery.
8759-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) {
8760- if required {
8761- if i != nil {
8762- i.invalidate()
8763- }
8764- return
8765- }
8753+func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) {
87668754 i.trackSubagentWorker(a, prompt, true, false)
87678755 }
87688756
@@ -8770,13 +8758,7 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri
87708758 // sent. The returned cleanup removes the registration if the resume operation
87718759 // fails before delivery. It is used by the resume tool's pre-send hook; the
87728760 // existing TrackResumedSubagentWorker signature remains the post-success API.
8773-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() {
8774- if required {
8775- if i != nil {
8776- i.invalidate()
8777- }
8778- return nil
8779- }
8761+func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) func() {
87808762 return i.trackSubagentWorker(a, prompt, true, true)
87818763 }
87828764
@@ -8743,26 +8743,14 @@ func (a telegramSenderAdapter) Active() bool {
8743 8743
8744 // TrackSubagentWorker is the exported entry point used by the cli to8744 // TrackSubagentWorker is the exported entry point used by the cli to
8745 // hand a freshly-spawned auto-subagents agent off to the shared tracker.8745 // hand a freshly-spawned auto-subagents agent off to the shared tracker.
8746-func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required bool) {8746+func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, _ bool) {
8747- if required {
8748- if i != nil {
8749- i.invalidate()
8750- }
8751- return
8752- }
8753 i.trackSubagentWorker(a, task, false, false)8747 i.trackSubagentWorker(a, task, false, false)
8754 }8748 }
8755 8749
8756 // TrackResumedSubagentWorker watches a resumed follow-up independently of the8750 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
8757 // worker's original task. A long-lived daemon can complete several manager8751 // worker's original task. A long-lived daemon can complete several manager
8758 // turns, each of which must produce its own automatic delivery.8752 // turns, each of which must produce its own automatic delivery.
8759-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) {8753+func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) {
8760- if required {
8761- if i != nil {
8762- i.invalidate()
8763- }
8764- return
8765- }
8766 i.trackSubagentWorker(a, prompt, true, false)8754 i.trackSubagentWorker(a, prompt, true, false)
8767 }8755 }
8768 8756
@@ -8770,13 +8758,7 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri
8770 // sent. The returned cleanup removes the registration if the resume operation8758 // sent. The returned cleanup removes the registration if the resume operation
8771 // fails before delivery. It is used by the resume tool's pre-send hook; the8759 // fails before delivery. It is used by the resume tool's pre-send hook; the
8772 // existing TrackResumedSubagentWorker signature remains the post-success API.8760 // existing TrackResumedSubagentWorker signature remains the post-success API.
8773-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() {8761+func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) func() {
8774- if required {
8775- if i != nil {
8776- i.invalidate()
8777- }
8778- return nil
8779- }
8780 return i.trackSubagentWorker(a, prompt, true, true)8762 return i.trackSubagentWorker(a, prompt, true, true)
8781 }8763 }
8782 8764
M packages/agent/orchestration_modes.go +5 -12
@@ -192,23 +192,16 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
192192 }
193193
194194 func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
195- onSpawned := func(a *subagents.Agent, task string, required bool) {
196- if !required {
197- tracker.TrackTurn(a, task, false)
198- }
195+ onSpawned := func(a *subagents.Agent, task string, _ bool) {
196+ tracker.TrackTurn(a, task, false)
199197 }
200- onResumed := func(a *subagents.Agent, prompt string, required bool) {
198+ onResumed := func(a *subagents.Agent, prompt string, _ bool) {
201199 // BeforeResumed normally owns future-turn registration. Keep this
202200 // fallback for direct runtime callers that do not install the pre-hook;
203201 // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
204- if !required {
205- tracker.TrackTurn(a, prompt, true)
206- }
202+ tracker.TrackTurn(a, prompt, true)
207203 }
208- onBeforeResumed := func(a *subagents.Agent, prompt string, required bool) func() {
209- if required {
210- return nil
211- }
204+ onBeforeResumed := func(a *subagents.Agent, prompt string, _ bool) func() {
212205 return tracker.TrackFutureTurn(a, prompt, true)
213206 }
214207 onStopRequested := func(a *subagents.Agent) {
@@ -192,23 +192,16 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
192 }192 }
193 193
194 func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {194 func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
195- onSpawned := func(a *subagents.Agent, task string, required bool) {195+ onSpawned := func(a *subagents.Agent, task string, _ bool) {
196- if !required {196+ tracker.TrackTurn(a, task, false)
197- tracker.TrackTurn(a, task, false)
198- }
199 }197 }
200- onResumed := func(a *subagents.Agent, prompt string, required bool) {198+ onResumed := func(a *subagents.Agent, prompt string, _ bool) {
201 // BeforeResumed normally owns future-turn registration. Keep this199 // BeforeResumed normally owns future-turn registration. Keep this
202 // fallback for direct runtime callers that do not install the pre-hook;200 // fallback for direct runtime callers that do not install the pre-hook;
203 // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.201 // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
204- if !required {202+ tracker.TrackTurn(a, prompt, true)
205- tracker.TrackTurn(a, prompt, true)
206- }
207 }203 }
208- onBeforeResumed := func(a *subagents.Agent, prompt string, required bool) func() {204+ onBeforeResumed := func(a *subagents.Agent, prompt string, _ bool) func() {
209- if required {
210- return nil
211- }
212 return tracker.TrackFutureTurn(a, prompt, true)205 return tracker.TrackFutureTurn(a, prompt, true)
213 }206 }
214 onStopRequested := func(a *subagents.Agent) {207 onStopRequested := func(a *subagents.Agent) {
M packages/agent/settings_store.go +4 -4
@@ -272,15 +272,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro
272272
273273 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.
274274
275-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. A required manager call waits event-driven and returns the worker outcome in the current turn. 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.
275+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: 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.
276276
277-Completion is host-event-driven for optional workers. After spawning an optional worker, never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. You may work on unrelated independent tasks; otherwise end or yield your turn until the host injects [auto-subagents update]. Completion updates are the only completion signal for optional workers. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
277+Completion is host-event-driven for every worker. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, 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. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
278278
279-When workers finish, use the required tool result or 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.`
279+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.`
280280
281281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
282282 // available without enabling proactive primary-agent orchestration.
283-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. Required calls wait for a durable terminal outcome: timed-out or canceled 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. Deliver required results through the blocking tool result and optional results only through host updates. 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.`
283+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. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. 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.`
284284
285285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
286286 // orchestrator contract still applies when launch-time policy withholds the
@@ -272,15 +272,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro
272 272
273 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.273 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.
274 274
275-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. A required manager call waits event-driven and returns the worker outcome in the current turn. 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.275+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: 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.
276 276
277-Completion is host-event-driven for optional workers. After spawning an optional worker, never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. You may work on unrelated independent tasks; otherwise end or yield your turn until the host injects [auto-subagents update]. Completion updates are the only completion signal for optional workers. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.277+Completion is host-event-driven for every worker. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, 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. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
278 278
279-When workers finish, use the required tool result or 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.`279+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.`
280 280
281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
282 // available without enabling proactive primary-agent orchestration.282 // available without enabling proactive primary-agent orchestration.
283-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. Required calls wait for a durable terminal outcome: timed-out or canceled 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. Deliver required results through the blocking tool result and optional results only through host updates. 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.`283+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. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. 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.`
284 284
285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
286 // orchestrator contract still applies when launch-time policy withholds the286 // orchestrator contract still applies when launch-time policy withholds the
M packages/agent/subagent_requirement_gate_test.go +24 -32
@@ -5,14 +5,13 @@ import (
55 "errors"
66 "strings"
77 "testing"
8- "time"
98
109 "github.com/bnema/zut/packages/agent/subagents"
1110 "github.com/bnema/zut/packages/core"
1211 "github.com/bnema/zut/packages/provider"
1312 )
1413
15-func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) {
14+func TestRequiredWorkerGateKeepsParentFreeWhileWorkerRuns(t *testing.T) {
1615 started := make(chan struct{})
1716 release := make(chan struct{})
1817 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
@@ -41,36 +40,22 @@ func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) {
4140
4241 parent := &core.Agent{}
4342 rt.WireRequiredWorkerGate(parent)
44- if parent.BeforeTurnContext == nil {
45- t.Fatal("required worker gate did not install a before-turn hook")
43+ allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
44+ if !allowed || reason != "" || turnContext != "" {
45+ t.Fatalf("parent turn while worker runs = (%v, %q, %q), want immediate access", allowed, reason, turnContext)
4646 }
47-
48- type turnResult struct {
49- allowed bool
50- reason string
51- context string
52- }
53- result := make(chan turnResult, 1)
54- go func() {
55- allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
56- result <- turnResult{allowed: allowed, reason: reason, context: turnContext}
57- }()
58- select {
59- case got := <-result:
60- t.Fatalf("parent gate returned while required worker was running: %+v", got)
61- default:
47+ if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
48+ t.Fatalf("independent parent tool allowed=%v reason=%q", allowed, reason)
49+ }
50+ if allowed, reason, _ := parent.BeforeAssistantMessage("premature final answer"); allowed || !strings.Contains(reason, a.ID) {
51+ t.Fatalf("terminal parent answer allowed=%v reason=%q, want deferred finalization", allowed, reason)
6252 }
6353
6454 close(release)
65- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
66- defer cancel()
67- select {
68- case got := <-result:
69- if !got.allowed || got.reason != "" || !strings.Contains(got.context, "required") || !strings.Contains(got.context, a.ID) {
70- t.Fatalf("gate result = %+v, want required completion context", got)
71- }
72- case <-ctx.Done():
73- t.Fatal("parent gate did not wake when required worker finished")
55+ a.Wait()
56+ allowed, reason, turnContext = parent.BeforeTurnContext(context.Background(), 2)
57+ if !allowed || reason != "" || !strings.Contains(turnContext, a.ID) {
58+ t.Fatalf("completion context = (%v, %q, %q), want required outcome", allowed, reason, turnContext)
7459 }
7560 }
7661
@@ -150,8 +135,11 @@ func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) {
150135 worker.Wait()
151136 parent := &core.Agent{}
152137 rt.WireRequiredWorkerGate(parent)
153- if allowed, _, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); allowed {
154- t.Fatal("unmet requirement did not block tools")
138+ if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
139+ t.Fatalf("unmet requirement blocked independent tool: allowed=%v reason=%q", allowed, reason)
140+ }
141+ if allowed, _, _ := parent.BeforeAssistantMessage("done"); allowed {
142+ t.Fatal("unmet requirement allowed terminal answer")
155143 }
156144 if err := rt.Supervisor().Remove(worker.ID); err != nil {
157145 t.Fatalf("explicit user removal waiver: %v", err)
@@ -188,9 +176,13 @@ func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *te
188176
189177 parent := &core.Agent{}
190178 rt.WireRequiredWorkerGate(parent)
179+ turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
180+ if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") {
181+ t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext)
182+ }
191183 toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"})
192- if toolAllowed || !strings.Contains(toolReason, worker.ID) {
193- t.Fatalf("finalization tool allowed=%v reason=%q, want failed required worker gate", toolAllowed, toolReason)
184+ if !toolAllowed || toolReason != "" {
185+ t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason)
194186 }
195187 toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
196188 if !toolAllowed || toolReason != "" {
@@ -5,14 +5,13 @@ import (
5 "errors"5 "errors"
6 "strings"6 "strings"
7 "testing"7 "testing"
8- "time"
9 8
10 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
13 )12 )
14 13
15-func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) {14+func TestRequiredWorkerGateKeepsParentFreeWhileWorkerRuns(t *testing.T) {
16 started := make(chan struct{})15 started := make(chan struct{})
17 release := make(chan struct{})16 release := make(chan struct{})
18 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {17 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
@@ -41,36 +40,22 @@ func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) {
41 40
42 parent := &core.Agent{}41 parent := &core.Agent{}
43 rt.WireRequiredWorkerGate(parent)42 rt.WireRequiredWorkerGate(parent)
44- if parent.BeforeTurnContext == nil {43+ allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
45- t.Fatal("required worker gate did not install a before-turn hook")44+ if !allowed || reason != "" || turnContext != "" {
45+ t.Fatalf("parent turn while worker runs = (%v, %q, %q), want immediate access", allowed, reason, turnContext)
46 }46 }
47-47+ if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
48- type turnResult struct {48+ t.Fatalf("independent parent tool allowed=%v reason=%q", allowed, reason)
49- allowed bool49+ }
50- reason string50+ if allowed, reason, _ := parent.BeforeAssistantMessage("premature final answer"); allowed || !strings.Contains(reason, a.ID) {
51- context string51+ t.Fatalf("terminal parent answer allowed=%v reason=%q, want deferred finalization", allowed, reason)
52- }
53- result := make(chan turnResult, 1)
54- go func() {
55- allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
56- result <- turnResult{allowed: allowed, reason: reason, context: turnContext}
57- }()
58- select {
59- case got := <-result:
60- t.Fatalf("parent gate returned while required worker was running: %+v", got)
61- default:
62 }52 }
63 53
64 close(release)54 close(release)
65- ctx, cancel := context.WithTimeout(context.Background(), time.Second)55+ a.Wait()
66- defer cancel()56+ allowed, reason, turnContext = parent.BeforeTurnContext(context.Background(), 2)
67- select {57+ if !allowed || reason != "" || !strings.Contains(turnContext, a.ID) {
68- case got := <-result:58+ t.Fatalf("completion context = (%v, %q, %q), want required outcome", allowed, reason, turnContext)
69- if !got.allowed || got.reason != "" || !strings.Contains(got.context, "required") || !strings.Contains(got.context, a.ID) {
70- t.Fatalf("gate result = %+v, want required completion context", got)
71- }
72- case <-ctx.Done():
73- t.Fatal("parent gate did not wake when required worker finished")
74 }59 }
75 }60 }
76 61
@@ -150,8 +135,11 @@ func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) {
150 worker.Wait()135 worker.Wait()
151 parent := &core.Agent{}136 parent := &core.Agent{}
152 rt.WireRequiredWorkerGate(parent)137 rt.WireRequiredWorkerGate(parent)
153- if allowed, _, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); allowed {138+ if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
154- t.Fatal("unmet requirement did not block tools")139+ t.Fatalf("unmet requirement blocked independent tool: allowed=%v reason=%q", allowed, reason)
140+ }
141+ if allowed, _, _ := parent.BeforeAssistantMessage("done"); allowed {
142+ t.Fatal("unmet requirement allowed terminal answer")
155 }143 }
156 if err := rt.Supervisor().Remove(worker.ID); err != nil {144 if err := rt.Supervisor().Remove(worker.ID); err != nil {
157 t.Fatalf("explicit user removal waiver: %v", err)145 t.Fatalf("explicit user removal waiver: %v", err)
@@ -188,9 +176,13 @@ func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *te
188 176
189 parent := &core.Agent{}177 parent := &core.Agent{}
190 rt.WireRequiredWorkerGate(parent)178 rt.WireRequiredWorkerGate(parent)
179+ turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
180+ if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") {
181+ t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext)
182+ }
191 toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"})183 toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"})
192- if toolAllowed || !strings.Contains(toolReason, worker.ID) {184+ if !toolAllowed || toolReason != "" {
193- t.Fatalf("finalization tool allowed=%v reason=%q, want failed required worker gate", toolAllowed, toolReason)185+ t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason)
194 }186 }
195 toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})187 toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
196 if !toolAllowed || toolReason != "" {188 if !toolAllowed || toolReason != "" {
M packages/agent/subagent_requirements.go +5 -22
@@ -8,7 +8,6 @@ import (
88 "strings"
99
1010 "github.com/bnema/zut/packages/agent/subagents"
11- "github.com/bnema/zut/packages/agent/tools"
1211 "github.com/bnema/zut/packages/core"
1312 "github.com/bnema/zut/packages/provider"
1413 )
@@ -16,10 +15,10 @@ import (
1615 const requiredWorkerContextHeader = "[required-subagents update]"
1716
1817 // WireRequiredWorkerGate composes host-enforced requirement handling with any
19-// extension hooks already installed on the parent. Blocking required tool calls
20-// handle the normal in-turn path; the before-turn hook covers cancellation,
21-// restart, and session resume, while the assistant hook fails closed after an
22-// unsuccessful required outcome.
18+// extension hooks already installed on the parent. Parent turns and tools stay
19+// available while required work runs; terminal outcomes are injected on the
20+// next turn, and only a terminal assistant response is held while an obligation
21+// remains unmet.
2322 func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
2423 if rt == nil || rt.supervisor == nil || parent == nil {
2524 return
@@ -37,10 +36,7 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
3736 if err := rt.waitRequiredWorkerReady(ctx); err != nil {
3837 return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited
3938 }
40- required, err := rt.supervisor.WaitActiveRequirements(ctx)
41- if err != nil {
42- return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
43- }
39+ required := rt.supervisor.RequiredSnapshots()
4440 update := rt.formatRequiredWorkerContext(required)
4541 if update == "" {
4642 return true, "", inherited
@@ -50,10 +46,6 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
5046
5147 previousBeforeTool := parent.BeforeToolExecute
5248 parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
53- unmet := rt.supervisor.UnmetRequirements()
54- if len(unmet) != 0 && !requiredRecoveryTool(call.Name) {
55- return false, formatRequiredWorkerBlock(unmet), nil
56- }
5749 if previousBeforeTool != nil {
5850 return previousBeforeTool(call)
5951 }
@@ -163,15 +155,6 @@ func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.Agen
163155 return contextText
164156 }
165157
166-func requiredRecoveryTool(name string) bool {
167- switch name {
168- case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName:
169- return true
170- default:
171- return false
172- }
173-}
174-
175158 func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {
176159 var items []subagents.AgentSnapshot
177160 for _, snapshot := range required {
@@ -8,7 +8,6 @@ import (
8 "strings"8 "strings"
9 9
10 "github.com/bnema/zut/packages/agent/subagents"10 "github.com/bnema/zut/packages/agent/subagents"
11- "github.com/bnema/zut/packages/agent/tools"
12 "github.com/bnema/zut/packages/core"11 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"12 "github.com/bnema/zut/packages/provider"
14 )13 )
@@ -16,10 +15,10 @@ import (
16 const requiredWorkerContextHeader = "[required-subagents update]"15 const requiredWorkerContextHeader = "[required-subagents update]"
17 16
18 // WireRequiredWorkerGate composes host-enforced requirement handling with any17 // WireRequiredWorkerGate composes host-enforced requirement handling with any
19-// extension hooks already installed on the parent. Blocking required tool calls18+// extension hooks already installed on the parent. Parent turns and tools stay
20-// handle the normal in-turn path; the before-turn hook covers cancellation,19+// available while required work runs; terminal outcomes are injected on the
21-// restart, and session resume, while the assistant hook fails closed after an20+// next turn, and only a terminal assistant response is held while an obligation
22-// unsuccessful required outcome.21+// remains unmet.
23 func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {22 func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
24 if rt == nil || rt.supervisor == nil || parent == nil {23 if rt == nil || rt.supervisor == nil || parent == nil {
25 return24 return
@@ -37,10 +36,7 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
37 if err := rt.waitRequiredWorkerReady(ctx); err != nil {36 if err := rt.waitRequiredWorkerReady(ctx); err != nil {
38 return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited37 return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited
39 }38 }
40- required, err := rt.supervisor.WaitActiveRequirements(ctx)39+ required := rt.supervisor.RequiredSnapshots()
41- if err != nil {
42- return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
43- }
44 update := rt.formatRequiredWorkerContext(required)40 update := rt.formatRequiredWorkerContext(required)
45 if update == "" {41 if update == "" {
46 return true, "", inherited42 return true, "", inherited
@@ -50,10 +46,6 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
50 46
51 previousBeforeTool := parent.BeforeToolExecute47 previousBeforeTool := parent.BeforeToolExecute
52 parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {48 parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
53- unmet := rt.supervisor.UnmetRequirements()
54- if len(unmet) != 0 && !requiredRecoveryTool(call.Name) {
55- return false, formatRequiredWorkerBlock(unmet), nil
56- }
57 if previousBeforeTool != nil {49 if previousBeforeTool != nil {
58 return previousBeforeTool(call)50 return previousBeforeTool(call)
59 }51 }
@@ -163,15 +155,6 @@ func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.Agen
163 return contextText155 return contextText
164 }156 }
165 157
166-func requiredRecoveryTool(name string) bool {
167- switch name {
168- case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName:
169- return true
170- default:
171- return false
172- }
173-}
174-
175 func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {158 func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {
176 var items []subagents.AgentSnapshot159 var items []subagents.AgentSnapshot
177 for _, snapshot := range required {160 for _, snapshot := range required {
M packages/agent/subagents/requirement.go +3 -47
@@ -2,7 +2,6 @@ package subagents
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "sort"
87 "strconv"
@@ -303,24 +302,8 @@ func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error
303302 }
304303 }
305304
306-var errRequiredAgentMissing = errors.New("subagents: required agent no longer exists")
307-
308-// WaitRequired blocks on requirement state transitions, never filesystem or
309-// status polling, until the selected required turn reaches a terminal outcome.
310-func (f *Supervisor) WaitRequired(ctx context.Context, id string) (RequirementSnapshot, error) {
311- a := f.Get(strings.TrimSpace(id))
312- if a == nil {
313- return RequirementSnapshot{}, fmt.Errorf("%w: %q", errRequiredAgentMissing, id)
314- }
315- requirement := a.requirementSnapshot()
316- if !requirement.Required {
317- return requirement, fmt.Errorf("subagents: agent %s is not required", a.ID)
318- }
319- return a.waitRequirement(ctx)
320-}
321-
322305 // MarkRequirementNotified records that the parent received the terminal
323-// outcome through a blocking manager tool or host-injected update.
306+// outcome through a host-injected update.
324307 func (f *Supervisor) MarkRequirementNotified(id string) error {
325308 a := f.Get(strings.TrimSpace(id))
326309 if a == nil {
@@ -358,8 +341,8 @@ func (f *Supervisor) RequiredSnapshots() []AgentSnapshot {
358341 return out
359342 }
360343
361-// UnmetRequirements returns every required worker that still blocks parent
362-// completion in the active session.
344+// UnmetRequirements returns every required worker that still blocks the
345+// parent's terminal response in the active session.
363346 func (f *Supervisor) UnmetRequirements() []AgentSnapshot {
364347 required := f.RequiredSnapshots()
365348 out := required[:0]
@@ -370,30 +353,3 @@ func (f *Supervisor) UnmetRequirements() []AgentSnapshot {
370353 }
371354 return out
372355 }
373-
374-// WaitActiveRequirements waits for all currently pending required workers in
375-// the active session. It returns all required snapshots so the host can inject
376-// both successful outcomes and unresolved failures before the next model turn.
377-func (f *Supervisor) WaitActiveRequirements(ctx context.Context) ([]AgentSnapshot, error) {
378- if ctx == nil {
379- ctx = context.Background()
380- }
381- for {
382- pending := false
383- for _, snapshot := range f.RequiredSnapshots() {
384- if snapshot.Requirement.pending() {
385- pending = true
386- if _, err := f.WaitRequired(ctx, snapshot.ID); err != nil {
387- if errors.Is(err, errRequiredAgentMissing) {
388- break
389- }
390- return nil, err
391- }
392- break
393- }
394- }
395- if !pending {
396- return f.RequiredSnapshots(), nil
397- }
398- }
399-}
@@ -2,7 +2,6 @@ package subagents
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "sort"6 "sort"
8 "strconv"7 "strconv"
@@ -303,24 +302,8 @@ func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error
303 }302 }
304 }303 }
305 304
306-var errRequiredAgentMissing = errors.New("subagents: required agent no longer exists")
307-
308-// WaitRequired blocks on requirement state transitions, never filesystem or
309-// status polling, until the selected required turn reaches a terminal outcome.
310-func (f *Supervisor) WaitRequired(ctx context.Context, id string) (RequirementSnapshot, error) {
311- a := f.Get(strings.TrimSpace(id))
312- if a == nil {
313- return RequirementSnapshot{}, fmt.Errorf("%w: %q", errRequiredAgentMissing, id)
314- }
315- requirement := a.requirementSnapshot()
316- if !requirement.Required {
317- return requirement, fmt.Errorf("subagents: agent %s is not required", a.ID)
318- }
319- return a.waitRequirement(ctx)
320-}
321-
322 // MarkRequirementNotified records that the parent received the terminal305 // MarkRequirementNotified records that the parent received the terminal
323-// outcome through a blocking manager tool or host-injected update.306+// outcome through a host-injected update.
324 func (f *Supervisor) MarkRequirementNotified(id string) error {307 func (f *Supervisor) MarkRequirementNotified(id string) error {
325 a := f.Get(strings.TrimSpace(id))308 a := f.Get(strings.TrimSpace(id))
326 if a == nil {309 if a == nil {
@@ -358,8 +341,8 @@ func (f *Supervisor) RequiredSnapshots() []AgentSnapshot {
358 return out341 return out
359 }342 }
360 343
361-// UnmetRequirements returns every required worker that still blocks parent344+// UnmetRequirements returns every required worker that still blocks the
362-// completion in the active session.345+// parent's terminal response in the active session.
363 func (f *Supervisor) UnmetRequirements() []AgentSnapshot {346 func (f *Supervisor) UnmetRequirements() []AgentSnapshot {
364 required := f.RequiredSnapshots()347 required := f.RequiredSnapshots()
365 out := required[:0]348 out := required[:0]
@@ -370,30 +353,3 @@ func (f *Supervisor) UnmetRequirements() []AgentSnapshot {
370 }353 }
371 return out354 return out
372 }355 }
373-
374-// WaitActiveRequirements waits for all currently pending required workers in
375-// the active session. It returns all required snapshots so the host can inject
376-// both successful outcomes and unresolved failures before the next model turn.
377-func (f *Supervisor) WaitActiveRequirements(ctx context.Context) ([]AgentSnapshot, error) {
378- if ctx == nil {
379- ctx = context.Background()
380- }
381- for {
382- pending := false
383- for _, snapshot := range f.RequiredSnapshots() {
384- if snapshot.Requirement.pending() {
385- pending = true
386- if _, err := f.WaitRequired(ctx, snapshot.ID); err != nil {
387- if errors.Is(err, errRequiredAgentMissing) {
388- break
389- }
390- return nil, err
391- }
392- break
393- }
394- }
395- if !pending {
396- return f.RequiredSnapshots(), nil
397- }
398- }
399-}
M packages/agent/subagents/requirement_test.go +3 -3
@@ -120,7 +120,7 @@ func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T)
120120 }
121121 ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout)
122122 defer cancel()
123- got, err := f.WaitRequired(ctx, a.ID)
123+ got, err := a.waitRequirement(ctx)
124124 if err != nil {
125125 events, _ := ReadEventLog(a.EventLogPath)
126126 t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events))
@@ -321,7 +321,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
321321 waited := make(chan RequirementSnapshot, 1)
322322 waitErr := make(chan error, 1)
323323 go func() {
324- result, err := f.WaitRequired(ctx, a.ID)
324+ result, err := a.waitRequirement(ctx)
325325 if err != nil {
326326 waitErr <- err
327327 return
@@ -354,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
354354 t.Fatalf("required retry: %v", err)
355355 }
356356 resumed.Wait()
357- got, err := f.WaitRequired(context.Background(), a.ID)
357+ got, err := resumed.waitRequirement(context.Background())
358358 if err != nil {
359359 t.Fatalf("wait for retry: %v", err)
360360 }
@@ -120,7 +120,7 @@ func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T)
120 }120 }
121 ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout)121 ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout)
122 defer cancel()122 defer cancel()
123- got, err := f.WaitRequired(ctx, a.ID)123+ got, err := a.waitRequirement(ctx)
124 if err != nil {124 if err != nil {
125 events, _ := ReadEventLog(a.EventLogPath)125 events, _ := ReadEventLog(a.EventLogPath)
126 t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events))126 t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events))
@@ -321,7 +321,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
321 waited := make(chan RequirementSnapshot, 1)321 waited := make(chan RequirementSnapshot, 1)
322 waitErr := make(chan error, 1)322 waitErr := make(chan error, 1)
323 go func() {323 go func() {
324- result, err := f.WaitRequired(ctx, a.ID)324+ result, err := a.waitRequirement(ctx)
325 if err != nil {325 if err != nil {
326 waitErr <- err326 waitErr <- err
327 return327 return
@@ -354,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
354 t.Fatalf("required retry: %v", err)354 t.Fatalf("required retry: %v", err)
355 }355 }
356 resumed.Wait()356 resumed.Wait()
357- got, err := f.WaitRequired(context.Background(), a.ID)357+ got, err := resumed.waitRequirement(context.Background())
358 if err != nil {358 if err != nil {
359 t.Fatalf("wait for retry: %v", err)359 t.Fatalf("wait for retry: %v", err)
360 }360 }
M packages/agent/subagents/supervisor.go +2 -2
@@ -357,8 +357,8 @@ type SpawnRequest struct {
357357 RootSessionID string
358358 RequesterAgentID string
359359
360- // Required keeps this delegated turn on the parent's critical path. The
361- // supervisor persists its outcome and the manager tool waits for it.
360+ // Required keeps this delegated turn on the parent's completion path. The
361+ // supervisor persists its outcome while the manager remains asynchronous.
362362 Required bool
363363 Timeout time.Duration
364364 MaxTurns int
@@ -357,8 +357,8 @@ type SpawnRequest struct {
357 RootSessionID string357 RootSessionID string
358 RequesterAgentID string358 RequesterAgentID string
359 359
360- // Required keeps this delegated turn on the parent's critical path. The360+ // Required keeps this delegated turn on the parent's completion path. The
361- // supervisor persists its outcome and the manager tool waits for it.361+ // supervisor persists its outcome while the manager remains asynchronous.
362 Required bool362 Required bool
363 Timeout time.Duration363 Timeout time.Duration
364 MaxTurns int364 MaxTurns int
M packages/agent/tools/subagent_required_test.go +42 -51
@@ -8,13 +8,11 @@ import (
88 "path/filepath"
99 "strings"
1010 "testing"
11- "time"
1211
1312 "github.com/bnema/zut/packages/agent/subagents"
14- "github.com/bnema/zut/packages/core"
1513 )
1614
17-func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
15+func TestRequiredSubagentSpawnReturnsWhileWorkerRuns(t *testing.T) {
1816 root := t.TempDir()
1917 started := make(chan struct{})
2018 release := make(chan struct{})
@@ -43,24 +41,17 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
4341 spawned <- required
4442 },
4543 }
46- result := make(chan core.ToolResult, 1)
47- errCh := make(chan error, 1)
48- go func() {
49- got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
50- if err != nil {
51- errCh <- err
52- return
53- }
54- result <- got
55- }()
56-
44+ got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
45+ if err != nil {
46+ t.Fatal(err)
47+ }
5748 <-started
58- select {
59- case got := <-result:
60- t.Fatalf("required spawn returned while worker was running: %s", textResult(got.Content))
61- case err := <-errCh:
62- t.Fatalf("required spawn failed while worker was running: %v", err)
63- default:
49+ text := textResult(got.Content)
50+ if got.IsError || !strings.Contains(text, "required: pending") || !strings.Contains(text, "manager remains free") {
51+ t.Fatalf("required spawn result = %#v\n%s", got, text)
52+ }
53+ if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "pending" {
54+ t.Fatalf("required details = %#v", got.Details)
6455 }
6556 select {
6657 case required := <-spawned:
@@ -72,52 +63,45 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
7263 }
7364
7465 close(release)
75- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
76- defer cancel()
77- select {
78- case err := <-errCh:
79- t.Fatalf("required spawn failed: %v", err)
80- case got := <-result:
81- text := textResult(got.Content)
82- if got.IsError || !strings.Contains(text, "required") || !strings.Contains(text, "satisfied") {
83- t.Fatalf("required result = %#v\n%s", got, text)
84- }
85- if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "satisfied" {
86- t.Fatalf("required details = %#v", got.Details)
87- }
88- case <-ctx.Done():
89- t.Fatal("required spawn did not return after worker completion")
90- }
66+ manager.List()[0].Wait()
9167 }
9268
93-func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
69+func TestRequiredSubagentResumeRetriesUnmetWorkWithoutBlocking(t *testing.T) {
9470 root := t.TempDir()
9571 attempt := 0
72+ retryStarted := make(chan struct{})
73+ releaseRetry := make(chan struct{})
9674 manager := subagents.New(subagents.Config{
9775 Root: filepath.Join(root, "subagents"),
9876 RepoRoot: root,
9977 NewRunner: func(*subagents.Agent) subagents.Runner {
10078 attempt++
10179 current := attempt
102- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
80+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
10381 if current == 1 {
10482 return errors.New("first review failed")
10583 }
106- return nil
84+ close(retryStarted)
85+ select {
86+ case <-releaseRetry:
87+ return nil
88+ case <-ctx.Done():
89+ return ctx.Err()
90+ }
10791 })
10892 },
10993 })
11094 t.Cleanup(manager.StopAll)
11195 spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
11296 first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil)
113- if err != nil {
114- t.Fatal(err)
115- }
116- if !first.IsError {
117- t.Fatalf("first required result should remain unmet: %s", textResult(first.Content))
97+ if err != nil || first.IsError {
98+ t.Fatalf("required spawn = (%+v, %v), want asynchronous success", first, err)
11899 }
119100 worker := manager.List()[0]
120101 worker.Wait()
102+ if !worker.Snapshot().Requirement.Unmet() {
103+ t.Fatal("failed required worker was not retained as unmet")
104+ }
121105
122106 var callbackRequired bool
123107 resume := &SubagentResumeTool{
@@ -132,9 +116,15 @@ func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
132116 if err != nil {
133117 t.Fatal(err)
134118 }
135- if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") {
119+ <-retryStarted
120+ if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), `"requirement":{"state":"pending"`) {
136121 t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))
137122 }
123+ close(releaseRetry)
124+ manager.List()[0].Wait()
125+ if state := manager.List()[0].Snapshot().Requirement.State; state != subagents.RequirementSatisfied {
126+ t.Fatalf("required retry state = %s, want satisfied", state)
127+ }
138128 }
139129
140130 func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {
@@ -204,14 +194,15 @@ func TestRequiredSubagentFailureStaysUnmet(t *testing.T) {
204194 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
205195
206196 got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
207- if err != nil {
208- t.Fatal(err)
209- }
210- if !strings.Contains(textResult(got.Content), "failed") {
211- t.Fatalf("required failure was not surfaced: %s", textResult(got.Content))
197+ if err != nil || got.IsError || !strings.Contains(textResult(got.Content), "required: pending") {
198+ t.Fatalf("required spawn = (%+v, %v), want asynchronous pending result", got, err)
212199 }
213200 agents := manager.List()
214- if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() {
201+ if len(agents) != 1 {
202+ t.Fatalf("spawned agents = %d, want 1", len(agents))
203+ }
204+ agents[0].Wait()
205+ if !agents[0].Snapshot().Requirement.Unmet() {
215206 t.Fatalf("failed required worker was not retained as unmet: %#v", agents)
216207 }
217208 status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}
@@ -8,13 +8,11 @@ import (
8 "path/filepath"8 "path/filepath"
9 "strings"9 "strings"
10 "testing"10 "testing"
11- "time"
12 11
13 "github.com/bnema/zut/packages/agent/subagents"12 "github.com/bnema/zut/packages/agent/subagents"
14- "github.com/bnema/zut/packages/core"
15 )13 )
16 14
17-func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {15+func TestRequiredSubagentSpawnReturnsWhileWorkerRuns(t *testing.T) {
18 root := t.TempDir()16 root := t.TempDir()
19 started := make(chan struct{})17 started := make(chan struct{})
20 release := make(chan struct{})18 release := make(chan struct{})
@@ -43,24 +41,17 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
43 spawned <- required41 spawned <- required
44 },42 },
45 }43 }
46- result := make(chan core.ToolResult, 1)44+ got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
47- errCh := make(chan error, 1)45+ if err != nil {
48- go func() {46+ t.Fatal(err)
49- got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)47+ }
50- if err != nil {
51- errCh <- err
52- return
53- }
54- result <- got
55- }()
56-
57 <-started48 <-started
58- select {49+ text := textResult(got.Content)
59- case got := <-result:50+ if got.IsError || !strings.Contains(text, "required: pending") || !strings.Contains(text, "manager remains free") {
60- t.Fatalf("required spawn returned while worker was running: %s", textResult(got.Content))51+ t.Fatalf("required spawn result = %#v\n%s", got, text)
61- case err := <-errCh:52+ }
62- t.Fatalf("required spawn failed while worker was running: %v", err)53+ if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "pending" {
63- default:54+ t.Fatalf("required details = %#v", got.Details)
64 }55 }
65 select {56 select {
66 case required := <-spawned:57 case required := <-spawned:
@@ -72,52 +63,45 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
72 }63 }
73 64
74 close(release)65 close(release)
75- ctx, cancel := context.WithTimeout(context.Background(), time.Second)66+ manager.List()[0].Wait()
76- defer cancel()
77- select {
78- case err := <-errCh:
79- t.Fatalf("required spawn failed: %v", err)
80- case got := <-result:
81- text := textResult(got.Content)
82- if got.IsError || !strings.Contains(text, "required") || !strings.Contains(text, "satisfied") {
83- t.Fatalf("required result = %#v\n%s", got, text)
84- }
85- if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "satisfied" {
86- t.Fatalf("required details = %#v", got.Details)
87- }
88- case <-ctx.Done():
89- t.Fatal("required spawn did not return after worker completion")
90- }
91 }67 }
92 68
93-func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {69+func TestRequiredSubagentResumeRetriesUnmetWorkWithoutBlocking(t *testing.T) {
94 root := t.TempDir()70 root := t.TempDir()
95 attempt := 071 attempt := 0
72+ retryStarted := make(chan struct{})
73+ releaseRetry := make(chan struct{})
96 manager := subagents.New(subagents.Config{74 manager := subagents.New(subagents.Config{
97 Root: filepath.Join(root, "subagents"),75 Root: filepath.Join(root, "subagents"),
98 RepoRoot: root,76 RepoRoot: root,
99 NewRunner: func(*subagents.Agent) subagents.Runner {77 NewRunner: func(*subagents.Agent) subagents.Runner {
100 attempt++78 attempt++
101 current := attempt79 current := attempt
102- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {80+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
103 if current == 1 {81 if current == 1 {
104 return errors.New("first review failed")82 return errors.New("first review failed")
105 }83 }
106- return nil84+ close(retryStarted)
85+ select {
86+ case <-releaseRetry:
87+ return nil
88+ case <-ctx.Done():
89+ return ctx.Err()
90+ }
107 })91 })
108 },92 },
109 })93 })
110 t.Cleanup(manager.StopAll)94 t.Cleanup(manager.StopAll)
111 spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}95 spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
112 first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil)96 first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil)
113- if err != nil {97+ if err != nil || first.IsError {
114- t.Fatal(err)98+ t.Fatalf("required spawn = (%+v, %v), want asynchronous success", first, err)
115- }
116- if !first.IsError {
117- t.Fatalf("first required result should remain unmet: %s", textResult(first.Content))
118 }99 }
119 worker := manager.List()[0]100 worker := manager.List()[0]
120 worker.Wait()101 worker.Wait()
102+ if !worker.Snapshot().Requirement.Unmet() {
103+ t.Fatal("failed required worker was not retained as unmet")
104+ }
121 105
122 var callbackRequired bool106 var callbackRequired bool
123 resume := &SubagentResumeTool{107 resume := &SubagentResumeTool{
@@ -132,9 +116,15 @@ func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
132 if err != nil {116 if err != nil {
133 t.Fatal(err)117 t.Fatal(err)
134 }118 }
135- if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") {119+ <-retryStarted
120+ if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), `"requirement":{"state":"pending"`) {
136 t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))121 t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))
137 }122 }
123+ close(releaseRetry)
124+ manager.List()[0].Wait()
125+ if state := manager.List()[0].Snapshot().Requirement.State; state != subagents.RequirementSatisfied {
126+ t.Fatalf("required retry state = %s, want satisfied", state)
127+ }
138 }128 }
139 129
140 func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {130 func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {
@@ -204,14 +194,15 @@ func TestRequiredSubagentFailureStaysUnmet(t *testing.T) {
204 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}194 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
205 195
206 got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)196 got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
207- if err != nil {197+ if err != nil || got.IsError || !strings.Contains(textResult(got.Content), "required: pending") {
208- t.Fatal(err)198+ t.Fatalf("required spawn = (%+v, %v), want asynchronous pending result", got, err)
209- }
210- if !strings.Contains(textResult(got.Content), "failed") {
211- t.Fatalf("required failure was not surfaced: %s", textResult(got.Content))
212 }199 }
213 agents := manager.List()200 agents := manager.List()
214- if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() {201+ if len(agents) != 1 {
202+ t.Fatalf("spawned agents = %d, want 1", len(agents))
203+ }
204+ agents[0].Wait()
205+ if !agents[0].Snapshot().Requirement.Unmet() {
215 t.Fatalf("failed required worker was not retained as unmet: %#v", agents)206 t.Fatalf("failed required worker was not retained as unmet: %#v", agents)
216 }207 }
217 status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}208 status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}
M packages/agent/tools/subagent_resume.go +2 -6
@@ -51,7 +51,7 @@ const subagentResumeSchema = `{
5151 },
5252 "required": {
5353 "type": "boolean",
54- "description": "Set true to make this follow-up mandatory. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation."
54+ "description": "Set true to make this asynchronous follow-up mandatory before the parent's terminal response. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation."
5555 }
5656 },
5757 "required": ["agent_id", "prompt"]
@@ -67,7 +67,7 @@ func (t *SubagentResumeTool) Schema() json.RawMessage {
6767 return json.RawMessage(subagentResumeSchema)
6868 }
6969
70-func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
70+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
7171 if ctx != nil {
7272 select {
7373 case <-ctx.Done():
@@ -121,10 +121,6 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, p
121121 if t.BeforeResumed == nil && t.OnResumed != nil {
122122 t.OnResumed(resumed, args.Prompt, required)
123123 }
124- if required {
125- return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress)
126- }
127-
128124 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
129125 if !ok {
130126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
@@ -51,7 +51,7 @@ const subagentResumeSchema = `{
51 },51 },
52 "required": {52 "required": {
53 "type": "boolean",53 "type": "boolean",
54- "description": "Set true to make this follow-up mandatory. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation."54+ "description": "Set true to make this asynchronous follow-up mandatory before the parent's terminal response. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation."
55 }55 }
56 },56 },
57 "required": ["agent_id", "prompt"]57 "required": ["agent_id", "prompt"]
@@ -67,7 +67,7 @@ func (t *SubagentResumeTool) Schema() json.RawMessage {
67 return json.RawMessage(subagentResumeSchema)67 return json.RawMessage(subagentResumeSchema)
68 }68 }
69 69
70-func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {70+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
71 if ctx != nil {71 if ctx != nil {
72 select {72 select {
73 case <-ctx.Done():73 case <-ctx.Done():
@@ -121,10 +121,6 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, p
121 if t.BeforeResumed == nil && t.OnResumed != nil {121 if t.BeforeResumed == nil && t.OnResumed != nil {
122 t.OnResumed(resumed, args.Prompt, required)122 t.OnResumed(resumed, args.Prompt, required)
123 }123 }
124- if required {
125- return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress)
126- }
127-
128 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)124 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
129 if !ok {125 if !ok {
130 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
M packages/agent/tools/subagent_spawn.go +30 -75
@@ -13,9 +13,9 @@ import (
1313 )
1414
1515 // SubagentSpawnTool lets the main agent delegate work against the host's cwd
16-// via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs
17-// in the background. Required work keeps the tool call open until the delegated
18-// turn reaches a durable terminal outcome.
16+// via subagents.Supervisor.SpawnReq. Every spawn returns immediately and runs in
17+// the background. Required work records a durable obligation that must be
18+// resolved before the parent can produce its terminal response.
1919 //
2020 // Available when the host's launch-time tool policy permits delegation. The
2121 // primary-agent prompt decides whether delegation is proactive or only on an
@@ -49,8 +49,7 @@ type SubagentSpawnTool struct {
4949 ResolveSubagent func(name string) (*subagents.Profile, error)
5050
5151 // OnSpawned, if set, is called after every successful spawn with the
52- // requirement mode. Hosts track optional work for asynchronous completion;
53- // required work is delivered by this blocking tool call instead.
52+ // requirement mode. Hosts track every worker for asynchronous completion.
5453 OnSpawned func(agent *subagents.Agent, task string, required bool)
5554 }
5655
@@ -103,7 +102,7 @@ const subagentSpawnSchemaTemplate = `{
103102 },
104103 "required": {
105104 "type": "boolean",
106- "description": "Set true when the parent must receive this delegated result before it can finish. Required calls wait event-driven for a terminal outcome; failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
105+ "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
107106 },
108107 "isolation": {
109108 "type": "string",
@@ -126,7 +125,7 @@ const subagentSpawnSchemaTemplate = `{
126125
127126 func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
128127 func (t *SubagentSpawnTool) Description() string {
129- return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
128+ return "Delegate work to a sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
130129 }
131130 func (t *SubagentSpawnTool) Schema() json.RawMessage {
132131 maxTurns := 3
@@ -138,7 +137,7 @@ func (t *SubagentSpawnTool) Schema() json.RawMessage {
138137 return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
139138 }
140139
141-func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
140+func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
142141 prefix := t.Name()
143142 if t.Supervisor == nil {
144143 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
@@ -272,10 +271,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
272271 if t.OnSpawned != nil {
273272 t.OnSpawned(agent, task, a.Required)
274273 }
275- if a.Required {
276- return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
277- }
278-
279274 var sb strings.Builder
280275 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
281276 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
@@ -299,75 +294,35 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
299294 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
300295 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
301296 }
302- sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven: wait for the injected [auto-subagents update], the only completion signal. ")
297+ if a.Required {
298+ sb.WriteString("required: pending\n")
299+ }
300+ sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
303301 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
304302 sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.")
305- return core.ToolResult{
306- Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
307- Details: map[string]any{
308- "agent_id": agent.ID,
309- "task": task,
310- "agent": agentName,
311- "model": model,
312- "provider": providerID,
313- "reasoning": reasoning,
314- "fast_mode": agent.FastMode,
315- "required": false,
316- "isolation": string(workspaceMode),
317- "timeout": agent.Timeout.String(),
318- "max_turns": agent.MaxTurns,
319- "state": agent.Status(),
320- "process_state": string(agent.ProcessState()),
321- "turn_state": string(agent.TurnState()),
322- "result_ref": subagents.ResultRef(agent.ID),
323- },
324- }, nil
325-}
326-
327-func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) {
328- if progress != nil {
329- progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID))
330- }
331- requirement, err := supervisor.WaitRequired(ctx, agent.ID)
332- if err != nil {
333- return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)
303+ details := map[string]any{
304+ "agent_id": agent.ID,
305+ "task": task,
306+ "agent": agentName,
307+ "model": model,
308+ "provider": providerID,
309+ "reasoning": reasoning,
310+ "fast_mode": agent.FastMode,
311+ "required": a.Required,
312+ "isolation": string(workspaceMode),
313+ "timeout": agent.Timeout.String(),
314+ "max_turns": agent.MaxTurns,
315+ "state": agent.Status(),
316+ "process_state": string(agent.ProcessState()),
317+ "turn_state": string(agent.TurnState()),
318+ "result_ref": subagents.ResultRef(agent.ID),
334319 }
335- snapshot := agent.Snapshot()
336- var sb strings.Builder
337- fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State)
338- fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
339- fmt.Fprintf(&sb, "requirement: %s\n", requirement.State)
340- if requirement.ErrorCode != "" {
341- fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode)
342- fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID))
343- }
344- output := strings.TrimSpace(snapshot.LastAssistant)
345- if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
346- output = strings.TrimSpace(snapshot.Result.Output)
347- }
348- if output != "" {
349- sb.WriteString("\nresult:\n")
350- sb.WriteString(output)
351- sb.WriteByte('\n')
352- }
353- if requirement.Unmet() {
354- sb.WriteString("\nThis required work is still unmet. Retry it with subagent_resume before producing a terminal answer. The user may explicitly waive it by removing the terminal worker with /subagents rm.")
355- } else {
356- sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.")
320+ if a.Required {
321+ details["requirement_state"] = string(subagents.RequirementPending)
357322 }
358323 return core.ToolResult{
359324 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
360- Details: map[string]any{
361- "agent_id": agent.ID,
362- "task": task,
363- "required": true,
364- "requirement_state": string(requirement.State),
365- "state": snapshot.Status,
366- "process_state": string(snapshot.ProcessState),
367- "turn_state": string(snapshot.TurnState),
368- "result_ref": subagents.ResultRef(agent.ID),
369- },
370- IsError: requirement.Unmet(),
325+ Details: details,
371326 }, nil
372327 }
373328
@@ -13,9 +13,9 @@ import (
13 )13 )
14 14
15 // SubagentSpawnTool lets the main agent delegate work against the host's cwd15 // SubagentSpawnTool lets the main agent delegate work against the host's cwd
16-// via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs16+// via subagents.Supervisor.SpawnReq. Every spawn returns immediately and runs in
17-// in the background. Required work keeps the tool call open until the delegated17+// the background. Required work records a durable obligation that must be
18-// turn reaches a durable terminal outcome.18+// resolved before the parent can produce its terminal response.
19 //19 //
20 // Available when the host's launch-time tool policy permits delegation. The20 // Available when the host's launch-time tool policy permits delegation. The
21 // primary-agent prompt decides whether delegation is proactive or only on an21 // primary-agent prompt decides whether delegation is proactive or only on an
@@ -49,8 +49,7 @@ type SubagentSpawnTool struct {
49 ResolveSubagent func(name string) (*subagents.Profile, error)49 ResolveSubagent func(name string) (*subagents.Profile, error)
50 50
51 // OnSpawned, if set, is called after every successful spawn with the51 // OnSpawned, if set, is called after every successful spawn with the
52- // requirement mode. Hosts track optional work for asynchronous completion;52+ // requirement mode. Hosts track every worker for asynchronous completion.
53- // required work is delivered by this blocking tool call instead.
54 OnSpawned func(agent *subagents.Agent, task string, required bool)53 OnSpawned func(agent *subagents.Agent, task string, required bool)
55 }54 }
56 55
@@ -103,7 +102,7 @@ const subagentSpawnSchemaTemplate = `{
103 },102 },
104 "required": {103 "required": {
105 "type": "boolean",104 "type": "boolean",
106- "description": "Set true when the parent must receive this delegated result before it can finish. Required calls wait event-driven for a terminal outcome; failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."105+ "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
107 },106 },
108 "isolation": {107 "isolation": {
109 "type": "string",108 "type": "string",
@@ -126,7 +125,7 @@ const subagentSpawnSchemaTemplate = `{
126 125
127 func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }126 func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
128 func (t *SubagentSpawnTool) Description() string {127 func (t *SubagentSpawnTool) Description() string {
129- return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."128+ return "Delegate work to a sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
130 }129 }
131 func (t *SubagentSpawnTool) Schema() json.RawMessage {130 func (t *SubagentSpawnTool) Schema() json.RawMessage {
132 maxTurns := 3131 maxTurns := 3
@@ -138,7 +137,7 @@ func (t *SubagentSpawnTool) Schema() json.RawMessage {
138 return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))137 return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
139 }138 }
140 139
141-func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {140+func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
142 prefix := t.Name()141 prefix := t.Name()
143 if t.Supervisor == nil {142 if t.Supervisor == nil {
144 return protocolToolError(prefix + ": subagent supervisor not available in this mode")143 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
@@ -272,10 +271,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
272 if t.OnSpawned != nil {271 if t.OnSpawned != nil {
273 t.OnSpawned(agent, task, a.Required)272 t.OnSpawned(agent, task, a.Required)
274 }273 }
275- if a.Required {
276- return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
277- }
278-
279 var sb strings.Builder274 var sb strings.Builder
280 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)275 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
281 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())276 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
@@ -299,75 +294,35 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
299 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {294 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
300 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")295 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
301 }296 }
302- sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven: wait for the injected [auto-subagents update], the only completion signal. ")297+ if a.Required {
298+ sb.WriteString("required: pending\n")
299+ }
300+ sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
303 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")301 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
304 sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.")302 sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.")
305- return core.ToolResult{303+ details := map[string]any{
306- Content: []provider.Content{provider.TextBlock{Text: sb.String()}},304+ "agent_id": agent.ID,
307- Details: map[string]any{305+ "task": task,
308- "agent_id": agent.ID,306+ "agent": agentName,
309- "task": task,307+ "model": model,
310- "agent": agentName,308+ "provider": providerID,
311- "model": model,309+ "reasoning": reasoning,
312- "provider": providerID,310+ "fast_mode": agent.FastMode,
313- "reasoning": reasoning,311+ "required": a.Required,
314- "fast_mode": agent.FastMode,312+ "isolation": string(workspaceMode),
315- "required": false,313+ "timeout": agent.Timeout.String(),
316- "isolation": string(workspaceMode),314+ "max_turns": agent.MaxTurns,
317- "timeout": agent.Timeout.String(),315+ "state": agent.Status(),
318- "max_turns": agent.MaxTurns,316+ "process_state": string(agent.ProcessState()),
319- "state": agent.Status(),317+ "turn_state": string(agent.TurnState()),
320- "process_state": string(agent.ProcessState()),318+ "result_ref": subagents.ResultRef(agent.ID),
321- "turn_state": string(agent.TurnState()),
322- "result_ref": subagents.ResultRef(agent.ID),
323- },
324- }, nil
325-}
326-
327-func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) {
328- if progress != nil {
329- progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID))
330- }
331- requirement, err := supervisor.WaitRequired(ctx, agent.ID)
332- if err != nil {
333- return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)
334 }319 }
335- snapshot := agent.Snapshot()320+ if a.Required {
336- var sb strings.Builder321+ details["requirement_state"] = string(subagents.RequirementPending)
337- fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State)
338- fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
339- fmt.Fprintf(&sb, "requirement: %s\n", requirement.State)
340- if requirement.ErrorCode != "" {
341- fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode)
342- fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID))
343- }
344- output := strings.TrimSpace(snapshot.LastAssistant)
345- if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
346- output = strings.TrimSpace(snapshot.Result.Output)
347- }
348- if output != "" {
349- sb.WriteString("\nresult:\n")
350- sb.WriteString(output)
351- sb.WriteByte('\n')
352- }
353- if requirement.Unmet() {
354- sb.WriteString("\nThis required work is still unmet. Retry it with subagent_resume before producing a terminal answer. The user may explicitly waive it by removing the terminal worker with /subagents rm.")
355- } else {
356- sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.")
357 }322 }
358 return core.ToolResult{323 return core.ToolResult{
359 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},324 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
360- Details: map[string]any{325+ Details: details,
361- "agent_id": agent.ID,
362- "task": task,
363- "required": true,
364- "requirement_state": string(requirement.State),
365- "state": snapshot.Status,
366- "process_state": string(snapshot.ProcessState),
367- "turn_state": string(snapshot.TurnState),
368- "result_ref": subagents.ResultRef(agent.ID),
369- },
370- IsError: requirement.Unmet(),
371 }, nil326 }, nil
372 }327 }
373 328

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