fix(subagents): enforce required worker outcomes
- commit
- a6e4bd29312705d4166f577716baa8338516a7fa
- author
- brice · · 2026-08-09 22:03 UTC
- committer
- brice · · 2026-08-09 22:04 UTC
- parent
- 352349c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +1763 -97
M README.md +2 -2
| @@ -410,7 +410,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 410 | 410 | ||
| 411 | 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 | 415 | ### Named subagent profiles | |
| 416 | 416 | ||
| @@ -424,7 +424,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 424 | 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 | 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 | 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 | 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 | 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 | 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`. | |
| @@ -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 profiles | 415 | ### 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. | |||
| 97 | 97 | ||
| 98 | 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 | 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 | 136 | ## 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 | 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 | 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 | 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 | 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 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, 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. 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. 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 | 165 | ## Resource policy | |
| 154 | 166 | ||
| @@ -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 recovery | 136 | ## 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 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, 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. 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. 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 policy | 165 | ## 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 | |||
| 1456 | 1456 | var subagentsMgr *subagents.Supervisor | |
| 1457 | 1457 | // These callbacks deliberately resolve iv lazily because the Interactive | |
| 1458 | 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 | 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 | 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 | 1470 | if iv != nil { | |
| 1471 | - | return iv.PrepareResumedSubagentWorker(a, prompt) | |
| 1471 | + | return iv.PrepareResumedSubagentWorker(a, prompt, required) | |
| 1472 | 1472 | } | |
| 1473 | 1473 | return nil | |
| 1474 | 1474 | } | |
| @@ -1513,6 +1513,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1513 | 1513 | subagentsMgr = runtime.Supervisor() | |
| 1514 | 1514 | reloadDone := make(chan struct{}) | |
| 1515 | 1515 | var reloadErrs []error | |
| 1516 | + | runtime.SetRequiredWorkerReady(reloadDone, func() error { | |
| 1517 | + | return errors.Join(reloadErrs...) | |
| 1518 | + | }) | |
| 1516 | 1519 | if subagentsMgr != nil { | |
| 1517 | 1520 | // Replaying historical event logs can take seconds. Populate the | |
| 1518 | 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 | 1613 | return true, "", r.ReplaceText | |
| 1611 | 1614 | } | |
| 1612 | 1615 | a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | |
| 1616 | + | runtime.WireRequiredWorkerGate(a) | |
| 1613 | 1617 | return a | |
| 1614 | 1618 | } | |
| 1615 | 1619 | ||
| @@ -1456,19 +1456,19 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1456 | var subagentsMgr *subagents.Supervisor | 1456 | var subagentsMgr *subagents.Supervisor | ||
| 1457 | // These callbacks deliberately resolve iv lazily because the Interactive | 1457 | // 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 nil | 1473 | 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 []error | 1515 | 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 the | 1520 | // 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.ReplaceText | 1613 | 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 a | 1617 | return a | ||
| 1614 | } | 1618 | } | ||
| 1615 | 1619 | ||||
M packages/agent/modes/interactive.go +29 -7
| @@ -8743,14 +8743,26 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 8743 | 8743 | ||
| 8744 | 8744 | // TrackSubagentWorker is the exported entry point used by the cli to | |
| 8745 | 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 len(required) != 0 && required[0] { | |
| 8748 | + | if i != nil { | |
| 8749 | + | i.invalidate() | |
| 8750 | + | } | |
| 8751 | + | return | |
| 8752 | + | } | |
| 8747 | 8753 | i.trackSubagentWorker(a, task, false, false) | |
| 8748 | 8754 | } | |
| 8749 | 8755 | ||
| 8750 | 8756 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | |
| 8751 | 8757 | // worker's original task. A long-lived daemon can complete several manager | |
| 8752 | 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 len(required) != 0 && required[0] { | |
| 8761 | + | if i != nil { | |
| 8762 | + | i.invalidate() | |
| 8763 | + | } | |
| 8764 | + | return | |
| 8765 | + | } | |
| 8754 | 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 | 8770 | // sent. The returned cleanup removes the registration if the resume operation | |
| 8759 | 8771 | // fails before delivery. It is used by the resume tool's pre-send hook; the | |
| 8760 | 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 len(required) != 0 && required[0] { | |
| 8775 | + | if i != nil { | |
| 8776 | + | i.invalidate() | |
| 8777 | + | } | |
| 8778 | + | return nil | |
| 8779 | + | } | |
| 8762 | 8780 | return i.trackSubagentWorker(a, prompt, true, true) | |
| 8763 | 8781 | } | |
| 8764 | 8782 | ||
| @@ -9069,7 +9087,9 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 9069 | 9087 | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 9070 | 9088 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| 9071 | 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 | 9094 | next[canonical.Name()] = canonical | |
| 9075 | 9095 | } | |
| @@ -9090,9 +9110,11 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 9090 | 9110 | } | |
| 9091 | 9111 | if i.autoSubagentsResumeToolAllowed() { | |
| 9092 | 9112 | 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 | + | }, | |
| 9096 | 9118 | } | |
| 9097 | 9119 | next[resumeTool.Name()] = resumeTool | |
| 9098 | 9120 | } | |
| @@ -8743,14 +8743,26 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 8743 | 8743 | ||||
| 8744 | // TrackSubagentWorker is the exported entry point used by the cli to | 8744 | // 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 len(required) != 0 && required[0] { | |||
| 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 the | 8756 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | ||
| 8751 | // worker's original task. A long-lived daemon can complete several manager | 8757 | // 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 len(required) != 0 && required[0] { | |||
| 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 operation | 8770 | // 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; the | 8771 | // 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 len(required) != 0 && required[0] { | |||
| 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()] = canonical | 9094 | 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()] = resumeTool | 9119 | 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 | |||
| 68 | 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 | 77 | prepareRegistry := func(reg core.Registry) core.Registry { | |
| 73 | 78 | return runtime.PrepareRegistry(reg) | |
| @@ -102,6 +107,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 102 | 107 | runtime.SetActiveSession(sess.ID) | |
| 103 | 108 | } | |
| 104 | 109 | announceSession(extMgr, sess) | |
| 110 | + | runtime.WireRequiredWorkerGate(ag) | |
| 105 | 111 | ||
| 106 | 112 | start := len(ag.Messages()) | |
| 107 | 113 | preSink, finishPre := hooks.startupSink() | |
| @@ -186,16 +192,23 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 186 | 192 | } | |
| 187 | 193 | ||
| 188 | 194 | 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 | + | } | |
| 191 | 199 | } | |
| 192 | - | onResumed := func(a *subagents.Agent, prompt string) { | |
| 200 | + | onResumed := func(a *subagents.Agent, prompt string, required bool) { | |
| 193 | 201 | // BeforeResumed normally owns future-turn registration. Keep this | |
| 194 | 202 | // fallback for direct runtime callers that do not install the pre-hook; | |
| 195 | 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 | 212 | return tracker.TrackFutureTurn(a, prompt, true) | |
| 200 | 213 | } | |
| 201 | 214 | 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 this | 201 | // 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 | |||
| 272 | 272 | ||
| 273 | 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 | 281 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | |
| 280 | 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 when the user asks you to delegate 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; failed required work must be retried and cannot be silently ignored. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | |
| 282 | 284 | ||
| 283 | 285 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | |
| 284 | 286 | // 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 tools | 281 | // 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 when the user asks you to delegate 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; failed required work must be retried and cannot be silently ignored. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` |
| 282 | 284 | ||||
| 283 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | 285 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | ||
| 284 | // orchestrator contract still applies when launch-time policy withholds the | 286 | // 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; 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; 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 +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const requiredWorkerContextHeader = "[required-subagents update]" | |
| 15 | + | ||
| 16 | + | // WireRequiredWorkerGate composes host-enforced requirement handling with any | |
| 17 | + | // extension hooks already installed on the parent. Blocking required tool calls | |
| 18 | + | // handle the normal in-turn path; the before-turn hook covers cancellation, | |
| 19 | + | // restart, and session resume, while the assistant hook fails closed after an | |
| 20 | + | // unsuccessful required outcome. | |
| 21 | + | func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |
| 22 | + | if rt == nil || rt.supervisor == nil || parent == nil { | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | previousBeforeTurn := parent.BeforeTurnContext | |
| 26 | + | parent.BeforeTurnContext = func(ctx context.Context, step int) (bool, string, string) { | |
| 27 | + | var inherited string | |
| 28 | + | if previousBeforeTurn != nil { | |
| 29 | + | allowed, reason, contextText := previousBeforeTurn(ctx, step) | |
| 30 | + | if !allowed { | |
| 31 | + | return false, reason, contextText | |
| 32 | + | } | |
| 33 | + | inherited = contextText | |
| 34 | + | } | |
| 35 | + | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | |
| 36 | + | return false, "loading persisted subagents failed; resolve the supervisor reload error before continuing", inherited | |
| 37 | + | } | |
| 38 | + | required, err := rt.supervisor.WaitActiveRequirements(ctx) | |
| 39 | + | if err != nil { | |
| 40 | + | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | |
| 41 | + | } | |
| 42 | + | update := formatRequiredWorkerContext(required) | |
| 43 | + | if update == "" { | |
| 44 | + | return true, "", inherited | |
| 45 | + | } | |
| 46 | + | return true, "", joinRequiredContext(inherited, update) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | previousBeforeTool := parent.BeforeToolExecute | |
| 50 | + | parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 51 | + | unmet := rt.supervisor.UnmetRequirements() | |
| 52 | + | if len(unmet) != 0 && !requiredRecoveryTool(call.Name) { | |
| 53 | + | return false, formatRequiredWorkerBlock(unmet), nil | |
| 54 | + | } | |
| 55 | + | if previousBeforeTool != nil { | |
| 56 | + | return previousBeforeTool(call) | |
| 57 | + | } | |
| 58 | + | return true, "", nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | previousBeforeAssistant := parent.BeforeAssistantMessage | |
| 62 | + | parent.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 63 | + | unmet := rt.supervisor.UnmetRequirements() | |
| 64 | + | if len(unmet) != 0 { | |
| 65 | + | reason := formatRequiredWorkerBlock(unmet) | |
| 66 | + | parent.QueueMessage(reason) | |
| 67 | + | return false, reason, "" | |
| 68 | + | } | |
| 69 | + | if previousBeforeAssistant != nil { | |
| 70 | + | allowed, reason, replacement := previousBeforeAssistant(text) | |
| 71 | + | if !allowed { | |
| 72 | + | return false, reason, replacement | |
| 73 | + | } | |
| 74 | + | rt.acknowledgeSatisfiedRequirements() | |
| 75 | + | return true, reason, replacement | |
| 76 | + | } | |
| 77 | + | rt.acknowledgeSatisfiedRequirements() | |
| 78 | + | return true, "", "" | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // SetRequiredWorkerReady makes the gate wait for an asynchronous supervisor | |
| 83 | + | // reload before it checks persisted obligations. A nil channel means no reload | |
| 84 | + | // is pending. The error callback is read only after ready closes. | |
| 85 | + | func (rt *subagentRuntime) SetRequiredWorkerReady(ready <-chan struct{}, result func() error) { | |
| 86 | + | if rt == nil { | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | rt.requiredReadyMu.Lock() | |
| 90 | + | rt.requiredReady = ready | |
| 91 | + | rt.requiredReadyErr = result | |
| 92 | + | rt.requiredReadyMu.Unlock() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (rt *subagentRuntime) waitRequiredWorkerReady(ctx context.Context) error { | |
| 96 | + | rt.requiredReadyMu.RLock() | |
| 97 | + | ready := rt.requiredReady | |
| 98 | + | result := rt.requiredReadyErr | |
| 99 | + | rt.requiredReadyMu.RUnlock() | |
| 100 | + | if ready == nil { | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | select { | |
| 104 | + | case <-ctx.Done(): | |
| 105 | + | return ctx.Err() | |
| 106 | + | case <-ready: | |
| 107 | + | if result != nil { | |
| 108 | + | return result() | |
| 109 | + | } | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() { | |
| 115 | + | for _, snapshot := range rt.supervisor.RequiredSnapshots() { | |
| 116 | + | if snapshot.Requirement.State == subagents.RequirementSatisfied && !snapshot.Requirement.Notified { | |
| 117 | + | _ = rt.supervisor.MarkRequirementNotified(snapshot.ID) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func requiredRecoveryTool(name string) bool { | |
| 123 | + | switch name { | |
| 124 | + | case "subagent_resume", "subagent_status", "subagent_stop": | |
| 125 | + | return true | |
| 126 | + | default: | |
| 127 | + | return false | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |
| 132 | + | var items []subagents.AgentSnapshot | |
| 133 | + | for _, snapshot := range required { | |
| 134 | + | requirement := snapshot.Requirement | |
| 135 | + | if !requirement.Required || requirement.State == subagents.RequirementPending { | |
| 136 | + | continue | |
| 137 | + | } | |
| 138 | + | if requirement.Notified && !requirement.Unmet() { | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | items = append(items, snapshot) | |
| 142 | + | } | |
| 143 | + | if len(items) == 0 { | |
| 144 | + | return "" | |
| 145 | + | } | |
| 146 | + | var sb strings.Builder | |
| 147 | + | sb.WriteString(requiredWorkerContextHeader) | |
| 148 | + | sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n") | |
| 149 | + | for _, snapshot := range items { | |
| 150 | + | requirement := snapshot.Requirement | |
| 151 | + | fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task)) | |
| 152 | + | if requirement.ErrorCode != "" { | |
| 153 | + | fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | |
| 154 | + | } | |
| 155 | + | output := strings.TrimSpace(snapshot.LastAssistant) | |
| 156 | + | if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" { | |
| 157 | + | output = strings.TrimSpace(snapshot.Result.Output) | |
| 158 | + | } | |
| 159 | + | if output != "" { | |
| 160 | + | fmt.Fprintf(&sb, " result:\n%s\n", indentRequiredOutput(output, " ", 6000)) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | if hasIndeterminateRequirement(items) { | |
| 164 | + | 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.") | |
| 165 | + | } else if hasUnmetRequirement(items) { | |
| 166 | + | 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.") | |
| 167 | + | } else { | |
| 168 | + | sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.") | |
| 169 | + | } | |
| 170 | + | return sb.String() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string { | |
| 174 | + | var sb strings.Builder | |
| 175 | + | sb.WriteString(requiredWorkerContextHeader) | |
| 176 | + | sb.WriteString("\nTerminal completion is blocked by required delegated work:") | |
| 177 | + | for _, snapshot := range unmet { | |
| 178 | + | fmt.Fprintf(&sb, "\n- %s: %s — %s", snapshot.ID, snapshot.Requirement.State, strings.TrimSpace(snapshot.Task)) | |
| 179 | + | if snapshot.Requirement.ErrorCode != "" { | |
| 180 | + | fmt.Fprintf(&sb, " (%s; diagnostic: %s)", snapshot.Requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | if hasIndeterminateRequirement(unmet) { | |
| 184 | + | 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.") | |
| 185 | + | } else { | |
| 186 | + | sb.WriteString("\nRetry unresolved work with subagent_resume. Do not claim completion. The user may explicitly waive a terminal worker with /subagents rm.") | |
| 187 | + | } | |
| 188 | + | return sb.String() | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func hasIndeterminateRequirement(required []subagents.AgentSnapshot) bool { | |
| 192 | + | for _, snapshot := range required { | |
| 193 | + | if snapshot.Requirement.State == subagents.RequirementIndeterminate { | |
| 194 | + | return true | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func hasUnmetRequirement(required []subagents.AgentSnapshot) bool { | |
| 201 | + | for _, snapshot := range required { | |
| 202 | + | if snapshot.Requirement.Unmet() { | |
| 203 | + | return true | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func joinRequiredContext(first, second string) string { | |
| 210 | + | first = strings.TrimSpace(first) | |
| 211 | + | second = strings.TrimSpace(second) | |
| 212 | + | switch { | |
| 213 | + | case first == "": | |
| 214 | + | return second | |
| 215 | + | case second == "": | |
| 216 | + | return first | |
| 217 | + | default: | |
| 218 | + | return first + "\n\n" + second | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func indentRequiredOutput(text, prefix string, maxRunes int) string { | |
| 223 | + | text = strings.TrimSpace(text) | |
| 224 | + | runes := []rune(text) | |
| 225 | + | if maxRunes > 0 && len(runes) > maxRunes { | |
| 226 | + | text = string(runes[:maxRunes]) + "\n…(truncated)" | |
| 227 | + | } | |
| 228 | + | return prefix + strings.ReplaceAll(text, "\n", "\n"+prefix) | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const requiredWorkerContextHeader = "[required-subagents update]" | |||
| 15 | + | ||||
| 16 | + | // WireRequiredWorkerGate composes host-enforced requirement handling with any | |||
| 17 | + | // extension hooks already installed on the parent. Blocking required tool calls | |||
| 18 | + | // handle the normal in-turn path; the before-turn hook covers cancellation, | |||
| 19 | + | // restart, and session resume, while the assistant hook fails closed after an | |||
| 20 | + | // unsuccessful required outcome. | |||
| 21 | + | func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||
| 22 | + | if rt == nil || rt.supervisor == nil || parent == nil { | |||
| 23 | + | return | |||
| 24 | + | } | |||
| 25 | + | previousBeforeTurn := parent.BeforeTurnContext | |||
| 26 | + | parent.BeforeTurnContext = func(ctx context.Context, step int) (bool, string, string) { | |||
| 27 | + | var inherited string | |||
| 28 | + | if previousBeforeTurn != nil { | |||
| 29 | + | allowed, reason, contextText := previousBeforeTurn(ctx, step) | |||
| 30 | + | if !allowed { | |||
| 31 | + | return false, reason, contextText | |||
| 32 | + | } | |||
| 33 | + | inherited = contextText | |||
| 34 | + | } | |||
| 35 | + | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | |||
| 36 | + | return false, "loading persisted subagents failed; resolve the supervisor reload error before continuing", inherited | |||
| 37 | + | } | |||
| 38 | + | required, err := rt.supervisor.WaitActiveRequirements(ctx) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | |||
| 41 | + | } | |||
| 42 | + | update := formatRequiredWorkerContext(required) | |||
| 43 | + | if update == "" { | |||
| 44 | + | return true, "", inherited | |||
| 45 | + | } | |||
| 46 | + | return true, "", joinRequiredContext(inherited, update) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | previousBeforeTool := parent.BeforeToolExecute | |||
| 50 | + | parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 51 | + | unmet := rt.supervisor.UnmetRequirements() | |||
| 52 | + | if len(unmet) != 0 && !requiredRecoveryTool(call.Name) { | |||
| 53 | + | return false, formatRequiredWorkerBlock(unmet), nil | |||
| 54 | + | } | |||
| 55 | + | if previousBeforeTool != nil { | |||
| 56 | + | return previousBeforeTool(call) | |||
| 57 | + | } | |||
| 58 | + | return true, "", nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | previousBeforeAssistant := parent.BeforeAssistantMessage | |||
| 62 | + | parent.BeforeAssistantMessage = func(text string) (bool, string, string) { | |||
| 63 | + | unmet := rt.supervisor.UnmetRequirements() | |||
| 64 | + | if len(unmet) != 0 { | |||
| 65 | + | reason := formatRequiredWorkerBlock(unmet) | |||
| 66 | + | parent.QueueMessage(reason) | |||
| 67 | + | return false, reason, "" | |||
| 68 | + | } | |||
| 69 | + | if previousBeforeAssistant != nil { | |||
| 70 | + | allowed, reason, replacement := previousBeforeAssistant(text) | |||
| 71 | + | if !allowed { | |||
| 72 | + | return false, reason, replacement | |||
| 73 | + | } | |||
| 74 | + | rt.acknowledgeSatisfiedRequirements() | |||
| 75 | + | return true, reason, replacement | |||
| 76 | + | } | |||
| 77 | + | rt.acknowledgeSatisfiedRequirements() | |||
| 78 | + | return true, "", "" | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // SetRequiredWorkerReady makes the gate wait for an asynchronous supervisor | |||
| 83 | + | // reload before it checks persisted obligations. A nil channel means no reload | |||
| 84 | + | // is pending. The error callback is read only after ready closes. | |||
| 85 | + | func (rt *subagentRuntime) SetRequiredWorkerReady(ready <-chan struct{}, result func() error) { | |||
| 86 | + | if rt == nil { | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | rt.requiredReadyMu.Lock() | |||
| 90 | + | rt.requiredReady = ready | |||
| 91 | + | rt.requiredReadyErr = result | |||
| 92 | + | rt.requiredReadyMu.Unlock() | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (rt *subagentRuntime) waitRequiredWorkerReady(ctx context.Context) error { | |||
| 96 | + | rt.requiredReadyMu.RLock() | |||
| 97 | + | ready := rt.requiredReady | |||
| 98 | + | result := rt.requiredReadyErr | |||
| 99 | + | rt.requiredReadyMu.RUnlock() | |||
| 100 | + | if ready == nil { | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | select { | |||
| 104 | + | case <-ctx.Done(): | |||
| 105 | + | return ctx.Err() | |||
| 106 | + | case <-ready: | |||
| 107 | + | if result != nil { | |||
| 108 | + | return result() | |||
| 109 | + | } | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() { | |||
| 115 | + | for _, snapshot := range rt.supervisor.RequiredSnapshots() { | |||
| 116 | + | if snapshot.Requirement.State == subagents.RequirementSatisfied && !snapshot.Requirement.Notified { | |||
| 117 | + | _ = rt.supervisor.MarkRequirementNotified(snapshot.ID) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func requiredRecoveryTool(name string) bool { | |||
| 123 | + | switch name { | |||
| 124 | + | case "subagent_resume", "subagent_status", "subagent_stop": | |||
| 125 | + | return true | |||
| 126 | + | default: | |||
| 127 | + | return false | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |||
| 132 | + | var items []subagents.AgentSnapshot | |||
| 133 | + | for _, snapshot := range required { | |||
| 134 | + | requirement := snapshot.Requirement | |||
| 135 | + | if !requirement.Required || requirement.State == subagents.RequirementPending { | |||
| 136 | + | continue | |||
| 137 | + | } | |||
| 138 | + | if requirement.Notified && !requirement.Unmet() { | |||
| 139 | + | continue | |||
| 140 | + | } | |||
| 141 | + | items = append(items, snapshot) | |||
| 142 | + | } | |||
| 143 | + | if len(items) == 0 { | |||
| 144 | + | return "" | |||
| 145 | + | } | |||
| 146 | + | var sb strings.Builder | |||
| 147 | + | sb.WriteString(requiredWorkerContextHeader) | |||
| 148 | + | sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n") | |||
| 149 | + | for _, snapshot := range items { | |||
| 150 | + | requirement := snapshot.Requirement | |||
| 151 | + | fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task)) | |||
| 152 | + | if requirement.ErrorCode != "" { | |||
| 153 | + | fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | |||
| 154 | + | } | |||
| 155 | + | output := strings.TrimSpace(snapshot.LastAssistant) | |||
| 156 | + | if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" { | |||
| 157 | + | output = strings.TrimSpace(snapshot.Result.Output) | |||
| 158 | + | } | |||
| 159 | + | if output != "" { | |||
| 160 | + | fmt.Fprintf(&sb, " result:\n%s\n", indentRequiredOutput(output, " ", 6000)) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | if hasIndeterminateRequirement(items) { | |||
| 164 | + | 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.") | |||
| 165 | + | } else if hasUnmetRequirement(items) { | |||
| 166 | + | 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.") | |||
| 167 | + | } else { | |||
| 168 | + | sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.") | |||
| 169 | + | } | |||
| 170 | + | return sb.String() | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string { | |||
| 174 | + | var sb strings.Builder | |||
| 175 | + | sb.WriteString(requiredWorkerContextHeader) | |||
| 176 | + | sb.WriteString("\nTerminal completion is blocked by required delegated work:") | |||
| 177 | + | for _, snapshot := range unmet { | |||
| 178 | + | fmt.Fprintf(&sb, "\n- %s: %s — %s", snapshot.ID, snapshot.Requirement.State, strings.TrimSpace(snapshot.Task)) | |||
| 179 | + | if snapshot.Requirement.ErrorCode != "" { | |||
| 180 | + | fmt.Fprintf(&sb, " (%s; diagnostic: %s)", snapshot.Requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | if hasIndeterminateRequirement(unmet) { | |||
| 184 | + | 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.") | |||
| 185 | + | } else { | |||
| 186 | + | sb.WriteString("\nRetry unresolved work with subagent_resume. Do not claim completion. The user may explicitly waive a terminal worker with /subagents rm.") | |||
| 187 | + | } | |||
| 188 | + | return sb.String() | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func hasIndeterminateRequirement(required []subagents.AgentSnapshot) bool { | |||
| 192 | + | for _, snapshot := range required { | |||
| 193 | + | if snapshot.Requirement.State == subagents.RequirementIndeterminate { | |||
| 194 | + | return true | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | return false | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func hasUnmetRequirement(required []subagents.AgentSnapshot) bool { | |||
| 201 | + | for _, snapshot := range required { | |||
| 202 | + | if snapshot.Requirement.Unmet() { | |||
| 203 | + | return true | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | return false | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func joinRequiredContext(first, second string) string { | |||
| 210 | + | first = strings.TrimSpace(first) | |||
| 211 | + | second = strings.TrimSpace(second) | |||
| 212 | + | switch { | |||
| 213 | + | case first == "": | |||
| 214 | + | return second | |||
| 215 | + | case second == "": | |||
| 216 | + | return first | |||
| 217 | + | default: | |||
| 218 | + | return first + "\n\n" + second | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func indentRequiredOutput(text, prefix string, maxRunes int) string { | |||
| 223 | + | text = strings.TrimSpace(text) | |||
| 224 | + | runes := []rune(text) | |||
| 225 | + | if maxRunes > 0 && len(runes) > maxRunes { | |||
| 226 | + | text = string(runes[:maxRunes]) + "\n…(truncated)" | |||
| 227 | + | } | |||
| 228 | + | return prefix + strings.ReplaceAll(text, "\n", "\n"+prefix) | |||
| 229 | + | } | |||
M packages/agent/subagent_runtime.go +12 -9
| @@ -38,14 +38,17 @@ type subagentRuntime struct { | |||
| 38 | 38 | webSearchPolicy subagents.WebSearchPolicy | |
| 39 | 39 | webSearchGuard *webSearchSessionGuard | |
| 40 | 40 | ||
| 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() | |
| 44 | 44 | onStopRequested func(*subagents.Agent) | |
| 45 | 45 | ||
| 46 | - | settingsMu sync.RWMutex | |
| 47 | - | closeMu sync.Mutex | |
| 48 | - | closed bool | |
| 46 | + | requiredReadyMu sync.RWMutex | |
| 47 | + | requiredReady <-chan struct{} | |
| 48 | + | requiredReadyErr func() error | |
| 49 | + | settingsMu sync.RWMutex | |
| 50 | + | closeMu sync.Mutex | |
| 51 | + | closed bool | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | type subagentRuntimeConfiguration struct { | |
| @@ -83,9 +86,9 @@ type subagentRuntimeConfig struct { | |||
| 83 | 86 | ActiveReasoning func() string | |
| 84 | 87 | NewRunner func(*subagents.Agent) subagents.Runner | |
| 85 | 88 | ||
| 86 | - | OnSpawned func(*subagents.Agent, string) | |
| 87 | - | OnResumed func(*subagents.Agent, string) | |
| 88 | - | BeforeResumed func(*subagents.Agent, string) func() | |
| 89 | + | OnSpawned func(*subagents.Agent, string, bool) | |
| 90 | + | OnResumed func(*subagents.Agent, string, bool) | |
| 91 | + | BeforeResumed func(*subagents.Agent, string, bool) func() | |
| 89 | 92 | OnStopRequested func(*subagents.Agent) | |
| 90 | 93 | } | |
| 91 | 94 | ||
| @@ -38,14 +38,17 @@ type subagentRuntime struct { | |||||
| 38 | webSearchPolicy subagents.WebSearchPolicy | 38 | webSearchPolicy subagents.WebSearchPolicy | ||
| 39 | webSearchGuard *webSearchSessionGuard | 39 | 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.RWMutex | 46 | + | requiredReadyMu sync.RWMutex |
| 47 | - | closeMu sync.Mutex | 47 | + | requiredReady <-chan struct{} |
| 48 | - | closed bool | 48 | + | requiredReadyErr func() error |
| 49 | + | settingsMu sync.RWMutex | |||
| 50 | + | closeMu sync.Mutex | |||
| 51 | + | closed bool | |||
| 49 | } | 52 | } | ||
| 50 | 53 | ||||
| 51 | type subagentRuntimeConfiguration struct { | 54 | type subagentRuntimeConfiguration struct { | ||
| @@ -83,9 +86,9 @@ type subagentRuntimeConfig struct { | |||||
| 83 | ActiveReasoning func() string | 86 | ActiveReasoning func() string | ||
| 84 | NewRunner func(*subagents.Agent) subagents.Runner | 87 | NewRunner func(*subagents.Agent) subagents.Runner | ||
| 85 | 88 | ||||
| 86 | - | OnSpawned func(*subagents.Agent, string) | 89 | + | OnSpawned func(*subagents.Agent, string, bool) |
| 87 | - | OnResumed func(*subagents.Agent, string) | 90 | + | OnResumed func(*subagents.Agent, string, bool) |
| 88 | - | BeforeResumed func(*subagents.Agent, string) func() | 91 | + | BeforeResumed func(*subagents.Agent, string, bool) func() |
| 89 | OnStopRequested func(*subagents.Agent) | 92 | OnStopRequested func(*subagents.Agent) | ||
| 90 | } | 93 | } | ||
| 91 | 94 | ||||
M packages/agent/subagent_runtime_test.go +6 -1
| @@ -329,7 +329,12 @@ func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) { | |||
| 329 | 329 | var spawned atomic.Int32 | |
| 330 | 330 | var stopped atomic.Int32 | |
| 331 | 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 | 338 | cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) } | |
| 334 | 339 | rt := newSubagentRuntime(cfg) | |
| 335 | 340 | registry := rt.InjectTools(core.NewRegistry()) | |
| @@ -329,7 +329,12 @@ func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) { | |||||
| 329 | var spawned atomic.Int32 | 329 | var spawned atomic.Int32 | ||
| 330 | var stopped atomic.Int32 | 330 | 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 { | |||
| 157 | 157 | legacyEventState bool | |
| 158 | 158 | transcriptMu sync.Mutex | |
| 159 | 159 | ||
| 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() | |
| 181 | 184 | ||
| 182 | 185 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | |
| 183 | 186 | // emitted by the subagent daemon wrapper. Provider/tool-loop | |
| @@ -157,27 +157,30 @@ type Agent struct { | |||||
| 157 | legacyEventState bool | 157 | legacyEventState bool | ||
| 158 | transcriptMu sync.Mutex | 158 | transcriptMu sync.Mutex | ||
| 159 | 159 | ||||
| 160 | - | lifecycleMu sync.Mutex | 160 | + | lifecycleMu sync.Mutex |
| 161 | - | processState ProcessState | 161 | + | processState ProcessState |
| 162 | - | turnState TurnState | 162 | + | turnState TurnState |
| 163 | - | currentTurnID string | 163 | + | currentTurnID string |
| 164 | - | updatedAt time.Time | 164 | + | updatedAt time.Time |
| 165 | - | lastActivity time.Time | 165 | + | lastActivity time.Time |
| 166 | - | result *TurnResult | 166 | + | result *TurnResult |
| 167 | - | resultRef string | 167 | + | resultRef string |
| 168 | - | patchRef string | 168 | + | requirement RequirementSnapshot |
| 169 | - | changedFiles []string | 169 | + | requirementPersisting bool |
| 170 | - | outputBytes int | 170 | + | requirementChanged chan struct{} |
| 171 | - | outputLines int | 171 | + | patchRef string |
| 172 | - | outputTruncated bool | 172 | + | changedFiles []string |
| 173 | - | maxOutputBytes int | 173 | + | outputBytes int |
| 174 | - | maxOutputLines int | 174 | + | outputLines int |
| 175 | - | persistFn func(*Agent) error | 175 | + | outputTruncated bool |
| 176 | - | persistMu sync.Mutex | 176 | + | maxOutputBytes int |
| 177 | - | stateDir string | 177 | + | maxOutputLines int |
| 178 | - | workspaceCleanup func() error | 178 | + | 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 event | 185 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | ||
| 183 | // emitted by the subagent daemon wrapper. Provider/tool-loop | 186 | // emitted by the subagent daemon wrapper. Provider/tool-loop | ||
M packages/agent/subagents/persist.go +83 -13
| @@ -34,6 +34,8 @@ import ( | |||
| 34 | 34 | // Historical fields like `branch` and `isolated` are silently dropped | |
| 35 | 35 | // by encoding/json's permissive decoder when an older meta.json is | |
| 36 | 36 | // loaded; we don't need to keep them in the struct. | |
| 37 | + | type requirementMeta = RequirementSnapshot | |
| 38 | + | ||
| 37 | 39 | type agentMeta struct { | |
| 38 | 40 | ID string `json:"id"` | |
| 39 | 41 | Task string `json:"task"` | |
| @@ -73,6 +75,7 @@ type agentMeta struct { | |||
| 73 | 75 | UpdatedAt time.Time `json:"updated_at,omitempty"` | |
| 74 | 76 | LastActivity time.Time `json:"last_activity,omitempty"` | |
| 75 | 77 | ResultRef string `json:"result_ref,omitempty"` | |
| 78 | + | Requirement requirementMeta `json:"requirement,omitempty"` | |
| 76 | 79 | PatchRef string `json:"patch_ref,omitempty"` | |
| 77 | 80 | ChangedFiles []string `json:"changed_files,omitempty"` | |
| 78 | 81 | InboxPath string `json:"inbox_path"` | |
| @@ -134,6 +137,7 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 134 | 137 | updatedAt := a.updatedAt | |
| 135 | 138 | lastActivity := a.lastActivity | |
| 136 | 139 | resultRef := a.resultRef | |
| 140 | + | requirement := a.requirement | |
| 137 | 141 | patchRef := a.patchRef | |
| 138 | 142 | changedFiles := append([]string(nil), a.changedFiles...) | |
| 139 | 143 | a.lifecycleMu.Unlock() | |
| @@ -176,6 +180,7 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 176 | 180 | UpdatedAt: updatedAt, | |
| 177 | 181 | LastActivity: lastActivity, | |
| 178 | 182 | ResultRef: resultRef, | |
| 183 | + | Requirement: requirement, | |
| 179 | 184 | PatchRef: patchRef, | |
| 180 | 185 | ChangedFiles: changedFiles, | |
| 181 | 186 | InboxPath: a.InboxPath, | |
| @@ -383,10 +388,15 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) { | |||
| 383 | 388 | f.mu.Unlock() | |
| 384 | 389 | continue | |
| 385 | 390 | } | |
| 386 | - | a := f.buildDetachedAgent(m) | |
| 391 | + | a, requirementReconciled := f.buildDetachedAgent(m) | |
| 387 | 392 | f.agents[m.ID] = a | |
| 388 | 393 | f.order = append(f.order, m.ID) | |
| 389 | 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 | 400 | loaded++ | |
| 391 | 401 | } | |
| 392 | 402 | return loaded, errs | |
| @@ -398,7 +408,7 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) { | |||
| 398 | 408 | // | |
| 399 | 409 | // The returned Agent has a closed `done` channel because Wait should return | |
| 400 | 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 | 412 | // Metadata without a repository identity uses the current RepoRoot; | |
| 403 | 413 | // records with one retain their persisted repository so a restart from | |
| 404 | 414 | // another checkout cannot silently redirect the child. | |
| @@ -517,12 +527,14 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 517 | 527 | lastActivity: lastActivity, | |
| 518 | 528 | stateDir: agentStateDir, | |
| 519 | 529 | resultRef: m.ResultRef, | |
| 530 | + | requirement: m.Requirement, | |
| 520 | 531 | patchRef: m.PatchRef, | |
| 521 | 532 | changedFiles: append([]string(nil), m.ChangedFiles...), | |
| 522 | 533 | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 523 | 534 | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 524 | 535 | done: make(chan struct{}), | |
| 525 | 536 | turnResults: make(chan *TurnResult, 16), | |
| 537 | + | persistFn: f.persistAgent, | |
| 526 | 538 | } | |
| 527 | 539 | if a.updatedAt.IsZero() { | |
| 528 | 540 | a.updatedAt = lastActivity | |
| @@ -544,10 +556,24 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 544 | 556 | if a.EventLogPath != "" { | |
| 545 | 557 | resultDir = filepath.Dir(a.EventLogPath) | |
| 546 | 558 | } | |
| 559 | + | beforeRequirement := a.requirementSnapshot() | |
| 547 | 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 | + | } | |
| 549 | 566 | } | |
| 550 | - | return a | |
| 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) | |
| 575 | + | } | |
| 576 | + | return a, beforeRequirement != a.requirementSnapshot() | |
| 551 | 577 | } | |
| 552 | 578 | ||
| 553 | 579 | // LoadTranscript hydrates one reloaded agent's durable event history. Reload | |
| @@ -790,13 +816,13 @@ func replayEventTranscript(a *Agent, ev Event) { | |||
| 790 | 816 | // Killed). Resuming a still-running agent returns an error so two | |
| 791 | 817 | // runners don't race for the same session. | |
| 792 | 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 | 822 | // ResumeSession continues the existing child session without replaying the | |
| 797 | 823 | // original task. It is the explicit canonical spelling for Resume. | |
| 798 | 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 | 828 | // 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 | 831 | // terminal worker is restarted with the follow-up as its initial prompt, | |
| 806 | 832 | // avoiding a race with inbox listener startup. | |
| 807 | 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 | 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 | 877 | // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The | |
| 812 | 878 | // hook runs after the target Agent is known but before a queued or direct | |
| 813 | 879 | // 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 | 1076 | // RestartTask intentionally runs the stored original task again in a fresh | |
| 1011 | 1077 | // worker attempt. Callers should use ResumeSession for normal recovery. | |
| 1012 | 1078 | 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)) | |
| 1018 | 1080 | } | |
| 1019 | 1081 | ||
| 1020 | 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 | 1193 | MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...), | |
| 1132 | 1194 | WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools), | |
| 1133 | 1195 | 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, | |
| 1136 | 1199 | SessionPath: existing.SessionPath, | |
| 1137 | 1200 | ResumePrompt: pendingResumePrompt, | |
| 1138 | 1201 | ResumePromptAt: pendingResumePromptAt, | |
| @@ -1240,6 +1303,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1240 | 1303 | updatedAt: now, | |
| 1241 | 1304 | lastActivity: now, | |
| 1242 | 1305 | currentTurnID: m.CurrentTurnID, | |
| 1306 | + | requirement: m.Requirement, | |
| 1243 | 1307 | stateDir: stateDir, | |
| 1244 | 1308 | lease: lease, | |
| 1245 | 1309 | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| @@ -1300,6 +1364,12 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1300 | 1364 | // that needs the hook's rollback after this point. | |
| 1301 | 1365 | _ = before(a, resumePrompt) | |
| 1302 | 1366 | } | |
| 1367 | + | if !hadPendingResumePrompt && resumePrompt == "" { | |
| 1368 | + | _ = before(a, "") | |
| 1369 | + | } | |
| 1370 | + | if err := f.persistAgent(a); err != nil { | |
| 1371 | + | a.recordPersistenceError(err) | |
| 1372 | + | } | |
| 1303 | 1373 | } | |
| 1304 | 1374 | f.armQueueTimeout(a) | |
| 1305 | 1375 | f.schedule() | |
| @@ -34,6 +34,8 @@ import ( | |||||
| 34 | // Historical fields like `branch` and `isolated` are silently dropped | 34 | // Historical fields like `branch` and `isolated` are silently dropped | ||
| 35 | // by encoding/json's permissive decoder when an older meta.json is | 35 | // 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.updatedAt | 137 | updatedAt := a.updatedAt | ||
| 135 | lastActivity := a.lastActivity | 138 | lastActivity := a.lastActivity | ||
| 136 | resultRef := a.resultRef | 139 | resultRef := a.resultRef | ||
| 140 | + | requirement := a.requirement | |||
| 137 | patchRef := a.patchRef | 141 | 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 | continue | 389 | continue | ||
| 385 | } | 390 | } | ||
| 386 | - | a := f.buildDetachedAgent(m) | 391 | + | a, requirementReconciled := f.buildDetachedAgent(m) |
| 387 | f.agents[m.ID] = a | 392 | 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, errs | 402 | 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 return | 409 | // 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 from | 413 | // 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 = lastActivity | 540 | 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 | + | } | |||
| 549 | } | 566 | } | ||
| 550 | - | return a | 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) | |||
| 575 | + | } | |||
| 576 | + | return a, beforeRequirement != a.requirementSnapshot() | |||
| 551 | } | 577 | } | ||
| 552 | 578 | ||||
| 553 | // LoadTranscript hydrates one reloaded agent's durable event history. Reload | 579 | // 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 two | 816 | // 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 the | 822 | // 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 retaining | 828 | // 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. The | 877 | // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The | ||
| 812 | // hook runs after the target Agent is known but before a queued or direct | 878 | // 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 new | 879 | // 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 fresh | 1076 | // 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, | ||
| @@ -1300,6 +1364,12 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1300 | // that needs the hook's rollback after this point. | 1364 | // that needs the hook's rollback after this point. | ||
| 1301 | _ = before(a, resumePrompt) | 1365 | _ = before(a, resumePrompt) | ||
| 1302 | } | 1366 | } | ||
| 1367 | + | if !hadPendingResumePrompt && resumePrompt == "" { | |||
| 1368 | + | _ = before(a, "") | |||
| 1369 | + | } | |||
| 1370 | + | if err := f.persistAgent(a); err != nil { | |||
| 1371 | + | a.recordPersistenceError(err) | |||
| 1372 | + | } | |||
| 1303 | } | 1373 | } | ||
| 1304 | f.armQueueTimeout(a) | 1374 | f.armQueueTimeout(a) | ||
| 1305 | f.schedule() | 1375 | f.schedule() | ||
A packages/agent/subagents/requirement.go +382 -0
| @@ -0,0 +1,382 @@ | |||
| 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 | + | a.lifecycleMu.Unlock() | |
| 158 | + | if err != nil { | |
| 159 | + | a.recordPersistenceError(fmt.Errorf("persist required result: %w", err)) | |
| 160 | + | } | |
| 161 | + | return current | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | state, errorCode := classifyRequirementOutcome(result, errMsg) | |
| 165 | + | a.requirement.State = state | |
| 166 | + | a.requirement.ErrorCode = errorCode | |
| 167 | + | a.requirement.Notified = false | |
| 168 | + | a.requirement.UpdatedAt = time.Now().UTC() | |
| 169 | + | resolved := a.requirement | |
| 170 | + | if a.persistFn != nil { | |
| 171 | + | a.requirementPersisting = true | |
| 172 | + | } | |
| 173 | + | a.lifecycleMu.Unlock() | |
| 174 | + | if a.persistFn != nil { | |
| 175 | + | if err := a.persistFn(a); err != nil { | |
| 176 | + | a.lifecycleMu.Lock() | |
| 177 | + | if a.requirement == resolved { | |
| 178 | + | a.requirement = current | |
| 179 | + | a.requirementPersisting = false | |
| 180 | + | } | |
| 181 | + | a.lifecycleMu.Unlock() | |
| 182 | + | a.recordPersistenceError(err) | |
| 183 | + | return current | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | a.lifecycleMu.Lock() | |
| 187 | + | if a.requirement == resolved { | |
| 188 | + | a.requirementPersisting = false | |
| 189 | + | a.signalRequirementLocked() | |
| 190 | + | } | |
| 191 | + | a.lifecycleMu.Unlock() | |
| 192 | + | return resolved | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func unobservedRequirementResult(result *TurnResult) bool { | |
| 196 | + | return result != nil && result.Error != nil && result.Error.Code == "required_outcome_unobserved" | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func requirementAcceptsResult(requirement RequirementSnapshot, result *TurnResult) bool { | |
| 200 | + | if !requirement.pending() || result == nil { | |
| 201 | + | return false | |
| 202 | + | } | |
| 203 | + | turnID := strings.TrimSpace(result.TurnID) | |
| 204 | + | if strings.HasPrefix(turnID, "turn-") { | |
| 205 | + | if turn, err := strconv.Atoi(strings.TrimPrefix(turnID, "turn-")); err == nil { | |
| 206 | + | return turn == requirement.TargetTurn | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | // Legacy first-turn results did not always carry a turn id. They can | |
| 210 | + | // satisfy only the initial requirement; retries fail closed without proof. | |
| 211 | + | return requirement.TargetTurn == 1 | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func classifyRequirementOutcome(result *TurnResult, errMsg string) (RequirementState, string) { | |
| 215 | + | if result != nil { | |
| 216 | + | errorCode := "" | |
| 217 | + | if result.Error != nil { | |
| 218 | + | errorCode = strings.TrimSpace(result.Error.Code) | |
| 219 | + | } | |
| 220 | + | switch { | |
| 221 | + | case errorCode == "required_outcome_unobserved": | |
| 222 | + | return RequirementIndeterminate, "required_outcome_unobserved" | |
| 223 | + | case result.ShutdownOrigin == ShutdownOriginDeadline || errorCode == "deadline_exceeded" || errorCode == "timeout": | |
| 224 | + | return RequirementTimedOut, "deadline_exceeded" | |
| 225 | + | case result.Status == ResultCanceled || errorCode == "canceled" || errorCode == "cancelled": | |
| 226 | + | return RequirementCanceled, "canceled" | |
| 227 | + | case result.Status == ResultFailed: | |
| 228 | + | return RequirementFailed, "failed" | |
| 229 | + | default: | |
| 230 | + | return RequirementSatisfied, "" | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | lower := strings.ToLower(strings.TrimSpace(errMsg)) | |
| 234 | + | switch { | |
| 235 | + | case lower == "": | |
| 236 | + | return RequirementSatisfied, "" | |
| 237 | + | case strings.Contains(lower, "deadline exceeded") || strings.Contains(lower, "timed out"): | |
| 238 | + | return RequirementTimedOut, "deadline_exceeded" | |
| 239 | + | case strings.Contains(lower, "canceled") || strings.Contains(lower, "cancelled"): | |
| 240 | + | return RequirementCanceled, "canceled" | |
| 241 | + | default: | |
| 242 | + | return RequirementFailed, "failed" | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func (a *Agent) visibleRequirementLocked() RequirementSnapshot { | |
| 247 | + | requirement := a.requirement | |
| 248 | + | if a.requirementPersisting { | |
| 249 | + | requirement.State = RequirementPending | |
| 250 | + | requirement.ErrorCode = "" | |
| 251 | + | requirement.Notified = false | |
| 252 | + | } | |
| 253 | + | return requirement | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (a *Agent) initRequirementSignalLocked() { | |
| 257 | + | if a.requirementChanged == nil { | |
| 258 | + | a.requirementChanged = make(chan struct{}) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (a *Agent) signalRequirementLocked() { | |
| 263 | + | a.initRequirementSignalLocked() | |
| 264 | + | close(a.requirementChanged) | |
| 265 | + | a.requirementChanged = make(chan struct{}) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error) { | |
| 269 | + | if ctx == nil { | |
| 270 | + | ctx = context.Background() | |
| 271 | + | } | |
| 272 | + | for { | |
| 273 | + | a.lifecycleMu.Lock() | |
| 274 | + | a.initRequirementSignalLocked() | |
| 275 | + | requirement := a.visibleRequirementLocked() | |
| 276 | + | changed := a.requirementChanged | |
| 277 | + | a.lifecycleMu.Unlock() | |
| 278 | + | if !requirement.pending() { | |
| 279 | + | return requirement, nil | |
| 280 | + | } | |
| 281 | + | select { | |
| 282 | + | case <-ctx.Done(): | |
| 283 | + | return RequirementSnapshot{}, ctx.Err() | |
| 284 | + | case <-changed: | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | var errRequiredAgentMissing = errors.New("subagents: required agent no longer exists") | |
| 290 | + | ||
| 291 | + | // WaitRequired blocks on requirement state transitions, never filesystem or | |
| 292 | + | // status polling, until the selected required turn reaches a terminal outcome. | |
| 293 | + | func (f *Supervisor) WaitRequired(ctx context.Context, id string) (RequirementSnapshot, error) { | |
| 294 | + | a := f.Get(strings.TrimSpace(id)) | |
| 295 | + | if a == nil { | |
| 296 | + | return RequirementSnapshot{}, fmt.Errorf("%w: %q", errRequiredAgentMissing, id) | |
| 297 | + | } | |
| 298 | + | requirement := a.requirementSnapshot() | |
| 299 | + | if !requirement.Required { | |
| 300 | + | return requirement, fmt.Errorf("subagents: agent %s is not required", a.ID) | |
| 301 | + | } | |
| 302 | + | return a.waitRequirement(ctx) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // MarkRequirementNotified records that the parent received the terminal | |
| 306 | + | // outcome through a blocking manager tool or host-injected update. | |
| 307 | + | func (f *Supervisor) MarkRequirementNotified(id string) error { | |
| 308 | + | a := f.Get(strings.TrimSpace(id)) | |
| 309 | + | if a == nil { | |
| 310 | + | return fmt.Errorf("subagents: no such agent %q", id) | |
| 311 | + | } | |
| 312 | + | a.markRequirementNotified() | |
| 313 | + | return nil | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // RequiredSnapshots returns required workers owned by the active host session. | |
| 317 | + | // Unlike dashboard snapshots, terminal workers from other sessions are never | |
| 318 | + | // included because they must not gate an unrelated parent conversation. | |
| 319 | + | func (f *Supervisor) RequiredSnapshots() []AgentSnapshot { | |
| 320 | + | f.mu.Lock() | |
| 321 | + | active := f.activeSession | |
| 322 | + | agents := make([]*Agent, 0, len(f.order)) | |
| 323 | + | for _, id := range f.order { | |
| 324 | + | if a := f.agents[id]; a != nil { | |
| 325 | + | agents = append(agents, a) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | f.mu.Unlock() | |
| 329 | + | ||
| 330 | + | out := make([]AgentSnapshot, 0, len(agents)) | |
| 331 | + | for _, a := range agents { | |
| 332 | + | if active != "" && a.SessionID != "" && a.SessionID != active { | |
| 333 | + | continue | |
| 334 | + | } | |
| 335 | + | snapshot := a.Snapshot() | |
| 336 | + | if snapshot.Requirement.Required { | |
| 337 | + | out = append(out, snapshot) | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |
| 341 | + | return out | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // UnmetRequirements returns every required worker that still blocks parent | |
| 345 | + | // completion in the active session. | |
| 346 | + | func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | |
| 347 | + | required := f.RequiredSnapshots() | |
| 348 | + | out := required[:0] | |
| 349 | + | for _, snapshot := range required { | |
| 350 | + | if snapshot.Requirement.Unmet() { | |
| 351 | + | out = append(out, snapshot) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | return out | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // WaitActiveRequirements waits for all currently pending required workers in | |
| 358 | + | // the active session. It returns all required snapshots so the host can inject | |
| 359 | + | // both successful outcomes and unresolved failures before the next model turn. | |
| 360 | + | func (f *Supervisor) WaitActiveRequirements(ctx context.Context) ([]AgentSnapshot, error) { | |
| 361 | + | if ctx == nil { | |
| 362 | + | ctx = context.Background() | |
| 363 | + | } | |
| 364 | + | for { | |
| 365 | + | pending := false | |
| 366 | + | for _, snapshot := range f.RequiredSnapshots() { | |
| 367 | + | if snapshot.Requirement.pending() { | |
| 368 | + | pending = true | |
| 369 | + | if _, err := f.WaitRequired(ctx, snapshot.ID); err != nil { | |
| 370 | + | if errors.Is(err, errRequiredAgentMissing) { | |
| 371 | + | break | |
| 372 | + | } | |
| 373 | + | return nil, err | |
| 374 | + | } | |
| 375 | + | break | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | if !pending { | |
| 379 | + | return f.RequiredSnapshots(), nil | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | } | |
| @@ -0,0 +1,382 @@ | |||||
| 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 | + | a.lifecycleMu.Unlock() | |||
| 158 | + | if err != nil { | |||
| 159 | + | a.recordPersistenceError(fmt.Errorf("persist required result: %w", err)) | |||
| 160 | + | } | |||
| 161 | + | return current | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | state, errorCode := classifyRequirementOutcome(result, errMsg) | |||
| 165 | + | a.requirement.State = state | |||
| 166 | + | a.requirement.ErrorCode = errorCode | |||
| 167 | + | a.requirement.Notified = false | |||
| 168 | + | a.requirement.UpdatedAt = time.Now().UTC() | |||
| 169 | + | resolved := a.requirement | |||
| 170 | + | if a.persistFn != nil { | |||
| 171 | + | a.requirementPersisting = true | |||
| 172 | + | } | |||
| 173 | + | a.lifecycleMu.Unlock() | |||
| 174 | + | if a.persistFn != nil { | |||
| 175 | + | if err := a.persistFn(a); err != nil { | |||
| 176 | + | a.lifecycleMu.Lock() | |||
| 177 | + | if a.requirement == resolved { | |||
| 178 | + | a.requirement = current | |||
| 179 | + | a.requirementPersisting = false | |||
| 180 | + | } | |||
| 181 | + | a.lifecycleMu.Unlock() | |||
| 182 | + | a.recordPersistenceError(err) | |||
| 183 | + | return current | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | a.lifecycleMu.Lock() | |||
| 187 | + | if a.requirement == resolved { | |||
| 188 | + | a.requirementPersisting = false | |||
| 189 | + | a.signalRequirementLocked() | |||
| 190 | + | } | |||
| 191 | + | a.lifecycleMu.Unlock() | |||
| 192 | + | return resolved | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func unobservedRequirementResult(result *TurnResult) bool { | |||
| 196 | + | return result != nil && result.Error != nil && result.Error.Code == "required_outcome_unobserved" | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func requirementAcceptsResult(requirement RequirementSnapshot, result *TurnResult) bool { | |||
| 200 | + | if !requirement.pending() || result == nil { | |||
| 201 | + | return false | |||
| 202 | + | } | |||
| 203 | + | turnID := strings.TrimSpace(result.TurnID) | |||
| 204 | + | if strings.HasPrefix(turnID, "turn-") { | |||
| 205 | + | if turn, err := strconv.Atoi(strings.TrimPrefix(turnID, "turn-")); err == nil { | |||
| 206 | + | return turn == requirement.TargetTurn | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | // Legacy first-turn results did not always carry a turn id. They can | |||
| 210 | + | // satisfy only the initial requirement; retries fail closed without proof. | |||
| 211 | + | return requirement.TargetTurn == 1 | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func classifyRequirementOutcome(result *TurnResult, errMsg string) (RequirementState, string) { | |||
| 215 | + | if result != nil { | |||
| 216 | + | errorCode := "" | |||
| 217 | + | if result.Error != nil { | |||
| 218 | + | errorCode = strings.TrimSpace(result.Error.Code) | |||
| 219 | + | } | |||
| 220 | + | switch { | |||
| 221 | + | case errorCode == "required_outcome_unobserved": | |||
| 222 | + | return RequirementIndeterminate, "required_outcome_unobserved" | |||
| 223 | + | case result.ShutdownOrigin == ShutdownOriginDeadline || errorCode == "deadline_exceeded" || errorCode == "timeout": | |||
| 224 | + | return RequirementTimedOut, "deadline_exceeded" | |||
| 225 | + | case result.Status == ResultCanceled || errorCode == "canceled" || errorCode == "cancelled": | |||
| 226 | + | return RequirementCanceled, "canceled" | |||
| 227 | + | case result.Status == ResultFailed: | |||
| 228 | + | return RequirementFailed, "failed" | |||
| 229 | + | default: | |||
| 230 | + | return RequirementSatisfied, "" | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | lower := strings.ToLower(strings.TrimSpace(errMsg)) | |||
| 234 | + | switch { | |||
| 235 | + | case lower == "": | |||
| 236 | + | return RequirementSatisfied, "" | |||
| 237 | + | case strings.Contains(lower, "deadline exceeded") || strings.Contains(lower, "timed out"): | |||
| 238 | + | return RequirementTimedOut, "deadline_exceeded" | |||
| 239 | + | case strings.Contains(lower, "canceled") || strings.Contains(lower, "cancelled"): | |||
| 240 | + | return RequirementCanceled, "canceled" | |||
| 241 | + | default: | |||
| 242 | + | return RequirementFailed, "failed" | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func (a *Agent) visibleRequirementLocked() RequirementSnapshot { | |||
| 247 | + | requirement := a.requirement | |||
| 248 | + | if a.requirementPersisting { | |||
| 249 | + | requirement.State = RequirementPending | |||
| 250 | + | requirement.ErrorCode = "" | |||
| 251 | + | requirement.Notified = false | |||
| 252 | + | } | |||
| 253 | + | return requirement | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (a *Agent) initRequirementSignalLocked() { | |||
| 257 | + | if a.requirementChanged == nil { | |||
| 258 | + | a.requirementChanged = make(chan struct{}) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (a *Agent) signalRequirementLocked() { | |||
| 263 | + | a.initRequirementSignalLocked() | |||
| 264 | + | close(a.requirementChanged) | |||
| 265 | + | a.requirementChanged = make(chan struct{}) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error) { | |||
| 269 | + | if ctx == nil { | |||
| 270 | + | ctx = context.Background() | |||
| 271 | + | } | |||
| 272 | + | for { | |||
| 273 | + | a.lifecycleMu.Lock() | |||
| 274 | + | a.initRequirementSignalLocked() | |||
| 275 | + | requirement := a.visibleRequirementLocked() | |||
| 276 | + | changed := a.requirementChanged | |||
| 277 | + | a.lifecycleMu.Unlock() | |||
| 278 | + | if !requirement.pending() { | |||
| 279 | + | return requirement, nil | |||
| 280 | + | } | |||
| 281 | + | select { | |||
| 282 | + | case <-ctx.Done(): | |||
| 283 | + | return RequirementSnapshot{}, ctx.Err() | |||
| 284 | + | case <-changed: | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | var errRequiredAgentMissing = errors.New("subagents: required agent no longer exists") | |||
| 290 | + | ||||
| 291 | + | // WaitRequired blocks on requirement state transitions, never filesystem or | |||
| 292 | + | // status polling, until the selected required turn reaches a terminal outcome. | |||
| 293 | + | func (f *Supervisor) WaitRequired(ctx context.Context, id string) (RequirementSnapshot, error) { | |||
| 294 | + | a := f.Get(strings.TrimSpace(id)) | |||
| 295 | + | if a == nil { | |||
| 296 | + | return RequirementSnapshot{}, fmt.Errorf("%w: %q", errRequiredAgentMissing, id) | |||
| 297 | + | } | |||
| 298 | + | requirement := a.requirementSnapshot() | |||
| 299 | + | if !requirement.Required { | |||
| 300 | + | return requirement, fmt.Errorf("subagents: agent %s is not required", a.ID) | |||
| 301 | + | } | |||
| 302 | + | return a.waitRequirement(ctx) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // MarkRequirementNotified records that the parent received the terminal | |||
| 306 | + | // outcome through a blocking manager tool or host-injected update. | |||
| 307 | + | func (f *Supervisor) MarkRequirementNotified(id string) error { | |||
| 308 | + | a := f.Get(strings.TrimSpace(id)) | |||
| 309 | + | if a == nil { | |||
| 310 | + | return fmt.Errorf("subagents: no such agent %q", id) | |||
| 311 | + | } | |||
| 312 | + | a.markRequirementNotified() | |||
| 313 | + | return nil | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // RequiredSnapshots returns required workers owned by the active host session. | |||
| 317 | + | // Unlike dashboard snapshots, terminal workers from other sessions are never | |||
| 318 | + | // included because they must not gate an unrelated parent conversation. | |||
| 319 | + | func (f *Supervisor) RequiredSnapshots() []AgentSnapshot { | |||
| 320 | + | f.mu.Lock() | |||
| 321 | + | active := f.activeSession | |||
| 322 | + | agents := make([]*Agent, 0, len(f.order)) | |||
| 323 | + | for _, id := range f.order { | |||
| 324 | + | if a := f.agents[id]; a != nil { | |||
| 325 | + | agents = append(agents, a) | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | f.mu.Unlock() | |||
| 329 | + | ||||
| 330 | + | out := make([]AgentSnapshot, 0, len(agents)) | |||
| 331 | + | for _, a := range agents { | |||
| 332 | + | if active != "" && a.SessionID != "" && a.SessionID != active { | |||
| 333 | + | continue | |||
| 334 | + | } | |||
| 335 | + | snapshot := a.Snapshot() | |||
| 336 | + | if snapshot.Requirement.Required { | |||
| 337 | + | out = append(out, snapshot) | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |||
| 341 | + | return out | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // UnmetRequirements returns every required worker that still blocks parent | |||
| 345 | + | // completion in the active session. | |||
| 346 | + | func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | |||
| 347 | + | required := f.RequiredSnapshots() | |||
| 348 | + | out := required[:0] | |||
| 349 | + | for _, snapshot := range required { | |||
| 350 | + | if snapshot.Requirement.Unmet() { | |||
| 351 | + | out = append(out, snapshot) | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | return out | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | // WaitActiveRequirements waits for all currently pending required workers in | |||
| 358 | + | // the active session. It returns all required snapshots so the host can inject | |||
| 359 | + | // both successful outcomes and unresolved failures before the next model turn. | |||
| 360 | + | func (f *Supervisor) WaitActiveRequirements(ctx context.Context) ([]AgentSnapshot, error) { | |||
| 361 | + | if ctx == nil { | |||
| 362 | + | ctx = context.Background() | |||
| 363 | + | } | |||
| 364 | + | for { | |||
| 365 | + | pending := false | |||
| 366 | + | for _, snapshot := range f.RequiredSnapshots() { | |||
| 367 | + | if snapshot.Requirement.pending() { | |||
| 368 | + | pending = true | |||
| 369 | + | if _, err := f.WaitRequired(ctx, snapshot.ID); err != nil { | |||
| 370 | + | if errors.Is(err, errRequiredAgentMissing) { | |||
| 371 | + | break | |||
| 372 | + | } | |||
| 373 | + | return nil, err | |||
| 374 | + | } | |||
| 375 | + | break | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | if !pending { | |||
| 379 | + | return f.RequiredSnapshots(), nil | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | } | |||
A packages/agent/subagents/requirement_test.go +363 -0
| @@ -0,0 +1,363 @@ | |||
| 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 | + | waited := make(chan RequirementSnapshot, 1) | |
| 32 | + | go func() { | |
| 33 | + | got, _ := a.waitRequirement(context.Background()) | |
| 34 | + | waited <- got | |
| 35 | + | }() | |
| 36 | + | select { | |
| 37 | + | case got := <-waited: | |
| 38 | + | t.Fatalf("wait woke before durable persistence: %+v", got) | |
| 39 | + | default: | |
| 40 | + | } | |
| 41 | + | close(release) | |
| 42 | + | <-resolved | |
| 43 | + | if got := <-waited; got.State != RequirementSatisfied { | |
| 44 | + | t.Fatalf("durable outcome = %+v", got) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestRequiredOutcomeUsesDelegatedTurnOrdinal(t *testing.T) { | |
| 49 | + | a := &Agent{ | |
| 50 | + | requirement: newRequirement(true, 1, time.Now()), | |
| 51 | + | requirementChanged: make(chan struct{}), | |
| 52 | + | } | |
| 53 | + | if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{ | |
| 54 | + | "step": float64(1), "lifetime_turns": float64(1), "current_run_turns": float64(1), | |
| 55 | + | })); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(1)})); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 1 { | |
| 62 | + | t.Fatalf("canonical first-turn outcome = %+v", got) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | a.prepareRequired(0) | |
| 66 | + | if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{ | |
| 67 | + | "step": float64(2), "lifetime_turns": float64(2), "current_run_turns": float64(1), | |
| 68 | + | })); err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2)})); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 2 { | |
| 75 | + | t.Fatalf("canonical retry outcome = %+v", got) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | for _, step := range []int{0, 1} { | |
| 79 | + | legacy := &Agent{ | |
| 80 | + | requirement: newRequirement(true, 1, time.Now()), | |
| 81 | + | requirementChanged: make(chan struct{}), | |
| 82 | + | } | |
| 83 | + | if err := updateAgentFromEvent(legacy, NewEvent("turn_end", map[string]any{"step": float64(step)})); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | if got := legacy.requirementSnapshot(); got.State != RequirementSatisfied { | |
| 87 | + | t.Fatalf("lifetime-free step %d outcome = %+v", step, got) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T) { | |
| 93 | + | if runtime.GOOS == "windows" { | |
| 94 | + | t.Skip("unix sockets not supported") | |
| 95 | + | } | |
| 96 | + | if testing.Short() { | |
| 97 | + | t.Skip("skip end-to-end runner test in -short mode") | |
| 98 | + | } | |
| 99 | + | exe := buildStubChild(t) | |
| 100 | + | t.Setenv("ZUT_STUB_PROTOCOL", "1") | |
| 101 | + | root := t.TempDir() | |
| 102 | + | f := New(Config{ | |
| 103 | + | Root: filepath.Join(root, "subagents"), | |
| 104 | + | RepoRoot: root, | |
| 105 | + | NewRunner: func(a *Agent) Runner { | |
| 106 | + | return &execRunner{ | |
| 107 | + | agent: a, | |
| 108 | + | Command: subagentWorkerArgs(subagentWorkerArgsOpts{ | |
| 109 | + | Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task, | |
| 110 | + | }), | |
| 111 | + | GracePeriod: time.Second, | |
| 112 | + | } | |
| 113 | + | }, | |
| 114 | + | }) | |
| 115 | + | t.Cleanup(f.StopAll) | |
| 116 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true}) | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout) | |
| 121 | + | defer cancel() | |
| 122 | + | got, err := f.WaitRequired(ctx, a.ID) | |
| 123 | + | if err != nil { | |
| 124 | + | events, _ := ReadEventLog(a.EventLogPath) | |
| 125 | + | t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events)) | |
| 126 | + | } | |
| 127 | + | if got.State != RequirementSatisfied { | |
| 128 | + | t.Fatalf("requirement = %+v, want satisfied", got) | |
| 129 | + | } | |
| 130 | + | if snapshot := a.Snapshot(); snapshot.Status != StatusRunning { | |
| 131 | + | t.Fatalf("worker lifecycle = (%s, %s), want live daemon", snapshot.Status, snapshot.TurnState) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestReloadFailsClosedWhenRequiredOutcomeWasNotObserved(t *testing.T) { | |
| 136 | + | root := t.TempDir() | |
| 137 | + | started := make(chan struct{}) | |
| 138 | + | first := New(Config{ | |
| 139 | + | Root: root, RepoRoot: root, | |
| 140 | + | NewRunner: func(*Agent) Runner { | |
| 141 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 142 | + | close(started) | |
| 143 | + | <-ctx.Done() | |
| 144 | + | return ctx.Err() | |
| 145 | + | }) | |
| 146 | + | }, | |
| 147 | + | }) | |
| 148 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true}) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatal(err) | |
| 151 | + | } | |
| 152 | + | <-started | |
| 153 | + | t.Cleanup(first.StopAll) | |
| 154 | + | ||
| 155 | + | second := New(Config{Root: root, RepoRoot: root}) | |
| 156 | + | loaded, errs := second.Reload() | |
| 157 | + | if len(errs) != 0 || loaded != 1 { | |
| 158 | + | t.Fatalf("reload loaded=%d errors=%v", loaded, errs) | |
| 159 | + | } | |
| 160 | + | got := second.Get(a.ID).Snapshot().Requirement | |
| 161 | + | if got.State != RequirementIndeterminate || !got.Unmet() || got.ErrorCode != "required_outcome_unobserved" { | |
| 162 | + | t.Fatalf("reconciled requirement = %+v, want durable indeterminate outcome", got) | |
| 163 | + | } | |
| 164 | + | meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |
| 165 | + | if err != nil { | |
| 166 | + | t.Fatal(err) | |
| 167 | + | } | |
| 168 | + | if meta.Requirement.State != RequirementIndeterminate { | |
| 169 | + | t.Fatalf("persisted reconciled requirement = %+v", meta.Requirement) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestRequiredOutcomePersistsAcrossReload(t *testing.T) { | |
| 174 | + | root := t.TempDir() | |
| 175 | + | first := New(Config{ | |
| 176 | + | Root: root, | |
| 177 | + | RepoRoot: root, | |
| 178 | + | NewRunner: func(*Agent) Runner { | |
| 179 | + | return RunnerFunc(func(context.Context, Sink) error { return errors.New("review failed") }) | |
| 180 | + | }, | |
| 181 | + | }) | |
| 182 | + | t.Cleanup(first.StopAll) | |
| 183 | + | ||
| 184 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true}) | |
| 185 | + | if err != nil { | |
| 186 | + | t.Fatalf("spawn required worker: %v", err) | |
| 187 | + | } | |
| 188 | + | a.Wait() | |
| 189 | + | got := a.Snapshot().Requirement | |
| 190 | + | if !got.Required || got.State != RequirementFailed { | |
| 191 | + | t.Fatalf("live requirement = %+v, want required failed", got) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | second := New(Config{Root: root, RepoRoot: root}) | |
| 195 | + | loaded, errs := second.Reload() | |
| 196 | + | if len(errs) != 0 { | |
| 197 | + | t.Fatalf("reload errors: %v", errs) | |
| 198 | + | } | |
| 199 | + | if loaded != 1 { | |
| 200 | + | t.Fatalf("loaded = %d, want 1", loaded) | |
| 201 | + | } | |
| 202 | + | reloaded := second.Get(a.ID) | |
| 203 | + | if reloaded == nil { | |
| 204 | + | t.Fatalf("reloaded worker %q not found", a.ID) | |
| 205 | + | } | |
| 206 | + | got = reloaded.Snapshot().Requirement | |
| 207 | + | if !got.Required || got.State != RequirementFailed || got.TargetTurn != 1 { | |
| 208 | + | t.Fatalf("reloaded requirement = %+v, want durable failed turn 1", got) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |
| 212 | + | if err != nil { | |
| 213 | + | t.Fatalf("read agent metadata: %v", err) | |
| 214 | + | } | |
| 215 | + | if !meta.Requirement.Required || meta.Requirement.State != RequirementFailed { | |
| 216 | + | t.Fatalf("persisted requirement = %+v", meta.Requirement) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) { | |
| 221 | + | root := t.TempDir() | |
| 222 | + | first := New(Config{ | |
| 223 | + | Root: root, RepoRoot: root, | |
| 224 | + | NewRunner: func(*Agent) Runner { | |
| 225 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 226 | + | }, | |
| 227 | + | }) | |
| 228 | + | t.Cleanup(first.StopAll) | |
| 229 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required validation", Required: true}) | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | a.Wait() | |
| 234 | + | if err := first.MarkRequirementNotified(a.ID); err != nil { | |
| 235 | + | t.Fatal(err) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | second := New(Config{Root: root, RepoRoot: root}) | |
| 239 | + | if _, errs := second.Reload(); len(errs) != 0 { | |
| 240 | + | t.Fatalf("reload errors: %v", errs) | |
| 241 | + | } | |
| 242 | + | reloaded := second.Get(a.ID) | |
| 243 | + | if got := reloaded.requirementSnapshot(); !got.Notified || got.State != RequirementSatisfied { | |
| 244 | + | t.Fatalf("reloaded notified requirement = %+v", got) | |
| 245 | + | } | |
| 246 | + | previous := reloaded.prepareRequired(0) | |
| 247 | + | if previous.TargetTurn != 1 { | |
| 248 | + | t.Fatalf("previous target = %d, want 1", previous.TargetTurn) | |
| 249 | + | } | |
| 250 | + | pending := reloaded.requirementSnapshot() | |
| 251 | + | if pending.Notified || pending.TargetTurn != 2 { | |
| 252 | + | t.Fatalf("retried requirement = %+v, want unnotified turn 2", pending) | |
| 253 | + | } | |
| 254 | + | reloaded.prepareRequired(0) | |
| 255 | + | if got := reloaded.requirementSnapshot(); got.TargetTurn != 2 { | |
| 256 | + | t.Fatalf("idempotent prepare target = %d, want 2", got.TargetTurn) | |
| 257 | + | } | |
| 258 | + | reloaded.resolveRequirement(2, nil, "", false) | |
| 259 | + | if got := reloaded.requirementSnapshot(); got.Notified || got.State != RequirementSatisfied { | |
| 260 | + | t.Fatalf("resolved retry = %+v, want unnotified satisfaction", got) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestRequiredTerminalOutcomesRemainUnmet(t *testing.T) { | |
| 265 | + | tests := []struct { | |
| 266 | + | name string | |
| 267 | + | run func(context.Context) error | |
| 268 | + | want RequirementState | |
| 269 | + | }{ | |
| 270 | + | {name: "failed", run: func(context.Context) error { return errors.New("boom") }, want: RequirementFailed}, | |
| 271 | + | {name: "timed out", run: func(context.Context) error { return context.DeadlineExceeded }, want: RequirementTimedOut}, | |
| 272 | + | {name: "canceled", run: func(context.Context) error { return context.Canceled }, want: RequirementCanceled}, | |
| 273 | + | } | |
| 274 | + | for _, tt := range tests { | |
| 275 | + | t.Run(tt.name, func(t *testing.T) { | |
| 276 | + | f := newTestSupervisor(t, func(*Agent) Runner { | |
| 277 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { return tt.run(ctx) }) | |
| 278 | + | }) | |
| 279 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: tt.name, Required: true}) | |
| 280 | + | if err != nil { | |
| 281 | + | t.Fatal(err) | |
| 282 | + | } | |
| 283 | + | a.Wait() | |
| 284 | + | got := a.Snapshot().Requirement | |
| 285 | + | if got.State != tt.want || !got.Unmet() { | |
| 286 | + | t.Fatalf("requirement = %+v, want unmet %s", got, tt.want) | |
| 287 | + | } | |
| 288 | + | }) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |
| 293 | + | started := make(chan struct{}) | |
| 294 | + | release := make(chan struct{}) | |
| 295 | + | attempt := 0 | |
| 296 | + | f := newTestSupervisor(t, func(*Agent) Runner { | |
| 297 | + | attempt++ | |
| 298 | + | current := attempt | |
| 299 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 300 | + | if current == 1 { | |
| 301 | + | close(started) | |
| 302 | + | select { | |
| 303 | + | case <-release: | |
| 304 | + | return errors.New("first attempt failed") | |
| 305 | + | case <-ctx.Done(): | |
| 306 | + | return ctx.Err() | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | return nil | |
| 310 | + | }) | |
| 311 | + | }) | |
| 312 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required finalizer", Required: true}) | |
| 313 | + | if err != nil { | |
| 314 | + | t.Fatal(err) | |
| 315 | + | } | |
| 316 | + | <-started | |
| 317 | + | ||
| 318 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 319 | + | defer cancel() | |
| 320 | + | waited := make(chan RequirementSnapshot, 1) | |
| 321 | + | waitErr := make(chan error, 1) | |
| 322 | + | go func() { | |
| 323 | + | result, err := f.WaitRequired(ctx, a.ID) | |
| 324 | + | if err != nil { | |
| 325 | + | waitErr <- err | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | waited <- result | |
| 329 | + | }() | |
| 330 | + | select { | |
| 331 | + | case got := <-waited: | |
| 332 | + | t.Fatalf("wait returned before the worker finished: %+v", got) | |
| 333 | + | case err := <-waitErr: | |
| 334 | + | t.Fatalf("wait failed before the worker finished: %v", err) | |
| 335 | + | default: | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | close(release) | |
| 339 | + | select { | |
| 340 | + | case err := <-waitErr: | |
| 341 | + | t.Fatalf("wait failed: %v", err) | |
| 342 | + | case got := <-waited: | |
| 343 | + | if got.State != RequirementFailed || !got.Unmet() { | |
| 344 | + | t.Fatalf("first outcome = %+v, want unmet failure", got) | |
| 345 | + | } | |
| 346 | + | case <-ctx.Done(): | |
| 347 | + | t.Fatal("event-driven requirement wait did not wake") | |
| 348 | + | } | |
| 349 | + | a.Wait() | |
| 350 | + | ||
| 351 | + | resumed, err := f.ResumeRequiredWithPrompt(context.Background(), a.ID, "retry the finalization") | |
| 352 | + | if err != nil { | |
| 353 | + | t.Fatalf("required retry: %v", err) | |
| 354 | + | } | |
| 355 | + | resumed.Wait() | |
| 356 | + | got, err := f.WaitRequired(ctx, a.ID) | |
| 357 | + | if err != nil { | |
| 358 | + | t.Fatalf("wait for retry: %v", err) | |
| 359 | + | } | |
| 360 | + | if got.State != RequirementSatisfied || got.Unmet() || got.TargetTurn != 2 { | |
| 361 | + | t.Fatalf("retry outcome = %+v, want satisfied turn 2", got) | |
| 362 | + | } | |
| 363 | + | } | |
| @@ -0,0 +1,363 @@ | |||||
| 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 | + | waited := make(chan RequirementSnapshot, 1) | |||
| 32 | + | go func() { | |||
| 33 | + | got, _ := a.waitRequirement(context.Background()) | |||
| 34 | + | waited <- got | |||
| 35 | + | }() | |||
| 36 | + | select { | |||
| 37 | + | case got := <-waited: | |||
| 38 | + | t.Fatalf("wait woke before durable persistence: %+v", got) | |||
| 39 | + | default: | |||
| 40 | + | } | |||
| 41 | + | close(release) | |||
| 42 | + | <-resolved | |||
| 43 | + | if got := <-waited; got.State != RequirementSatisfied { | |||
| 44 | + | t.Fatalf("durable outcome = %+v", got) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestRequiredOutcomeUsesDelegatedTurnOrdinal(t *testing.T) { | |||
| 49 | + | a := &Agent{ | |||
| 50 | + | requirement: newRequirement(true, 1, time.Now()), | |||
| 51 | + | requirementChanged: make(chan struct{}), | |||
| 52 | + | } | |||
| 53 | + | if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{ | |||
| 54 | + | "step": float64(1), "lifetime_turns": float64(1), "current_run_turns": float64(1), | |||
| 55 | + | })); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(1)})); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 1 { | |||
| 62 | + | t.Fatalf("canonical first-turn outcome = %+v", got) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | a.prepareRequired(0) | |||
| 66 | + | if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{ | |||
| 67 | + | "step": float64(2), "lifetime_turns": float64(2), "current_run_turns": float64(1), | |||
| 68 | + | })); err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | if err := updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2)})); err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | if got := a.requirementSnapshot(); got.State != RequirementSatisfied || got.TargetTurn != 2 { | |||
| 75 | + | t.Fatalf("canonical retry outcome = %+v", got) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | for _, step := range []int{0, 1} { | |||
| 79 | + | legacy := &Agent{ | |||
| 80 | + | requirement: newRequirement(true, 1, time.Now()), | |||
| 81 | + | requirementChanged: make(chan struct{}), | |||
| 82 | + | } | |||
| 83 | + | if err := updateAgentFromEvent(legacy, NewEvent("turn_end", map[string]any{"step": float64(step)})); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | if got := legacy.requirementSnapshot(); got.State != RequirementSatisfied { | |||
| 87 | + | t.Fatalf("lifetime-free step %d outcome = %+v", step, got) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T) { | |||
| 93 | + | if runtime.GOOS == "windows" { | |||
| 94 | + | t.Skip("unix sockets not supported") | |||
| 95 | + | } | |||
| 96 | + | if testing.Short() { | |||
| 97 | + | t.Skip("skip end-to-end runner test in -short mode") | |||
| 98 | + | } | |||
| 99 | + | exe := buildStubChild(t) | |||
| 100 | + | t.Setenv("ZUT_STUB_PROTOCOL", "1") | |||
| 101 | + | root := t.TempDir() | |||
| 102 | + | f := New(Config{ | |||
| 103 | + | Root: filepath.Join(root, "subagents"), | |||
| 104 | + | RepoRoot: root, | |||
| 105 | + | NewRunner: func(a *Agent) Runner { | |||
| 106 | + | return &execRunner{ | |||
| 107 | + | agent: a, | |||
| 108 | + | Command: subagentWorkerArgs(subagentWorkerArgsOpts{ | |||
| 109 | + | Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task, | |||
| 110 | + | }), | |||
| 111 | + | GracePeriod: time.Second, | |||
| 112 | + | } | |||
| 113 | + | }, | |||
| 114 | + | }) | |||
| 115 | + | t.Cleanup(f.StopAll) | |||
| 116 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true}) | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout) | |||
| 121 | + | defer cancel() | |||
| 122 | + | got, err := f.WaitRequired(ctx, a.ID) | |||
| 123 | + | if err != nil { | |||
| 124 | + | events, _ := ReadEventLog(a.EventLogPath) | |||
| 125 | + | t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events)) | |||
| 126 | + | } | |||
| 127 | + | if got.State != RequirementSatisfied { | |||
| 128 | + | t.Fatalf("requirement = %+v, want satisfied", got) | |||
| 129 | + | } | |||
| 130 | + | if snapshot := a.Snapshot(); snapshot.Status != StatusRunning { | |||
| 131 | + | t.Fatalf("worker lifecycle = (%s, %s), want live daemon", snapshot.Status, snapshot.TurnState) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestReloadFailsClosedWhenRequiredOutcomeWasNotObserved(t *testing.T) { | |||
| 136 | + | root := t.TempDir() | |||
| 137 | + | started := make(chan struct{}) | |||
| 138 | + | first := New(Config{ | |||
| 139 | + | Root: root, RepoRoot: root, | |||
| 140 | + | NewRunner: func(*Agent) Runner { | |||
| 141 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 142 | + | close(started) | |||
| 143 | + | <-ctx.Done() | |||
| 144 | + | return ctx.Err() | |||
| 145 | + | }) | |||
| 146 | + | }, | |||
| 147 | + | }) | |||
| 148 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true}) | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Fatal(err) | |||
| 151 | + | } | |||
| 152 | + | <-started | |||
| 153 | + | t.Cleanup(first.StopAll) | |||
| 154 | + | ||||
| 155 | + | second := New(Config{Root: root, RepoRoot: root}) | |||
| 156 | + | loaded, errs := second.Reload() | |||
| 157 | + | if len(errs) != 0 || loaded != 1 { | |||
| 158 | + | t.Fatalf("reload loaded=%d errors=%v", loaded, errs) | |||
| 159 | + | } | |||
| 160 | + | got := second.Get(a.ID).Snapshot().Requirement | |||
| 161 | + | if got.State != RequirementIndeterminate || !got.Unmet() || got.ErrorCode != "required_outcome_unobserved" { | |||
| 162 | + | t.Fatalf("reconciled requirement = %+v, want durable indeterminate outcome", got) | |||
| 163 | + | } | |||
| 164 | + | meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |||
| 165 | + | if err != nil { | |||
| 166 | + | t.Fatal(err) | |||
| 167 | + | } | |||
| 168 | + | if meta.Requirement.State != RequirementIndeterminate { | |||
| 169 | + | t.Fatalf("persisted reconciled requirement = %+v", meta.Requirement) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestRequiredOutcomePersistsAcrossReload(t *testing.T) { | |||
| 174 | + | root := t.TempDir() | |||
| 175 | + | first := New(Config{ | |||
| 176 | + | Root: root, | |||
| 177 | + | RepoRoot: root, | |||
| 178 | + | NewRunner: func(*Agent) Runner { | |||
| 179 | + | return RunnerFunc(func(context.Context, Sink) error { return errors.New("review failed") }) | |||
| 180 | + | }, | |||
| 181 | + | }) | |||
| 182 | + | t.Cleanup(first.StopAll) | |||
| 183 | + | ||||
| 184 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required review", Required: true}) | |||
| 185 | + | if err != nil { | |||
| 186 | + | t.Fatalf("spawn required worker: %v", err) | |||
| 187 | + | } | |||
| 188 | + | a.Wait() | |||
| 189 | + | got := a.Snapshot().Requirement | |||
| 190 | + | if !got.Required || got.State != RequirementFailed { | |||
| 191 | + | t.Fatalf("live requirement = %+v, want required failed", got) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | second := New(Config{Root: root, RepoRoot: root}) | |||
| 195 | + | loaded, errs := second.Reload() | |||
| 196 | + | if len(errs) != 0 { | |||
| 197 | + | t.Fatalf("reload errors: %v", errs) | |||
| 198 | + | } | |||
| 199 | + | if loaded != 1 { | |||
| 200 | + | t.Fatalf("loaded = %d, want 1", loaded) | |||
| 201 | + | } | |||
| 202 | + | reloaded := second.Get(a.ID) | |||
| 203 | + | if reloaded == nil { | |||
| 204 | + | t.Fatalf("reloaded worker %q not found", a.ID) | |||
| 205 | + | } | |||
| 206 | + | got = reloaded.Snapshot().Requirement | |||
| 207 | + | if !got.Required || got.State != RequirementFailed || got.TargetTurn != 1 { | |||
| 208 | + | t.Fatalf("reloaded requirement = %+v, want durable failed turn 1", got) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | meta, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |||
| 212 | + | if err != nil { | |||
| 213 | + | t.Fatalf("read agent metadata: %v", err) | |||
| 214 | + | } | |||
| 215 | + | if !meta.Requirement.Required || meta.Requirement.State != RequirementFailed { | |||
| 216 | + | t.Fatalf("persisted requirement = %+v", meta.Requirement) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) { | |||
| 221 | + | root := t.TempDir() | |||
| 222 | + | first := New(Config{ | |||
| 223 | + | Root: root, RepoRoot: root, | |||
| 224 | + | NewRunner: func(*Agent) Runner { | |||
| 225 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 226 | + | }, | |||
| 227 | + | }) | |||
| 228 | + | t.Cleanup(first.StopAll) | |||
| 229 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "required validation", Required: true}) | |||
| 230 | + | if err != nil { | |||
| 231 | + | t.Fatal(err) | |||
| 232 | + | } | |||
| 233 | + | a.Wait() | |||
| 234 | + | if err := first.MarkRequirementNotified(a.ID); err != nil { | |||
| 235 | + | t.Fatal(err) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | second := New(Config{Root: root, RepoRoot: root}) | |||
| 239 | + | if _, errs := second.Reload(); len(errs) != 0 { | |||
| 240 | + | t.Fatalf("reload errors: %v", errs) | |||
| 241 | + | } | |||
| 242 | + | reloaded := second.Get(a.ID) | |||
| 243 | + | if got := reloaded.requirementSnapshot(); !got.Notified || got.State != RequirementSatisfied { | |||
| 244 | + | t.Fatalf("reloaded notified requirement = %+v", got) | |||
| 245 | + | } | |||
| 246 | + | previous := reloaded.prepareRequired(0) | |||
| 247 | + | if previous.TargetTurn != 1 { | |||
| 248 | + | t.Fatalf("previous target = %d, want 1", previous.TargetTurn) | |||
| 249 | + | } | |||
| 250 | + | pending := reloaded.requirementSnapshot() | |||
| 251 | + | if pending.Notified || pending.TargetTurn != 2 { | |||
| 252 | + | t.Fatalf("retried requirement = %+v, want unnotified turn 2", pending) | |||
| 253 | + | } | |||
| 254 | + | reloaded.prepareRequired(0) | |||
| 255 | + | if got := reloaded.requirementSnapshot(); got.TargetTurn != 2 { | |||
| 256 | + | t.Fatalf("idempotent prepare target = %d, want 2", got.TargetTurn) | |||
| 257 | + | } | |||
| 258 | + | reloaded.resolveRequirement(2, nil, "", false) | |||
| 259 | + | if got := reloaded.requirementSnapshot(); got.Notified || got.State != RequirementSatisfied { | |||
| 260 | + | t.Fatalf("resolved retry = %+v, want unnotified satisfaction", got) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestRequiredTerminalOutcomesRemainUnmet(t *testing.T) { | |||
| 265 | + | tests := []struct { | |||
| 266 | + | name string | |||
| 267 | + | run func(context.Context) error | |||
| 268 | + | want RequirementState | |||
| 269 | + | }{ | |||
| 270 | + | {name: "failed", run: func(context.Context) error { return errors.New("boom") }, want: RequirementFailed}, | |||
| 271 | + | {name: "timed out", run: func(context.Context) error { return context.DeadlineExceeded }, want: RequirementTimedOut}, | |||
| 272 | + | {name: "canceled", run: func(context.Context) error { return context.Canceled }, want: RequirementCanceled}, | |||
| 273 | + | } | |||
| 274 | + | for _, tt := range tests { | |||
| 275 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 276 | + | f := newTestSupervisor(t, func(*Agent) Runner { | |||
| 277 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { return tt.run(ctx) }) | |||
| 278 | + | }) | |||
| 279 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: tt.name, Required: true}) | |||
| 280 | + | if err != nil { | |||
| 281 | + | t.Fatal(err) | |||
| 282 | + | } | |||
| 283 | + | a.Wait() | |||
| 284 | + | got := a.Snapshot().Requirement | |||
| 285 | + | if got.State != tt.want || !got.Unmet() { | |||
| 286 | + | t.Fatalf("requirement = %+v, want unmet %s", got, tt.want) | |||
| 287 | + | } | |||
| 288 | + | }) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||
| 293 | + | started := make(chan struct{}) | |||
| 294 | + | release := make(chan struct{}) | |||
| 295 | + | attempt := 0 | |||
| 296 | + | f := newTestSupervisor(t, func(*Agent) Runner { | |||
| 297 | + | attempt++ | |||
| 298 | + | current := attempt | |||
| 299 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 300 | + | if current == 1 { | |||
| 301 | + | close(started) | |||
| 302 | + | select { | |||
| 303 | + | case <-release: | |||
| 304 | + | return errors.New("first attempt failed") | |||
| 305 | + | case <-ctx.Done(): | |||
| 306 | + | return ctx.Err() | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | return nil | |||
| 310 | + | }) | |||
| 311 | + | }) | |||
| 312 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "required finalizer", Required: true}) | |||
| 313 | + | if err != nil { | |||
| 314 | + | t.Fatal(err) | |||
| 315 | + | } | |||
| 316 | + | <-started | |||
| 317 | + | ||||
| 318 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 319 | + | defer cancel() | |||
| 320 | + | waited := make(chan RequirementSnapshot, 1) | |||
| 321 | + | waitErr := make(chan error, 1) | |||
| 322 | + | go func() { | |||
| 323 | + | result, err := f.WaitRequired(ctx, a.ID) | |||
| 324 | + | if err != nil { | |||
| 325 | + | waitErr <- err | |||
| 326 | + | return | |||
| 327 | + | } | |||
| 328 | + | waited <- result | |||
| 329 | + | }() | |||
| 330 | + | select { | |||
| 331 | + | case got := <-waited: | |||
| 332 | + | t.Fatalf("wait returned before the worker finished: %+v", got) | |||
| 333 | + | case err := <-waitErr: | |||
| 334 | + | t.Fatalf("wait failed before the worker finished: %v", err) | |||
| 335 | + | default: | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | close(release) | |||
| 339 | + | select { | |||
| 340 | + | case err := <-waitErr: | |||
| 341 | + | t.Fatalf("wait failed: %v", err) | |||
| 342 | + | case got := <-waited: | |||
| 343 | + | if got.State != RequirementFailed || !got.Unmet() { | |||
| 344 | + | t.Fatalf("first outcome = %+v, want unmet failure", got) | |||
| 345 | + | } | |||
| 346 | + | case <-ctx.Done(): | |||
| 347 | + | t.Fatal("event-driven requirement wait did not wake") | |||
| 348 | + | } | |||
| 349 | + | a.Wait() | |||
| 350 | + | ||||
| 351 | + | resumed, err := f.ResumeRequiredWithPrompt(context.Background(), a.ID, "retry the finalization") | |||
| 352 | + | if err != nil { | |||
| 353 | + | t.Fatalf("required retry: %v", err) | |||
| 354 | + | } | |||
| 355 | + | resumed.Wait() | |||
| 356 | + | got, err := f.WaitRequired(ctx, a.ID) | |||
| 357 | + | if err != nil { | |||
| 358 | + | t.Fatalf("wait for retry: %v", err) | |||
| 359 | + | } | |||
| 360 | + | if got.State != RequirementSatisfied || got.Unmet() || got.TargetTurn != 2 { | |||
| 361 | + | t.Fatalf("retry outcome = %+v, want satisfied turn 2", got) | |||
| 362 | + | } | |||
| 363 | + | } | |||
M packages/agent/subagents/result.go +1 -0
| @@ -578,6 +578,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 578 | 578 | a.resultRef = ResultRef(a.ID) | |
| 579 | 579 | a.lifecycleMu.Unlock() | |
| 580 | 580 | } | |
| 581 | + | a.resolveRequirement(0, result, "", true) | |
| 581 | 582 | } | |
| 582 | 583 | ||
| 583 | 584 | // ReadResult returns the durable structured result for an agent. | |
| @@ -578,6 +578,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||||
| 578 | a.resultRef = ResultRef(a.ID) | 578 | a.resultRef = ResultRef(a.ID) | ||
| 579 | a.lifecycleMu.Unlock() | 579 | a.lifecycleMu.Unlock() | ||
| 580 | } | 580 | } | ||
| 581 | + | a.resolveRequirement(0, result, "", true) | |||
| 581 | } | 582 | } | ||
| 582 | 583 | ||||
| 583 | // ReadResult returns the durable structured result for an agent. | 584 | // 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 { | |||
| 861 | 861 | if !isDelegatedTurnEnd(ev) { | |
| 862 | 862 | break | |
| 863 | 863 | } | |
| 864 | - | if message, _ := ev.Data["error"].(string); message != "" { | |
| 864 | + | message, _ := ev.Data["error"].(string) | |
| 865 | + | if message != "" { | |
| 865 | 866 | a.setTurnState(TurnFailed, ev.TurnID) | |
| 866 | 867 | } else { | |
| 867 | 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 | 890 | persist = true | |
| 870 | 891 | } | |
| 871 | 892 | 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 | break | 862 | 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 = true | 890 | 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 { | |||
| 357 | 357 | RootSessionID string | |
| 358 | 358 | 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 | 363 | Timeout time.Duration | |
| 361 | 364 | MaxTurns int | |
| 362 | 365 | Tools []string | |
| @@ -531,6 +534,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 531 | 534 | turnState: TurnQueued, | |
| 532 | 535 | updatedAt: now, | |
| 533 | 536 | lastActivity: now, | |
| 537 | + | requirement: newRequirement(req.Required, 1, now), | |
| 534 | 538 | stateDir: stateDir, | |
| 535 | 539 | lease: lease, | |
| 536 | 540 | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| @@ -1114,6 +1118,7 @@ type AgentSnapshot struct { | |||
| 1114 | 1118 | LastAssistant string // complete final assistant message, without role formatting | |
| 1115 | 1119 | Result *TurnResult | |
| 1116 | 1120 | ResultRef string | |
| 1121 | + | Requirement RequirementSnapshot | |
| 1117 | 1122 | PatchRef string | |
| 1118 | 1123 | ChangedFiles []string | |
| 1119 | 1124 | OutputTruncated bool | |
| @@ -1170,6 +1175,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 1170 | 1175 | updatedAt := a.updatedAt | |
| 1171 | 1176 | result := cloneTurnResult(a.result) | |
| 1172 | 1177 | resultRef := a.resultRef | |
| 1178 | + | requirement := a.visibleRequirementLocked() | |
| 1173 | 1179 | patchRef := a.patchRef | |
| 1174 | 1180 | changedFiles := append([]string(nil), a.changedFiles...) | |
| 1175 | 1181 | a.lifecycleMu.Unlock() | |
| @@ -1180,7 +1186,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 1180 | 1186 | Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity, | |
| 1181 | 1187 | Err: errStr, Tail: tail, Lines: lines, | |
| 1182 | 1188 | LastAssistant: lastAssistant, Result: result, ResultRef: resultRef, | |
| 1183 | - | PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated, | |
| 1189 | + | Requirement: requirement, PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated, | |
| 1184 | 1190 | Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL, | |
| 1185 | 1191 | InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning, | |
| 1186 | 1192 | FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode, | |
| @@ -357,6 +357,9 @@ type SpawnRequest struct { | |||||
| 357 | RootSessionID string | 357 | RootSessionID string | ||
| 358 | RequesterAgentID string | 358 | 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.Duration | 363 | Timeout time.Duration | ||
| 361 | MaxTurns int | 364 | MaxTurns int | ||
| 362 | Tools []string | 365 | 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 formatting | 1118 | LastAssistant string // complete final assistant message, without role formatting | ||
| 1115 | Result *TurnResult | 1119 | Result *TurnResult | ||
| 1116 | ResultRef string | 1120 | ResultRef string | ||
| 1121 | + | Requirement RequirementSnapshot | |||
| 1117 | PatchRef string | 1122 | PatchRef string | ||
| 1118 | ChangedFiles []string | 1123 | ChangedFiles []string | ||
| 1119 | OutputTruncated bool | 1124 | OutputTruncated bool | ||
| @@ -1170,6 +1175,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 1170 | updatedAt := a.updatedAt | 1175 | updatedAt := a.updatedAt | ||
| 1171 | result := cloneTurnResult(a.result) | 1176 | result := cloneTurnResult(a.result) | ||
| 1172 | resultRef := a.resultRef | 1177 | resultRef := a.resultRef | ||
| 1178 | + | requirement := a.visibleRequirementLocked() | |||
| 1173 | patchRef := a.patchRef | 1179 | 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) { | |||
| 149 | 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 | 155 | 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) { | |||
| 124 | 124 | tool := &SubagentResumeTool{ | |
| 125 | 125 | Supervisor: manager, | |
| 126 | 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 | 128 | beforeAgent = agent | |
| 129 | 129 | beforePrompt = gotPrompt | |
| 130 | 130 | return tracker.TrackFutureTurn(agent, gotPrompt, true) | |
| 131 | 131 | }, | |
| 132 | - | OnResumed: func(*subagents.Agent, string) { | |
| 132 | + | OnResumed: func(*subagents.Agent, string, bool) { | |
| 133 | 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 | 201 | tool := &SubagentResumeTool{ | |
| 202 | 202 | Supervisor: manager, | |
| 203 | 203 | Enabled: func() bool { return true }, | |
| 204 | - | OnResumed: func(agent *subagents.Agent, gotPrompt string) { | |
| 204 | + | OnResumed: func(agent *subagents.Agent, gotPrompt string, _ bool) { | |
| 205 | 205 | tracked = agent | |
| 206 | 206 | trackedPrompt = gotPrompt | |
| 207 | 207 | }, | |
| @@ -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 = agent | 128 | beforeAgent = agent | ||
| 129 | beforePrompt = gotPrompt | 129 | 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 = agent | 205 | tracked = agent | ||
| 206 | trackedPrompt = gotPrompt | 206 | trackedPrompt = gotPrompt | ||
| 207 | }, | 207 | }, | ||
A packages/agent/tools/subagent_required_test.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 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 | + | if _, errs := second.Reload(); len(errs) != 0 { | |
| 169 | + | t.Fatalf("reload errors: %v", errs) | |
| 170 | + | } | |
| 171 | + | resume := &SubagentResumeTool{Supervisor: second, Enabled: func() bool { return true }} | |
| 172 | + | got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, a.ID)), nil) | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | if !got.IsError || runs != 0 || !strings.Contains(textResult(got.Content), "user must inspect") { | |
| 177 | + | t.Fatalf("automatic indeterminate retry = %#v runs=%d\n%s", got, runs, textResult(got.Content)) | |
| 178 | + | } | |
| 179 | + | first.StopAll() | |
| 180 | + | ||
| 181 | + | confirmed, err := second.RestartTask(context.Background(), a.ID) | |
| 182 | + | if err != nil { | |
| 183 | + | t.Fatalf("explicit user restart: %v", err) | |
| 184 | + | } | |
| 185 | + | confirmed.Wait() | |
| 186 | + | if runs != 1 || confirmed.Snapshot().Requirement.State != subagents.RequirementSatisfied { | |
| 187 | + | t.Fatalf("explicit reconciliation runs=%d requirement=%+v", runs, confirmed.Snapshot().Requirement) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestRequiredSubagentFailureStaysUnmet(t *testing.T) { | |
| 192 | + | root := t.TempDir() | |
| 193 | + | manager := subagents.New(subagents.Config{ | |
| 194 | + | Root: filepath.Join(root, "subagents"), | |
| 195 | + | RepoRoot: root, | |
| 196 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 197 | + | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { | |
| 198 | + | return errors.New("finalizer failed") | |
| 199 | + | }) | |
| 200 | + | }, | |
| 201 | + | }) | |
| 202 | + | t.Cleanup(manager.StopAll) | |
| 203 | + | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 204 | + | ||
| 205 | + | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | |
| 206 | + | if err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | if !strings.Contains(textResult(got.Content), "failed") { | |
| 210 | + | t.Fatalf("required failure was not surfaced: %s", textResult(got.Content)) | |
| 211 | + | } | |
| 212 | + | agents := manager.List() | |
| 213 | + | if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() { | |
| 214 | + | t.Fatalf("failed required worker was not retained as unmet: %#v", agents) | |
| 215 | + | } | |
| 216 | + | status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 217 | + | public, err := status.Execute(context.Background(), json.RawMessage(`{}`), nil) | |
| 218 | + | if err != nil { | |
| 219 | + | t.Fatal(err) | |
| 220 | + | } | |
| 221 | + | publicText := textResult(public.Content) | |
| 222 | + | if !strings.Contains(publicText, `"error_code":"failed"`) || strings.Contains(publicText, "finalizer failed") { | |
| 223 | + | t.Fatalf("public requirement status exposed raw diagnostics: %s", publicText) | |
| 224 | + | } | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 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 | + | if _, errs := second.Reload(); len(errs) != 0 { | |||
| 169 | + | t.Fatalf("reload errors: %v", errs) | |||
| 170 | + | } | |||
| 171 | + | resume := &SubagentResumeTool{Supervisor: second, Enabled: func() bool { return true }} | |||
| 172 | + | got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, a.ID)), nil) | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | if !got.IsError || runs != 0 || !strings.Contains(textResult(got.Content), "user must inspect") { | |||
| 177 | + | t.Fatalf("automatic indeterminate retry = %#v runs=%d\n%s", got, runs, textResult(got.Content)) | |||
| 178 | + | } | |||
| 179 | + | first.StopAll() | |||
| 180 | + | ||||
| 181 | + | confirmed, err := second.RestartTask(context.Background(), a.ID) | |||
| 182 | + | if err != nil { | |||
| 183 | + | t.Fatalf("explicit user restart: %v", err) | |||
| 184 | + | } | |||
| 185 | + | confirmed.Wait() | |||
| 186 | + | if runs != 1 || confirmed.Snapshot().Requirement.State != subagents.RequirementSatisfied { | |||
| 187 | + | t.Fatalf("explicit reconciliation runs=%d requirement=%+v", runs, confirmed.Snapshot().Requirement) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestRequiredSubagentFailureStaysUnmet(t *testing.T) { | |||
| 192 | + | root := t.TempDir() | |||
| 193 | + | manager := subagents.New(subagents.Config{ | |||
| 194 | + | Root: filepath.Join(root, "subagents"), | |||
| 195 | + | RepoRoot: root, | |||
| 196 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 197 | + | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { | |||
| 198 | + | return errors.New("finalizer failed") | |||
| 199 | + | }) | |||
| 200 | + | }, | |||
| 201 | + | }) | |||
| 202 | + | t.Cleanup(manager.StopAll) | |||
| 203 | + | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |||
| 204 | + | ||||
| 205 | + | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | |||
| 206 | + | if err != nil { | |||
| 207 | + | t.Fatal(err) | |||
| 208 | + | } | |||
| 209 | + | if !strings.Contains(textResult(got.Content), "failed") { | |||
| 210 | + | t.Fatalf("required failure was not surfaced: %s", textResult(got.Content)) | |||
| 211 | + | } | |||
| 212 | + | agents := manager.List() | |||
| 213 | + | if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() { | |||
| 214 | + | t.Fatalf("failed required worker was not retained as unmet: %#v", agents) | |||
| 215 | + | } | |||
| 216 | + | status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }} | |||
| 217 | + | public, err := status.Execute(context.Background(), json.RawMessage(`{}`), nil) | |||
| 218 | + | if err != nil { | |||
| 219 | + | t.Fatal(err) | |||
| 220 | + | } | |||
| 221 | + | publicText := textResult(public.Content) | |||
| 222 | + | if !strings.Contains(publicText, `"error_code":"failed"`) || strings.Contains(publicText, "finalizer failed") { | |||
| 223 | + | t.Fatalf("public requirement status exposed raw diagnostics: %s", publicText) | |||
| 224 | + | } | |||
| 225 | + | } | |||
M packages/agent/tools/subagent_resume.go +31 -7
| @@ -24,17 +24,18 @@ type SubagentResumeTool struct { | |||
| 24 | 24 | // sends or queues its prompt. It returns a cleanup for resume failures that | |
| 25 | 25 | // happen before delivery. The interactive host uses this to avoid losing a | |
| 26 | 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 | 29 | // OnResumed receives the worker and follow-up after a successful resume. | |
| 30 | 30 | // It remains available for callers that only need post-success | |
| 31 | 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 | 35 | 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"` | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | const subagentResumeSchema = `{ | |
| @@ -47,6 +48,10 @@ const subagentResumeSchema = `{ | |||
| 47 | 48 | "prompt": { | |
| 48 | 49 | "type": "string", | |
| 49 | 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 | 57 | "required": ["agent_id", "prompt"] | |
| @@ -55,7 +60,7 @@ const subagentResumeSchema = `{ | |||
| 55 | 60 | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | |
| 56 | 61 | ||
| 57 | 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 | 66 | func (t *SubagentResumeTool) Schema() json.RawMessage { | |
| @@ -93,12 +98,31 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 93 | 98 | if !ok { | |
| 94 | 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 | + | before := func(agent *subagents.Agent, prompt string) func() { | |
| 106 | + | if t.BeforeResumed == nil { | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | return t.BeforeResumed(agent, prompt, required) | |
| 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 | 118 | if err != nil { | |
| 98 | 119 | return protocolToolError(prefix + ": " + err.Error()) | |
| 99 | 120 | } | |
| 100 | 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) | |
| 102 | 126 | } | |
| 103 | 127 | ||
| 104 | 128 | 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 that | 24 | // 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 a | 25 | // 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-success | 30 | // 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,6 +48,10 @@ 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"] | ||
| @@ -55,7 +60,7 @@ const subagentResumeSchema = `{ | |||||
| 55 | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | 60 | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | ||
| 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 { | ||
| @@ -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 | + | before := func(agent *subagents.Agent, prompt string) func() { | |||
| 106 | + | if t.BeforeResumed == nil { | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
| 109 | + | return t.BeforeResumed(agent, prompt, required) | |||
| 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) | |||
| 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 +64 -12
| @@ -12,11 +12,10 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zut/packages/provider" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 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. | |
| 20 | 19 | // | |
| 21 | 20 | // Available when the host's launch-time tool policy permits delegation. The | |
| 22 | 21 | // primary-agent prompt decides whether delegation is proactive or only on an | |
| @@ -42,11 +41,10 @@ type SubagentSpawnTool struct { | |||
| 42 | 41 | // The child receives only the name and loads the profile itself. | |
| 43 | 42 | ResolveSubagent func(name string) (*subagents.Profile, error) | |
| 44 | 43 | ||
| 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) | |
| 44 | + | // OnSpawned, if set, is called after every successful spawn with the | |
| 45 | + | // requirement mode. Hosts track optional work for asynchronous completion; | |
| 46 | + | // required work is delivered by this blocking tool call instead. | |
| 47 | + | OnSpawned func(agent *subagents.Agent, task string, required bool) | |
| 50 | 48 | } | |
| 51 | 49 | ||
| 52 | 50 | type subagentSpawnArgs struct { | |
| @@ -57,6 +55,7 @@ type subagentSpawnArgs struct { | |||
| 57 | 55 | Reasoning string `json:"reasoning,omitempty"` | |
| 58 | 56 | Thinking string `json:"thinking,omitempty"` | |
| 59 | 57 | FastMode *bool `json:"fast_mode,omitempty"` | |
| 58 | + | Required bool `json:"required,omitempty"` | |
| 60 | 59 | Isolation string `json:"isolation,omitempty"` | |
| 61 | 60 | Timeout string `json:"timeout,omitempty"` | |
| 62 | 61 | MaxTurns *int `json:"max_turns,omitempty"` | |
| @@ -95,6 +94,10 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 95 | 94 | "type": "boolean", | |
| 96 | 95 | "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting." | |
| 97 | 96 | }, | |
| 97 | + | "required": { | |
| 98 | + | "type": "boolean", | |
| 99 | + | "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." | |
| 100 | + | }, | |
| 98 | 101 | "isolation": { | |
| 99 | 102 | "type": "string", | |
| 100 | 103 | "enum": ["shared", "worktree"], | |
| @@ -116,7 +119,7 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 116 | 119 | ||
| 117 | 120 | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } | |
| 118 | 121 | 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." | |
| 122 | + | 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 | 123 | } | |
| 121 | 124 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | |
| 122 | 125 | maxTurns := 3 | |
| @@ -250,6 +253,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 250 | 253 | Reasoning: reasoning, | |
| 251 | 254 | FastMode: fastModeOverride, | |
| 252 | 255 | Subagent: agentName, | |
| 256 | + | Required: a.Required, | |
| 253 | 257 | Timeout: timeout, | |
| 254 | 258 | MaxTurns: maxTurns, | |
| 255 | 259 | WorkspaceMode: workspaceMode, | |
| @@ -259,7 +263,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 259 | 263 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 260 | 264 | } | |
| 261 | 265 | if t.OnSpawned != nil { | |
| 262 | - | t.OnSpawned(agent, task) | |
| 266 | + | t.OnSpawned(agent, task, a.Required) | |
| 267 | + | } | |
| 268 | + | if a.Required { | |
| 269 | + | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task) | |
| 263 | 270 | } | |
| 264 | 271 | ||
| 265 | 272 | var sb strings.Builder | |
| @@ -298,6 +305,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 298 | 305 | "provider": providerID, | |
| 299 | 306 | "reasoning": reasoning, | |
| 300 | 307 | "fast_mode": agent.FastMode, | |
| 308 | + | "required": false, | |
| 301 | 309 | "isolation": string(workspaceMode), | |
| 302 | 310 | "timeout": agent.Timeout.String(), | |
| 303 | 311 | "max_turns": agent.MaxTurns, | |
| @@ -309,6 +317,50 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 309 | 317 | }, nil | |
| 310 | 318 | } | |
| 311 | 319 | ||
| 320 | + | func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string) (core.ToolResult, error) { | |
| 321 | + | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | |
| 322 | + | if err != nil { | |
| 323 | + | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | |
| 324 | + | } | |
| 325 | + | snapshot := agent.Snapshot() | |
| 326 | + | var sb strings.Builder | |
| 327 | + | fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State) | |
| 328 | + | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |
| 329 | + | fmt.Fprintf(&sb, "requirement: %s\n", requirement.State) | |
| 330 | + | if requirement.ErrorCode != "" { | |
| 331 | + | fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode) | |
| 332 | + | fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID)) | |
| 333 | + | } | |
| 334 | + | output := strings.TrimSpace(snapshot.LastAssistant) | |
| 335 | + | if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" { | |
| 336 | + | output = strings.TrimSpace(snapshot.Result.Output) | |
| 337 | + | } | |
| 338 | + | if output != "" { | |
| 339 | + | sb.WriteString("\nresult:\n") | |
| 340 | + | sb.WriteString(output) | |
| 341 | + | sb.WriteByte('\n') | |
| 342 | + | } | |
| 343 | + | if requirement.Unmet() { | |
| 344 | + | 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.") | |
| 345 | + | } else { | |
| 346 | + | sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.") | |
| 347 | + | } | |
| 348 | + | return core.ToolResult{ | |
| 349 | + | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 350 | + | Details: map[string]any{ | |
| 351 | + | "agent_id": agent.ID, | |
| 352 | + | "task": task, | |
| 353 | + | "required": true, | |
| 354 | + | "requirement_state": string(requirement.State), | |
| 355 | + | "state": snapshot.Status, | |
| 356 | + | "process_state": string(snapshot.ProcessState), | |
| 357 | + | "turn_state": string(snapshot.TurnState), | |
| 358 | + | "result_ref": subagents.ResultRef(agent.ID), | |
| 359 | + | }, | |
| 360 | + | IsError: requirement.Unmet(), | |
| 361 | + | }, nil | |
| 362 | + | } | |
| 363 | + | ||
| 312 | 364 | func reasoningOverride(reasoning, thinking string) (string, error) { | |
| 313 | 365 | reasoning = strings.TrimSpace(reasoning) | |
| 314 | 366 | thinking = strings.TrimSpace(thinking) | |
| @@ -12,11 +12,10 @@ 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-agent | 15 | + | // 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 runs | 16 | + | // via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs |
| 17 | - | // in parallel: the tool returns the agent id immediately and the main | 17 | + | // in the background. Required work keeps the tool call open until the delegated |
| 18 | - | // turn continues uninterrupted. The user can monitor / chat with the | 18 | + | // 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. The | 20 | // Available when the host's launch-time tool policy permits delegation. The | ||
| 22 | // primary-agent prompt decides whether delegation is proactive or only on an | 21 | // primary-agent prompt decides whether delegation is proactive or only on an | ||
| @@ -42,11 +41,10 @@ type SubagentSpawnTool struct { | |||||
| 42 | // The child receives only the name and loads the profile itself. | 41 | // The child receives only the name and loads the profile itself. | ||
| 43 | ResolveSubagent func(name string) (*subagents.Profile, error) | 42 | ResolveSubagent func(name string) (*subagents.Profile, error) | ||
| 44 | 43 | ||||
| 45 | - | // OnSpawned, if set, is called after every successful spawn with | 44 | + | // OnSpawned, if set, is called after every successful spawn with the |
| 46 | - | // the new agent + the task it was started with. Used by the | 45 | + | // requirement mode. Hosts track optional work for asynchronous completion; |
| 47 | - | // interactive host to track agents and surface a summary back | 46 | + | // required work is delivered by this blocking tool call instead. |
| 48 | - | // in chat when all sub-agents finish. | 47 | + | OnSpawned func(agent *subagents.Agent, task string, required bool) |
| 49 | - | OnSpawned func(agent *subagents.Agent, task string) | |||
| 50 | } | 48 | } | ||
| 51 | 49 | ||||
| 52 | type subagentSpawnArgs struct { | 50 | type subagentSpawnArgs struct { | ||
| @@ -57,6 +55,7 @@ type subagentSpawnArgs struct { | |||||
| 57 | Reasoning string `json:"reasoning,omitempty"` | 55 | Reasoning string `json:"reasoning,omitempty"` | ||
| 58 | Thinking string `json:"thinking,omitempty"` | 56 | Thinking string `json:"thinking,omitempty"` | ||
| 59 | FastMode *bool `json:"fast_mode,omitempty"` | 57 | FastMode *bool `json:"fast_mode,omitempty"` | ||
| 58 | + | Required bool `json:"required,omitempty"` | |||
| 60 | Isolation string `json:"isolation,omitempty"` | 59 | Isolation string `json:"isolation,omitempty"` | ||
| 61 | Timeout string `json:"timeout,omitempty"` | 60 | Timeout string `json:"timeout,omitempty"` | ||
| 62 | MaxTurns *int `json:"max_turns,omitempty"` | 61 | MaxTurns *int `json:"max_turns,omitempty"` | ||
| @@ -95,6 +94,10 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 95 | "type": "boolean", | 94 | "type": "boolean", | ||
| 96 | "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting." | 95 | "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting." | ||
| 97 | }, | 96 | }, | ||
| 97 | + | "required": { | |||
| 98 | + | "type": "boolean", | |||
| 99 | + | "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." | |||
| 100 | + | }, | |||
| 98 | "isolation": { | 101 | "isolation": { | ||
| 99 | "type": "string", | 102 | "type": "string", | ||
| 100 | "enum": ["shared", "worktree"], | 103 | "enum": ["shared", "worktree"], | ||
| @@ -116,7 +119,7 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 116 | 119 | ||||
| 117 | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } | 120 | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } | ||
| 118 | func (t *SubagentSpawnTool) Description() string { | 121 | 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." | 122 | + | 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 | } | 123 | } | ||
| 121 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | 124 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | ||
| 122 | maxTurns := 3 | 125 | maxTurns := 3 | ||
| @@ -250,6 +253,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 250 | Reasoning: reasoning, | 253 | Reasoning: reasoning, | ||
| 251 | FastMode: fastModeOverride, | 254 | FastMode: fastModeOverride, | ||
| 252 | Subagent: agentName, | 255 | Subagent: agentName, | ||
| 256 | + | Required: a.Required, | |||
| 253 | Timeout: timeout, | 257 | Timeout: timeout, | ||
| 254 | MaxTurns: maxTurns, | 258 | MaxTurns: maxTurns, | ||
| 255 | WorkspaceMode: workspaceMode, | 259 | WorkspaceMode: workspaceMode, | ||
| @@ -259,7 +263,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 259 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | 263 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | ||
| 260 | } | 264 | } | ||
| 261 | if t.OnSpawned != nil { | 265 | if t.OnSpawned != nil { | ||
| 262 | - | t.OnSpawned(agent, task) | 266 | + | t.OnSpawned(agent, task, a.Required) |
| 267 | + | } | |||
| 268 | + | if a.Required { | |||
| 269 | + | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task) | |||
| 263 | } | 270 | } | ||
| 264 | 271 | ||||
| 265 | var sb strings.Builder | 272 | var sb strings.Builder | ||
| @@ -298,6 +305,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 298 | "provider": providerID, | 305 | "provider": providerID, | ||
| 299 | "reasoning": reasoning, | 306 | "reasoning": reasoning, | ||
| 300 | "fast_mode": agent.FastMode, | 307 | "fast_mode": agent.FastMode, | ||
| 308 | + | "required": false, | |||
| 301 | "isolation": string(workspaceMode), | 309 | "isolation": string(workspaceMode), | ||
| 302 | "timeout": agent.Timeout.String(), | 310 | "timeout": agent.Timeout.String(), | ||
| 303 | "max_turns": agent.MaxTurns, | 311 | "max_turns": agent.MaxTurns, | ||
| @@ -309,6 +317,50 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 309 | }, nil | 317 | }, nil | ||
| 310 | } | 318 | } | ||
| 311 | 319 | ||||
| 320 | + | func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string) (core.ToolResult, error) { | |||
| 321 | + | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | |||
| 322 | + | if err != nil { | |||
| 323 | + | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | |||
| 324 | + | } | |||
| 325 | + | snapshot := agent.Snapshot() | |||
| 326 | + | var sb strings.Builder | |||
| 327 | + | fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State) | |||
| 328 | + | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |||
| 329 | + | fmt.Fprintf(&sb, "requirement: %s\n", requirement.State) | |||
| 330 | + | if requirement.ErrorCode != "" { | |||
| 331 | + | fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode) | |||
| 332 | + | fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID)) | |||
| 333 | + | } | |||
| 334 | + | output := strings.TrimSpace(snapshot.LastAssistant) | |||
| 335 | + | if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" { | |||
| 336 | + | output = strings.TrimSpace(snapshot.Result.Output) | |||
| 337 | + | } | |||
| 338 | + | if output != "" { | |||
| 339 | + | sb.WriteString("\nresult:\n") | |||
| 340 | + | sb.WriteString(output) | |||
| 341 | + | sb.WriteByte('\n') | |||
| 342 | + | } | |||
| 343 | + | if requirement.Unmet() { | |||
| 344 | + | 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.") | |||
| 345 | + | } else { | |||
| 346 | + | sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.") | |||
| 347 | + | } | |||
| 348 | + | return core.ToolResult{ | |||
| 349 | + | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |||
| 350 | + | Details: map[string]any{ | |||
| 351 | + | "agent_id": agent.ID, | |||
| 352 | + | "task": task, | |||
| 353 | + | "required": true, | |||
| 354 | + | "requirement_state": string(requirement.State), | |||
| 355 | + | "state": snapshot.Status, | |||
| 356 | + | "process_state": string(snapshot.ProcessState), | |||
| 357 | + | "turn_state": string(snapshot.TurnState), | |||
| 358 | + | "result_ref": subagents.ResultRef(agent.ID), | |||
| 359 | + | }, | |||
| 360 | + | IsError: requirement.Unmet(), | |||
| 361 | + | }, nil | |||
| 362 | + | } | |||
| 363 | + | ||||
| 312 | func reasoningOverride(reasoning, thinking string) (string, error) { | 364 | func reasoningOverride(reasoning, thinking string) (string, error) { | ||
| 313 | reasoning = strings.TrimSpace(reasoning) | 365 | reasoning = strings.TrimSpace(reasoning) | ||
| 314 | thinking = strings.TrimSpace(thinking) | 366 | thinking = strings.TrimSpace(thinking) | ||
M packages/agent/tools/subagent_status.go +16 -0
| @@ -44,9 +44,17 @@ type subagentStatusEntry struct { | |||
| 44 | 44 | UpdatedAt time.Time `json:"updated_at"` | |
| 45 | 45 | FinishedAt *time.Time `json:"finished_at,omitempty"` | |
| 46 | 46 | TaskSummary string `json:"task_summary,omitempty"` | |
| 47 | + | Requirement *statusRequirement `json:"requirement,omitempty"` | |
| 47 | 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 | 58 | type subagentStatusResult struct { | |
| 51 | 59 | State string `json:"state"` | |
| 52 | 60 | Available bool `json:"available"` | |
| @@ -148,6 +156,14 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry | |||
| 148 | 156 | finished := snapshot.Finished | |
| 149 | 157 | 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 | 167 | if snapshot.Result != nil { | |
| 152 | 168 | entry.Result = &subagentStatusResult{ | |
| 153 | 169 | 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"` | ||
| @@ -148,6 +156,14 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry | |||||
| 148 | finished := snapshot.Finished | 156 | finished := snapshot.Finished | ||
| 149 | entry.FinishedAt = &finished | 157 | 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), | ||