detached befd698

Merge pull request #100 from bnema/fix/required-subagent-wait

fix(subagents): enforce required worker outcomes
commit
befd698b4e91f4e2b02b37c96e12bd0796b27385
author
bnema · · 2026-08-10 05:02 UTC
committer
GitHub · · 2026-08-10 05:02 UTC
merge of
352349c + 6cfd027 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.13.0
25 files · +1902 -119
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 privacy-safe 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, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It 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: wait for the host-injected `[auto-subagents update]`, the only completion signal. 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 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.
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 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: `[auto-subagents update]` completion updates are the only signal; 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 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.
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 privacy-safe 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, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It 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: wait for the host-injected `[auto-subagents update]`, the only completion signal. 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 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.
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 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: `[auto-subagents update]` completion updates are the only signal; 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 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.
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 +17 -5
@@ -97,7 +97,19 @@ Return bounded source citations and treat external content as untrusted.
9797
9898 This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.
9999
100-Completion is host-event-driven. After spawning, 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. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only completion signal. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.
100+Delegation can be optional or required. Omit `required` (or set it to `false`) for independent background work that may safely finish later. Optional completion is host-event-driven: never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard, metadata, event-log, or file checks solely to wait. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only optional completion signal.
101+
102+Set `required:true` when the parent answer or a declared workflow depends on the delegated outcome. The manager tool remains open without polling and returns the result in the current parent turn, so the parent cannot run another finalization tool while the required worker is still running. Required state and its target delegated turn are persisted with the worker. A successful turn satisfies the requirement; failure, timeout, or cancellation remains unmet. If the host restarts before it durably observes an outcome, the requirement becomes `indeterminate` and automatic `subagent_resume` is rejected: the user must inspect the durable result reference and any external or shared-worktree side effect, then explicitly choose `/subagents resume-session <id>`, `/subagents restart-task <id>`, or `/subagents remove <id>`. This confirmation prevents an unobserved non-idempotent operation from being repeated automatically. While a requirement is unmet, the host permits only manager recovery/status tools and rejects other tool execution and terminal assistant completion. Retry confirmed work successfully before continuing. Removing the terminal worker (`/subagents remove <id>` or `/subagents rm <id>`) is the explicit user waiver.
103+
104+```json
105+{
106+ "task": "Inspect the existing release-validation results and report blockers.",
107+ "agent": "reviewer",
108+ "required": true
109+}
110+```
111+
112+Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.
101113
102114 Per-spawn reasoning and fast-mode overrides are also accepted:
103115
@@ -123,13 +135,13 @@ Shared mode preserves the historical host working directory, so workers there mu
123135
124136 ## Lifecycle, results, and recovery
125137
126-Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references.
138+Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, logical result references, and any required-work state, target turn, stable outcome error code, and parent-notification marker.
127139
128140 During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.
129141
130142 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.
131143
132-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`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, 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`. 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:
133145
134146 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.
135147
@@ -146,9 +158,9 @@ A subagent timeout applies to each active delegated turn. When the deadline expi
146158
147159 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
148160
149-When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. 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, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.
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.
150162
151-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. 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. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. 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, 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.
152164
153165 ## Resource policy
154166
@@ -97,7 +97,19 @@ Return bounded source citations and treat external content as untrusted.
97 97
98 This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.98 This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.
99 99
100-Completion is host-event-driven. After spawning, 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. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only completion signal. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.100+Delegation can be optional or required. Omit `required` (or set it to `false`) for independent background work that may safely finish later. Optional completion is host-event-driven: never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard, metadata, event-log, or file checks solely to wait. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only optional completion signal.
101+
102+Set `required:true` when the parent answer or a declared workflow depends on the delegated outcome. The manager tool remains open without polling and returns the result in the current parent turn, so the parent cannot run another finalization tool while the required worker is still running. Required state and its target delegated turn are persisted with the worker. A successful turn satisfies the requirement; failure, timeout, or cancellation remains unmet. If the host restarts before it durably observes an outcome, the requirement becomes `indeterminate` and automatic `subagent_resume` is rejected: the user must inspect the durable result reference and any external or shared-worktree side effect, then explicitly choose `/subagents resume-session <id>`, `/subagents restart-task <id>`, or `/subagents remove <id>`. This confirmation prevents an unobserved non-idempotent operation from being repeated automatically. While a requirement is unmet, the host permits only manager recovery/status tools and rejects other tool execution and terminal assistant completion. Retry confirmed work successfully before continuing. Removing the terminal worker (`/subagents remove <id>` or `/subagents rm <id>`) is the explicit user waiver.
103+
104+```json
105+{
106+ "task": "Inspect the existing release-validation results and report blockers.",
107+ "agent": "reviewer",
108+ "required": true
109+}
110+```
111+
112+Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.
101 113
102 Per-spawn reasoning and fast-mode overrides are also accepted:114 Per-spawn reasoning and fast-mode overrides are also accepted:
103 115
@@ -123,13 +135,13 @@ Shared mode preserves the historical host working directory, so workers there mu
123 135
124 ## Lifecycle, results, and recovery136 ## Lifecycle, results, and recovery
125 137
126-Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references.138+Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, logical result references, and any required-work state, target turn, stable outcome error code, and parent-notification marker.
127 139
128 During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.140 During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.
129 141
130 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.
131 143
132-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`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, 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`. 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:
133 145
134 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.
135 147
@@ -146,9 +158,9 @@ A subagent timeout applies to each active delegated turn. When the deadline expi
146 158
147 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.159 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
148 160
149-When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. 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, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.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.
150 162
151-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. 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. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. 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, 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.
152 164
153 ## Resource policy165 ## Resource policy
154 166
M packages/agent/cli.go +10 -6
@@ -1456,19 +1456,19 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14561456 var subagentsMgr *subagents.Supervisor
14571457 // These callbacks deliberately resolve iv lazily because the Interactive
14581458 // instance is constructed after the agent and its registry.
1459- onSpawnedSupervisor := func(a *subagents.Agent, task string) {
1459+ onSpawnedSupervisor := func(a *subagents.Agent, task string, required bool) {
14601460 if iv != nil {
1461- iv.TrackSubagentWorker(a, task)
1461+ iv.TrackSubagentWorker(a, task, required)
14621462 }
14631463 }
1464- onResumedSupervisor := func(a *subagents.Agent, prompt string) {
1464+ onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) {
14651465 if iv != nil {
1466- iv.TrackResumedSubagentWorker(a, prompt)
1466+ iv.TrackResumedSubagentWorker(a, prompt, required)
14671467 }
14681468 }
1469- onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string) func() {
1469+ onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) func() {
14701470 if iv != nil {
1471- return iv.PrepareResumedSubagentWorker(a, prompt)
1471+ return iv.PrepareResumedSubagentWorker(a, prompt, required)
14721472 }
14731473 return nil
14741474 }
@@ -1513,6 +1513,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
15131513 subagentsMgr = runtime.Supervisor()
15141514 reloadDone := make(chan struct{})
15151515 var reloadErrs []error
1516+ runtime.SetRequiredWorkerReady(reloadDone, func() error {
1517+ return errors.Join(reloadErrs...)
1518+ })
15161519 if subagentsMgr != nil {
15171520 // Replaying historical event logs can take seconds. Populate the
15181521 // detached-agent dashboard without blocking the first interactive paint.
@@ -1610,6 +1613,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16101613 return true, "", r.ReplaceText
16111614 }
16121615 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1616+ runtime.WireRequiredWorkerGate(a)
16131617 return a
16141618 }
16151619
@@ -1456,19 +1456,19 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1456 var subagentsMgr *subagents.Supervisor1456 var subagentsMgr *subagents.Supervisor
1457 // These callbacks deliberately resolve iv lazily because the Interactive1457 // These callbacks deliberately resolve iv lazily because the Interactive
1458 // instance is constructed after the agent and its registry.1458 // instance is constructed after the agent and its registry.
1459- onSpawnedSupervisor := func(a *subagents.Agent, task string) {1459+ onSpawnedSupervisor := func(a *subagents.Agent, task string, required bool) {
1460 if iv != nil {1460 if iv != nil {
1461- iv.TrackSubagentWorker(a, task)1461+ iv.TrackSubagentWorker(a, task, required)
1462 }1462 }
1463 }1463 }
1464- onResumedSupervisor := func(a *subagents.Agent, prompt string) {1464+ onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) {
1465 if iv != nil {1465 if iv != nil {
1466- iv.TrackResumedSubagentWorker(a, prompt)1466+ iv.TrackResumedSubagentWorker(a, prompt, required)
1467 }1467 }
1468 }1468 }
1469- onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string) func() {1469+ onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) func() {
1470 if iv != nil {1470 if iv != nil {
1471- return iv.PrepareResumedSubagentWorker(a, prompt)1471+ return iv.PrepareResumedSubagentWorker(a, prompt, required)
1472 }1472 }
1473 return nil1473 return nil
1474 }1474 }
@@ -1513,6 +1513,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1513 subagentsMgr = runtime.Supervisor()1513 subagentsMgr = runtime.Supervisor()
1514 reloadDone := make(chan struct{})1514 reloadDone := make(chan struct{})
1515 var reloadErrs []error1515 var reloadErrs []error
1516+ runtime.SetRequiredWorkerReady(reloadDone, func() error {
1517+ return errors.Join(reloadErrs...)
1518+ })
1516 if subagentsMgr != nil {1519 if subagentsMgr != nil {
1517 // Replaying historical event logs can take seconds. Populate the1520 // Replaying historical event logs can take seconds. Populate the
1518 // detached-agent dashboard without blocking the first interactive paint.1521 // detached-agent dashboard without blocking the first interactive paint.
@@ -1610,6 +1613,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1610 return true, "", r.ReplaceText1613 return true, "", r.ReplaceText
1611 }1614 }
1612 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }1615 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1616+ runtime.WireRequiredWorkerGate(a)
1613 return a1617 return a
1614 }1618 }
1615 1619
M packages/agent/modes/auto_subagents_test.go +9 -9
@@ -91,7 +91,7 @@ func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testi
9191 }
9292
9393 iv := newQueuedAutoSubagentsInteractive()
94- iv.TrackSubagentWorker(a, a.Task)
94+ iv.TrackSubagentWorker(a, a.Task, false)
9595 update := waitForQueuedPrompt(t, iv)
9696 if !strings.Contains(update, "final response:\n"+response) {
9797 t.Fatalf("update missing complete final response: %q", update)
@@ -114,7 +114,7 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
114114 }
115115
116116 iv := newQueuedAutoSubagentsInteractive()
117- iv.TrackSubagentWorker(a, a.Task)
117+ iv.TrackSubagentWorker(a, a.Task, false)
118118
119119 update := waitForQueuedPrompt(t, iv)
120120 if !strings.Contains(update, "status: failed") {
@@ -182,7 +182,7 @@ func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) {
182182 }
183183
184184 iv := newQueuedAutoSubagentsInteractive()
185- iv.TrackSubagentWorker(a, a.Task)
185+ iv.TrackSubagentWorker(a, a.Task, false)
186186 update := waitForQueuedPrompt(t, iv)
187187 if !strings.Contains(update, "status: failed") {
188188 t.Fatalf("deadline update changed failed status: %q", update)
@@ -218,7 +218,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
218218
219219 iv := newQueuedAutoSubagentsInteractive()
220220 const followUp = "I applied your review. What do you think now?"
221- iv.TrackResumedSubagentWorker(a, followUp)
221+ iv.TrackResumedSubagentWorker(a, followUp, false)
222222 if a.OnTurnEnd == nil {
223223 t.Fatal("resumed worker has no completion watcher")
224224 }
@@ -260,9 +260,9 @@ func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T
260260 })
261261
262262 iv := newQueuedAutoSubagentsInteractive()
263- iv.TrackSubagentWorker(first, first.Task)
263+ iv.TrackSubagentWorker(first, first.Task, false)
264264 const followUp = "queued follow-up"
265- cancel := iv.PrepareResumedSubagentWorker(first, followUp)
265+ cancel := iv.PrepareResumedSubagentWorker(first, followUp, false)
266266 if cancel == nil {
267267 t.Fatal("resume registration returned nil cleanup")
268268 }
@@ -309,9 +309,9 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
309309
310310 iv := newQueuedAutoSubagentsInteractive()
311311 release := iv.beginCompletionDeliveryHold()
312- iv.TrackSubagentWorker(first, first.Task)
312+ iv.TrackSubagentWorker(first, first.Task, false)
313313 first.OnTurnEnd(1, "")
314- iv.TrackSubagentWorker(second, second.Task)
314+ iv.TrackSubagentWorker(second, second.Task, false)
315315 second.OnTurnEnd(1, "")
316316 release()
317317
@@ -354,7 +354,7 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
354354 })
355355
356356 iv := newQueuedAutoSubagentsInteractive()
357- iv.TrackSubagentWorker(a, a.Task)
357+ iv.TrackSubagentWorker(a, a.Task, false)
358358 if a.OnTurnEnd == nil {
359359 t.Fatal("worker has no completion watcher")
360360 }
@@ -91,7 +91,7 @@ func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testi
91 }91 }
92 92
93 iv := newQueuedAutoSubagentsInteractive()93 iv := newQueuedAutoSubagentsInteractive()
94- iv.TrackSubagentWorker(a, a.Task)94+ iv.TrackSubagentWorker(a, a.Task, false)
95 update := waitForQueuedPrompt(t, iv)95 update := waitForQueuedPrompt(t, iv)
96 if !strings.Contains(update, "final response:\n"+response) {96 if !strings.Contains(update, "final response:\n"+response) {
97 t.Fatalf("update missing complete final response: %q", update)97 t.Fatalf("update missing complete final response: %q", update)
@@ -114,7 +114,7 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
114 }114 }
115 115
116 iv := newQueuedAutoSubagentsInteractive()116 iv := newQueuedAutoSubagentsInteractive()
117- iv.TrackSubagentWorker(a, a.Task)117+ iv.TrackSubagentWorker(a, a.Task, false)
118 118
119 update := waitForQueuedPrompt(t, iv)119 update := waitForQueuedPrompt(t, iv)
120 if !strings.Contains(update, "status: failed") {120 if !strings.Contains(update, "status: failed") {
@@ -182,7 +182,7 @@ func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) {
182 }182 }
183 183
184 iv := newQueuedAutoSubagentsInteractive()184 iv := newQueuedAutoSubagentsInteractive()
185- iv.TrackSubagentWorker(a, a.Task)185+ iv.TrackSubagentWorker(a, a.Task, false)
186 update := waitForQueuedPrompt(t, iv)186 update := waitForQueuedPrompt(t, iv)
187 if !strings.Contains(update, "status: failed") {187 if !strings.Contains(update, "status: failed") {
188 t.Fatalf("deadline update changed failed status: %q", update)188 t.Fatalf("deadline update changed failed status: %q", update)
@@ -218,7 +218,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
218 218
219 iv := newQueuedAutoSubagentsInteractive()219 iv := newQueuedAutoSubagentsInteractive()
220 const followUp = "I applied your review. What do you think now?"220 const followUp = "I applied your review. What do you think now?"
221- iv.TrackResumedSubagentWorker(a, followUp)221+ iv.TrackResumedSubagentWorker(a, followUp, false)
222 if a.OnTurnEnd == nil {222 if a.OnTurnEnd == nil {
223 t.Fatal("resumed worker has no completion watcher")223 t.Fatal("resumed worker has no completion watcher")
224 }224 }
@@ -260,9 +260,9 @@ func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T
260 })260 })
261 261
262 iv := newQueuedAutoSubagentsInteractive()262 iv := newQueuedAutoSubagentsInteractive()
263- iv.TrackSubagentWorker(first, first.Task)263+ iv.TrackSubagentWorker(first, first.Task, false)
264 const followUp = "queued follow-up"264 const followUp = "queued follow-up"
265- cancel := iv.PrepareResumedSubagentWorker(first, followUp)265+ cancel := iv.PrepareResumedSubagentWorker(first, followUp, false)
266 if cancel == nil {266 if cancel == nil {
267 t.Fatal("resume registration returned nil cleanup")267 t.Fatal("resume registration returned nil cleanup")
268 }268 }
@@ -309,9 +309,9 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
309 309
310 iv := newQueuedAutoSubagentsInteractive()310 iv := newQueuedAutoSubagentsInteractive()
311 release := iv.beginCompletionDeliveryHold()311 release := iv.beginCompletionDeliveryHold()
312- iv.TrackSubagentWorker(first, first.Task)312+ iv.TrackSubagentWorker(first, first.Task, false)
313 first.OnTurnEnd(1, "")313 first.OnTurnEnd(1, "")
314- iv.TrackSubagentWorker(second, second.Task)314+ iv.TrackSubagentWorker(second, second.Task, false)
315 second.OnTurnEnd(1, "")315 second.OnTurnEnd(1, "")
316 release()316 release()
317 317
@@ -354,7 +354,7 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
354 })354 })
355 355
356 iv := newQueuedAutoSubagentsInteractive()356 iv := newQueuedAutoSubagentsInteractive()
357- iv.TrackSubagentWorker(a, a.Task)357+ iv.TrackSubagentWorker(a, a.Task, false)
358 if a.OnTurnEnd == nil {358 if a.OnTurnEnd == nil {
359 t.Fatal("worker has no completion watcher")359 t.Fatal("worker has no completion watcher")
360 }360 }
M packages/agent/modes/interactive.go +29 -7
@@ -8743,14 +8743,26 @@ 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) {
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+ }
87478753 i.trackSubagentWorker(a, task, false, false)
87488754 }
87498755
87508756 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
87518757 // worker's original task. A long-lived daemon can complete several manager
87528758 // turns, each of which must produce its own automatic delivery.
8753-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {
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+ }
87548766 i.trackSubagentWorker(a, prompt, true, false)
87558767 }
87568768
@@ -8758,7 +8770,13 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri
87588770 // sent. The returned cleanup removes the registration if the resume operation
87598771 // fails before delivery. It is used by the resume tool's pre-send hook; the
87608772 // existing TrackResumedSubagentWorker signature remains the post-success API.
8761-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string) func() {
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+ }
87628780 return i.trackSubagentWorker(a, prompt, true, true)
87638781 }
87648782
@@ -9069,7 +9087,9 @@ func (i *Interactive) applyAutoSubagentsTool() {
90699087 DefaultProvider: func() string { return i.cfg.Provider },
90709088 DefaultReasoning: func() string { return i.cfg.Reasoning },
90719089 ResolveSubagent: i.cfg.ResolveSubagent,
9072- OnSpawned: i.TrackSubagentWorker,
9090+ OnSpawned: func(a *subagents.Agent, task string, required bool) {
9091+ i.TrackSubagentWorker(a, task, required)
9092+ },
90739093 }
90749094 next[canonical.Name()] = canonical
90759095 }
@@ -9090,9 +9110,11 @@ func (i *Interactive) applyAutoSubagentsTool() {
90909110 }
90919111 if i.autoSubagentsResumeToolAllowed() {
90929112 resumeTool := &tools.SubagentResumeTool{
9093- Supervisor: i.cfg.Supervisor,
9094- Enabled: func() bool { return true },
9095- BeforeResumed: i.PrepareResumedSubagentWorker,
9113+ Supervisor: i.cfg.Supervisor,
9114+ Enabled: func() bool { return true },
9115+ BeforeResumed: func(a *subagents.Agent, prompt string, required bool) func() {
9116+ return i.PrepareResumedSubagentWorker(a, prompt, required)
9117+ },
90969118 }
90979119 next[resumeTool.Name()] = resumeTool
90989120 }
@@ -8743,14 +8743,26 @@ 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) {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+ }
8747 i.trackSubagentWorker(a, task, false, false)8753 i.trackSubagentWorker(a, task, false, false)
8748 }8754 }
8749 8755
8750 // TrackResumedSubagentWorker watches a resumed follow-up independently of the8756 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
8751 // worker's original task. A long-lived daemon can complete several manager8757 // worker's original task. A long-lived daemon can complete several manager
8752 // turns, each of which must produce its own automatic delivery.8758 // turns, each of which must produce its own automatic delivery.
8753-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {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+ }
8754 i.trackSubagentWorker(a, prompt, true, false)8766 i.trackSubagentWorker(a, prompt, true, false)
8755 }8767 }
8756 8768
@@ -8758,7 +8770,13 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri
8758 // sent. The returned cleanup removes the registration if the resume operation8770 // sent. The returned cleanup removes the registration if the resume operation
8759 // fails before delivery. It is used by the resume tool's pre-send hook; the8771 // fails before delivery. It is used by the resume tool's pre-send hook; the
8760 // existing TrackResumedSubagentWorker signature remains the post-success API.8772 // existing TrackResumedSubagentWorker signature remains the post-success API.
8761-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string) func() {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+ }
8762 return i.trackSubagentWorker(a, prompt, true, true)8780 return i.trackSubagentWorker(a, prompt, true, true)
8763 }8781 }
8764 8782
@@ -9069,7 +9087,9 @@ func (i *Interactive) applyAutoSubagentsTool() {
9069 DefaultProvider: func() string { return i.cfg.Provider },9087 DefaultProvider: func() string { return i.cfg.Provider },
9070 DefaultReasoning: func() string { return i.cfg.Reasoning },9088 DefaultReasoning: func() string { return i.cfg.Reasoning },
9071 ResolveSubagent: i.cfg.ResolveSubagent,9089 ResolveSubagent: i.cfg.ResolveSubagent,
9072- OnSpawned: i.TrackSubagentWorker,9090+ OnSpawned: func(a *subagents.Agent, task string, required bool) {
9091+ i.TrackSubagentWorker(a, task, required)
9092+ },
9073 }9093 }
9074 next[canonical.Name()] = canonical9094 next[canonical.Name()] = canonical
9075 }9095 }
@@ -9090,9 +9110,11 @@ func (i *Interactive) applyAutoSubagentsTool() {
9090 }9110 }
9091 if i.autoSubagentsResumeToolAllowed() {9111 if i.autoSubagentsResumeToolAllowed() {
9092 resumeTool := &tools.SubagentResumeTool{9112 resumeTool := &tools.SubagentResumeTool{
9093- Supervisor: i.cfg.Supervisor,9113+ Supervisor: i.cfg.Supervisor,
9094- Enabled: func() bool { return true },9114+ Enabled: func() bool { return true },
9095- BeforeResumed: i.PrepareResumedSubagentWorker,9115+ BeforeResumed: func(a *subagents.Agent, prompt string, required bool) func() {
9116+ return i.PrepareResumedSubagentWorker(a, prompt, required)
9117+ },
9096 }9118 }
9097 next[resumeTool.Name()] = resumeTool9119 next[resumeTool.Name()] = resumeTool
9098 }9120 }
M packages/agent/orchestration_modes.go +18 -5
@@ -68,6 +68,11 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
6868 runErr = errors.Join(runErr, closeErr)
6969 }
7070 }()
71+ if runtime.Supervisor() != nil {
72+ if _, reloadErrs := runtime.Supervisor().Reload(); len(reloadErrs) != 0 {
73+ return fmt.Errorf("reload subagents: %w", errors.Join(reloadErrs...))
74+ }
75+ }
7176
7277 prepareRegistry := func(reg core.Registry) core.Registry {
7378 return runtime.PrepareRegistry(reg)
@@ -102,6 +107,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
102107 runtime.SetActiveSession(sess.ID)
103108 }
104109 announceSession(extMgr, sess)
110+ runtime.WireRequiredWorkerGate(ag)
105111
106112 start := len(ag.Messages())
107113 preSink, finishPre := hooks.startupSink()
@@ -186,16 +192,23 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
186192 }
187193
188194 func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
189- onSpawned := func(a *subagents.Agent, task string) {
190- tracker.TrackTurn(a, task, false)
195+ onSpawned := func(a *subagents.Agent, task string, required bool) {
196+ if !required {
197+ tracker.TrackTurn(a, task, false)
198+ }
191199 }
192- onResumed := func(a *subagents.Agent, prompt string) {
200+ onResumed := func(a *subagents.Agent, prompt string, required bool) {
193201 // BeforeResumed normally owns future-turn registration. Keep this
194202 // fallback for direct runtime callers that do not install the pre-hook;
195203 // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
196- tracker.TrackTurn(a, prompt, true)
204+ if !required {
205+ tracker.TrackTurn(a, prompt, true)
206+ }
197207 }
198- onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
208+ onBeforeResumed := func(a *subagents.Agent, prompt string, required bool) func() {
209+ if required {
210+ return nil
211+ }
199212 return tracker.TrackFutureTurn(a, prompt, true)
200213 }
201214 onStopRequested := func(a *subagents.Agent) {
@@ -68,6 +68,11 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
68 runErr = errors.Join(runErr, closeErr)68 runErr = errors.Join(runErr, closeErr)
69 }69 }
70 }()70 }()
71+ if runtime.Supervisor() != nil {
72+ if _, reloadErrs := runtime.Supervisor().Reload(); len(reloadErrs) != 0 {
73+ return fmt.Errorf("reload subagents: %w", errors.Join(reloadErrs...))
74+ }
75+ }
71 76
72 prepareRegistry := func(reg core.Registry) core.Registry {77 prepareRegistry := func(reg core.Registry) core.Registry {
73 return runtime.PrepareRegistry(reg)78 return runtime.PrepareRegistry(reg)
@@ -102,6 +107,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
102 runtime.SetActiveSession(sess.ID)107 runtime.SetActiveSession(sess.ID)
103 }108 }
104 announceSession(extMgr, sess)109 announceSession(extMgr, sess)
110+ runtime.WireRequiredWorkerGate(ag)
105 111
106 start := len(ag.Messages())112 start := len(ag.Messages())
107 preSink, finishPre := hooks.startupSink()113 preSink, finishPre := hooks.startupSink()
@@ -186,16 +192,23 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
186 }192 }
187 193
188 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 {
189- onSpawned := func(a *subagents.Agent, task string) {195+ onSpawned := func(a *subagents.Agent, task string, required bool) {
190- tracker.TrackTurn(a, task, false)196+ if !required {
197+ tracker.TrackTurn(a, task, false)
198+ }
191 }199 }
192- onResumed := func(a *subagents.Agent, prompt string) {200+ onResumed := func(a *subagents.Agent, prompt string, required bool) {
193 // BeforeResumed normally owns future-turn registration. Keep this201 // BeforeResumed normally owns future-turn registration. Keep this
194 // fallback for direct runtime callers that do not install the pre-hook;202 // fallback for direct runtime callers that do not install the pre-hook;
195 // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.203 // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
196- tracker.TrackTurn(a, prompt, true)204+ if !required {
205+ tracker.TrackTurn(a, prompt, true)
206+ }
197 }207 }
198- onBeforeResumed := func(a *subagents.Agent, prompt string) func() {208+ onBeforeResumed := func(a *subagents.Agent, prompt string, required bool) func() {
209+ if required {
210+ return nil
211+ }
199 return tracker.TrackFutureTurn(a, prompt, true)212 return tracker.TrackFutureTurn(a, prompt, true)
200 }213 }
201 onStopRequested := func(a *subagents.Agent) {214 onStopRequested := func(a *subagents.Agent) {
M packages/agent/settings_store.go +5 -3
@@ -272,13 +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-Completion is host-event-driven. After spawning a 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. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
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.
276276
277-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 the [auto-subagents update] message as observed worker state, not as a new user request.`
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.
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.`
278280
279281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
280282 // available without enabling proactive primary-agent orchestration.
281-const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. 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. 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.`
282284
283285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
284286 // orchestrator contract still applies when launch-time policy withholds the
@@ -272,13 +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-Completion is host-event-driven. After spawning a 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. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.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.
276 276
277-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 the [auto-subagents update] message as observed worker state, not as a new user request.`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.
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.`
278 280
279 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
280 // available without enabling proactive primary-agent orchestration.282 // available without enabling proactive primary-agent orchestration.
281-const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. 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. 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.`
282 284
283 // AutoSubagentsDelegationUnavailableAddendum explains why the strict285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
284 // orchestrator contract still applies when launch-time policy withholds the286 // orchestrator contract still applies when launch-time policy withholds the
A packages/agent/subagent_requirement_gate_test.go +213 -0
@@ -0,0 +1,213 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) {
16+ started := make(chan struct{})
17+ release := make(chan struct{})
18+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
19+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
20+ close(started)
21+ select {
22+ case <-release:
23+ return nil
24+ case <-ctx.Done():
25+ return ctx.Err()
26+ }
27+ })
28+ })
29+ rt := newSubagentRuntime(cfg)
30+ defer func() { _ = rt.Close(context.Background()) }()
31+ rt.SetActiveSession("parent-session")
32+ a, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
33+ Task: "required review",
34+ RootSessionID: "parent-session",
35+ Required: true,
36+ })
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ <-started
41+
42+ parent := &core.Agent{}
43+ rt.WireRequiredWorkerGate(parent)
44+ if parent.BeforeTurnContext == nil {
45+ t.Fatal("required worker gate did not install a before-turn hook")
46+ }
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:
62+ }
63+
64+ 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")
74+ }
75+}
76+
77+func TestRequiredWorkerUpdateIsAcknowledgedOnlyAfterParentCompletion(t *testing.T) {
78+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
79+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
80+ })
81+ rt := newSubagentRuntime(cfg)
82+ defer func() { _ = rt.Close(context.Background()) }()
83+ rt.SetActiveSession("parent-session")
84+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
85+ Task: "required validation", RootSessionID: "parent-session", Required: true,
86+ })
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ worker.Wait()
91+ parent := &core.Agent{}
92+ rt.WireRequiredWorkerGate(parent)
93+
94+ allowed, reason, update := parent.BeforeTurnContext(context.Background(), 1)
95+ if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
96+ t.Fatalf("first required context allowed=%v reason=%q update=%q", allowed, reason, update)
97+ }
98+ if worker.Snapshot().Requirement.Notified {
99+ t.Fatal("before-turn context was acknowledged before parent completion")
100+ }
101+ allowed, reason, update = parent.BeforeTurnContext(context.Background(), 1)
102+ if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
103+ t.Fatalf("replayed required context allowed=%v reason=%q update=%q", allowed, reason, update)
104+ }
105+ if allowed, reason, _ := parent.BeforeAssistantMessage("consumed the required result"); !allowed || reason != "" {
106+ t.Fatalf("parent completion allowed=%v reason=%q", allowed, reason)
107+ }
108+ if !worker.Snapshot().Requirement.Notified {
109+ t.Fatal("successful parent completion did not acknowledge required context")
110+ }
111+}
112+
113+func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) {
114+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
115+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
116+ })
117+ rt := newSubagentRuntime(cfg)
118+ defer func() { _ = rt.Close(context.Background()) }()
119+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{Task: "persisted requirement", Required: true})
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ worker.Wait()
124+ ready := make(chan struct{})
125+ close(ready)
126+ rt.SetRequiredWorkerReady(ready, func() error { return errors.New("malformed persisted worker") })
127+ parent := &core.Agent{}
128+ rt.WireRequiredWorkerGate(parent)
129+ allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)
130+ if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" {
131+ t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)
132+ }
133+}
134+
135+func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) {
136+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
137+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
138+ return errors.New("worker failed")
139+ })
140+ })
141+ rt := newSubagentRuntime(cfg)
142+ defer func() { _ = rt.Close(context.Background()) }()
143+ rt.SetActiveSession("parent-session")
144+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
145+ Task: "required validation", RootSessionID: "parent-session", Required: true,
146+ })
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ worker.Wait()
151+ parent := &core.Agent{}
152+ rt.WireRequiredWorkerGate(parent)
153+ if allowed, _, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); allowed {
154+ t.Fatal("unmet requirement did not block tools")
155+ }
156+ if err := rt.Supervisor().Remove(worker.ID); err != nil {
157+ t.Fatalf("explicit user removal waiver: %v", err)
158+ }
159+ if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
160+ t.Fatalf("tool after explicit waiver allowed=%v reason=%q", allowed, reason)
161+ }
162+}
163+
164+func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *testing.T) {
165+ attempt := 0
166+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
167+ attempt++
168+ current := attempt
169+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
170+ if current == 1 {
171+ return errors.New("review crashed")
172+ }
173+ return nil
174+ })
175+ })
176+ rt := newSubagentRuntime(cfg)
177+ defer func() { _ = rt.Close(context.Background()) }()
178+ rt.SetActiveSession("parent-session")
179+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
180+ Task: "required review",
181+ RootSessionID: "parent-session",
182+ Required: true,
183+ })
184+ if err != nil {
185+ t.Fatal(err)
186+ }
187+ worker.Wait()
188+
189+ parent := &core.Agent{}
190+ rt.WireRequiredWorkerGate(parent)
191+ 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)
194+ }
195+ toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
196+ if !toolAllowed || toolReason != "" {
197+ t.Fatalf("recovery tool allowed=%v reason=%q, want allowed", toolAllowed, toolReason)
198+ }
199+ allowed, reason, _ := parent.BeforeAssistantMessage("I opened the pull request")
200+ if allowed || !strings.Contains(reason, worker.ID) {
201+ t.Fatalf("terminal response allowed=%v reason=%q, want failed required worker gate", allowed, reason)
202+ }
203+
204+ retried, err := rt.Supervisor().ResumeRequiredWithPrompt(context.Background(), worker.ID, "retry review")
205+ if err != nil {
206+ t.Fatal(err)
207+ }
208+ retried.Wait()
209+ allowed, reason, _ = parent.BeforeAssistantMessage("The required review passed")
210+ if !allowed || reason != "" {
211+ t.Fatalf("terminal response after retry allowed=%v reason=%q, want allowed", allowed, reason)
212+ }
213+}
@@ -0,0 +1,213 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) {
16+ started := make(chan struct{})
17+ release := make(chan struct{})
18+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
19+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
20+ close(started)
21+ select {
22+ case <-release:
23+ return nil
24+ case <-ctx.Done():
25+ return ctx.Err()
26+ }
27+ })
28+ })
29+ rt := newSubagentRuntime(cfg)
30+ defer func() { _ = rt.Close(context.Background()) }()
31+ rt.SetActiveSession("parent-session")
32+ a, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
33+ Task: "required review",
34+ RootSessionID: "parent-session",
35+ Required: true,
36+ })
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ <-started
41+
42+ parent := &core.Agent{}
43+ rt.WireRequiredWorkerGate(parent)
44+ if parent.BeforeTurnContext == nil {
45+ t.Fatal("required worker gate did not install a before-turn hook")
46+ }
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:
62+ }
63+
64+ 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")
74+ }
75+}
76+
77+func TestRequiredWorkerUpdateIsAcknowledgedOnlyAfterParentCompletion(t *testing.T) {
78+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
79+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
80+ })
81+ rt := newSubagentRuntime(cfg)
82+ defer func() { _ = rt.Close(context.Background()) }()
83+ rt.SetActiveSession("parent-session")
84+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
85+ Task: "required validation", RootSessionID: "parent-session", Required: true,
86+ })
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ worker.Wait()
91+ parent := &core.Agent{}
92+ rt.WireRequiredWorkerGate(parent)
93+
94+ allowed, reason, update := parent.BeforeTurnContext(context.Background(), 1)
95+ if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
96+ t.Fatalf("first required context allowed=%v reason=%q update=%q", allowed, reason, update)
97+ }
98+ if worker.Snapshot().Requirement.Notified {
99+ t.Fatal("before-turn context was acknowledged before parent completion")
100+ }
101+ allowed, reason, update = parent.BeforeTurnContext(context.Background(), 1)
102+ if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
103+ t.Fatalf("replayed required context allowed=%v reason=%q update=%q", allowed, reason, update)
104+ }
105+ if allowed, reason, _ := parent.BeforeAssistantMessage("consumed the required result"); !allowed || reason != "" {
106+ t.Fatalf("parent completion allowed=%v reason=%q", allowed, reason)
107+ }
108+ if !worker.Snapshot().Requirement.Notified {
109+ t.Fatal("successful parent completion did not acknowledge required context")
110+ }
111+}
112+
113+func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) {
114+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
115+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
116+ })
117+ rt := newSubagentRuntime(cfg)
118+ defer func() { _ = rt.Close(context.Background()) }()
119+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{Task: "persisted requirement", Required: true})
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ worker.Wait()
124+ ready := make(chan struct{})
125+ close(ready)
126+ rt.SetRequiredWorkerReady(ready, func() error { return errors.New("malformed persisted worker") })
127+ parent := &core.Agent{}
128+ rt.WireRequiredWorkerGate(parent)
129+ allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)
130+ if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" {
131+ t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)
132+ }
133+}
134+
135+func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) {
136+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
137+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
138+ return errors.New("worker failed")
139+ })
140+ })
141+ rt := newSubagentRuntime(cfg)
142+ defer func() { _ = rt.Close(context.Background()) }()
143+ rt.SetActiveSession("parent-session")
144+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
145+ Task: "required validation", RootSessionID: "parent-session", Required: true,
146+ })
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ worker.Wait()
151+ parent := &core.Agent{}
152+ rt.WireRequiredWorkerGate(parent)
153+ if allowed, _, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); allowed {
154+ t.Fatal("unmet requirement did not block tools")
155+ }
156+ if err := rt.Supervisor().Remove(worker.ID); err != nil {
157+ t.Fatalf("explicit user removal waiver: %v", err)
158+ }
159+ if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
160+ t.Fatalf("tool after explicit waiver allowed=%v reason=%q", allowed, reason)
161+ }
162+}
163+
164+func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *testing.T) {
165+ attempt := 0
166+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
167+ attempt++
168+ current := attempt
169+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
170+ if current == 1 {
171+ return errors.New("review crashed")
172+ }
173+ return nil
174+ })
175+ })
176+ rt := newSubagentRuntime(cfg)
177+ defer func() { _ = rt.Close(context.Background()) }()
178+ rt.SetActiveSession("parent-session")
179+ worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
180+ Task: "required review",
181+ RootSessionID: "parent-session",
182+ Required: true,
183+ })
184+ if err != nil {
185+ t.Fatal(err)
186+ }
187+ worker.Wait()
188+
189+ parent := &core.Agent{}
190+ rt.WireRequiredWorkerGate(parent)
191+ 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)
194+ }
195+ toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
196+ if !toolAllowed || toolReason != "" {
197+ t.Fatalf("recovery tool allowed=%v reason=%q, want allowed", toolAllowed, toolReason)
198+ }
199+ allowed, reason, _ := parent.BeforeAssistantMessage("I opened the pull request")
200+ if allowed || !strings.Contains(reason, worker.ID) {
201+ t.Fatalf("terminal response allowed=%v reason=%q, want failed required worker gate", allowed, reason)
202+ }
203+
204+ retried, err := rt.Supervisor().ResumeRequiredWithPrompt(context.Background(), worker.ID, "retry review")
205+ if err != nil {
206+ t.Fatal(err)
207+ }
208+ retried.Wait()
209+ allowed, reason, _ = parent.BeforeAssistantMessage("The required review passed")
210+ if !allowed || reason != "" {
211+ t.Fatalf("terminal response after retry allowed=%v reason=%q, want allowed", allowed, reason)
212+ }
213+}
A packages/agent/subagent_requirements.go +287 -0
@@ -0,0 +1,287 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+const requiredWorkerContextHeader = "[required-subagents update]"
17+
18+// 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.
23+func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
24+ if rt == nil || rt.supervisor == nil || parent == nil {
25+ return
26+ }
27+ previousBeforeTurn := parent.BeforeTurnContext
28+ parent.BeforeTurnContext = func(ctx context.Context, step int) (bool, string, string) {
29+ var inherited string
30+ if previousBeforeTurn != nil {
31+ allowed, reason, contextText := previousBeforeTurn(ctx, step)
32+ if !allowed {
33+ return false, reason, contextText
34+ }
35+ inherited = contextText
36+ }
37+ 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), inherited
39+ }
40+ required, err := rt.supervisor.WaitActiveRequirements(ctx)
41+ if err != nil {
42+ return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
43+ }
44+ update := rt.formatRequiredWorkerContext(required)
45+ if update == "" {
46+ return true, "", inherited
47+ }
48+ return true, "", joinRequiredContext(inherited, update)
49+ }
50+
51+ previousBeforeTool := parent.BeforeToolExecute
52+ 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 {
58+ return previousBeforeTool(call)
59+ }
60+ return true, "", nil
61+ }
62+
63+ previousBeforeAssistant := parent.BeforeAssistantMessage
64+ parent.BeforeAssistantMessage = func(text string) (bool, string, string) {
65+ unmet := rt.supervisor.UnmetRequirements()
66+ if len(unmet) != 0 {
67+ reason := formatRequiredWorkerBlock(unmet)
68+ if rt.noteRequiredBlock(unmet) {
69+ parent.QueueMessage(reason)
70+ }
71+ return false, reason, ""
72+ }
73+ rt.clearRequiredBlock()
74+ if previousBeforeAssistant != nil {
75+ allowed, reason, replacement := previousBeforeAssistant(text)
76+ if !allowed {
77+ return false, reason, replacement
78+ }
79+ rt.acknowledgeSatisfiedRequirements()
80+ return true, reason, replacement
81+ }
82+ rt.acknowledgeSatisfiedRequirements()
83+ return true, "", ""
84+ }
85+}
86+
87+// SetRequiredWorkerReady makes the gate wait for an asynchronous supervisor
88+// reload before it checks persisted obligations. A nil channel means no reload
89+// is pending. The error callback is read only after ready closes.
90+func (rt *subagentRuntime) SetRequiredWorkerReady(ready <-chan struct{}, result func() error) {
91+ if rt == nil {
92+ return
93+ }
94+ rt.requiredReadyMu.Lock()
95+ rt.requiredReady = ready
96+ rt.requiredReadyErr = result
97+ rt.requiredReadyMu.Unlock()
98+}
99+
100+func (rt *subagentRuntime) waitRequiredWorkerReady(ctx context.Context) error {
101+ rt.requiredReadyMu.RLock()
102+ ready := rt.requiredReady
103+ result := rt.requiredReadyErr
104+ rt.requiredReadyMu.RUnlock()
105+ if ready == nil {
106+ return nil
107+ }
108+ select {
109+ case <-ctx.Done():
110+ return ctx.Err()
111+ case <-ready:
112+ if result != nil {
113+ return result()
114+ }
115+ return nil
116+ }
117+}
118+
119+func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() {
120+ for _, snapshot := range rt.supervisor.RequiredSnapshots() {
121+ if snapshot.Requirement.State == subagents.RequirementSatisfied && !snapshot.Requirement.Notified {
122+ _ = rt.supervisor.MarkRequirementNotified(snapshot.ID)
123+ }
124+ }
125+}
126+
127+const requiredWorkerContextRuneBudget = 6000
128+
129+func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool {
130+ ids := make([]string, 0, len(unmet))
131+ for _, snapshot := range unmet {
132+ ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode))
133+ }
134+ sort.Strings(ids)
135+ key := strings.Join(ids, "\n")
136+ rt.requiredContextMu.Lock()
137+ defer rt.requiredContextMu.Unlock()
138+ if key == rt.lastRequiredBlock {
139+ return false
140+ }
141+ rt.lastRequiredBlock = key
142+ return true
143+}
144+
145+func (rt *subagentRuntime) clearRequiredBlock() {
146+ rt.requiredContextMu.Lock()
147+ rt.lastRequiredBlock = ""
148+ rt.requiredContextMu.Unlock()
149+}
150+
151+func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
152+ rt.requiredContextMu.Lock()
153+ defer rt.requiredContextMu.Unlock()
154+ contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet)
155+ if rt.reportedRequiredUnmet == nil {
156+ rt.reportedRequiredUnmet = make(map[string]struct{})
157+ }
158+ for _, snapshot := range required {
159+ if snapshot.Requirement.Unmet() {
160+ rt.reportedRequiredUnmet[snapshot.ID] = struct{}{}
161+ }
162+ }
163+ return contextText
164+}
165+
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 {
176+ var items []subagents.AgentSnapshot
177+ for _, snapshot := range required {
178+ requirement := snapshot.Requirement
179+ if !requirement.Required || requirement.State == subagents.RequirementPending {
180+ continue
181+ }
182+ if requirement.Notified && !requirement.Unmet() {
183+ continue
184+ }
185+ items = append(items, snapshot)
186+ }
187+ if len(items) == 0 {
188+ return ""
189+ }
190+ var sb strings.Builder
191+ sb.WriteString(requiredWorkerContextHeader)
192+ sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")
193+ for _, snapshot := range items {
194+ requirement := snapshot.Requirement
195+ if requirement.Unmet() {
196+ if _, reported := previouslyReported[snapshot.ID]; reported {
197+ fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID))
198+ continue
199+ }
200+ }
201+ fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))
202+ if requirement.ErrorCode != "" {
203+ fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
204+ }
205+ output := strings.TrimSpace(snapshot.LastAssistant)
206+ if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
207+ output = strings.TrimSpace(snapshot.Result.Output)
208+ }
209+ if output != "" {
210+ fmt.Fprintf(&sb, " result:\n%s\n", indentRequiredOutput(output, " ", 6000))
211+ }
212+ }
213+ if hasIndeterminateRequirement(items) {
214+ sb.WriteString("\nA required outcome is indeterminate after host restart. Do not retry it automatically: the user must inspect the durable result or external side effect, then explicitly resume, restart, or remove the terminal worker.")
215+ } else if hasUnmetRequirement(items) {
216+ sb.WriteString("\nAt least one required outcome is unresolved. Retry that worker with subagent_resume; do not emit a terminal answer. Only the user can waive it by removing the terminal worker with /subagents rm.")
217+ } else {
218+ sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")
219+ }
220+ return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget)
221+}
222+
223+func truncateRequiredContext(text string, limit int) string {
224+ runes := []rune(text)
225+ if len(runes) <= limit {
226+ return text
227+ }
228+ return string(runes[:limit])
229+}
230+
231+func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {
232+ var sb strings.Builder
233+ sb.WriteString(requiredWorkerContextHeader)
234+ sb.WriteString("\nTerminal completion is blocked by required delegated work:")
235+ for _, snapshot := range unmet {
236+ fmt.Fprintf(&sb, "\n- %s: %s — %s", snapshot.ID, snapshot.Requirement.State, strings.TrimSpace(snapshot.Task))
237+ if snapshot.Requirement.ErrorCode != "" {
238+ fmt.Fprintf(&sb, " (%s; diagnostic: %s)", snapshot.Requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
239+ }
240+ }
241+ if hasIndeterminateRequirement(unmet) {
242+ sb.WriteString("\nDo not retry an indeterminate outcome automatically. The user must inspect its durable result or external side effect, then explicitly resume, restart, or remove the worker.")
243+ } else {
244+ sb.WriteString("\nRetry unresolved work with subagent_resume. Do not claim completion. The user may explicitly waive a terminal worker with /subagents rm.")
245+ }
246+ return sb.String()
247+}
248+
249+func hasIndeterminateRequirement(required []subagents.AgentSnapshot) bool {
250+ for _, snapshot := range required {
251+ if snapshot.Requirement.State == subagents.RequirementIndeterminate {
252+ return true
253+ }
254+ }
255+ return false
256+}
257+
258+func hasUnmetRequirement(required []subagents.AgentSnapshot) bool {
259+ for _, snapshot := range required {
260+ if snapshot.Requirement.Unmet() {
261+ return true
262+ }
263+ }
264+ return false
265+}
266+
267+func joinRequiredContext(first, second string) string {
268+ first = strings.TrimSpace(first)
269+ second = strings.TrimSpace(second)
270+ switch {
271+ case first == "":
272+ return second
273+ case second == "":
274+ return first
275+ default:
276+ return first + "\n\n" + second
277+ }
278+}
279+
280+func indentRequiredOutput(text, prefix string, maxRunes int) string {
281+ text = strings.TrimSpace(text)
282+ runes := []rune(text)
283+ if maxRunes > 0 && len(runes) > maxRunes {
284+ text = string(runes[:maxRunes]) + "\n…(truncated)"
285+ }
286+ return prefix + strings.ReplaceAll(text, "\n", "\n"+prefix)
287+}
@@ -0,0 +1,287 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+const requiredWorkerContextHeader = "[required-subagents update]"
17+
18+// 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.
23+func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
24+ if rt == nil || rt.supervisor == nil || parent == nil {
25+ return
26+ }
27+ previousBeforeTurn := parent.BeforeTurnContext
28+ parent.BeforeTurnContext = func(ctx context.Context, step int) (bool, string, string) {
29+ var inherited string
30+ if previousBeforeTurn != nil {
31+ allowed, reason, contextText := previousBeforeTurn(ctx, step)
32+ if !allowed {
33+ return false, reason, contextText
34+ }
35+ inherited = contextText
36+ }
37+ 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), inherited
39+ }
40+ required, err := rt.supervisor.WaitActiveRequirements(ctx)
41+ if err != nil {
42+ return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
43+ }
44+ update := rt.formatRequiredWorkerContext(required)
45+ if update == "" {
46+ return true, "", inherited
47+ }
48+ return true, "", joinRequiredContext(inherited, update)
49+ }
50+
51+ previousBeforeTool := parent.BeforeToolExecute
52+ 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 {
58+ return previousBeforeTool(call)
59+ }
60+ return true, "", nil
61+ }
62+
63+ previousBeforeAssistant := parent.BeforeAssistantMessage
64+ parent.BeforeAssistantMessage = func(text string) (bool, string, string) {
65+ unmet := rt.supervisor.UnmetRequirements()
66+ if len(unmet) != 0 {
67+ reason := formatRequiredWorkerBlock(unmet)
68+ if rt.noteRequiredBlock(unmet) {
69+ parent.QueueMessage(reason)
70+ }
71+ return false, reason, ""
72+ }
73+ rt.clearRequiredBlock()
74+ if previousBeforeAssistant != nil {
75+ allowed, reason, replacement := previousBeforeAssistant(text)
76+ if !allowed {
77+ return false, reason, replacement
78+ }
79+ rt.acknowledgeSatisfiedRequirements()
80+ return true, reason, replacement
81+ }
82+ rt.acknowledgeSatisfiedRequirements()
83+ return true, "", ""
84+ }
85+}
86+
87+// SetRequiredWorkerReady makes the gate wait for an asynchronous supervisor
88+// reload before it checks persisted obligations. A nil channel means no reload
89+// is pending. The error callback is read only after ready closes.
90+func (rt *subagentRuntime) SetRequiredWorkerReady(ready <-chan struct{}, result func() error) {
91+ if rt == nil {
92+ return
93+ }
94+ rt.requiredReadyMu.Lock()
95+ rt.requiredReady = ready
96+ rt.requiredReadyErr = result
97+ rt.requiredReadyMu.Unlock()
98+}
99+
100+func (rt *subagentRuntime) waitRequiredWorkerReady(ctx context.Context) error {
101+ rt.requiredReadyMu.RLock()
102+ ready := rt.requiredReady
103+ result := rt.requiredReadyErr
104+ rt.requiredReadyMu.RUnlock()
105+ if ready == nil {
106+ return nil
107+ }
108+ select {
109+ case <-ctx.Done():
110+ return ctx.Err()
111+ case <-ready:
112+ if result != nil {
113+ return result()
114+ }
115+ return nil
116+ }
117+}
118+
119+func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() {
120+ for _, snapshot := range rt.supervisor.RequiredSnapshots() {
121+ if snapshot.Requirement.State == subagents.RequirementSatisfied && !snapshot.Requirement.Notified {
122+ _ = rt.supervisor.MarkRequirementNotified(snapshot.ID)
123+ }
124+ }
125+}
126+
127+const requiredWorkerContextRuneBudget = 6000
128+
129+func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool {
130+ ids := make([]string, 0, len(unmet))
131+ for _, snapshot := range unmet {
132+ ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode))
133+ }
134+ sort.Strings(ids)
135+ key := strings.Join(ids, "\n")
136+ rt.requiredContextMu.Lock()
137+ defer rt.requiredContextMu.Unlock()
138+ if key == rt.lastRequiredBlock {
139+ return false
140+ }
141+ rt.lastRequiredBlock = key
142+ return true
143+}
144+
145+func (rt *subagentRuntime) clearRequiredBlock() {
146+ rt.requiredContextMu.Lock()
147+ rt.lastRequiredBlock = ""
148+ rt.requiredContextMu.Unlock()
149+}
150+
151+func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
152+ rt.requiredContextMu.Lock()
153+ defer rt.requiredContextMu.Unlock()
154+ contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet)
155+ if rt.reportedRequiredUnmet == nil {
156+ rt.reportedRequiredUnmet = make(map[string]struct{})
157+ }
158+ for _, snapshot := range required {
159+ if snapshot.Requirement.Unmet() {
160+ rt.reportedRequiredUnmet[snapshot.ID] = struct{}{}
161+ }
162+ }
163+ return contextText
164+}
165+
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 {
176+ var items []subagents.AgentSnapshot
177+ for _, snapshot := range required {
178+ requirement := snapshot.Requirement
179+ if !requirement.Required || requirement.State == subagents.RequirementPending {
180+ continue
181+ }
182+ if requirement.Notified && !requirement.Unmet() {
183+ continue
184+ }
185+ items = append(items, snapshot)
186+ }
187+ if len(items) == 0 {
188+ return ""
189+ }
190+ var sb strings.Builder
191+ sb.WriteString(requiredWorkerContextHeader)
192+ sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")
193+ for _, snapshot := range items {
194+ requirement := snapshot.Requirement
195+ if requirement.Unmet() {
196+ if _, reported := previouslyReported[snapshot.ID]; reported {
197+ fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID))
198+ continue
199+ }
200+ }
201+ fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))
202+ if requirement.ErrorCode != "" {
203+ fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
204+ }
205+ output := strings.TrimSpace(snapshot.LastAssistant)
206+ if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
207+ output = strings.TrimSpace(snapshot.Result.Output)
208+ }
209+ if output != "" {
210+ fmt.Fprintf(&sb, " result:\n%s\n", indentRequiredOutput(output, " ", 6000))
211+ }
212+ }
213+ if hasIndeterminateRequirement(items) {
214+ sb.WriteString("\nA required outcome is indeterminate after host restart. Do not retry it automatically: the user must inspect the durable result or external side effect, then explicitly resume, restart, or remove the terminal worker.")
215+ } else if hasUnmetRequirement(items) {
216+ sb.WriteString("\nAt least one required outcome is unresolved. Retry that worker with subagent_resume; do not emit a terminal answer. Only the user can waive it by removing the terminal worker with /subagents rm.")
217+ } else {
218+ sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")
219+ }
220+ return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget)
221+}
222+
223+func truncateRequiredContext(text string, limit int) string {
224+ runes := []rune(text)
225+ if len(runes) <= limit {
226+ return text
227+ }
228+ return string(runes[:limit])
229+}
230+
231+func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {
232+ var sb strings.Builder
233+ sb.WriteString(requiredWorkerContextHeader)
234+ sb.WriteString("\nTerminal completion is blocked by required delegated work:")
235+ for _, snapshot := range unmet {
236+ fmt.Fprintf(&sb, "\n- %s: %s — %s", snapshot.ID, snapshot.Requirement.State, strings.TrimSpace(snapshot.Task))
237+ if snapshot.Requirement.ErrorCode != "" {
238+ fmt.Fprintf(&sb, " (%s; diagnostic: %s)", snapshot.Requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
239+ }
240+ }
241+ if hasIndeterminateRequirement(unmet) {
242+ sb.WriteString("\nDo not retry an indeterminate outcome automatically. The user must inspect its durable result or external side effect, then explicitly resume, restart, or remove the worker.")
243+ } else {
244+ sb.WriteString("\nRetry unresolved work with subagent_resume. Do not claim completion. The user may explicitly waive a terminal worker with /subagents rm.")
245+ }
246+ return sb.String()
247+}
248+
249+func hasIndeterminateRequirement(required []subagents.AgentSnapshot) bool {
250+ for _, snapshot := range required {
251+ if snapshot.Requirement.State == subagents.RequirementIndeterminate {
252+ return true
253+ }
254+ }
255+ return false
256+}
257+
258+func hasUnmetRequirement(required []subagents.AgentSnapshot) bool {
259+ for _, snapshot := range required {
260+ if snapshot.Requirement.Unmet() {
261+ return true
262+ }
263+ }
264+ return false
265+}
266+
267+func joinRequiredContext(first, second string) string {
268+ first = strings.TrimSpace(first)
269+ second = strings.TrimSpace(second)
270+ switch {
271+ case first == "":
272+ return second
273+ case second == "":
274+ return first
275+ default:
276+ return first + "\n\n" + second
277+ }
278+}
279+
280+func indentRequiredOutput(text, prefix string, maxRunes int) string {
281+ text = strings.TrimSpace(text)
282+ runes := []rune(text)
283+ if maxRunes > 0 && len(runes) > maxRunes {
284+ text = string(runes[:maxRunes]) + "\n…(truncated)"
285+ }
286+ return prefix + strings.ReplaceAll(text, "\n", "\n"+prefix)
287+}
M packages/agent/subagent_runtime.go +15 -9
@@ -38,14 +38,20 @@ type subagentRuntime struct {
3838 webSearchPolicy subagents.WebSearchPolicy
3939 webSearchGuard *webSearchSessionGuard
4040
41- onSpawned func(*subagents.Agent, string)
42- onResumed func(*subagents.Agent, string)
43- beforeResumed func(*subagents.Agent, string) func()
41+ onSpawned func(*subagents.Agent, string, bool)
42+ onResumed func(*subagents.Agent, string, bool)
43+ beforeResumed func(*subagents.Agent, string, bool) func()
4444 onStopRequested func(*subagents.Agent)
4545
46- settingsMu sync.RWMutex
47- closeMu sync.Mutex
48- closed bool
46+ requiredReadyMu sync.RWMutex
47+ requiredReady <-chan struct{}
48+ requiredReadyErr func() error
49+ requiredContextMu sync.Mutex
50+ reportedRequiredUnmet map[string]struct{}
51+ lastRequiredBlock string
52+ settingsMu sync.RWMutex
53+ closeMu sync.Mutex
54+ closed bool
4955 }
5056
5157 type subagentRuntimeConfiguration struct {
@@ -83,9 +89,9 @@ type subagentRuntimeConfig struct {
8389 ActiveReasoning func() string
8490 NewRunner func(*subagents.Agent) subagents.Runner
8591
86- OnSpawned func(*subagents.Agent, string)
87- OnResumed func(*subagents.Agent, string)
88- BeforeResumed func(*subagents.Agent, string) func()
92+ OnSpawned func(*subagents.Agent, string, bool)
93+ OnResumed func(*subagents.Agent, string, bool)
94+ BeforeResumed func(*subagents.Agent, string, bool) func()
8995 OnStopRequested func(*subagents.Agent)
9096 }
9197
@@ -38,14 +38,20 @@ type subagentRuntime struct {
38 webSearchPolicy subagents.WebSearchPolicy38 webSearchPolicy subagents.WebSearchPolicy
39 webSearchGuard *webSearchSessionGuard39 webSearchGuard *webSearchSessionGuard
40 40
41- onSpawned func(*subagents.Agent, string)41+ onSpawned func(*subagents.Agent, string, bool)
42- onResumed func(*subagents.Agent, string)42+ onResumed func(*subagents.Agent, string, bool)
43- beforeResumed func(*subagents.Agent, string) func()43+ beforeResumed func(*subagents.Agent, string, bool) func()
44 onStopRequested func(*subagents.Agent)44 onStopRequested func(*subagents.Agent)
45 45
46- settingsMu sync.RWMutex46+ requiredReadyMu sync.RWMutex
47- closeMu sync.Mutex47+ requiredReady <-chan struct{}
48- closed bool48+ requiredReadyErr func() error
49+ requiredContextMu sync.Mutex
50+ reportedRequiredUnmet map[string]struct{}
51+ lastRequiredBlock string
52+ settingsMu sync.RWMutex
53+ closeMu sync.Mutex
54+ closed bool
49 }55 }
50 56
51 type subagentRuntimeConfiguration struct {57 type subagentRuntimeConfiguration struct {
@@ -83,9 +89,9 @@ type subagentRuntimeConfig struct {
83 ActiveReasoning func() string89 ActiveReasoning func() string
84 NewRunner func(*subagents.Agent) subagents.Runner90 NewRunner func(*subagents.Agent) subagents.Runner
85 91
86- OnSpawned func(*subagents.Agent, string)92+ OnSpawned func(*subagents.Agent, string, bool)
87- OnResumed func(*subagents.Agent, string)93+ OnResumed func(*subagents.Agent, string, bool)
88- BeforeResumed func(*subagents.Agent, string) func()94+ BeforeResumed func(*subagents.Agent, string, bool) func()
89 OnStopRequested func(*subagents.Agent)95 OnStopRequested func(*subagents.Agent)
90 }96 }
91 97
M packages/agent/subagent_runtime_test.go +6 -1
@@ -329,7 +329,12 @@ func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) {
329329 var spawned atomic.Int32
330330 var stopped atomic.Int32
331331 cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
332- cfg.OnSpawned = func(*subagents.Agent, string) { spawned.Add(1) }
332+ cfg.OnSpawned = func(_ *subagents.Agent, _ string, required bool) {
333+ if required {
334+ t.Errorf("optional spawn callback required = true")
335+ }
336+ spawned.Add(1)
337+ }
333338 cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) }
334339 rt := newSubagentRuntime(cfg)
335340 registry := rt.InjectTools(core.NewRegistry())
@@ -329,7 +329,12 @@ func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) {
329 var spawned atomic.Int32329 var spawned atomic.Int32
330 var stopped atomic.Int32330 var stopped atomic.Int32
331 cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })331 cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
332- cfg.OnSpawned = func(*subagents.Agent, string) { spawned.Add(1) }332+ cfg.OnSpawned = func(_ *subagents.Agent, _ string, required bool) {
333+ if required {
334+ t.Errorf("optional spawn callback required = true")
335+ }
336+ spawned.Add(1)
337+ }
333 cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) }338 cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) }
334 rt := newSubagentRuntime(cfg)339 rt := newSubagentRuntime(cfg)
335 registry := rt.InjectTools(core.NewRegistry())340 registry := rt.InjectTools(core.NewRegistry())
M packages/agent/subagents/agent.go +24 -21
@@ -157,27 +157,30 @@ type Agent struct {
157157 legacyEventState bool
158158 transcriptMu sync.Mutex
159159
160- lifecycleMu sync.Mutex
161- processState ProcessState
162- turnState TurnState
163- currentTurnID string
164- updatedAt time.Time
165- lastActivity time.Time
166- result *TurnResult
167- resultRef string
168- patchRef string
169- changedFiles []string
170- outputBytes int
171- outputLines int
172- outputTruncated bool
173- maxOutputBytes int
174- maxOutputLines int
175- persistFn func(*Agent) error
176- persistMu sync.Mutex
177- stateDir string
178- workspaceCleanup func() error
179- workspaceCapture func() (WorkspaceCapture, error)
180- onTurnIdle func()
160+ lifecycleMu sync.Mutex
161+ processState ProcessState
162+ turnState TurnState
163+ currentTurnID string
164+ updatedAt time.Time
165+ lastActivity time.Time
166+ result *TurnResult
167+ resultRef string
168+ requirement RequirementSnapshot
169+ requirementPersisting bool
170+ requirementChanged chan struct{}
171+ patchRef string
172+ changedFiles []string
173+ outputBytes int
174+ outputLines int
175+ outputTruncated bool
176+ maxOutputBytes int
177+ maxOutputLines int
178+ persistFn func(*Agent) error
179+ persistMu sync.Mutex
180+ stateDir string
181+ workspaceCleanup func() error
182+ workspaceCapture func() (WorkspaceCapture, error)
183+ onTurnIdle func()
181184
182185 // OnTurnEnd, if set, fires once per prompt-level turn_end event
183186 // emitted by the subagent daemon wrapper. Provider/tool-loop
@@ -157,27 +157,30 @@ type Agent struct {
157 legacyEventState bool157 legacyEventState bool
158 transcriptMu sync.Mutex158 transcriptMu sync.Mutex
159 159
160- lifecycleMu sync.Mutex160+ lifecycleMu sync.Mutex
161- processState ProcessState161+ processState ProcessState
162- turnState TurnState162+ turnState TurnState
163- currentTurnID string163+ currentTurnID string
164- updatedAt time.Time164+ updatedAt time.Time
165- lastActivity time.Time165+ lastActivity time.Time
166- result *TurnResult166+ result *TurnResult
167- resultRef string167+ resultRef string
168- patchRef string168+ requirement RequirementSnapshot
169- changedFiles []string169+ requirementPersisting bool
170- outputBytes int170+ requirementChanged chan struct{}
171- outputLines int171+ patchRef string
172- outputTruncated bool172+ changedFiles []string
173- maxOutputBytes int173+ outputBytes int
174- maxOutputLines int174+ outputLines int
175- persistFn func(*Agent) error175+ outputTruncated bool
176- persistMu sync.Mutex176+ maxOutputBytes int
177- stateDir string177+ maxOutputLines int
178- workspaceCleanup func() error178+ persistFn func(*Agent) error
179- workspaceCapture func() (WorkspaceCapture, error)179+ persistMu sync.Mutex
180- onTurnIdle func()180+ stateDir string
181+ workspaceCleanup func() error
182+ workspaceCapture func() (WorkspaceCapture, error)
183+ onTurnIdle func()
181 184
182 // OnTurnEnd, if set, fires once per prompt-level turn_end event185 // OnTurnEnd, if set, fires once per prompt-level turn_end event
183 // emitted by the subagent daemon wrapper. Provider/tool-loop186 // emitted by the subagent daemon wrapper. Provider/tool-loop
M packages/agent/subagents/persist.go +111 -17
@@ -34,6 +34,8 @@ import (
3434 // Historical fields like `branch` and `isolated` are silently dropped
3535 // by encoding/json's permissive decoder when an older meta.json is
3636 // loaded; we don't need to keep them in the struct.
37+type requirementMeta = RequirementSnapshot
38+
3739 type agentMeta struct {
3840 ID string `json:"id"`
3941 Task string `json:"task"`
@@ -73,6 +75,7 @@ type agentMeta struct {
7375 UpdatedAt time.Time `json:"updated_at,omitempty"`
7476 LastActivity time.Time `json:"last_activity,omitempty"`
7577 ResultRef string `json:"result_ref,omitempty"`
78+ Requirement requirementMeta `json:"requirement,omitempty"`
7679 PatchRef string `json:"patch_ref,omitempty"`
7780 ChangedFiles []string `json:"changed_files,omitempty"`
7881 InboxPath string `json:"inbox_path"`
@@ -134,6 +137,7 @@ func writeAgentMeta(stateDir string, a *Agent) error {
134137 updatedAt := a.updatedAt
135138 lastActivity := a.lastActivity
136139 resultRef := a.resultRef
140+ requirement := a.requirement
137141 patchRef := a.patchRef
138142 changedFiles := append([]string(nil), a.changedFiles...)
139143 a.lifecycleMu.Unlock()
@@ -176,6 +180,7 @@ func writeAgentMeta(stateDir string, a *Agent) error {
176180 UpdatedAt: updatedAt,
177181 LastActivity: lastActivity,
178182 ResultRef: resultRef,
183+ Requirement: requirement,
179184 PatchRef: patchRef,
180185 ChangedFiles: changedFiles,
181186 InboxPath: a.InboxPath,
@@ -383,10 +388,15 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
383388 f.mu.Unlock()
384389 continue
385390 }
386- a := f.buildDetachedAgent(m)
391+ a, requirementReconciled := f.buildDetachedAgent(m)
387392 f.agents[m.ID] = a
388393 f.order = append(f.order, m.ID)
389394 f.mu.Unlock()
395+ if requirementReconciled {
396+ if err := f.persistAgent(a); err != nil {
397+ errs = append(errs, fmt.Errorf("persist reconciled requirement %s: %w", a.ID, err))
398+ }
399+ }
390400 loaded++
391401 }
392402 return loaded, errs
@@ -398,7 +408,7 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
398408 //
399409 // The returned Agent has a closed `done` channel because Wait should return
400410 // instantly: there is nothing to wait for.
401-func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
411+func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
402412 // Metadata without a repository identity uses the current RepoRoot;
403413 // records with one retain their persisted repository so a restart from
404414 // another checkout cannot silently redirect the child.
@@ -517,12 +527,14 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
517527 lastActivity: lastActivity,
518528 stateDir: agentStateDir,
519529 resultRef: m.ResultRef,
530+ requirement: m.Requirement,
520531 patchRef: m.PatchRef,
521532 changedFiles: append([]string(nil), m.ChangedFiles...),
522533 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
523534 maxOutputLines: f.cfg.Policy.MaxOutputLines,
524535 done: make(chan struct{}),
525536 turnResults: make(chan *TurnResult, 16),
537+ persistFn: f.persistAgent,
526538 }
527539 if a.updatedAt.IsZero() {
528540 a.updatedAt = lastActivity
@@ -544,10 +556,24 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
544556 if a.EventLogPath != "" {
545557 resultDir = filepath.Dir(a.EventLogPath)
546558 }
559+ beforeRequirement := a.requirementSnapshot()
547560 if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil {
548- a.setResult(result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines))
561+ bounded := result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)
562+ a.setResult(bounded)
563+ if requirementAcceptsResult(beforeRequirement, bounded) {
564+ a.resolveRequirement(0, bounded, "", true)
565+ }
566+ }
567+ if a.requirementSnapshot().pending() {
568+ a.resolveRequirement(0, &TurnResult{
569+ Status: ResultFailed,
570+ Error: &ResultError{
571+ Code: "required_outcome_unobserved",
572+ Message: "host restarted before the required worker outcome was durably observed",
573+ },
574+ }, "", true)
549575 }
550- return a
576+ return a, beforeRequirement != a.requirementSnapshot()
551577 }
552578
553579 // LoadTranscript hydrates one reloaded agent's durable event history. Reload
@@ -790,13 +816,13 @@ func replayEventTranscript(a *Agent, ev Event) {
790816 // Killed). Resuming a still-running agent returns an error so two
791817 // runners don't race for the same session.
792818 func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {
793- return f.resume(ctx, id, true, "")
819+ return f.resumeWithHook(ctx, id, true, "", f.requiredRetryHook(id))
794820 }
795821
796822 // ResumeSession continues the existing child session without replaying the
797823 // original task. It is the explicit canonical spelling for Resume.
798824 func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {
799- return f.resume(ctx, id, true, "")
825+ return f.resumeWithHook(ctx, id, true, "", f.requiredRetryHook(id))
800826 }
801827
802828 // ResumeWithPrompt continues an agent with a manager follow-up while retaining
@@ -805,9 +831,49 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro
805831 // terminal worker is restarted with the follow-up as its initial prompt,
806832 // avoiding a race with inbox listener startup.
807833 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
834+ if hook := f.requiredRetryHook(id); hook != nil {
835+ return f.ResumeWithPromptBefore(ctx, id, prompt, hook)
836+ }
808837 return f.ResumeWithPromptBefore(ctx, id, prompt, nil)
809838 }
810839
840+// ResumeRequiredWithPrompt retries required work. The next delegated turn
841+// becomes the new durable target before it can be queued or delivered, so a
842+// fast worker cannot finish between the resume call and requirement tracking.
843+func (f *Supervisor) ResumeRequiredWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
844+ return f.ResumeRequiredWithPromptBefore(ctx, id, prompt, nil)
845+}
846+
847+// ResumeRequiredWithPromptBefore combines durable requirement registration
848+// with the host's pre-delivery callback.
849+func (f *Supervisor) ResumeRequiredWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {
850+ return f.ResumeWithPromptBefore(ctx, id, prompt, combineRequiredRetryHook(before))
851+}
852+
853+func (f *Supervisor) requiredRetryHook(id string) func(*Agent, string) func() {
854+ a := f.Get(strings.TrimSpace(id))
855+ if a == nil || !a.requirementSnapshot().Unmet() {
856+ return nil
857+ }
858+ return combineRequiredRetryHook(nil)
859+}
860+
861+func combineRequiredRetryHook(before func(*Agent, string) func()) func(*Agent, string) func() {
862+ return func(a *Agent, prompt string) func() {
863+ previous := a.prepareRequired(0)
864+ var cleanup func()
865+ if before != nil {
866+ cleanup = before(a, prompt)
867+ }
868+ return func() {
869+ if cleanup != nil {
870+ cleanup()
871+ }
872+ a.restoreRequirement(previous)
873+ }
874+ }
875+}
876+
811877 // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The
812878 // hook runs after the target Agent is known but before a queued or direct
813879 // follow-up is made deliverable. For a restarted worker it runs after the new
@@ -1010,11 +1076,7 @@ func (f *Supervisor) dispatchQueuedResume(ctx context.Context, a *Agent) {
10101076 // RestartTask intentionally runs the stored original task again in a fresh
10111077 // worker attempt. Callers should use ResumeSession for normal recovery.
10121078 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
1013- return f.resume(ctx, id, false, "")
1014-}
1015-
1016-func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
1017- return f.resumeWithHook(ctx, id, resuming, resumePrompt, nil)
1079+ return f.resumeWithHook(ctx, id, false, "", f.requiredRetryHook(id))
10181080 }
10191081
10201082 func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming bool, resumePrompt string, before func(*Agent, string) func()) (*Agent, error) {
@@ -1131,8 +1193,9 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
11311193 MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
11321194 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
11331195 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
1134- SessionID: existing.SessionID,
1135- InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
1196+ Requirement: existingSnapshot.Requirement,
1197+ SessionID: existing.SessionID,
1198+ InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
11361199 SessionPath: existing.SessionPath,
11371200 ResumePrompt: pendingResumePrompt,
11381201 ResumePromptAt: pendingResumePromptAt,
@@ -1240,6 +1303,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12401303 updatedAt: now,
12411304 lastActivity: now,
12421305 currentTurnID: m.CurrentTurnID,
1306+ requirement: m.Requirement,
12431307 stateDir: stateDir,
12441308 lease: lease,
12451309 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
@@ -1291,14 +1355,44 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12911355 // scheduler can start the worker and emit a fast turn_end. A durable pending
12921356 // prompt is consumed before the newly requested prompt, so tracker
12931357 // registrations must preserve that scheduler order.
1358+ var beforeCleanups []func()
12941359 if before != nil {
1360+ register := func(prompt string) {
1361+ if cleanup := before(a, prompt); cleanup != nil {
1362+ beforeCleanups = append(beforeCleanups, cleanup)
1363+ }
1364+ }
12951365 if hadPendingResumePrompt {
1296- _ = before(a, pendingResumePrompt)
1366+ register(pendingResumePrompt)
12971367 }
12981368 if resumePrompt != "" {
1299- // No rejecting operation remains before scheduling, so there is no path
1300- // that needs the hook's rollback after this point.
1301- _ = before(a, resumePrompt)
1369+ register(resumePrompt)
1370+ }
1371+ if !hadPendingResumePrompt && resumePrompt == "" {
1372+ register("")
1373+ }
1374+ if err := f.persistAgent(a); err != nil {
1375+ a.recordPersistenceError(err)
1376+ for idx := len(beforeCleanups) - 1; idx >= 0; idx-- {
1377+ beforeCleanups[idx]()
1378+ }
1379+ f.mu.Lock()
1380+ f.agents[a.ID] = existing
1381+ for idx, queued := range f.queue {
1382+ if queued == a {
1383+ f.queue = append(f.queue[:idx], f.queue[idx+1:]...)
1384+ break
1385+ }
1386+ }
1387+ f.mu.Unlock()
1388+ if restoreErr := f.persistAgent(existing); restoreErr != nil {
1389+ existing.recordPersistenceError(restoreErr)
1390+ }
1391+ if a.cancel != nil {
1392+ a.cancel()
1393+ }
1394+ _ = lease.Close()
1395+ return nil, err
13021396 }
13031397 }
13041398 f.armQueueTimeout(a)
@@ -34,6 +34,8 @@ import (
34 // Historical fields like `branch` and `isolated` are silently dropped34 // Historical fields like `branch` and `isolated` are silently dropped
35 // by encoding/json's permissive decoder when an older meta.json is35 // by encoding/json's permissive decoder when an older meta.json is
36 // loaded; we don't need to keep them in the struct.36 // loaded; we don't need to keep them in the struct.
37+type requirementMeta = RequirementSnapshot
38+
37 type agentMeta struct {39 type agentMeta struct {
38 ID string `json:"id"`40 ID string `json:"id"`
39 Task string `json:"task"`41 Task string `json:"task"`
@@ -73,6 +75,7 @@ type agentMeta struct {
73 UpdatedAt time.Time `json:"updated_at,omitempty"`75 UpdatedAt time.Time `json:"updated_at,omitempty"`
74 LastActivity time.Time `json:"last_activity,omitempty"`76 LastActivity time.Time `json:"last_activity,omitempty"`
75 ResultRef string `json:"result_ref,omitempty"`77 ResultRef string `json:"result_ref,omitempty"`
78+ Requirement requirementMeta `json:"requirement,omitempty"`
76 PatchRef string `json:"patch_ref,omitempty"`79 PatchRef string `json:"patch_ref,omitempty"`
77 ChangedFiles []string `json:"changed_files,omitempty"`80 ChangedFiles []string `json:"changed_files,omitempty"`
78 InboxPath string `json:"inbox_path"`81 InboxPath string `json:"inbox_path"`
@@ -134,6 +137,7 @@ func writeAgentMeta(stateDir string, a *Agent) error {
134 updatedAt := a.updatedAt137 updatedAt := a.updatedAt
135 lastActivity := a.lastActivity138 lastActivity := a.lastActivity
136 resultRef := a.resultRef139 resultRef := a.resultRef
140+ requirement := a.requirement
137 patchRef := a.patchRef141 patchRef := a.patchRef
138 changedFiles := append([]string(nil), a.changedFiles...)142 changedFiles := append([]string(nil), a.changedFiles...)
139 a.lifecycleMu.Unlock()143 a.lifecycleMu.Unlock()
@@ -176,6 +180,7 @@ func writeAgentMeta(stateDir string, a *Agent) error {
176 UpdatedAt: updatedAt,180 UpdatedAt: updatedAt,
177 LastActivity: lastActivity,181 LastActivity: lastActivity,
178 ResultRef: resultRef,182 ResultRef: resultRef,
183+ Requirement: requirement,
179 PatchRef: patchRef,184 PatchRef: patchRef,
180 ChangedFiles: changedFiles,185 ChangedFiles: changedFiles,
181 InboxPath: a.InboxPath,186 InboxPath: a.InboxPath,
@@ -383,10 +388,15 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
383 f.mu.Unlock()388 f.mu.Unlock()
384 continue389 continue
385 }390 }
386- a := f.buildDetachedAgent(m)391+ a, requirementReconciled := f.buildDetachedAgent(m)
387 f.agents[m.ID] = a392 f.agents[m.ID] = a
388 f.order = append(f.order, m.ID)393 f.order = append(f.order, m.ID)
389 f.mu.Unlock()394 f.mu.Unlock()
395+ if requirementReconciled {
396+ if err := f.persistAgent(a); err != nil {
397+ errs = append(errs, fmt.Errorf("persist reconciled requirement %s: %w", a.ID, err))
398+ }
399+ }
390 loaded++400 loaded++
391 }401 }
392 return loaded, errs402 return loaded, errs
@@ -398,7 +408,7 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
398 //408 //
399 // The returned Agent has a closed `done` channel because Wait should return409 // The returned Agent has a closed `done` channel because Wait should return
400 // instantly: there is nothing to wait for.410 // instantly: there is nothing to wait for.
401-func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {411+func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
402 // Metadata without a repository identity uses the current RepoRoot;412 // Metadata without a repository identity uses the current RepoRoot;
403 // records with one retain their persisted repository so a restart from413 // records with one retain their persisted repository so a restart from
404 // another checkout cannot silently redirect the child.414 // another checkout cannot silently redirect the child.
@@ -517,12 +527,14 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
517 lastActivity: lastActivity,527 lastActivity: lastActivity,
518 stateDir: agentStateDir,528 stateDir: agentStateDir,
519 resultRef: m.ResultRef,529 resultRef: m.ResultRef,
530+ requirement: m.Requirement,
520 patchRef: m.PatchRef,531 patchRef: m.PatchRef,
521 changedFiles: append([]string(nil), m.ChangedFiles...),532 changedFiles: append([]string(nil), m.ChangedFiles...),
522 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,533 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
523 maxOutputLines: f.cfg.Policy.MaxOutputLines,534 maxOutputLines: f.cfg.Policy.MaxOutputLines,
524 done: make(chan struct{}),535 done: make(chan struct{}),
525 turnResults: make(chan *TurnResult, 16),536 turnResults: make(chan *TurnResult, 16),
537+ persistFn: f.persistAgent,
526 }538 }
527 if a.updatedAt.IsZero() {539 if a.updatedAt.IsZero() {
528 a.updatedAt = lastActivity540 a.updatedAt = lastActivity
@@ -544,10 +556,24 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
544 if a.EventLogPath != "" {556 if a.EventLogPath != "" {
545 resultDir = filepath.Dir(a.EventLogPath)557 resultDir = filepath.Dir(a.EventLogPath)
546 }558 }
559+ beforeRequirement := a.requirementSnapshot()
547 if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil {560 if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil {
548- a.setResult(result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines))561+ bounded := result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)
562+ a.setResult(bounded)
563+ if requirementAcceptsResult(beforeRequirement, bounded) {
564+ a.resolveRequirement(0, bounded, "", true)
565+ }
566+ }
567+ if a.requirementSnapshot().pending() {
568+ a.resolveRequirement(0, &TurnResult{
569+ Status: ResultFailed,
570+ Error: &ResultError{
571+ Code: "required_outcome_unobserved",
572+ Message: "host restarted before the required worker outcome was durably observed",
573+ },
574+ }, "", true)
549 }575 }
550- return a576+ return a, beforeRequirement != a.requirementSnapshot()
551 }577 }
552 578
553 // LoadTranscript hydrates one reloaded agent's durable event history. Reload579 // LoadTranscript hydrates one reloaded agent's durable event history. Reload
@@ -790,13 +816,13 @@ func replayEventTranscript(a *Agent, ev Event) {
790 // Killed). Resuming a still-running agent returns an error so two816 // Killed). Resuming a still-running agent returns an error so two
791 // runners don't race for the same session.817 // runners don't race for the same session.
792 func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {818 func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {
793- return f.resume(ctx, id, true, "")819+ return f.resumeWithHook(ctx, id, true, "", f.requiredRetryHook(id))
794 }820 }
795 821
796 // ResumeSession continues the existing child session without replaying the822 // ResumeSession continues the existing child session without replaying the
797 // original task. It is the explicit canonical spelling for Resume.823 // original task. It is the explicit canonical spelling for Resume.
798 func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {824 func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {
799- return f.resume(ctx, id, true, "")825+ return f.resumeWithHook(ctx, id, true, "", f.requiredRetryHook(id))
800 }826 }
801 827
802 // ResumeWithPrompt continues an agent with a manager follow-up while retaining828 // ResumeWithPrompt continues an agent with a manager follow-up while retaining
@@ -805,9 +831,49 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro
805 // terminal worker is restarted with the follow-up as its initial prompt,831 // terminal worker is restarted with the follow-up as its initial prompt,
806 // avoiding a race with inbox listener startup.832 // avoiding a race with inbox listener startup.
807 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {833 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
834+ if hook := f.requiredRetryHook(id); hook != nil {
835+ return f.ResumeWithPromptBefore(ctx, id, prompt, hook)
836+ }
808 return f.ResumeWithPromptBefore(ctx, id, prompt, nil)837 return f.ResumeWithPromptBefore(ctx, id, prompt, nil)
809 }838 }
810 839
840+// ResumeRequiredWithPrompt retries required work. The next delegated turn
841+// becomes the new durable target before it can be queued or delivered, so a
842+// fast worker cannot finish between the resume call and requirement tracking.
843+func (f *Supervisor) ResumeRequiredWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
844+ return f.ResumeRequiredWithPromptBefore(ctx, id, prompt, nil)
845+}
846+
847+// ResumeRequiredWithPromptBefore combines durable requirement registration
848+// with the host's pre-delivery callback.
849+func (f *Supervisor) ResumeRequiredWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {
850+ return f.ResumeWithPromptBefore(ctx, id, prompt, combineRequiredRetryHook(before))
851+}
852+
853+func (f *Supervisor) requiredRetryHook(id string) func(*Agent, string) func() {
854+ a := f.Get(strings.TrimSpace(id))
855+ if a == nil || !a.requirementSnapshot().Unmet() {
856+ return nil
857+ }
858+ return combineRequiredRetryHook(nil)
859+}
860+
861+func combineRequiredRetryHook(before func(*Agent, string) func()) func(*Agent, string) func() {
862+ return func(a *Agent, prompt string) func() {
863+ previous := a.prepareRequired(0)
864+ var cleanup func()
865+ if before != nil {
866+ cleanup = before(a, prompt)
867+ }
868+ return func() {
869+ if cleanup != nil {
870+ cleanup()
871+ }
872+ a.restoreRequirement(previous)
873+ }
874+ }
875+}
876+
811 // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The877 // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The
812 // hook runs after the target Agent is known but before a queued or direct878 // hook runs after the target Agent is known but before a queued or direct
813 // follow-up is made deliverable. For a restarted worker it runs after the new879 // follow-up is made deliverable. For a restarted worker it runs after the new
@@ -1010,11 +1076,7 @@ func (f *Supervisor) dispatchQueuedResume(ctx context.Context, a *Agent) {
1010 // RestartTask intentionally runs the stored original task again in a fresh1076 // RestartTask intentionally runs the stored original task again in a fresh
1011 // worker attempt. Callers should use ResumeSession for normal recovery.1077 // worker attempt. Callers should use ResumeSession for normal recovery.
1012 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {1078 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
1013- return f.resume(ctx, id, false, "")1079+ return f.resumeWithHook(ctx, id, false, "", f.requiredRetryHook(id))
1014-}
1015-
1016-func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
1017- return f.resumeWithHook(ctx, id, resuming, resumePrompt, nil)
1018 }1080 }
1019 1081
1020 func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming bool, resumePrompt string, before func(*Agent, string) func()) (*Agent, error) {1082 func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming bool, resumePrompt string, before func(*Agent, string) func()) (*Agent, error) {
@@ -1131,8 +1193,9 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1131 MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),1193 MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
1132 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),1194 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
1133 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,1195 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
1134- SessionID: existing.SessionID,1196+ Requirement: existingSnapshot.Requirement,
1135- InboxPath: inboxPath, EventLogPath: existing.EventLogPath,1197+ SessionID: existing.SessionID,
1198+ InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
1136 SessionPath: existing.SessionPath,1199 SessionPath: existing.SessionPath,
1137 ResumePrompt: pendingResumePrompt,1200 ResumePrompt: pendingResumePrompt,
1138 ResumePromptAt: pendingResumePromptAt,1201 ResumePromptAt: pendingResumePromptAt,
@@ -1240,6 +1303,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1240 updatedAt: now,1303 updatedAt: now,
1241 lastActivity: now,1304 lastActivity: now,
1242 currentTurnID: m.CurrentTurnID,1305 currentTurnID: m.CurrentTurnID,
1306+ requirement: m.Requirement,
1243 stateDir: stateDir,1307 stateDir: stateDir,
1244 lease: lease,1308 lease: lease,
1245 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,1309 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
@@ -1291,14 +1355,44 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1291 // scheduler can start the worker and emit a fast turn_end. A durable pending1355 // scheduler can start the worker and emit a fast turn_end. A durable pending
1292 // prompt is consumed before the newly requested prompt, so tracker1356 // prompt is consumed before the newly requested prompt, so tracker
1293 // registrations must preserve that scheduler order.1357 // registrations must preserve that scheduler order.
1358+ var beforeCleanups []func()
1294 if before != nil {1359 if before != nil {
1360+ register := func(prompt string) {
1361+ if cleanup := before(a, prompt); cleanup != nil {
1362+ beforeCleanups = append(beforeCleanups, cleanup)
1363+ }
1364+ }
1295 if hadPendingResumePrompt {1365 if hadPendingResumePrompt {
1296- _ = before(a, pendingResumePrompt)1366+ register(pendingResumePrompt)
1297 }1367 }
1298 if resumePrompt != "" {1368 if resumePrompt != "" {
1299- // No rejecting operation remains before scheduling, so there is no path1369+ register(resumePrompt)
1300- // that needs the hook's rollback after this point.1370+ }
1301- _ = before(a, resumePrompt)1371+ if !hadPendingResumePrompt && resumePrompt == "" {
1372+ register("")
1373+ }
1374+ if err := f.persistAgent(a); err != nil {
1375+ a.recordPersistenceError(err)
1376+ for idx := len(beforeCleanups) - 1; idx >= 0; idx-- {
1377+ beforeCleanups[idx]()
1378+ }
1379+ f.mu.Lock()
1380+ f.agents[a.ID] = existing
1381+ for idx, queued := range f.queue {
1382+ if queued == a {
1383+ f.queue = append(f.queue[:idx], f.queue[idx+1:]...)
1384+ break
1385+ }
1386+ }
1387+ f.mu.Unlock()
1388+ if restoreErr := f.persistAgent(existing); restoreErr != nil {
1389+ existing.recordPersistenceError(restoreErr)
1390+ }
1391+ if a.cancel != nil {
1392+ a.cancel()
1393+ }
1394+ _ = lease.Close()
1395+ return nil, err
1302 }1396 }
1303 }1397 }
1304 f.armQueueTimeout(a)1398 f.armQueueTimeout(a)
A packages/agent/subagents/requirement.go +399 -0
@@ -0,0 +1,399 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strconv"
9+ "strings"
10+ "time"
11+)
12+
13+// RequirementState is the durable outcome of work that the parent declared
14+// mandatory before it may finish its own turn.
15+type RequirementState string
16+
17+const (
18+ RequirementPending RequirementState = "pending"
19+ RequirementSatisfied RequirementState = "satisfied"
20+ RequirementFailed RequirementState = "failed"
21+ RequirementTimedOut RequirementState = "timed_out"
22+ RequirementCanceled RequirementState = "canceled"
23+ RequirementIndeterminate RequirementState = "indeterminate"
24+)
25+
26+// RequirementSnapshot is persisted with its worker. A requirement targets a
27+// delegated message turn rather than a process attempt because resumable
28+// workers may execute several turns in one process.
29+type RequirementSnapshot struct {
30+ Required bool `json:"required"`
31+ State RequirementState `json:"state,omitempty"`
32+ TargetTurn int `json:"target_turn,omitempty"`
33+ UpdatedAt time.Time `json:"updated_at,omitempty"`
34+ ErrorCode string `json:"error_code,omitempty"`
35+ Notified bool `json:"notified,omitempty"`
36+}
37+
38+// Unmet reports whether this requirement still prevents parent completion.
39+func (r RequirementSnapshot) Unmet() bool {
40+ if !r.Required {
41+ return false
42+ }
43+ return r.State != RequirementSatisfied
44+}
45+
46+func (r RequirementSnapshot) pending() bool {
47+ return r.Required && r.State == RequirementPending
48+}
49+
50+func newRequirement(required bool, targetTurn int, now time.Time) RequirementSnapshot {
51+ if !required {
52+ return RequirementSnapshot{}
53+ }
54+ return RequirementSnapshot{
55+ Required: true,
56+ State: RequirementPending,
57+ TargetTurn: targetTurn,
58+ UpdatedAt: now.UTC(),
59+ }
60+}
61+
62+func (a *Agent) requirementSnapshot() RequirementSnapshot {
63+ if a == nil {
64+ return RequirementSnapshot{}
65+ }
66+ a.lifecycleMu.Lock()
67+ defer a.lifecycleMu.Unlock()
68+ return a.visibleRequirementLocked()
69+}
70+
71+func (a *Agent) prepareRequired(targetTurn int) RequirementSnapshot {
72+ a.lifecycleMu.Lock()
73+ defer a.lifecycleMu.Unlock()
74+ a.initRequirementSignalLocked()
75+ previous := a.requirement
76+ if targetTurn <= 0 {
77+ nextTurn := a.LifetimeTurns + 1
78+ if previous.pending() && previous.TargetTurn >= nextTurn {
79+ return previous
80+ }
81+ targetTurn = nextTurn
82+ if previous.TargetTurn >= targetTurn {
83+ targetTurn = previous.TargetTurn + 1
84+ }
85+ }
86+ a.requirement = RequirementSnapshot{
87+ Required: true,
88+ State: RequirementPending,
89+ TargetTurn: targetTurn,
90+ UpdatedAt: time.Now().UTC(),
91+ }
92+ a.signalRequirementLocked()
93+ return previous
94+}
95+
96+func (a *Agent) restoreRequirement(previous RequirementSnapshot) {
97+ a.lifecycleMu.Lock()
98+ a.initRequirementSignalLocked()
99+ a.requirement = previous
100+ a.requirementPersisting = false
101+ a.signalRequirementLocked()
102+ a.lifecycleMu.Unlock()
103+}
104+
105+func (a *Agent) markRequirementNotified() {
106+ if a == nil {
107+ return
108+ }
109+ a.lifecycleMu.Lock()
110+ if !a.requirement.Required || a.requirement.Notified {
111+ a.lifecycleMu.Unlock()
112+ return
113+ }
114+ previous := a.requirement
115+ a.requirement.Notified = true
116+ a.requirement.UpdatedAt = time.Now().UTC()
117+ notified := a.requirement
118+ a.lifecycleMu.Unlock()
119+ if a.persistFn != nil {
120+ if err := a.persistFn(a); err != nil {
121+ a.lifecycleMu.Lock()
122+ if a.requirement == notified {
123+ a.requirement = previous
124+ }
125+ a.lifecycleMu.Unlock()
126+ a.recordPersistenceError(err)
127+ return
128+ }
129+ }
130+ a.lifecycleMu.Lock()
131+ if a.requirement == notified {
132+ a.signalRequirementLocked()
133+ }
134+ a.lifecycleMu.Unlock()
135+}
136+
137+func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, force bool) RequirementSnapshot {
138+ if a == nil {
139+ return RequirementSnapshot{}
140+ }
141+ a.lifecycleMu.Lock()
142+ a.initRequirementSignalLocked()
143+ current := a.requirement
144+ if !current.pending() || (!force && step != current.TargetTurn) {
145+ a.lifecycleMu.Unlock()
146+ return current
147+ }
148+ if result != nil && strings.TrimSpace(result.TurnID) != "" && !requirementAcceptsResult(current, result) {
149+ result = nil
150+ if strings.TrimSpace(errMsg) == "" {
151+ errMsg = "required turn ended without a matching durable result"
152+ }
153+ }
154+ if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) {
155+ durable, err := readTurnResult(a.stateDirectory(""))
156+ if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID {
157+ if err == nil {
158+ err = fmt.Errorf("durable result identity does not match agent %s turn %s", result.AgentID, result.TurnID)
159+ }
160+ persistenceErr := fmt.Errorf("persist required result: %w", err)
161+ a.requirement.State = RequirementIndeterminate
162+ a.requirement.ErrorCode = "required_outcome_unobserved"
163+ a.requirement.Notified = false
164+ a.requirement.UpdatedAt = time.Now().UTC()
165+ resolved := a.requirement
166+ a.requirementPersisting = true
167+ a.lifecycleMu.Unlock()
168+ a.recordPersistenceError(persistenceErr)
169+ if err := a.persistFn(a); err != nil {
170+ a.recordPersistenceError(err)
171+ }
172+ a.lifecycleMu.Lock()
173+ if a.requirement == resolved {
174+ a.requirementPersisting = false
175+ a.signalRequirementLocked()
176+ }
177+ a.lifecycleMu.Unlock()
178+ return resolved
179+ }
180+ }
181+ state, errorCode := classifyRequirementOutcome(result, errMsg)
182+ a.requirement.State = state
183+ a.requirement.ErrorCode = errorCode
184+ a.requirement.Notified = false
185+ a.requirement.UpdatedAt = time.Now().UTC()
186+ resolved := a.requirement
187+ if a.persistFn != nil {
188+ a.requirementPersisting = true
189+ }
190+ a.lifecycleMu.Unlock()
191+ if a.persistFn != nil {
192+ if err := a.persistFn(a); err != nil {
193+ a.lifecycleMu.Lock()
194+ if a.requirement == resolved {
195+ a.requirement = current
196+ a.requirementPersisting = false
197+ }
198+ a.lifecycleMu.Unlock()
199+ a.recordPersistenceError(err)
200+ return current
201+ }
202+ }
203+ a.lifecycleMu.Lock()
204+ if a.requirement == resolved {
205+ a.requirementPersisting = false
206+ a.signalRequirementLocked()
207+ }
208+ a.lifecycleMu.Unlock()
209+ return resolved
210+}
211+
212+func unobservedRequirementResult(result *TurnResult) bool {
213+ return result != nil && result.Error != nil && result.Error.Code == "required_outcome_unobserved"
214+}
215+
216+func requirementAcceptsResult(requirement RequirementSnapshot, result *TurnResult) bool {
217+ if !requirement.pending() || result == nil {
218+ return false
219+ }
220+ turnID := strings.TrimSpace(result.TurnID)
221+ if strings.HasPrefix(turnID, "turn-") {
222+ if turn, err := strconv.Atoi(strings.TrimPrefix(turnID, "turn-")); err == nil {
223+ return turn == requirement.TargetTurn
224+ }
225+ }
226+ // Legacy first-turn results did not always carry a turn id. They can
227+ // satisfy only the initial requirement; retries fail closed without proof.
228+ return requirement.TargetTurn == 1
229+}
230+
231+func classifyRequirementOutcome(result *TurnResult, errMsg string) (RequirementState, string) {
232+ if result != nil {
233+ errorCode := ""
234+ if result.Error != nil {
235+ errorCode = strings.TrimSpace(result.Error.Code)
236+ }
237+ switch {
238+ case errorCode == "required_outcome_unobserved":
239+ return RequirementIndeterminate, "required_outcome_unobserved"
240+ case result.ShutdownOrigin == ShutdownOriginDeadline || errorCode == "deadline_exceeded" || errorCode == "timeout":
241+ return RequirementTimedOut, "deadline_exceeded"
242+ case result.Status == ResultCanceled || errorCode == "canceled" || errorCode == "cancelled":
243+ return RequirementCanceled, "canceled"
244+ case result.Status == ResultFailed:
245+ return RequirementFailed, "failed"
246+ default:
247+ return RequirementSatisfied, ""
248+ }
249+ }
250+ lower := strings.ToLower(strings.TrimSpace(errMsg))
251+ switch {
252+ case lower == "":
253+ return RequirementSatisfied, ""
254+ case strings.Contains(lower, "deadline exceeded") || strings.Contains(lower, "timed out"):
255+ return RequirementTimedOut, "deadline_exceeded"
256+ case strings.Contains(lower, "canceled") || strings.Contains(lower, "cancelled"):
257+ return RequirementCanceled, "canceled"
258+ default:
259+ return RequirementFailed, "failed"
260+ }
261+}
262+
263+func (a *Agent) visibleRequirementLocked() RequirementSnapshot {
264+ requirement := a.requirement
265+ if a.requirementPersisting {
266+ requirement.State = RequirementPending
267+ requirement.ErrorCode = ""
268+ requirement.Notified = false
269+ }
270+ return requirement
271+}
272+
273+func (a *Agent) initRequirementSignalLocked() {
274+ if a.requirementChanged == nil {
275+ a.requirementChanged = make(chan struct{})
276+ }
277+}
278+
279+func (a *Agent) signalRequirementLocked() {
280+ a.initRequirementSignalLocked()
281+ close(a.requirementChanged)
282+ a.requirementChanged = make(chan struct{})
283+}
284+
285+func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error) {
286+ if ctx == nil {
287+ ctx = context.Background()
288+ }
289+ for {
290+ a.lifecycleMu.Lock()
291+ a.initRequirementSignalLocked()
292+ requirement := a.visibleRequirementLocked()
293+ changed := a.requirementChanged
294+ a.lifecycleMu.Unlock()
295+ if !requirement.pending() {
296+ return requirement, nil
297+ }
298+ select {
299+ case <-ctx.Done():
300+ return RequirementSnapshot{}, ctx.Err()
301+ case <-changed:
302+ }
303+ }
304+}
305+
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 terminal
323+// outcome through a blocking manager tool or host-injected update.
324+func (f *Supervisor) MarkRequirementNotified(id string) error {
325+ a := f.Get(strings.TrimSpace(id))
326+ if a == nil {
327+ return fmt.Errorf("subagents: no such agent %q", id)
328+ }
329+ a.markRequirementNotified()
330+ return nil
331+}
332+
333+// RequiredSnapshots returns required workers owned by the active host session.
334+// Unlike dashboard snapshots, terminal workers from other sessions are never
335+// included because they must not gate an unrelated parent conversation.
336+func (f *Supervisor) RequiredSnapshots() []AgentSnapshot {
337+ f.mu.Lock()
338+ active := f.activeSession
339+ agents := make([]*Agent, 0, len(f.order))
340+ for _, id := range f.order {
341+ if a := f.agents[id]; a != nil {
342+ agents = append(agents, a)
343+ }
344+ }
345+ f.mu.Unlock()
346+
347+ out := make([]AgentSnapshot, 0, len(agents))
348+ for _, a := range agents {
349+ if active != "" && a.SessionID != "" && a.SessionID != active {
350+ continue
351+ }
352+ snapshot := a.Snapshot()
353+ if snapshot.Requirement.Required {
354+ out = append(out, snapshot)
355+ }
356+ }
357+ sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
358+ return out
359+}
360+
361+// UnmetRequirements returns every required worker that still blocks parent
362+// completion in the active session.
363+func (f *Supervisor) UnmetRequirements() []AgentSnapshot {
364+ required := f.RequiredSnapshots()
365+ out := required[:0]
366+ for _, snapshot := range required {
367+ if snapshot.Requirement.Unmet() {
368+ out = append(out, snapshot)
369+ }
370+ }
371+ return out
372+}
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+}
@@ -0,0 +1,399 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strconv"
9+ "strings"
10+ "time"
11+)
12+
13+// RequirementState is the durable outcome of work that the parent declared
14+// mandatory before it may finish its own turn.
15+type RequirementState string
16+
17+const (
18+ RequirementPending RequirementState = "pending"
19+ RequirementSatisfied RequirementState = "satisfied"
20+ RequirementFailed RequirementState = "failed"
21+ RequirementTimedOut RequirementState = "timed_out"
22+ RequirementCanceled RequirementState = "canceled"
23+ RequirementIndeterminate RequirementState = "indeterminate"
24+)
25+
26+// RequirementSnapshot is persisted with its worker. A requirement targets a
27+// delegated message turn rather than a process attempt because resumable
28+// workers may execute several turns in one process.
29+type RequirementSnapshot struct {
30+ Required bool `json:"required"`
31+ State RequirementState `json:"state,omitempty"`
32+ TargetTurn int `json:"target_turn,omitempty"`
33+ UpdatedAt time.Time `json:"updated_at,omitempty"`
34+ ErrorCode string `json:"error_code,omitempty"`
35+ Notified bool `json:"notified,omitempty"`
36+}
37+
38+// Unmet reports whether this requirement still prevents parent completion.
39+func (r RequirementSnapshot) Unmet() bool {
40+ if !r.Required {
41+ return false
42+ }
43+ return r.State != RequirementSatisfied
44+}
45+
46+func (r RequirementSnapshot) pending() bool {
47+ return r.Required && r.State == RequirementPending
48+}
49+
50+func newRequirement(required bool, targetTurn int, now time.Time) RequirementSnapshot {
51+ if !required {
52+ return RequirementSnapshot{}
53+ }
54+ return RequirementSnapshot{
55+ Required: true,
56+ State: RequirementPending,
57+ TargetTurn: targetTurn,
58+ UpdatedAt: now.UTC(),
59+ }
60+}
61+
62+func (a *Agent) requirementSnapshot() RequirementSnapshot {
63+ if a == nil {
64+ return RequirementSnapshot{}
65+ }
66+ a.lifecycleMu.Lock()
67+ defer a.lifecycleMu.Unlock()
68+ return a.visibleRequirementLocked()
69+}
70+
71+func (a *Agent) prepareRequired(targetTurn int) RequirementSnapshot {
72+ a.lifecycleMu.Lock()
73+ defer a.lifecycleMu.Unlock()
74+ a.initRequirementSignalLocked()
75+ previous := a.requirement
76+ if targetTurn <= 0 {
77+ nextTurn := a.LifetimeTurns + 1
78+ if previous.pending() && previous.TargetTurn >= nextTurn {
79+ return previous
80+ }
81+ targetTurn = nextTurn
82+ if previous.TargetTurn >= targetTurn {
83+ targetTurn = previous.TargetTurn + 1
84+ }
85+ }
86+ a.requirement = RequirementSnapshot{
87+ Required: true,
88+ State: RequirementPending,
89+ TargetTurn: targetTurn,
90+ UpdatedAt: time.Now().UTC(),
91+ }
92+ a.signalRequirementLocked()
93+ return previous
94+}
95+
96+func (a *Agent) restoreRequirement(previous RequirementSnapshot) {
97+ a.lifecycleMu.Lock()
98+ a.initRequirementSignalLocked()
99+ a.requirement = previous
100+ a.requirementPersisting = false
101+ a.signalRequirementLocked()
102+ a.lifecycleMu.Unlock()
103+}
104+
105+func (a *Agent) markRequirementNotified() {
106+ if a == nil {
107+ return
108+ }
109+ a.lifecycleMu.Lock()
110+ if !a.requirement.Required || a.requirement.Notified {
111+ a.lifecycleMu.Unlock()
112+ return
113+ }
114+ previous := a.requirement
115+ a.requirement.Notified = true
116+ a.requirement.UpdatedAt = time.Now().UTC()
117+ notified := a.requirement
118+ a.lifecycleMu.Unlock()
119+ if a.persistFn != nil {
120+ if err := a.persistFn(a); err != nil {
121+ a.lifecycleMu.Lock()
122+ if a.requirement == notified {
123+ a.requirement = previous
124+ }
125+ a.lifecycleMu.Unlock()
126+ a.recordPersistenceError(err)
127+ return
128+ }
129+ }
130+ a.lifecycleMu.Lock()
131+ if a.requirement == notified {
132+ a.signalRequirementLocked()
133+ }
134+ a.lifecycleMu.Unlock()
135+}
136+
137+func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, force bool) RequirementSnapshot {
138+ if a == nil {
139+ return RequirementSnapshot{}
140+ }
141+ a.lifecycleMu.Lock()
142+ a.initRequirementSignalLocked()
143+ current := a.requirement
144+ if !current.pending() || (!force && step != current.TargetTurn) {
145+ a.lifecycleMu.Unlock()
146+ return current
147+ }
148+ if result != nil && strings.TrimSpace(result.TurnID) != "" && !requirementAcceptsResult(current, result) {
149+ result = nil
150+ if strings.TrimSpace(errMsg) == "" {
151+ errMsg = "required turn ended without a matching durable result"
152+ }
153+ }
154+ if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) {
155+ durable, err := readTurnResult(a.stateDirectory(""))
156+ if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID {
157+ if err == nil {
158+ err = fmt.Errorf("durable result identity does not match agent %s turn %s", result.AgentID, result.TurnID)
159+ }
160+ persistenceErr := fmt.Errorf("persist required result: %w", err)
161+ a.requirement.State = RequirementIndeterminate
162+ a.requirement.ErrorCode = "required_outcome_unobserved"
163+ a.requirement.Notified = false
164+ a.requirement.UpdatedAt = time.Now().UTC()
165+ resolved := a.requirement
166+ a.requirementPersisting = true
167+ a.lifecycleMu.Unlock()
168+ a.recordPersistenceError(persistenceErr)
169+ if err := a.persistFn(a); err != nil {
170+ a.recordPersistenceError(err)
171+ }
172+ a.lifecycleMu.Lock()
173+ if a.requirement == resolved {
174+ a.requirementPersisting = false
175+ a.signalRequirementLocked()
176+ }
177+ a.lifecycleMu.Unlock()
178+ return resolved
179+ }
180+ }
181+ state, errorCode := classifyRequirementOutcome(result, errMsg)
182+ a.requirement.State = state
183+ a.requirement.ErrorCode = errorCode
184+ a.requirement.Notified = false
185+ a.requirement.UpdatedAt = time.Now().UTC()
186+ resolved := a.requirement
187+ if a.persistFn != nil {
188+ a.requirementPersisting = true
189+ }
190+ a.lifecycleMu.Unlock()
191+ if a.persistFn != nil {
192+ if err := a.persistFn(a); err != nil {
193+ a.lifecycleMu.Lock()
194+ if a.requirement == resolved {
195+ a.requirement = current
196+ a.requirementPersisting = false
197+ }
198+ a.lifecycleMu.Unlock()
199+ a.recordPersistenceError(err)
200+ return current
201+ }
202+ }
203+ a.lifecycleMu.Lock()
204+ if a.requirement == resolved {
205+ a.requirementPersisting = false
206+ a.signalRequirementLocked()
207+ }
208+ a.lifecycleMu.Unlock()
209+ return resolved
210+}
211+
212+func unobservedRequirementResult(result *TurnResult) bool {
213+ return result != nil && result.Error != nil && result.Error.Code == "required_outcome_unobserved"
214+}
215+
216+func requirementAcceptsResult(requirement RequirementSnapshot, result *TurnResult) bool {
217+ if !requirement.pending() || result == nil {
218+ return false
219+ }
220+ turnID := strings.TrimSpace(result.TurnID)
221+ if strings.HasPrefix(turnID, "turn-") {
222+ if turn, err := strconv.Atoi(strings.TrimPrefix(turnID, "turn-")); err == nil {
223+ return turn == requirement.TargetTurn
224+ }
225+ }
226+ // Legacy first-turn results did not always carry a turn id. They can
227+ // satisfy only the initial requirement; retries fail closed without proof.
228+ return requirement.TargetTurn == 1
229+}
230+
231+func classifyRequirementOutcome(result *TurnResult, errMsg string) (RequirementState, string) {
232+ if result != nil {
233+ errorCode := ""
234+ if result.Error != nil {
235+ errorCode = strings.TrimSpace(result.Error.Code)
236+ }
237+ switch {
238+ case errorCode == "required_outcome_unobserved":
239+ return RequirementIndeterminate, "required_outcome_unobserved"
240+ case result.ShutdownOrigin == ShutdownOriginDeadline || errorCode == "deadline_exceeded" || errorCode == "timeout":
241+ return RequirementTimedOut, "deadline_exceeded"
242+ case result.Status == ResultCanceled || errorCode == "canceled" || errorCode == "cancelled":
243+ return RequirementCanceled, "canceled"
244+ case result.Status == ResultFailed:
245+ return RequirementFailed, "failed"
246+ default:
247+ return RequirementSatisfied, ""
248+ }
249+ }
250+ lower := strings.ToLower(strings.TrimSpace(errMsg))
251+ switch {
252+ case lower == "":
253+ return RequirementSatisfied, ""
254+ case strings.Contains(lower, "deadline exceeded") || strings.Contains(lower, "timed out"):
255+ return RequirementTimedOut, "deadline_exceeded"
256+ case strings.Contains(lower, "canceled") || strings.Contains(lower, "cancelled"):
257+ return RequirementCanceled, "canceled"
258+ default:
259+ return RequirementFailed, "failed"
260+ }
261+}
262+
263+func (a *Agent) visibleRequirementLocked() RequirementSnapshot {
264+ requirement := a.requirement
265+ if a.requirementPersisting {
266+ requirement.State = RequirementPending
267+ requirement.ErrorCode = ""
268+ requirement.Notified = false
269+ }
270+ return requirement
271+}
272+
273+func (a *Agent) initRequirementSignalLocked() {
274+ if a.requirementChanged == nil {
275+ a.requirementChanged = make(chan struct{})
276+ }
277+}
278+
279+func (a *Agent) signalRequirementLocked() {
280+ a.initRequirementSignalLocked()
281+ close(a.requirementChanged)
282+ a.requirementChanged = make(chan struct{})
283+}
284+
285+func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error) {
286+ if ctx == nil {
287+ ctx = context.Background()
288+ }
289+ for {
290+ a.lifecycleMu.Lock()
291+ a.initRequirementSignalLocked()
292+ requirement := a.visibleRequirementLocked()
293+ changed := a.requirementChanged
294+ a.lifecycleMu.Unlock()
295+ if !requirement.pending() {
296+ return requirement, nil
297+ }
298+ select {
299+ case <-ctx.Done():
300+ return RequirementSnapshot{}, ctx.Err()
301+ case <-changed:
302+ }
303+ }
304+}
305+
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 terminal
323+// outcome through a blocking manager tool or host-injected update.
324+func (f *Supervisor) MarkRequirementNotified(id string) error {
325+ a := f.Get(strings.TrimSpace(id))
326+ if a == nil {
327+ return fmt.Errorf("subagents: no such agent %q", id)
328+ }
329+ a.markRequirementNotified()
330+ return nil
331+}
332+
333+// RequiredSnapshots returns required workers owned by the active host session.
334+// Unlike dashboard snapshots, terminal workers from other sessions are never
335+// included because they must not gate an unrelated parent conversation.
336+func (f *Supervisor) RequiredSnapshots() []AgentSnapshot {
337+ f.mu.Lock()
338+ active := f.activeSession
339+ agents := make([]*Agent, 0, len(f.order))
340+ for _, id := range f.order {
341+ if a := f.agents[id]; a != nil {
342+ agents = append(agents, a)
343+ }
344+ }
345+ f.mu.Unlock()
346+
347+ out := make([]AgentSnapshot, 0, len(agents))
348+ for _, a := range agents {
349+ if active != "" && a.SessionID != "" && a.SessionID != active {
350+ continue
351+ }
352+ snapshot := a.Snapshot()
353+ if snapshot.Requirement.Required {
354+ out = append(out, snapshot)
355+ }
356+ }
357+ sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
358+ return out
359+}
360+
361+// UnmetRequirements returns every required worker that still blocks parent
362+// completion in the active session.
363+func (f *Supervisor) UnmetRequirements() []AgentSnapshot {
364+ required := f.RequiredSnapshots()
365+ out := required[:0]
366+ for _, snapshot := range required {
367+ if snapshot.Requirement.Unmet() {
368+ out = append(out, snapshot)
369+ }
370+ }
371+ return out
372+}
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+}
A packages/agent/subagents/requirement_test.go +364 -0
@@ -0,0 +1,364 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+ "time"
10+)
11+
12+func TestRequiredWaitWakesOnlyAfterOutcomePersistence(t *testing.T) {
13+ persisting := make(chan struct{})
14+ release := make(chan struct{})
15+ a := &Agent{
16+ ID: "required-agent",
17+ requirement: newRequirement(true, 1, time.Now()),
18+ requirementChanged: make(chan struct{}),
19+ persistFn: func(*Agent) error {
20+ close(persisting)
21+ <-release
22+ return nil
23+ },
24+ }
25+ resolved := make(chan struct{})
26+ go func() {
27+ a.resolveRequirement(1, nil, "", false)
28+ close(resolved)
29+ }()
30+ <-persisting
31+ a.lifecycleMu.Lock()
32+ visible := a.visibleRequirementLocked()
33+ a.lifecycleMu.Unlock()
34+ if visible.State != RequirementPending {
35+ t.Fatalf("visible requirement while persistence is blocked = %+v, want pending", visible)
36+ }
37+ waited := make(chan RequirementSnapshot, 1)
38+ go func() {
39+ got, _ := a.waitRequirement(context.Background())
40+ waited <- got
41+ }()
42+ close(release)
43+ <-resolved
44+ if got := <-waited; got.State != RequirementSatisfied {
45+ t.Fatalf("durable outcome = %+v", got)
46+ }
47+}
48+
49+func TestRequiredOutcomeUsesDelegatedTurnOrdinal(t *testing.T) {
50+ a := &Agent{
51+ requirement: newRequirement(true, 1, time.Now()),
52+ requirementChanged: make(chan struct{}),
53+ }
54+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
55+ "step": float64(1), "lifetime_turns": float64(1), "current_run_turns": float64(1),
56+ })); err != nil {
57+ t.Fatal(err)
58+ }
59+ if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(1)})); err != nil {
60+ t.Fatal(err)
61+ }
62+ if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 1 {
63+ t.Fatalf("canonical first-turn outcome = %+v", got)
64+ }
65+
66+ a.prepareRequired(0)
67+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
68+ "step": float64(2), "lifetime_turns": float64(2), "current_run_turns": float64(1),
69+ })); err != nil {
70+ t.Fatal(err)
71+ }
72+ if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2)})); err != nil {
73+ t.Fatal(err)
74+ }
75+ if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 2 {
76+ t.Fatalf("canonical retry outcome = %+v", got)
77+ }
78+
79+ for _, step := range []int{0, 1} {
80+ legacy := &Agent{
81+ requirement: newRequirement(true, 1, time.Now()),
82+ requirementChanged: make(chan struct{}),
83+ }
84+ if err := updateAgentFromEvent(legacy, NewEvent("turn_end", map[string]any{"step": float64(step)})); err != nil {
85+ t.Fatal(err)
86+ }
87+ if got := legacy.requirementSnapshot(); got.State != RequirementSatisfied {
88+ t.Fatalf("lifetime-free step %d outcome = %+v", step, got)
89+ }
90+ }
91+}
92+
93+func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T) {
94+ if runtime.GOOS == "windows" {
95+ t.Skip("unix sockets not supported")
96+ }
97+ if testing.Short() {
98+ t.Skip("skip end-to-end runner test in -short mode")
99+ }
100+ exe := buildStubChild(t)
101+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
102+ root := t.TempDir()
103+ f := New(Config{
104+ Root: filepath.Join(root, "subagents"),
105+ RepoRoot: root,
106+ NewRunner: func(a *Agent) Runner {
107+ return &execRunner{
108+ agent: a,
109+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{
110+ Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task,
111+ }),
112+ GracePeriod: time.Second,
113+ }
114+ },
115+ })
116+ t.Cleanup(f.StopAll)
117+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true})
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout)
122+ defer cancel()
123+ got, err := f.WaitRequired(ctx, a.ID)
124+ if err != nil {
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))
127+ }
128+ if got.State != RequirementSatisfied {
129+ t.Fatalf("requirement = %+v, want satisfied", got)
130+ }
131+ if snapshot := a.Snapshot(); snapshot.Status != StatusRunning {
132+ t.Fatalf("worker lifecycle = (%s, %s), want live daemon", snapshot.Status, snapshot.TurnState)
133+ }
134+}
135+
136+func TestReloadFailsClosedWhenRequiredOutcomeWasNotObserved(t *testing.T) {
137+ root := t.TempDir()
138+ started := make(chan struct{})
139+ first := New(Config{
140+ Root: root, RepoRoot: root,
141+ NewRunner: func(*Agent) Runner {
142+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
143+ close(started)
144+ <-ctx.Done()
145+ return ctx.Err()
146+ })
147+ },
148+ })
149+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true})
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ <-started
154+ t.Cleanup(first.StopAll)
155+
156+ second := New(Config{Root: root, RepoRoot: root})
157+ loaded, errs := second.Reload()
158+ if len(errs) != 0 || loaded != 1 {
159+ t.Fatalf("reload loaded=%d errors=%v", loaded, errs)
160+ }
161+ got := second.Get(a.ID).Snapshot().Requirement
162+ if got.State != RequirementIndeterminate || !got.Unmet() || got.ErrorCode != "required_outcome_unobserved" {
163+ t.Fatalf("reconciled requirement = %+v, want durable indeterminate outcome", got)
164+ }
165+ meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if meta.Requirement.State != RequirementIndeterminate {
170+ t.Fatalf("persisted reconciled requirement = %+v", meta.Requirement)
171+ }
172+}
173+
174+func TestRequiredOutcomePersistsAcrossReload(t *testing.T) {
175+ root := t.TempDir()
176+ first := New(Config{
177+ Root: root,
178+ RepoRoot: root,
179+ NewRunner: func(*Agent) Runner {
180+ return RunnerFunc(func(context.Context, Sink) error { return errors.New("review failed") })
181+ },
182+ })
183+ t.Cleanup(first.StopAll)
184+
185+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true})
186+ if err != nil {
187+ t.Fatalf("spawn required worker: %v", err)
188+ }
189+ a.Wait()
190+ got := a.Snapshot().Requirement
191+ if !got.Required || got.State != RequirementFailed {
192+ t.Fatalf("live requirement = %+v, want required failed", got)
193+ }
194+
195+ second := New(Config{Root: root, RepoRoot: root})
196+ loaded, errs := second.Reload()
197+ if len(errs) != 0 {
198+ t.Fatalf("reload errors: %v", errs)
199+ }
200+ if loaded != 1 {
201+ t.Fatalf("loaded = %d, want 1", loaded)
202+ }
203+ reloaded := second.Get(a.ID)
204+ if reloaded == nil {
205+ t.Fatalf("reloaded worker %q not found", a.ID)
206+ }
207+ got = reloaded.Snapshot().Requirement
208+ if !got.Required || got.State != RequirementFailed || got.TargetTurn != 1 {
209+ t.Fatalf("reloaded requirement = %+v, want durable failed turn 1", got)
210+ }
211+
212+ meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
213+ if err != nil {
214+ t.Fatalf("read agent metadata: %v", err)
215+ }
216+ if !meta.Requirement.Required || meta.Requirement.State != RequirementFailed {
217+ t.Fatalf("persisted requirement = %+v", meta.Requirement)
218+ }
219+}
220+
221+func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) {
222+ root := t.TempDir()
223+ first := New(Config{
224+ Root: root, RepoRoot: root,
225+ NewRunner: func(*Agent) Runner {
226+ return RunnerFunc(func(context.Context, Sink) error { return nil })
227+ },
228+ })
229+ t.Cleanup(first.StopAll)
230+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required validation", Required: true})
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ a.Wait()
235+ if err := first.MarkRequirementNotified(a.ID); err != nil {
236+ t.Fatal(err)
237+ }
238+
239+ second := New(Config{Root: root, RepoRoot: root})
240+ if _, errs := second.Reload(); len(errs) != 0 {
241+ t.Fatalf("reload errors: %v", errs)
242+ }
243+ reloaded := second.Get(a.ID)
244+ if got := reloaded.requirementSnapshot(); !got.Notified || got.State != RequirementSatisfied {
245+ t.Fatalf("reloaded notified requirement = %+v", got)
246+ }
247+ previous := reloaded.prepareRequired(0)
248+ if previous.TargetTurn != 1 {
249+ t.Fatalf("previous target = %d, want 1", previous.TargetTurn)
250+ }
251+ pending := reloaded.requirementSnapshot()
252+ if pending.Notified || pending.TargetTurn != 2 {
253+ t.Fatalf("retried requirement = %+v, want unnotified turn 2", pending)
254+ }
255+ reloaded.prepareRequired(0)
256+ if got := reloaded.requirementSnapshot(); got.TargetTurn != 2 {
257+ t.Fatalf("idempotent prepare target = %d, want 2", got.TargetTurn)
258+ }
259+ reloaded.resolveRequirement(2, nil, "", false)
260+ if got := reloaded.requirementSnapshot(); got.Notified || got.State != RequirementSatisfied {
261+ t.Fatalf("resolved retry = %+v, want unnotified satisfaction", got)
262+ }
263+}
264+
265+func TestRequiredTerminalOutcomesRemainUnmet(t *testing.T) {
266+ tests := []struct {
267+ name string
268+ run func(context.Context) error
269+ want RequirementState
270+ }{
271+ {name: "failed", run: func(context.Context) error { return errors.New("boom") }, want: RequirementFailed},
272+ {name: "timed out", run: func(context.Context) error { return context.DeadlineExceeded }, want: RequirementTimedOut},
273+ {name: "canceled", run: func(context.Context) error { return context.Canceled }, want: RequirementCanceled},
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ f := newTestSupervisor(t, func(*Agent) Runner {
278+ return RunnerFunc(func(ctx context.Context, _ Sink) error { return tt.run(ctx) })
279+ })
280+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: tt.name, Required: true})
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ a.Wait()
285+ got := a.Snapshot().Requirement
286+ if got.State != tt.want || !got.Unmet() {
287+ t.Fatalf("requirement = %+v, want unmet %s", got, tt.want)
288+ }
289+ })
290+ }
291+}
292+
293+func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
294+ started := make(chan struct{})
295+ release := make(chan struct{})
296+ attempt := 0
297+ f := newTestSupervisor(t, func(*Agent) Runner {
298+ attempt++
299+ current := attempt
300+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
301+ if current == 1 {
302+ close(started)
303+ select {
304+ case <-release:
305+ return errors.New("first attempt failed")
306+ case <-ctx.Done():
307+ return ctx.Err()
308+ }
309+ }
310+ return nil
311+ })
312+ })
313+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required finalizer", Required: true})
314+ if err != nil {
315+ t.Fatal(err)
316+ }
317+ <-started
318+
319+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
320+ defer cancel()
321+ waited := make(chan RequirementSnapshot, 1)
322+ waitErr := make(chan error, 1)
323+ go func() {
324+ result, err := f.WaitRequired(ctx, a.ID)
325+ if err != nil {
326+ waitErr <- err
327+ return
328+ }
329+ waited <- result
330+ }()
331+ select {
332+ case got := <-waited:
333+ t.Fatalf("wait returned before the worker finished: %+v", got)
334+ case err := <-waitErr:
335+ t.Fatalf("wait failed before the worker finished: %v", err)
336+ default:
337+ }
338+
339+ close(release)
340+ select {
341+ case err := <-waitErr:
342+ t.Fatalf("wait failed: %v", err)
343+ case got := <-waited:
344+ if got.State != RequirementFailed || !got.Unmet() {
345+ t.Fatalf("first outcome = %+v, want unmet failure", got)
346+ }
347+ case <-ctx.Done():
348+ t.Fatal("event-driven requirement wait did not wake")
349+ }
350+ a.Wait()
351+
352+ resumed, err := f.ResumeRequiredWithPrompt(context.Background(), a.ID, "retry the finalization")
353+ if err != nil {
354+ t.Fatalf("required retry: %v", err)
355+ }
356+ resumed.Wait()
357+ got, err := f.WaitRequired(context.Background(), a.ID)
358+ if err != nil {
359+ t.Fatalf("wait for retry: %v", err)
360+ }
361+ if got.State != RequirementSatisfied || got.Unmet() || got.TargetTurn != 2 {
362+ t.Fatalf("retry outcome = %+v, want satisfied turn 2", got)
363+ }
364+}
@@ -0,0 +1,364 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+ "time"
10+)
11+
12+func TestRequiredWaitWakesOnlyAfterOutcomePersistence(t *testing.T) {
13+ persisting := make(chan struct{})
14+ release := make(chan struct{})
15+ a := &Agent{
16+ ID: "required-agent",
17+ requirement: newRequirement(true, 1, time.Now()),
18+ requirementChanged: make(chan struct{}),
19+ persistFn: func(*Agent) error {
20+ close(persisting)
21+ <-release
22+ return nil
23+ },
24+ }
25+ resolved := make(chan struct{})
26+ go func() {
27+ a.resolveRequirement(1, nil, "", false)
28+ close(resolved)
29+ }()
30+ <-persisting
31+ a.lifecycleMu.Lock()
32+ visible := a.visibleRequirementLocked()
33+ a.lifecycleMu.Unlock()
34+ if visible.State != RequirementPending {
35+ t.Fatalf("visible requirement while persistence is blocked = %+v, want pending", visible)
36+ }
37+ waited := make(chan RequirementSnapshot, 1)
38+ go func() {
39+ got, _ := a.waitRequirement(context.Background())
40+ waited <- got
41+ }()
42+ close(release)
43+ <-resolved
44+ if got := <-waited; got.State != RequirementSatisfied {
45+ t.Fatalf("durable outcome = %+v", got)
46+ }
47+}
48+
49+func TestRequiredOutcomeUsesDelegatedTurnOrdinal(t *testing.T) {
50+ a := &Agent{
51+ requirement: newRequirement(true, 1, time.Now()),
52+ requirementChanged: make(chan struct{}),
53+ }
54+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
55+ "step": float64(1), "lifetime_turns": float64(1), "current_run_turns": float64(1),
56+ })); err != nil {
57+ t.Fatal(err)
58+ }
59+ if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(1)})); err != nil {
60+ t.Fatal(err)
61+ }
62+ if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 1 {
63+ t.Fatalf("canonical first-turn outcome = %+v", got)
64+ }
65+
66+ a.prepareRequired(0)
67+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
68+ "step": float64(2), "lifetime_turns": float64(2), "current_run_turns": float64(1),
69+ })); err != nil {
70+ t.Fatal(err)
71+ }
72+ if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2)})); err != nil {
73+ t.Fatal(err)
74+ }
75+ if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 2 {
76+ t.Fatalf("canonical retry outcome = %+v", got)
77+ }
78+
79+ for _, step := range []int{0, 1} {
80+ legacy := &Agent{
81+ requirement: newRequirement(true, 1, time.Now()),
82+ requirementChanged: make(chan struct{}),
83+ }
84+ if err := updateAgentFromEvent(legacy, NewEvent("turn_end", map[string]any{"step": float64(step)})); err != nil {
85+ t.Fatal(err)
86+ }
87+ if got := legacy.requirementSnapshot(); got.State != RequirementSatisfied {
88+ t.Fatalf("lifetime-free step %d outcome = %+v", step, got)
89+ }
90+ }
91+}
92+
93+func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T) {
94+ if runtime.GOOS == "windows" {
95+ t.Skip("unix sockets not supported")
96+ }
97+ if testing.Short() {
98+ t.Skip("skip end-to-end runner test in -short mode")
99+ }
100+ exe := buildStubChild(t)
101+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
102+ root := t.TempDir()
103+ f := New(Config{
104+ Root: filepath.Join(root, "subagents"),
105+ RepoRoot: root,
106+ NewRunner: func(a *Agent) Runner {
107+ return &execRunner{
108+ agent: a,
109+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{
110+ Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task,
111+ }),
112+ GracePeriod: time.Second,
113+ }
114+ },
115+ })
116+ t.Cleanup(f.StopAll)
117+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true})
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout)
122+ defer cancel()
123+ got, err := f.WaitRequired(ctx, a.ID)
124+ if err != nil {
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))
127+ }
128+ if got.State != RequirementSatisfied {
129+ t.Fatalf("requirement = %+v, want satisfied", got)
130+ }
131+ if snapshot := a.Snapshot(); snapshot.Status != StatusRunning {
132+ t.Fatalf("worker lifecycle = (%s, %s), want live daemon", snapshot.Status, snapshot.TurnState)
133+ }
134+}
135+
136+func TestReloadFailsClosedWhenRequiredOutcomeWasNotObserved(t *testing.T) {
137+ root := t.TempDir()
138+ started := make(chan struct{})
139+ first := New(Config{
140+ Root: root, RepoRoot: root,
141+ NewRunner: func(*Agent) Runner {
142+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
143+ close(started)
144+ <-ctx.Done()
145+ return ctx.Err()
146+ })
147+ },
148+ })
149+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true})
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ <-started
154+ t.Cleanup(first.StopAll)
155+
156+ second := New(Config{Root: root, RepoRoot: root})
157+ loaded, errs := second.Reload()
158+ if len(errs) != 0 || loaded != 1 {
159+ t.Fatalf("reload loaded=%d errors=%v", loaded, errs)
160+ }
161+ got := second.Get(a.ID).Snapshot().Requirement
162+ if got.State != RequirementIndeterminate || !got.Unmet() || got.ErrorCode != "required_outcome_unobserved" {
163+ t.Fatalf("reconciled requirement = %+v, want durable indeterminate outcome", got)
164+ }
165+ meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if meta.Requirement.State != RequirementIndeterminate {
170+ t.Fatalf("persisted reconciled requirement = %+v", meta.Requirement)
171+ }
172+}
173+
174+func TestRequiredOutcomePersistsAcrossReload(t *testing.T) {
175+ root := t.TempDir()
176+ first := New(Config{
177+ Root: root,
178+ RepoRoot: root,
179+ NewRunner: func(*Agent) Runner {
180+ return RunnerFunc(func(context.Context, Sink) error { return errors.New("review failed") })
181+ },
182+ })
183+ t.Cleanup(first.StopAll)
184+
185+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true})
186+ if err != nil {
187+ t.Fatalf("spawn required worker: %v", err)
188+ }
189+ a.Wait()
190+ got := a.Snapshot().Requirement
191+ if !got.Required || got.State != RequirementFailed {
192+ t.Fatalf("live requirement = %+v, want required failed", got)
193+ }
194+
195+ second := New(Config{Root: root, RepoRoot: root})
196+ loaded, errs := second.Reload()
197+ if len(errs) != 0 {
198+ t.Fatalf("reload errors: %v", errs)
199+ }
200+ if loaded != 1 {
201+ t.Fatalf("loaded = %d, want 1", loaded)
202+ }
203+ reloaded := second.Get(a.ID)
204+ if reloaded == nil {
205+ t.Fatalf("reloaded worker %q not found", a.ID)
206+ }
207+ got = reloaded.Snapshot().Requirement
208+ if !got.Required || got.State != RequirementFailed || got.TargetTurn != 1 {
209+ t.Fatalf("reloaded requirement = %+v, want durable failed turn 1", got)
210+ }
211+
212+ meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
213+ if err != nil {
214+ t.Fatalf("read agent metadata: %v", err)
215+ }
216+ if !meta.Requirement.Required || meta.Requirement.State != RequirementFailed {
217+ t.Fatalf("persisted requirement = %+v", meta.Requirement)
218+ }
219+}
220+
221+func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) {
222+ root := t.TempDir()
223+ first := New(Config{
224+ Root: root, RepoRoot: root,
225+ NewRunner: func(*Agent) Runner {
226+ return RunnerFunc(func(context.Context, Sink) error { return nil })
227+ },
228+ })
229+ t.Cleanup(first.StopAll)
230+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required validation", Required: true})
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ a.Wait()
235+ if err := first.MarkRequirementNotified(a.ID); err != nil {
236+ t.Fatal(err)
237+ }
238+
239+ second := New(Config{Root: root, RepoRoot: root})
240+ if _, errs := second.Reload(); len(errs) != 0 {
241+ t.Fatalf("reload errors: %v", errs)
242+ }
243+ reloaded := second.Get(a.ID)
244+ if got := reloaded.requirementSnapshot(); !got.Notified || got.State != RequirementSatisfied {
245+ t.Fatalf("reloaded notified requirement = %+v", got)
246+ }
247+ previous := reloaded.prepareRequired(0)
248+ if previous.TargetTurn != 1 {
249+ t.Fatalf("previous target = %d, want 1", previous.TargetTurn)
250+ }
251+ pending := reloaded.requirementSnapshot()
252+ if pending.Notified || pending.TargetTurn != 2 {
253+ t.Fatalf("retried requirement = %+v, want unnotified turn 2", pending)
254+ }
255+ reloaded.prepareRequired(0)
256+ if got := reloaded.requirementSnapshot(); got.TargetTurn != 2 {
257+ t.Fatalf("idempotent prepare target = %d, want 2", got.TargetTurn)
258+ }
259+ reloaded.resolveRequirement(2, nil, "", false)
260+ if got := reloaded.requirementSnapshot(); got.Notified || got.State != RequirementSatisfied {
261+ t.Fatalf("resolved retry = %+v, want unnotified satisfaction", got)
262+ }
263+}
264+
265+func TestRequiredTerminalOutcomesRemainUnmet(t *testing.T) {
266+ tests := []struct {
267+ name string
268+ run func(context.Context) error
269+ want RequirementState
270+ }{
271+ {name: "failed", run: func(context.Context) error { return errors.New("boom") }, want: RequirementFailed},
272+ {name: "timed out", run: func(context.Context) error { return context.DeadlineExceeded }, want: RequirementTimedOut},
273+ {name: "canceled", run: func(context.Context) error { return context.Canceled }, want: RequirementCanceled},
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ f := newTestSupervisor(t, func(*Agent) Runner {
278+ return RunnerFunc(func(ctx context.Context, _ Sink) error { return tt.run(ctx) })
279+ })
280+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: tt.name, Required: true})
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ a.Wait()
285+ got := a.Snapshot().Requirement
286+ if got.State != tt.want || !got.Unmet() {
287+ t.Fatalf("requirement = %+v, want unmet %s", got, tt.want)
288+ }
289+ })
290+ }
291+}
292+
293+func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
294+ started := make(chan struct{})
295+ release := make(chan struct{})
296+ attempt := 0
297+ f := newTestSupervisor(t, func(*Agent) Runner {
298+ attempt++
299+ current := attempt
300+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
301+ if current == 1 {
302+ close(started)
303+ select {
304+ case <-release:
305+ return errors.New("first attempt failed")
306+ case <-ctx.Done():
307+ return ctx.Err()
308+ }
309+ }
310+ return nil
311+ })
312+ })
313+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required finalizer", Required: true})
314+ if err != nil {
315+ t.Fatal(err)
316+ }
317+ <-started
318+
319+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
320+ defer cancel()
321+ waited := make(chan RequirementSnapshot, 1)
322+ waitErr := make(chan error, 1)
323+ go func() {
324+ result, err := f.WaitRequired(ctx, a.ID)
325+ if err != nil {
326+ waitErr <- err
327+ return
328+ }
329+ waited <- result
330+ }()
331+ select {
332+ case got := <-waited:
333+ t.Fatalf("wait returned before the worker finished: %+v", got)
334+ case err := <-waitErr:
335+ t.Fatalf("wait failed before the worker finished: %v", err)
336+ default:
337+ }
338+
339+ close(release)
340+ select {
341+ case err := <-waitErr:
342+ t.Fatalf("wait failed: %v", err)
343+ case got := <-waited:
344+ if got.State != RequirementFailed || !got.Unmet() {
345+ t.Fatalf("first outcome = %+v, want unmet failure", got)
346+ }
347+ case <-ctx.Done():
348+ t.Fatal("event-driven requirement wait did not wake")
349+ }
350+ a.Wait()
351+
352+ resumed, err := f.ResumeRequiredWithPrompt(context.Background(), a.ID, "retry the finalization")
353+ if err != nil {
354+ t.Fatalf("required retry: %v", err)
355+ }
356+ resumed.Wait()
357+ got, err := f.WaitRequired(context.Background(), a.ID)
358+ if err != nil {
359+ t.Fatalf("wait for retry: %v", err)
360+ }
361+ if got.State != RequirementSatisfied || got.Unmet() || got.TargetTurn != 2 {
362+ t.Fatalf("retry outcome = %+v, want satisfied turn 2", got)
363+ }
364+}
M packages/agent/subagents/result.go +8 -4
@@ -573,11 +573,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
573573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
574574 a.setResult(result)
575575 stateDir := a.stateDirectory(f.cfg.Root)
576- if err := writeTurnResult(stateDir, result); err == nil {
577- a.lifecycleMu.Lock()
578- a.resultRef = ResultRef(a.ID)
579- a.lifecycleMu.Unlock()
576+ if err := writeTurnResult(stateDir, result); err != nil {
577+ a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))
578+ a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)
579+ return
580580 }
581+ a.lifecycleMu.Lock()
582+ a.resultRef = ResultRef(a.ID)
583+ a.lifecycleMu.Unlock()
584+ a.resolveRequirement(0, result, "", true)
581585 }
582586
583587 // ReadResult returns the durable structured result for an agent.
@@ -573,11 +573,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
574 a.setResult(result)574 a.setResult(result)
575 stateDir := a.stateDirectory(f.cfg.Root)575 stateDir := a.stateDirectory(f.cfg.Root)
576- if err := writeTurnResult(stateDir, result); err == nil {576+ if err := writeTurnResult(stateDir, result); err != nil {
577- a.lifecycleMu.Lock()577+ a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))
578- a.resultRef = ResultRef(a.ID)578+ a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)
579- a.lifecycleMu.Unlock()579+ return
580 }580 }
581+ a.lifecycleMu.Lock()
582+ a.resultRef = ResultRef(a.ID)
583+ a.lifecycleMu.Unlock()
584+ a.resolveRequirement(0, result, "", true)
581 }585 }
582 586
583 // ReadResult returns the durable structured result for an agent.587 // ReadResult returns the durable structured result for an agent.
M packages/agent/subagents/runner.go +22 -1
@@ -861,11 +861,32 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
861861 if !isDelegatedTurnEnd(ev) {
862862 break
863863 }
864- if message, _ := ev.Data["error"].(string); message != "" {
864+ message, _ := ev.Data["error"].(string)
865+ if message != "" {
865866 a.setTurnState(TurnFailed, ev.TurnID)
866867 } else {
867868 a.setTurnState(TurnSucceeded, ev.TurnID)
868869 }
870+ ordinal := a.LifetimeTurnsValue()
871+ if step, ok := eventCounter(ev.Data, "step"); ok {
872+ if ordinal == 0 {
873+ // Missing protocol-v1 turn.started counters can accompany
874+ // either zero-based or one-based step values. Normalize to
875+ // the durable target when either representation matches.
876+ target := a.requirementSnapshot().TargetTurn
877+ switch {
878+ case step == target || step+1 == target:
879+ ordinal = target
880+ case step == 0:
881+ ordinal = 1
882+ default:
883+ ordinal = step
884+ }
885+ } else if step > ordinal {
886+ ordinal = step
887+ }
888+ }
889+ a.resolveRequirement(ordinal, a.Result(), message, false)
869890 persist = true
870891 }
871892 if persist && a.persistFn != nil {
@@ -861,11 +861,32 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
861 if !isDelegatedTurnEnd(ev) {861 if !isDelegatedTurnEnd(ev) {
862 break862 break
863 }863 }
864- if message, _ := ev.Data["error"].(string); message != "" {864+ message, _ := ev.Data["error"].(string)
865+ if message != "" {
865 a.setTurnState(TurnFailed, ev.TurnID)866 a.setTurnState(TurnFailed, ev.TurnID)
866 } else {867 } else {
867 a.setTurnState(TurnSucceeded, ev.TurnID)868 a.setTurnState(TurnSucceeded, ev.TurnID)
868 }869 }
870+ ordinal := a.LifetimeTurnsValue()
871+ if step, ok := eventCounter(ev.Data, "step"); ok {
872+ if ordinal == 0 {
873+ // Missing protocol-v1 turn.started counters can accompany
874+ // either zero-based or one-based step values. Normalize to
875+ // the durable target when either representation matches.
876+ target := a.requirementSnapshot().TargetTurn
877+ switch {
878+ case step == target || step+1 == target:
879+ ordinal = target
880+ case step == 0:
881+ ordinal = 1
882+ default:
883+ ordinal = step
884+ }
885+ } else if step > ordinal {
886+ ordinal = step
887+ }
888+ }
889+ a.resolveRequirement(ordinal, a.Result(), message, false)
869 persist = true890 persist = true
870 }891 }
871 if persist && a.persistFn != nil {892 if persist && a.persistFn != nil {
M packages/agent/subagents/supervisor.go +7 -1
@@ -357,6 +357,9 @@ 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.
362+ Required bool
360363 Timeout time.Duration
361364 MaxTurns int
362365 Tools []string
@@ -531,6 +534,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
531534 turnState: TurnQueued,
532535 updatedAt: now,
533536 lastActivity: now,
537+ requirement: newRequirement(req.Required, 1, now),
534538 stateDir: stateDir,
535539 lease: lease,
536540 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
@@ -1114,6 +1118,7 @@ type AgentSnapshot struct {
11141118 LastAssistant string // complete final assistant message, without role formatting
11151119 Result *TurnResult
11161120 ResultRef string
1121+ Requirement RequirementSnapshot
11171122 PatchRef string
11181123 ChangedFiles []string
11191124 OutputTruncated bool
@@ -1170,6 +1175,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
11701175 updatedAt := a.updatedAt
11711176 result := cloneTurnResult(a.result)
11721177 resultRef := a.resultRef
1178+ requirement := a.visibleRequirementLocked()
11731179 patchRef := a.patchRef
11741180 changedFiles := append([]string(nil), a.changedFiles...)
11751181 a.lifecycleMu.Unlock()
@@ -1180,7 +1186,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
11801186 Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
11811187 Err: errStr, Tail: tail, Lines: lines,
11821188 LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
1183- PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,
1189+ Requirement: requirement, PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,
11841190 Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL,
11851191 InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning,
11861192 FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode,
@@ -357,6 +357,9 @@ 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. The
361+ // supervisor persists its outcome and the manager tool waits for it.
362+ Required bool
360 Timeout time.Duration363 Timeout time.Duration
361 MaxTurns int364 MaxTurns int
362 Tools []string365 Tools []string
@@ -531,6 +534,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
531 turnState: TurnQueued,534 turnState: TurnQueued,
532 updatedAt: now,535 updatedAt: now,
533 lastActivity: now,536 lastActivity: now,
537+ requirement: newRequirement(req.Required, 1, now),
534 stateDir: stateDir,538 stateDir: stateDir,
535 lease: lease,539 lease: lease,
536 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,540 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
@@ -1114,6 +1118,7 @@ type AgentSnapshot struct {
1114 LastAssistant string // complete final assistant message, without role formatting1118 LastAssistant string // complete final assistant message, without role formatting
1115 Result *TurnResult1119 Result *TurnResult
1116 ResultRef string1120 ResultRef string
1121+ Requirement RequirementSnapshot
1117 PatchRef string1122 PatchRef string
1118 ChangedFiles []string1123 ChangedFiles []string
1119 OutputTruncated bool1124 OutputTruncated bool
@@ -1170,6 +1175,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
1170 updatedAt := a.updatedAt1175 updatedAt := a.updatedAt
1171 result := cloneTurnResult(a.result)1176 result := cloneTurnResult(a.result)
1172 resultRef := a.resultRef1177 resultRef := a.resultRef
1178+ requirement := a.visibleRequirementLocked()
1173 patchRef := a.patchRef1179 patchRef := a.patchRef
1174 changedFiles := append([]string(nil), a.changedFiles...)1180 changedFiles := append([]string(nil), a.changedFiles...)
1175 a.lifecycleMu.Unlock()1181 a.lifecycleMu.Unlock()
@@ -1180,7 +1186,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
1180 Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,1186 Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
1181 Err: errStr, Tail: tail, Lines: lines,1187 Err: errStr, Tail: tail, Lines: lines,
1182 LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,1188 LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
1183- PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,1189+ Requirement: requirement, PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,
1184 Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL,1190 Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL,
1185 InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning,1191 InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning,
1186 FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode,1192 FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode,
M packages/agent/subagents/testdata/cmd/stubchild/main.go +1 -1
@@ -149,7 +149,7 @@ func runTurn(emit emitter, text string, step int) {
149149 map[string]any{"type": "text", "text": "echo: " + text},
150150 },
151151 })
152- emit("turn_end", map[string]any{"stop": "end"})
152+ emit("turn_end", map[string]any{"step": step, "stop": "end"})
153153 }
154154
155155 type emitter = func(string, map[string]any)
@@ -149,7 +149,7 @@ func runTurn(emit emitter, text string, step int) {
149 map[string]any{"type": "text", "text": "echo: " + text},149 map[string]any{"type": "text", "text": "echo: " + text},
150 },150 },
151 })151 })
152- emit("turn_end", map[string]any{"stop": "end"})152+ emit("turn_end", map[string]any{"step": step, "stop": "end"})
153 }153 }
154 154
155 type emitter = func(string, map[string]any)155 type emitter = func(string, map[string]any)
M packages/agent/tools/subagent_lifecycle_test.go +3 -3
@@ -124,12 +124,12 @@ func TestSubagentResumeBeforeHookRunsBeforeRestartedWorkerStarts(t *testing.T) {
124124 tool := &SubagentResumeTool{
125125 Supervisor: manager,
126126 Enabled: func() bool { return true },
127- BeforeResumed: func(agent *subagents.Agent, gotPrompt string) func() {
127+ BeforeResumed: func(agent *subagents.Agent, gotPrompt string, _ bool) func() {
128128 beforeAgent = agent
129129 beforePrompt = gotPrompt
130130 return tracker.TrackFutureTurn(agent, gotPrompt, true)
131131 },
132- OnResumed: func(*subagents.Agent, string) {
132+ OnResumed: func(*subagents.Agent, string, bool) {
133133 t.Fatal("post-success callback should not run with a pre-resume hook")
134134 },
135135 }
@@ -201,7 +201,7 @@ func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
201201 tool := &SubagentResumeTool{
202202 Supervisor: manager,
203203 Enabled: func() bool { return true },
204- OnResumed: func(agent *subagents.Agent, gotPrompt string) {
204+ OnResumed: func(agent *subagents.Agent, gotPrompt string, _ bool) {
205205 tracked = agent
206206 trackedPrompt = gotPrompt
207207 },
@@ -124,12 +124,12 @@ func TestSubagentResumeBeforeHookRunsBeforeRestartedWorkerStarts(t *testing.T) {
124 tool := &SubagentResumeTool{124 tool := &SubagentResumeTool{
125 Supervisor: manager,125 Supervisor: manager,
126 Enabled: func() bool { return true },126 Enabled: func() bool { return true },
127- BeforeResumed: func(agent *subagents.Agent, gotPrompt string) func() {127+ BeforeResumed: func(agent *subagents.Agent, gotPrompt string, _ bool) func() {
128 beforeAgent = agent128 beforeAgent = agent
129 beforePrompt = gotPrompt129 beforePrompt = gotPrompt
130 return tracker.TrackFutureTurn(agent, gotPrompt, true)130 return tracker.TrackFutureTurn(agent, gotPrompt, true)
131 },131 },
132- OnResumed: func(*subagents.Agent, string) {132+ OnResumed: func(*subagents.Agent, string, bool) {
133 t.Fatal("post-success callback should not run with a pre-resume hook")133 t.Fatal("post-success callback should not run with a pre-resume hook")
134 },134 },
135 }135 }
@@ -201,7 +201,7 @@ func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
201 tool := &SubagentResumeTool{201 tool := &SubagentResumeTool{
202 Supervisor: manager,202 Supervisor: manager,
203 Enabled: func() bool { return true },203 Enabled: func() bool { return true },
204- OnResumed: func(agent *subagents.Agent, gotPrompt string) {204+ OnResumed: func(agent *subagents.Agent, gotPrompt string, _ bool) {
205 tracked = agent205 tracked = agent
206 trackedPrompt = gotPrompt206 trackedPrompt = gotPrompt
207 },207 },
A packages/agent/tools/subagent_required_test.go +226 -0
@@ -0,0 +1,226 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/core"
15+)
16+
17+func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
18+ root := t.TempDir()
19+ started := make(chan struct{})
20+ release := make(chan struct{})
21+ manager := subagents.New(subagents.Config{
22+ Root: filepath.Join(root, "subagents"),
23+ RepoRoot: root,
24+ NewRunner: func(*subagents.Agent) subagents.Runner {
25+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
26+ close(started)
27+ select {
28+ case <-release:
29+ return nil
30+ case <-ctx.Done():
31+ return ctx.Err()
32+ }
33+ })
34+ },
35+ })
36+ t.Cleanup(manager.StopAll)
37+
38+ spawned := make(chan bool, 1)
39+ tool := &SubagentSpawnTool{
40+ Supervisor: manager,
41+ Enabled: func() bool { return true },
42+ OnSpawned: func(_ *subagents.Agent, _ string, required bool) {
43+ spawned <- required
44+ },
45+ }
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+
57+ <-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:
64+ }
65+ select {
66+ case required := <-spawned:
67+ if !required {
68+ t.Fatal("required callback was reported as optional")
69+ }
70+ default:
71+ t.Fatal("required spawn callback was not delivered")
72+ }
73+
74+ 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+ }
91+}
92+
93+func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
94+ root := t.TempDir()
95+ attempt := 0
96+ manager := subagents.New(subagents.Config{
97+ Root: filepath.Join(root, "subagents"),
98+ RepoRoot: root,
99+ NewRunner: func(*subagents.Agent) subagents.Runner {
100+ attempt++
101+ current := attempt
102+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
103+ if current == 1 {
104+ return errors.New("first review failed")
105+ }
106+ return nil
107+ })
108+ },
109+ })
110+ t.Cleanup(manager.StopAll)
111+ spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
112+ 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))
118+ }
119+ worker := manager.List()[0]
120+ worker.Wait()
121+
122+ var callbackRequired bool
123+ resume := &SubagentResumeTool{
124+ Supervisor: manager,
125+ Enabled: func() bool { return true },
126+ BeforeResumed: func(_ *subagents.Agent, _ string, required bool) func() {
127+ callbackRequired = required
128+ return nil
129+ },
130+ }
131+ got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, worker.ID)), nil)
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") {
136+ t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))
137+ }
138+}
139+
140+func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {
141+ root := t.TempDir()
142+ started := make(chan struct{})
143+ first := subagents.New(subagents.Config{
144+ Root: filepath.Join(root, "subagents"), RepoRoot: root,
145+ NewRunner: func(*subagents.Agent) subagents.Runner {
146+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
147+ close(started)
148+ <-ctx.Done()
149+ return ctx.Err()
150+ })
151+ },
152+ })
153+ a, err := first.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "non-idempotent release action", Required: true})
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ <-started
158+ t.Cleanup(first.StopAll)
159+
160+ runs := 0
161+ second := subagents.New(subagents.Config{
162+ Root: filepath.Join(root, "subagents"), RepoRoot: root,
163+ NewRunner: func(*subagents.Agent) subagents.Runner {
164+ runs++
165+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
166+ },
167+ })
168+ t.Cleanup(second.StopAll)
169+ if _, errs := second.Reload(); len(errs) != 0 {
170+ t.Fatalf("reload errors: %v", errs)
171+ }
172+ resume := &SubagentResumeTool{Supervisor: second, Enabled: func() bool { return true }}
173+ got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, a.ID)), nil)
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ if !got.IsError || runs != 0 || !strings.Contains(textResult(got.Content), "user must inspect") {
178+ t.Fatalf("automatic indeterminate retry = %#v runs=%d\n%s", got, runs, textResult(got.Content))
179+ }
180+ first.StopAll()
181+
182+ confirmed, err := second.RestartTask(context.Background(), a.ID)
183+ if err != nil {
184+ t.Fatalf("explicit user restart: %v", err)
185+ }
186+ confirmed.Wait()
187+ if runs != 1 || confirmed.Snapshot().Requirement.State != subagents.RequirementSatisfied {
188+ t.Fatalf("explicit reconciliation runs=%d requirement=%+v", runs, confirmed.Snapshot().Requirement)
189+ }
190+}
191+
192+func TestRequiredSubagentFailureStaysUnmet(t *testing.T) {
193+ root := t.TempDir()
194+ manager := subagents.New(subagents.Config{
195+ Root: filepath.Join(root, "subagents"),
196+ RepoRoot: root,
197+ NewRunner: func(*subagents.Agent) subagents.Runner {
198+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
199+ return errors.New("finalizer failed")
200+ })
201+ },
202+ })
203+ t.Cleanup(manager.StopAll)
204+ tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
205+
206+ 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))
212+ }
213+ agents := manager.List()
214+ if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() {
215+ t.Fatalf("failed required worker was not retained as unmet: %#v", agents)
216+ }
217+ status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}
218+ public, err := status.Execute(context.Background(), json.RawMessage(`{}`), nil)
219+ if err != nil {
220+ t.Fatal(err)
221+ }
222+ publicText := textResult(public.Content)
223+ if !strings.Contains(publicText, `"error_code":"failed"`) || strings.Contains(publicText, "finalizer failed") {
224+ t.Fatalf("public requirement status exposed raw diagnostics: %s", publicText)
225+ }
226+}
@@ -0,0 +1,226 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/core"
15+)
16+
17+func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
18+ root := t.TempDir()
19+ started := make(chan struct{})
20+ release := make(chan struct{})
21+ manager := subagents.New(subagents.Config{
22+ Root: filepath.Join(root, "subagents"),
23+ RepoRoot: root,
24+ NewRunner: func(*subagents.Agent) subagents.Runner {
25+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
26+ close(started)
27+ select {
28+ case <-release:
29+ return nil
30+ case <-ctx.Done():
31+ return ctx.Err()
32+ }
33+ })
34+ },
35+ })
36+ t.Cleanup(manager.StopAll)
37+
38+ spawned := make(chan bool, 1)
39+ tool := &SubagentSpawnTool{
40+ Supervisor: manager,
41+ Enabled: func() bool { return true },
42+ OnSpawned: func(_ *subagents.Agent, _ string, required bool) {
43+ spawned <- required
44+ },
45+ }
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+
57+ <-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:
64+ }
65+ select {
66+ case required := <-spawned:
67+ if !required {
68+ t.Fatal("required callback was reported as optional")
69+ }
70+ default:
71+ t.Fatal("required spawn callback was not delivered")
72+ }
73+
74+ 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+ }
91+}
92+
93+func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
94+ root := t.TempDir()
95+ attempt := 0
96+ manager := subagents.New(subagents.Config{
97+ Root: filepath.Join(root, "subagents"),
98+ RepoRoot: root,
99+ NewRunner: func(*subagents.Agent) subagents.Runner {
100+ attempt++
101+ current := attempt
102+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
103+ if current == 1 {
104+ return errors.New("first review failed")
105+ }
106+ return nil
107+ })
108+ },
109+ })
110+ t.Cleanup(manager.StopAll)
111+ spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
112+ 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))
118+ }
119+ worker := manager.List()[0]
120+ worker.Wait()
121+
122+ var callbackRequired bool
123+ resume := &SubagentResumeTool{
124+ Supervisor: manager,
125+ Enabled: func() bool { return true },
126+ BeforeResumed: func(_ *subagents.Agent, _ string, required bool) func() {
127+ callbackRequired = required
128+ return nil
129+ },
130+ }
131+ got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, worker.ID)), nil)
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") {
136+ t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))
137+ }
138+}
139+
140+func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {
141+ root := t.TempDir()
142+ started := make(chan struct{})
143+ first := subagents.New(subagents.Config{
144+ Root: filepath.Join(root, "subagents"), RepoRoot: root,
145+ NewRunner: func(*subagents.Agent) subagents.Runner {
146+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
147+ close(started)
148+ <-ctx.Done()
149+ return ctx.Err()
150+ })
151+ },
152+ })
153+ a, err := first.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "non-idempotent release action", Required: true})
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ <-started
158+ t.Cleanup(first.StopAll)
159+
160+ runs := 0
161+ second := subagents.New(subagents.Config{
162+ Root: filepath.Join(root, "subagents"), RepoRoot: root,
163+ NewRunner: func(*subagents.Agent) subagents.Runner {
164+ runs++
165+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
166+ },
167+ })
168+ t.Cleanup(second.StopAll)
169+ if _, errs := second.Reload(); len(errs) != 0 {
170+ t.Fatalf("reload errors: %v", errs)
171+ }
172+ resume := &SubagentResumeTool{Supervisor: second, Enabled: func() bool { return true }}
173+ got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, a.ID)), nil)
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ if !got.IsError || runs != 0 || !strings.Contains(textResult(got.Content), "user must inspect") {
178+ t.Fatalf("automatic indeterminate retry = %#v runs=%d\n%s", got, runs, textResult(got.Content))
179+ }
180+ first.StopAll()
181+
182+ confirmed, err := second.RestartTask(context.Background(), a.ID)
183+ if err != nil {
184+ t.Fatalf("explicit user restart: %v", err)
185+ }
186+ confirmed.Wait()
187+ if runs != 1 || confirmed.Snapshot().Requirement.State != subagents.RequirementSatisfied {
188+ t.Fatalf("explicit reconciliation runs=%d requirement=%+v", runs, confirmed.Snapshot().Requirement)
189+ }
190+}
191+
192+func TestRequiredSubagentFailureStaysUnmet(t *testing.T) {
193+ root := t.TempDir()
194+ manager := subagents.New(subagents.Config{
195+ Root: filepath.Join(root, "subagents"),
196+ RepoRoot: root,
197+ NewRunner: func(*subagents.Agent) subagents.Runner {
198+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
199+ return errors.New("finalizer failed")
200+ })
201+ },
202+ })
203+ t.Cleanup(manager.StopAll)
204+ tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
205+
206+ 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))
212+ }
213+ agents := manager.List()
214+ if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() {
215+ t.Fatalf("failed required worker was not retained as unmet: %#v", agents)
216+ }
217+ status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}
218+ public, err := status.Execute(context.Background(), json.RawMessage(`{}`), nil)
219+ if err != nil {
220+ t.Fatal(err)
221+ }
222+ publicText := textResult(public.Content)
223+ if !strings.Contains(publicText, `"error_code":"failed"`) || strings.Contains(publicText, "finalizer failed") {
224+ t.Fatalf("public requirement status exposed raw diagnostics: %s", publicText)
225+ }
226+}
M packages/agent/tools/subagent_resume.go +33 -9
@@ -24,17 +24,18 @@ type SubagentResumeTool struct {
2424 // sends or queues its prompt. It returns a cleanup for resume failures that
2525 // happen before delivery. The interactive host uses this to avoid losing a
2626 // fast idle or restarted-worker turn.
27- BeforeResumed func(agent *subagents.Agent, prompt string) func()
27+ BeforeResumed func(agent *subagents.Agent, prompt string, required bool) func()
2828
2929 // OnResumed receives the worker and follow-up after a successful resume.
3030 // It remains available for callers that only need post-success
3131 // notification; tracking callers should prefer BeforeResumed.
32- OnResumed func(agent *subagents.Agent, prompt string)
32+ OnResumed func(agent *subagents.Agent, prompt string, required bool)
3333 }
3434
3535 type subagentResumeArgs struct {
36- AgentID string `json:"agent_id"`
37- Prompt string `json:"prompt"`
36+ AgentID string `json:"agent_id"`
37+ Prompt string `json:"prompt"`
38+ Required bool `json:"required,omitempty"`
3839 }
3940
4041 const subagentResumeSchema = `{
@@ -47,22 +48,26 @@ const subagentResumeSchema = `{
4748 "prompt": {
4849 "type": "string",
4950 "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
51+ },
52+ "required": {
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."
5055 }
5156 },
5257 "required": ["agent_id", "prompt"]
5358 }`
5459
55-func (t *SubagentResumeTool) Name() string { return "subagent_resume" }
60+func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
5661
5762 func (t *SubagentResumeTool) Description() string {
58- return "Continue a sub-agent with a fresh turn budget and its retained session; deliver immediately when idle, queue while active, or restart a stopped worker."
63+ return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation."
5964 }
6065
6166 func (t *SubagentResumeTool) Schema() json.RawMessage {
6267 return json.RawMessage(subagentResumeSchema)
6368 }
6469
65-func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
70+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
6671 if ctx != nil {
6772 select {
6873 case <-ctx.Done():
@@ -93,12 +98,31 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
9398 if !ok {
9499 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
95100 }
96- resumed, err := t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, t.BeforeResumed)
101+ if snapshot.Requirement.State == subagents.RequirementIndeterminate {
102+ return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID))
103+ }
104+ required := args.Required || snapshot.Requirement.Unmet()
105+ var before func(*subagents.Agent, string) func()
106+ if t.BeforeResumed != nil {
107+ before = func(agent *subagents.Agent, prompt string) func() {
108+ return t.BeforeResumed(agent, prompt, required)
109+ }
110+ }
111+ var resumed *subagents.Agent
112+ var err error
113+ if required {
114+ resumed, err = t.Supervisor.ResumeRequiredWithPromptBefore(ctx, snapshot.ID, args.Prompt, before)
115+ } else {
116+ resumed, err = t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, before)
117+ }
97118 if err != nil {
98119 return protocolToolError(prefix + ": " + err.Error())
99120 }
100121 if t.BeforeResumed == nil && t.OnResumed != nil {
101- t.OnResumed(resumed, args.Prompt)
122+ t.OnResumed(resumed, args.Prompt, required)
123+ }
124+ if required {
125+ return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress)
102126 }
103127
104128 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
@@ -24,17 +24,18 @@ type SubagentResumeTool struct {
24 // sends or queues its prompt. It returns a cleanup for resume failures that24 // sends or queues its prompt. It returns a cleanup for resume failures that
25 // happen before delivery. The interactive host uses this to avoid losing a25 // happen before delivery. The interactive host uses this to avoid losing a
26 // fast idle or restarted-worker turn.26 // fast idle or restarted-worker turn.
27- BeforeResumed func(agent *subagents.Agent, prompt string) func()27+ BeforeResumed func(agent *subagents.Agent, prompt string, required bool) func()
28 28
29 // OnResumed receives the worker and follow-up after a successful resume.29 // OnResumed receives the worker and follow-up after a successful resume.
30 // It remains available for callers that only need post-success30 // It remains available for callers that only need post-success
31 // notification; tracking callers should prefer BeforeResumed.31 // notification; tracking callers should prefer BeforeResumed.
32- OnResumed func(agent *subagents.Agent, prompt string)32+ OnResumed func(agent *subagents.Agent, prompt string, required bool)
33 }33 }
34 34
35 type subagentResumeArgs struct {35 type subagentResumeArgs struct {
36- AgentID string `json:"agent_id"`36+ AgentID string `json:"agent_id"`
37- Prompt string `json:"prompt"`37+ Prompt string `json:"prompt"`
38+ Required bool `json:"required,omitempty"`
38 }39 }
39 40
40 const subagentResumeSchema = `{41 const subagentResumeSchema = `{
@@ -47,22 +48,26 @@ const subagentResumeSchema = `{
47 "prompt": {48 "prompt": {
48 "type": "string",49 "type": "string",
49 "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."50 "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
51+ },
52+ "required": {
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."
50 }55 }
51 },56 },
52 "required": ["agent_id", "prompt"]57 "required": ["agent_id", "prompt"]
53 }`58 }`
54 59
55-func (t *SubagentResumeTool) Name() string { return "subagent_resume" }60+func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
56 61
57 func (t *SubagentResumeTool) Description() string {62 func (t *SubagentResumeTool) Description() string {
58- return "Continue a sub-agent with a fresh turn budget and its retained session; deliver immediately when idle, queue while active, or restart a stopped worker."63+ return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation."
59 }64 }
60 65
61 func (t *SubagentResumeTool) Schema() json.RawMessage {66 func (t *SubagentResumeTool) Schema() json.RawMessage {
62 return json.RawMessage(subagentResumeSchema)67 return json.RawMessage(subagentResumeSchema)
63 }68 }
64 69
65-func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {70+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
66 if ctx != nil {71 if ctx != nil {
67 select {72 select {
68 case <-ctx.Done():73 case <-ctx.Done():
@@ -93,12 +98,31 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
93 if !ok {98 if !ok {
94 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))99 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
95 }100 }
96- resumed, err := t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, t.BeforeResumed)101+ if snapshot.Requirement.State == subagents.RequirementIndeterminate {
102+ return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID))
103+ }
104+ required := args.Required || snapshot.Requirement.Unmet()
105+ var before func(*subagents.Agent, string) func()
106+ if t.BeforeResumed != nil {
107+ before = func(agent *subagents.Agent, prompt string) func() {
108+ return t.BeforeResumed(agent, prompt, required)
109+ }
110+ }
111+ var resumed *subagents.Agent
112+ var err error
113+ if required {
114+ resumed, err = t.Supervisor.ResumeRequiredWithPromptBefore(ctx, snapshot.ID, args.Prompt, before)
115+ } else {
116+ resumed, err = t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, before)
117+ }
97 if err != nil {118 if err != nil {
98 return protocolToolError(prefix + ": " + err.Error())119 return protocolToolError(prefix + ": " + err.Error())
99 }120 }
100 if t.BeforeResumed == nil && t.OnResumed != nil {121 if t.BeforeResumed == nil && t.OnResumed != nil {
101- t.OnResumed(resumed, args.Prompt)122+ t.OnResumed(resumed, args.Prompt, required)
123+ }
124+ if required {
125+ return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress)
102 }126 }
103 127
104 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)128 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
M packages/agent/tools/subagent_spawn.go +75 -13
@@ -12,15 +12,21 @@ import (
1212 "github.com/bnema/zut/packages/provider"
1313 )
1414
15-// SubagentSpawnTool lets the main agent fork a background sub-agent
16-// against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs
17-// in parallel: the tool returns the agent id immediately and the main
18-// turn continues uninterrupted. The user can monitor / chat with the
19-// spawned agent via /subagents.
15+// 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.
2019 //
2120 // Available when the host's launch-time tool policy permits delegation. The
2221 // primary-agent prompt decides whether delegation is proactive or only on an
2322 // explicit user request.
23+const (
24+ SubagentSpawnToolName = "subagent_spawn"
25+ SubagentStatusToolName = "subagent_status"
26+ SubagentStopToolName = "subagent_stop"
27+ SubagentResumeToolName = "subagent_resume"
28+)
29+
2430 type SubagentSpawnTool struct {
2531 // Supervisor is the supervisor used to spawn agents. Nil means subagents
2632 // are unavailable in this mode and the tool always errors.
@@ -42,11 +48,10 @@ type SubagentSpawnTool struct {
4248 // The child receives only the name and loads the profile itself.
4349 ResolveSubagent func(name string) (*subagents.Profile, error)
4450
45- // OnSpawned, if set, is called after every successful spawn with
46- // the new agent + the task it was started with. Used by the
47- // interactive host to track agents and surface a summary back
48- // in chat when all sub-agents finish.
49- OnSpawned func(agent *subagents.Agent, task string)
51+ // 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.
54+ OnSpawned func(agent *subagents.Agent, task string, required bool)
5055 }
5156
5257 type subagentSpawnArgs struct {
@@ -57,6 +62,7 @@ type subagentSpawnArgs struct {
5762 Reasoning string `json:"reasoning,omitempty"`
5863 Thinking string `json:"thinking,omitempty"`
5964 FastMode *bool `json:"fast_mode,omitempty"`
65+ Required bool `json:"required,omitempty"`
6066 Isolation string `json:"isolation,omitempty"`
6167 Timeout string `json:"timeout,omitempty"`
6268 MaxTurns *int `json:"max_turns,omitempty"`
@@ -95,6 +101,10 @@ const subagentSpawnSchemaTemplate = `{
95101 "type": "boolean",
96102 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
97103 },
104+ "required": {
105+ "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."
107+ },
98108 "isolation": {
99109 "type": "string",
100110 "enum": ["shared", "worktree"],
@@ -114,9 +124,9 @@ const subagentSpawnSchemaTemplate = `{
114124 "required": ["task"]
115125 }`
116126
117-func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
127+func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
118128 func (t *SubagentSpawnTool) Description() string {
119- return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately. 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."
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."
120130 }
121131 func (t *SubagentSpawnTool) Schema() json.RawMessage {
122132 maxTurns := 3
@@ -250,6 +260,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
250260 Reasoning: reasoning,
251261 FastMode: fastModeOverride,
252262 Subagent: agentName,
263+ Required: a.Required,
253264 Timeout: timeout,
254265 MaxTurns: maxTurns,
255266 WorkspaceMode: workspaceMode,
@@ -259,7 +270,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
259270 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
260271 }
261272 if t.OnSpawned != nil {
262- t.OnSpawned(agent, task)
273+ t.OnSpawned(agent, task, a.Required)
274+ }
275+ if a.Required {
276+ return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
263277 }
264278
265279 var sb strings.Builder
@@ -298,6 +312,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
298312 "provider": providerID,
299313 "reasoning": reasoning,
300314 "fast_mode": agent.FastMode,
315+ "required": false,
301316 "isolation": string(workspaceMode),
302317 "timeout": agent.Timeout.String(),
303318 "max_turns": agent.MaxTurns,
@@ -309,6 +324,53 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
309324 }, nil
310325 }
311326
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+ }
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.")
357+ }
358+ return core.ToolResult{
359+ 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(),
371+ }, nil
372+}
373+
312374 func reasoningOverride(reasoning, thinking string) (string, error) {
313375 reasoning = strings.TrimSpace(reasoning)
314376 thinking = strings.TrimSpace(thinking)
@@ -12,15 +12,21 @@ import (
12 "github.com/bnema/zut/packages/provider"12 "github.com/bnema/zut/packages/provider"
13 )13 )
14 14
15-// SubagentSpawnTool lets the main agent fork a background sub-agent15+// SubagentSpawnTool lets the main agent delegate work against the host's cwd
16-// against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs16+// via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs
17-// in parallel: the tool returns the agent id immediately and the main17+// in the background. Required work keeps the tool call open until the delegated
18-// turn continues uninterrupted. The user can monitor / chat with the18+// turn reaches a durable terminal outcome.
19-// spawned agent via /subagents.
20 //19 //
21 // Available when the host's launch-time tool policy permits delegation. The20 // Available when the host's launch-time tool policy permits delegation. The
22 // primary-agent prompt decides whether delegation is proactive or only on an21 // primary-agent prompt decides whether delegation is proactive or only on an
23 // explicit user request.22 // explicit user request.
23+const (
24+ SubagentSpawnToolName = "subagent_spawn"
25+ SubagentStatusToolName = "subagent_status"
26+ SubagentStopToolName = "subagent_stop"
27+ SubagentResumeToolName = "subagent_resume"
28+)
29+
24 type SubagentSpawnTool struct {30 type SubagentSpawnTool struct {
25 // Supervisor is the supervisor used to spawn agents. Nil means subagents31 // Supervisor is the supervisor used to spawn agents. Nil means subagents
26 // are unavailable in this mode and the tool always errors.32 // are unavailable in this mode and the tool always errors.
@@ -42,11 +48,10 @@ type SubagentSpawnTool struct {
42 // The child receives only the name and loads the profile itself.48 // The child receives only the name and loads the profile itself.
43 ResolveSubagent func(name string) (*subagents.Profile, error)49 ResolveSubagent func(name string) (*subagents.Profile, error)
44 50
45- // OnSpawned, if set, is called after every successful spawn with51+ // OnSpawned, if set, is called after every successful spawn with the
46- // the new agent + the task it was started with. Used by the52+ // requirement mode. Hosts track optional work for asynchronous completion;
47- // interactive host to track agents and surface a summary back53+ // required work is delivered by this blocking tool call instead.
48- // in chat when all sub-agents finish.54+ OnSpawned func(agent *subagents.Agent, task string, required bool)
49- OnSpawned func(agent *subagents.Agent, task string)
50 }55 }
51 56
52 type subagentSpawnArgs struct {57 type subagentSpawnArgs struct {
@@ -57,6 +62,7 @@ type subagentSpawnArgs struct {
57 Reasoning string `json:"reasoning,omitempty"`62 Reasoning string `json:"reasoning,omitempty"`
58 Thinking string `json:"thinking,omitempty"`63 Thinking string `json:"thinking,omitempty"`
59 FastMode *bool `json:"fast_mode,omitempty"`64 FastMode *bool `json:"fast_mode,omitempty"`
65+ Required bool `json:"required,omitempty"`
60 Isolation string `json:"isolation,omitempty"`66 Isolation string `json:"isolation,omitempty"`
61 Timeout string `json:"timeout,omitempty"`67 Timeout string `json:"timeout,omitempty"`
62 MaxTurns *int `json:"max_turns,omitempty"`68 MaxTurns *int `json:"max_turns,omitempty"`
@@ -95,6 +101,10 @@ const subagentSpawnSchemaTemplate = `{
95 "type": "boolean",101 "type": "boolean",
96 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."102 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
97 },103 },
104+ "required": {
105+ "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."
107+ },
98 "isolation": {108 "isolation": {
99 "type": "string",109 "type": "string",
100 "enum": ["shared", "worktree"],110 "enum": ["shared", "worktree"],
@@ -114,9 +124,9 @@ const subagentSpawnSchemaTemplate = `{
114 "required": ["task"]124 "required": ["task"]
115 }`125 }`
116 126
117-func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }127+func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
118 func (t *SubagentSpawnTool) Description() string {128 func (t *SubagentSpawnTool) Description() string {
119- return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately. 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."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."
120 }130 }
121 func (t *SubagentSpawnTool) Schema() json.RawMessage {131 func (t *SubagentSpawnTool) Schema() json.RawMessage {
122 maxTurns := 3132 maxTurns := 3
@@ -250,6 +260,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
250 Reasoning: reasoning,260 Reasoning: reasoning,
251 FastMode: fastModeOverride,261 FastMode: fastModeOverride,
252 Subagent: agentName,262 Subagent: agentName,
263+ Required: a.Required,
253 Timeout: timeout,264 Timeout: timeout,
254 MaxTurns: maxTurns,265 MaxTurns: maxTurns,
255 WorkspaceMode: workspaceMode,266 WorkspaceMode: workspaceMode,
@@ -259,7 +270,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
259 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)270 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
260 }271 }
261 if t.OnSpawned != nil {272 if t.OnSpawned != nil {
262- t.OnSpawned(agent, task)273+ t.OnSpawned(agent, task, a.Required)
274+ }
275+ if a.Required {
276+ return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
263 }277 }
264 278
265 var sb strings.Builder279 var sb strings.Builder
@@ -298,6 +312,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
298 "provider": providerID,312 "provider": providerID,
299 "reasoning": reasoning,313 "reasoning": reasoning,
300 "fast_mode": agent.FastMode,314 "fast_mode": agent.FastMode,
315+ "required": false,
301 "isolation": string(workspaceMode),316 "isolation": string(workspaceMode),
302 "timeout": agent.Timeout.String(),317 "timeout": agent.Timeout.String(),
303 "max_turns": agent.MaxTurns,318 "max_turns": agent.MaxTurns,
@@ -309,6 +324,53 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
309 }, nil324 }, nil
310 }325 }
311 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+ }
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.")
357+ }
358+ return core.ToolResult{
359+ 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(),
371+ }, nil
372+}
373+
312 func reasoningOverride(reasoning, thinking string) (string, error) {374 func reasoningOverride(reasoning, thinking string) (string, error) {
313 reasoning = strings.TrimSpace(reasoning)375 reasoning = strings.TrimSpace(reasoning)
314 thinking = strings.TrimSpace(thinking)376 thinking = strings.TrimSpace(thinking)
M packages/agent/tools/subagent_status.go +17 -1
@@ -44,9 +44,17 @@ type subagentStatusEntry struct {
4444 UpdatedAt time.Time `json:"updated_at"`
4545 FinishedAt *time.Time `json:"finished_at,omitempty"`
4646 TaskSummary string `json:"task_summary,omitempty"`
47+ Requirement *statusRequirement `json:"requirement,omitempty"`
4748 Result *subagentStatusResult `json:"result,omitempty"`
4849 }
4950
51+type statusRequirement struct {
52+ State string `json:"state"`
53+ TargetTurn int `json:"target_turn"`
54+ Unmet bool `json:"unmet"`
55+ ErrorCode string `json:"error_code,omitempty"`
56+}
57+
5058 type subagentStatusResult struct {
5159 State string `json:"state"`
5260 Available bool `json:"available"`
@@ -63,7 +71,7 @@ const subagentStatusSchema = `{
6371 }
6472 }`
6573
66-func (t *SubagentStatusTool) Name() string { return "subagent_status" }
74+func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
6775
6876 func (t *SubagentStatusTool) Description() string {
6977 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
@@ -148,6 +156,14 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry
148156 finished := snapshot.Finished
149157 entry.FinishedAt = &finished
150158 }
159+ if snapshot.Requirement.Required {
160+ entry.Requirement = &statusRequirement{
161+ State: string(snapshot.Requirement.State),
162+ TargetTurn: snapshot.Requirement.TargetTurn,
163+ Unmet: snapshot.Requirement.Unmet(),
164+ ErrorCode: snapshot.Requirement.ErrorCode,
165+ }
166+ }
151167 if snapshot.Result != nil {
152168 entry.Result = &subagentStatusResult{
153169 State: publicResultState(snapshot.Result.Status),
@@ -44,9 +44,17 @@ type subagentStatusEntry struct {
44 UpdatedAt time.Time `json:"updated_at"`44 UpdatedAt time.Time `json:"updated_at"`
45 FinishedAt *time.Time `json:"finished_at,omitempty"`45 FinishedAt *time.Time `json:"finished_at,omitempty"`
46 TaskSummary string `json:"task_summary,omitempty"`46 TaskSummary string `json:"task_summary,omitempty"`
47+ Requirement *statusRequirement `json:"requirement,omitempty"`
47 Result *subagentStatusResult `json:"result,omitempty"`48 Result *subagentStatusResult `json:"result,omitempty"`
48 }49 }
49 50
51+type statusRequirement struct {
52+ State string `json:"state"`
53+ TargetTurn int `json:"target_turn"`
54+ Unmet bool `json:"unmet"`
55+ ErrorCode string `json:"error_code,omitempty"`
56+}
57+
50 type subagentStatusResult struct {58 type subagentStatusResult struct {
51 State string `json:"state"`59 State string `json:"state"`
52 Available bool `json:"available"`60 Available bool `json:"available"`
@@ -63,7 +71,7 @@ const subagentStatusSchema = `{
63 }71 }
64 }`72 }`
65 73
66-func (t *SubagentStatusTool) Name() string { return "subagent_status" }74+func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
67 75
68 func (t *SubagentStatusTool) Description() string {76 func (t *SubagentStatusTool) Description() string {
69 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."77 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
@@ -148,6 +156,14 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry
148 finished := snapshot.Finished156 finished := snapshot.Finished
149 entry.FinishedAt = &finished157 entry.FinishedAt = &finished
150 }158 }
159+ if snapshot.Requirement.Required {
160+ entry.Requirement = &statusRequirement{
161+ State: string(snapshot.Requirement.State),
162+ TargetTurn: snapshot.Requirement.TargetTurn,
163+ Unmet: snapshot.Requirement.Unmet(),
164+ ErrorCode: snapshot.Requirement.ErrorCode,
165+ }
166+ }
151 if snapshot.Result != nil {167 if snapshot.Result != nil {
152 entry.Result = &subagentStatusResult{168 entry.Result = &subagentStatusResult{
153 State: publicResultState(snapshot.Result.Status),169 State: publicResultState(snapshot.Result.Status),
M packages/agent/tools/subagent_stop.go +1 -1
@@ -44,7 +44,7 @@ const subagentStopSchema = `{
4444 "required": ["agent_id"]
4545 }`
4646
47-func (t *SubagentStopTool) Name() string { return "subagent_stop" }
47+func (t *SubagentStopTool) Name() string { return SubagentStopToolName }
4848
4949 func (t *SubagentStopTool) Description() string {
5050 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."
@@ -44,7 +44,7 @@ const subagentStopSchema = `{
44 "required": ["agent_id"]44 "required": ["agent_id"]
45 }`45 }`
46 46
47-func (t *SubagentStopTool) Name() string { return "subagent_stop" }47+func (t *SubagentStopTool) Name() string { return SubagentStopToolName }
48 48
49 func (t *SubagentStopTool) Description() string {49 func (t *SubagentStopTool) Description() string {
50 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."50 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."

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