fix(subagents): preserve lifecycle facts
- commit
- 7070f923ff297813d82169c9b8872ce759ba4e89
- author
- brice · · 2026-08-13 03:57 UTC
- parent
- f87ad92
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +150 -10
M README.md +3 -3
| @@ -272,7 +272,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 272 | 272 | ||
| 273 | 273 | Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | |
| 274 | 274 | ||
| 275 | - | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | |
| 275 | + | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | |
| 276 | 276 | ||
| 277 | 277 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | |
| 278 | 278 | ||
| @@ -439,7 +439,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 439 | 439 | - **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. | |
| 440 | 440 | - **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. | |
| 441 | 441 | - **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`. | |
| 442 | - | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 442 | + | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 443 | 443 | - **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. | |
| 444 | 444 | - **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. | |
| 445 | 445 | - **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`. | |
| @@ -502,7 +502,7 @@ When a turn fails because of a recoverable provider error — expired token (`40 | |||
| 502 | 502 | ||
| 503 | 503 | The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss. | |
| 504 | 504 | ||
| 505 | - | Before the actual provider request fires, the shared HTTP streaming clients used by OpenAI / Anthropic / Kimi / DeepSeek / Google / OpenAI-Codex also do up to four silent retries with short backoff (250ms, then 750ms) on transient HTTP failures and connection-reset / EOF-before-headers errors. Once a stream is open, five reconnect attempts and a five-minute idle timeout independently guard stalled or interrupted streams. Most edge-proxy blips disappear without you ever seeing the rescue picker. | |
| 505 | + | Before the actual provider request fires, the shared HTTP streaming clients used by OpenAI / Anthropic / Kimi / DeepSeek / Google / OpenAI-Codex also do up to four silent retries with short backoff (250ms, then 750ms, 750ms, and 750ms) on transient HTTP failures and connection-reset / EOF-before-headers errors. Once a stream is open, five reconnect attempts and a five-minute idle timeout independently guard stalled or interrupted streams. Most edge-proxy blips disappear without you ever seeing the rescue picker. | |
| 506 | 506 | ||
| 507 | 507 | A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. Use `/model` if you want overrides to stick. | |
| 508 | 508 | ||
| @@ -272,7 +272,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||||
| 272 | 272 | ||||
| 273 | Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | 273 | Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | ||
| 274 | 274 | ||||
| 275 | - | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | 275 | + | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. |
| 276 | 276 | ||||
| 277 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | 277 | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | ||
| 278 | 278 | ||||
| @@ -439,7 +439,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 439 | - **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. | 439 | - **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. | ||
| 440 | - **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. | 440 | - **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. | ||
| 441 | - **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`. | 441 | - **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`. | ||
| 442 | - | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | 442 | + | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. |
| 443 | - **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. | 443 | - **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. | ||
| 444 | - **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. | 444 | - **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. | ||
| 445 | - **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`. | 445 | - **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`. | ||
| @@ -502,7 +502,7 @@ When a turn fails because of a recoverable provider error — expired token (`40 | |||||
| 502 | 502 | ||||
| 503 | The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss. | 503 | The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss. | ||
| 504 | 504 | ||||
| 505 | - | Before the actual provider request fires, the shared HTTP streaming clients used by OpenAI / Anthropic / Kimi / DeepSeek / Google / OpenAI-Codex also do up to four silent retries with short backoff (250ms, then 750ms) on transient HTTP failures and connection-reset / EOF-before-headers errors. Once a stream is open, five reconnect attempts and a five-minute idle timeout independently guard stalled or interrupted streams. Most edge-proxy blips disappear without you ever seeing the rescue picker. | 505 | + | Before the actual provider request fires, the shared HTTP streaming clients used by OpenAI / Anthropic / Kimi / DeepSeek / Google / OpenAI-Codex also do up to four silent retries with short backoff (250ms, then 750ms, 750ms, and 750ms) on transient HTTP failures and connection-reset / EOF-before-headers errors. Once a stream is open, five reconnect attempts and a five-minute idle timeout independently guard stalled or interrupted streams. Most edge-proxy blips disappear without you ever seeing the rescue picker. |
| 506 | 506 | ||||
| 507 | A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. Use `/model` if you want overrides to stick. | 507 | A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. Use `/model` if you want overrides to stick. | ||
| 508 | 508 | ||||
M docs/subagents.md +2 -2
| @@ -62,7 +62,7 @@ The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited | |||
| 62 | 62 | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | |
| 63 | 63 | ``` | |
| 64 | 64 | ||
| 65 | - | The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it. | |
| 65 | + | The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it. | |
| 66 | 66 | ||
| 67 | 67 | Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log. | |
| 68 | 68 | ||
| @@ -174,7 +174,7 @@ zut debug trace <bundle> | |||
| 174 | 174 | ||
| 175 | 175 | Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress. | |
| 176 | 176 | ||
| 177 | - | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |
| 177 | + | 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 observable run while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction continue within the current run; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |
| 178 | 178 | ||
| 179 | 179 | ## Resource policy | |
| 180 | 180 | ||
| @@ -62,7 +62,7 @@ The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited | |||||
| 62 | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | 62 | zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result" | ||
| 63 | ``` | 63 | ``` | ||
| 64 | 64 | ||||
| 65 | - | The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it. | 65 | + | The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it. |
| 66 | 66 | ||||
| 67 | Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log. | 67 | Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log. | ||
| 68 | 68 | ||||
| @@ -174,7 +174,7 @@ zut debug trace <bundle> | |||||
| 174 | 174 | ||||
| 175 | Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress. | 175 | Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress. | ||
| 176 | 176 | ||||
| 177 | - | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | 177 | + | 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 observable run while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction continue within the current run; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. |
| 178 | 178 | ||||
| 179 | ## Resource policy | 179 | ## Resource policy | ||
| 180 | 180 | ||||
M packages/agent/modes/subagents_dialog.go +2 -0
| @@ -1642,12 +1642,14 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |||
| 1642 | 1642 | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | |
| 1643 | 1643 | fact := view.Summary() | |
| 1644 | 1644 | if primary, ok := view.Primary(); ok { | |
| 1645 | + | fact = truncateLineSafe(fact, 24) | |
| 1645 | 1646 | if observation := view.ObservationFor(primary); observation != nil { | |
| 1646 | 1647 | fact += " " + formatAge(observation.At) | |
| 1647 | 1648 | } else { | |
| 1648 | 1649 | fact += " " + formatAge(primary.StartedAt) | |
| 1649 | 1650 | } | |
| 1650 | 1651 | } else if view.LastEvent.Type != "" { | |
| 1652 | + | fact = truncateLineSafe(fact, 24) | |
| 1651 | 1653 | fact += " " + formatAge(view.LastEvent.Timestamp) | |
| 1652 | 1654 | } | |
| 1653 | 1655 | age := formatAge(r.Started) | |
| @@ -1642,12 +1642,14 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |||||
| 1642 | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | 1642 | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | ||
| 1643 | fact := view.Summary() | 1643 | fact := view.Summary() | ||
| 1644 | if primary, ok := view.Primary(); ok { | 1644 | if primary, ok := view.Primary(); ok { | ||
| 1645 | + | fact = truncateLineSafe(fact, 24) | |||
| 1645 | if observation := view.ObservationFor(primary); observation != nil { | 1646 | if observation := view.ObservationFor(primary); observation != nil { | ||
| 1646 | fact += " " + formatAge(observation.At) | 1647 | fact += " " + formatAge(observation.At) | ||
| 1647 | } else { | 1648 | } else { | ||
| 1648 | fact += " " + formatAge(primary.StartedAt) | 1649 | fact += " " + formatAge(primary.StartedAt) | ||
| 1649 | } | 1650 | } | ||
| 1650 | } else if view.LastEvent.Type != "" { | 1651 | } else if view.LastEvent.Type != "" { | ||
| 1652 | + | fact = truncateLineSafe(fact, 24) | |||
| 1651 | fact += " " + formatAge(view.LastEvent.Timestamp) | 1653 | fact += " " + formatAge(view.LastEvent.Timestamp) | ||
| 1652 | } | 1654 | } | ||
| 1653 | age := formatAge(r.Started) | 1655 | age := formatAge(r.Started) | ||
M packages/agent/modes/subagents_dialog_test.go +11 -0
| @@ -46,6 +46,17 @@ func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestFormatSupervisorRowPreservesAgeAfterLongSummary(t *testing.T) { | |
| 50 | + | now := time.Now() | |
| 51 | + | got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{ | |
| 52 | + | Terminal: strings.Repeat("completed ", 8), | |
| 53 | + | LastEvent: subagents.TraceEvent{Type: "agent.completed", Timestamp: now.Add(-2 * time.Minute)}, | |
| 54 | + | }, 120) | |
| 55 | + | if !strings.Contains(got, "2m") { | |
| 56 | + | t.Fatalf("row lost fact age: %q", got) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 49 | 60 | func TestSubagentsDialogTranscriptShowsProviderRequestDiagnostic(t *testing.T) { | |
| 50 | 61 | at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |
| 51 | 62 | d := newSubagentsDialog() | |
| @@ -46,6 +46,17 @@ func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | func TestFormatSupervisorRowPreservesAgeAfterLongSummary(t *testing.T) { | |||
| 50 | + | now := time.Now() | |||
| 51 | + | got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{ | |||
| 52 | + | Terminal: strings.Repeat("completed ", 8), | |||
| 53 | + | LastEvent: subagents.TraceEvent{Type: "agent.completed", Timestamp: now.Add(-2 * time.Minute)}, | |||
| 54 | + | }, 120) | |||
| 55 | + | if !strings.Contains(got, "2m") { | |||
| 56 | + | t.Fatalf("row lost fact age: %q", got) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 49 | func TestSubagentsDialogTranscriptShowsProviderRequestDiagnostic(t *testing.T) { | 60 | func TestSubagentsDialogTranscriptShowsProviderRequestDiagnostic(t *testing.T) { | ||
| 50 | at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | 61 | at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | ||
| 51 | d := newSubagentsDialog() | 62 | d := newSubagentsDialog() | ||
M packages/agent/subagents/agent.go +7 -1
| @@ -581,7 +581,13 @@ func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, | |||
| 581 | 581 | case <-changed: | |
| 582 | 582 | continue | |
| 583 | 583 | case <-done: | |
| 584 | - | if result := a.Result(); result != nil && (turnID == "" || result.TurnID == turnID) { | |
| 584 | + | a.lifecycleMu.Lock() | |
| 585 | + | result := cloneTurnResult(a.result) | |
| 586 | + | if turnID != "" && a.results != nil { | |
| 587 | + | result = cloneTurnResult(a.results[turnID]) | |
| 588 | + | } | |
| 589 | + | a.lifecycleMu.Unlock() | |
| 590 | + | if result != nil && (turnID == "" || result.TurnID == turnID) { | |
| 585 | 591 | return result, nil | |
| 586 | 592 | } | |
| 587 | 593 | return nil, fmt.Errorf("subagents: agent %s exited without turn result %q", a.ID, turnID) | |
| @@ -581,7 +581,13 @@ func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, | |||||
| 581 | case <-changed: | 581 | case <-changed: | ||
| 582 | continue | 582 | continue | ||
| 583 | case <-done: | 583 | case <-done: | ||
| 584 | - | if result := a.Result(); result != nil && (turnID == "" || result.TurnID == turnID) { | 584 | + | a.lifecycleMu.Lock() |
| 585 | + | result := cloneTurnResult(a.result) | |||
| 586 | + | if turnID != "" && a.results != nil { | |||
| 587 | + | result = cloneTurnResult(a.results[turnID]) | |||
| 588 | + | } | |||
| 589 | + | a.lifecycleMu.Unlock() | |||
| 590 | + | if result != nil && (turnID == "" || result.TurnID == turnID) { | |||
| 585 | return result, nil | 591 | return result, nil | ||
| 586 | } | 592 | } | ||
| 587 | return nil, fmt.Errorf("subagents: agent %s exited without turn result %q", a.ID, turnID) | 593 | return nil, fmt.Errorf("subagents: agent %s exited without turn result %q", a.ID, turnID) | ||
M packages/agent/subagents/agent_test.go +35 -0
| @@ -146,6 +146,41 @@ func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) { | |||
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | func TestWaitTurnResultReturnsRequestedResultAfterAgentExit(t *testing.T) { | |
| 150 | + | a := &Agent{ID: "worker-1", done: make(chan struct{}), resultChanged: make(chan struct{})} | |
| 151 | + | resultReady := make(chan *TurnResult, 1) | |
| 152 | + | errReady := make(chan error, 1) | |
| 153 | + | go func() { | |
| 154 | + | result, err := a.WaitTurnResult(context.Background(), "turn-1") | |
| 155 | + | resultReady <- result | |
| 156 | + | errReady <- err | |
| 157 | + | }() | |
| 158 | + | deadline := time.Now().Add(time.Second) | |
| 159 | + | for { | |
| 160 | + | a.lifecycleMu.Lock() | |
| 161 | + | registered := a.resultWaiters["turn-1"] > 0 | |
| 162 | + | a.lifecycleMu.Unlock() | |
| 163 | + | if registered { | |
| 164 | + | break | |
| 165 | + | } | |
| 166 | + | if time.Now().After(deadline) { | |
| 167 | + | t.Fatal("turn-1 waiter did not register") | |
| 168 | + | } | |
| 169 | + | runtime.Gosched() | |
| 170 | + | } | |
| 171 | + | a.lifecycleMu.Lock() | |
| 172 | + | a.results = map[string]*TurnResult{"turn-1": {Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded}} | |
| 173 | + | a.result = &TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded} | |
| 174 | + | a.lifecycleMu.Unlock() | |
| 175 | + | a.closeDone() | |
| 176 | + | if err := <-errReady; err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | if result := <-resultReady; result == nil || result.TurnID != "turn-1" { | |
| 180 | + | t.Fatalf("wait result = %#v, want retained turn-1", result) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 149 | 184 | func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) { | |
| 150 | 185 | a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"} | |
| 151 | 186 | if got := a.WaitTargetTurnID(); got != "turn-2" { | |
| @@ -146,6 +146,41 @@ func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) { | |||||
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | + | func TestWaitTurnResultReturnsRequestedResultAfterAgentExit(t *testing.T) { | |||
| 150 | + | a := &Agent{ID: "worker-1", done: make(chan struct{}), resultChanged: make(chan struct{})} | |||
| 151 | + | resultReady := make(chan *TurnResult, 1) | |||
| 152 | + | errReady := make(chan error, 1) | |||
| 153 | + | go func() { | |||
| 154 | + | result, err := a.WaitTurnResult(context.Background(), "turn-1") | |||
| 155 | + | resultReady <- result | |||
| 156 | + | errReady <- err | |||
| 157 | + | }() | |||
| 158 | + | deadline := time.Now().Add(time.Second) | |||
| 159 | + | for { | |||
| 160 | + | a.lifecycleMu.Lock() | |||
| 161 | + | registered := a.resultWaiters["turn-1"] > 0 | |||
| 162 | + | a.lifecycleMu.Unlock() | |||
| 163 | + | if registered { | |||
| 164 | + | break | |||
| 165 | + | } | |||
| 166 | + | if time.Now().After(deadline) { | |||
| 167 | + | t.Fatal("turn-1 waiter did not register") | |||
| 168 | + | } | |||
| 169 | + | runtime.Gosched() | |||
| 170 | + | } | |||
| 171 | + | a.lifecycleMu.Lock() | |||
| 172 | + | a.results = map[string]*TurnResult{"turn-1": {Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded}} | |||
| 173 | + | a.result = &TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded} | |||
| 174 | + | a.lifecycleMu.Unlock() | |||
| 175 | + | a.closeDone() | |||
| 176 | + | if err := <-errReady; err != nil { | |||
| 177 | + | t.Fatal(err) | |||
| 178 | + | } | |||
| 179 | + | if result := <-resultReady; result == nil || result.TurnID != "turn-1" { | |||
| 180 | + | t.Fatalf("wait result = %#v, want retained turn-1", result) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 149 | func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) { | 184 | func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) { | ||
| 150 | a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"} | 185 | a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"} | ||
| 151 | if got := a.WaitTargetTurnID(); got != "turn-2" { | 186 | if got := a.WaitTargetTurnID(); got != "turn-2" { | ||
M packages/agent/subagents/projection.go +1 -1
| @@ -423,7 +423,7 @@ func traceResultFact(event TraceEvent, previous *ResultFact) *ResultFact { | |||
| 423 | 423 | case "result.delivered": | |
| 424 | 424 | result.Available, result.Delivered, result.Failed = true, true, false | |
| 425 | 425 | case "result.delivery.failed": | |
| 426 | - | result.Available, result.Failed = true, true | |
| 426 | + | result.Available, result.Delivered, result.Failed = true, false, true | |
| 427 | 427 | } | |
| 428 | 428 | return &result | |
| 429 | 429 | } | |
| @@ -423,7 +423,7 @@ func traceResultFact(event TraceEvent, previous *ResultFact) *ResultFact { | |||||
| 423 | case "result.delivered": | 423 | case "result.delivered": | ||
| 424 | result.Available, result.Delivered, result.Failed = true, true, false | 424 | result.Available, result.Delivered, result.Failed = true, true, false | ||
| 425 | case "result.delivery.failed": | 425 | case "result.delivery.failed": | ||
| 426 | - | result.Available, result.Failed = true, true | 426 | + | result.Available, result.Delivered, result.Failed = true, false, true |
| 427 | } | 427 | } | ||
| 428 | return &result | 428 | return &result | ||
| 429 | } | 429 | } | ||
M packages/agent/subagents/projection_test.go +10 -0
| @@ -263,6 +263,16 @@ func TestProjectTraceNewAvailableResultResetsDelivery(t *testing.T) { | |||
| 263 | 263 | } | |
| 264 | 264 | } | |
| 265 | 265 | ||
| 266 | + | func TestProjectTraceDeliveryFailureClearsDelivered(t *testing.T) { | |
| 267 | + | view := ProjectTrace([]TraceEvent{ | |
| 268 | + | {Type: "result.delivered", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | |
| 269 | + | {Type: "result.delivery.failed", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | |
| 270 | + | })["agent-1"] | |
| 271 | + | if view.Result == nil || !view.Result.Available || view.Result.Delivered || !view.Result.Failed { | |
| 272 | + | t.Fatalf("result = %#v, want available undelivered failure", view.Result) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 266 | 276 | func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) { | |
| 267 | 277 | view := ProjectTrace([]TraceEvent{ | |
| 268 | 278 | {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| @@ -263,6 +263,16 @@ func TestProjectTraceNewAvailableResultResetsDelivery(t *testing.T) { | |||||
| 263 | } | 263 | } | ||
| 264 | } | 264 | } | ||
| 265 | 265 | ||||
| 266 | + | func TestProjectTraceDeliveryFailureClearsDelivered(t *testing.T) { | |||
| 267 | + | view := ProjectTrace([]TraceEvent{ | |||
| 268 | + | {Type: "result.delivered", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | |||
| 269 | + | {Type: "result.delivery.failed", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | |||
| 270 | + | })["agent-1"] | |||
| 271 | + | if view.Result == nil || !view.Result.Available || view.Result.Delivered || !view.Result.Failed { | |||
| 272 | + | t.Fatalf("result = %#v, want available undelivered failure", view.Result) | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 266 | func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) { | 276 | func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) { | ||
| 267 | view := ProjectTrace([]TraceEvent{ | 277 | view := ProjectTrace([]TraceEvent{ | ||
| 268 | {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | 278 | {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | ||
M packages/agent/subagents/requirement.go +10 -2
| @@ -103,11 +103,19 @@ func (a *Agent) restoreRequirement(previous RequirementSnapshot) { | |||
| 103 | 103 | a.lifecycleMu.Unlock() | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | - | func (a *Agent) markRequirementNotified() (bool, error) { | |
| 106 | + | func (a *Agent) markRequirementNotified(expectedTurnID string) (bool, error) { | |
| 107 | 107 | if a == nil { | |
| 108 | 108 | return false, nil | |
| 109 | 109 | } | |
| 110 | 110 | a.lifecycleMu.Lock() | |
| 111 | + | currentTurnID := strings.TrimSpace(a.requirement.ResultTurnID) | |
| 112 | + | if currentTurnID == "" && a.requirement.TargetTurn > 0 { | |
| 113 | + | currentTurnID = fmt.Sprintf("turn-%d", a.requirement.TargetTurn) | |
| 114 | + | } | |
| 115 | + | if expectedTurnID == "" || currentTurnID != expectedTurnID || a.result == nil || strings.TrimSpace(a.result.TurnID) != expectedTurnID || strings.TrimSpace(a.resultRef) == "" { | |
| 116 | + | a.lifecycleMu.Unlock() | |
| 117 | + | return false, fmt.Errorf("subagents: durable required result no longer matches %s", expectedTurnID) | |
| 118 | + | } | |
| 111 | 119 | if !a.requirement.Required || a.requirement.Notified { | |
| 112 | 120 | a.lifecycleMu.Unlock() | |
| 113 | 121 | return false, nil | |
| @@ -328,7 +336,7 @@ func (f *Supervisor) MarkRequirementNotified(id string) error { | |||
| 328 | 336 | if result == nil || strings.TrimSpace(result.TurnID) != expectedTurnID { | |
| 329 | 337 | return fmt.Errorf("subagents: durable required result does not match %s", expectedTurnID) | |
| 330 | 338 | } | |
| 331 | - | marked, err := a.markRequirementNotified() | |
| 339 | + | marked, err := a.markRequirementNotified(expectedTurnID) | |
| 332 | 340 | if err != nil { | |
| 333 | 341 | a.recordTrace(TraceEvent{Type: "result.delivery.failed", TurnID: expectedTurnID, Data: map[string]any{"ref": ResultRef(a.ID)}}) | |
| 334 | 342 | return fmt.Errorf("subagents: persist required result delivery: %w", err) | |
| @@ -103,11 +103,19 @@ func (a *Agent) restoreRequirement(previous RequirementSnapshot) { | |||||
| 103 | a.lifecycleMu.Unlock() | 103 | a.lifecycleMu.Unlock() | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | - | func (a *Agent) markRequirementNotified() (bool, error) { | 106 | + | func (a *Agent) markRequirementNotified(expectedTurnID string) (bool, error) { |
| 107 | if a == nil { | 107 | if a == nil { | ||
| 108 | return false, nil | 108 | return false, nil | ||
| 109 | } | 109 | } | ||
| 110 | a.lifecycleMu.Lock() | 110 | a.lifecycleMu.Lock() | ||
| 111 | + | currentTurnID := strings.TrimSpace(a.requirement.ResultTurnID) | |||
| 112 | + | if currentTurnID == "" && a.requirement.TargetTurn > 0 { | |||
| 113 | + | currentTurnID = fmt.Sprintf("turn-%d", a.requirement.TargetTurn) | |||
| 114 | + | } | |||
| 115 | + | if expectedTurnID == "" || currentTurnID != expectedTurnID || a.result == nil || strings.TrimSpace(a.result.TurnID) != expectedTurnID || strings.TrimSpace(a.resultRef) == "" { | |||
| 116 | + | a.lifecycleMu.Unlock() | |||
| 117 | + | return false, fmt.Errorf("subagents: durable required result no longer matches %s", expectedTurnID) | |||
| 118 | + | } | |||
| 111 | if !a.requirement.Required || a.requirement.Notified { | 119 | if !a.requirement.Required || a.requirement.Notified { | ||
| 112 | a.lifecycleMu.Unlock() | 120 | a.lifecycleMu.Unlock() | ||
| 113 | return false, nil | 121 | return false, nil | ||
| @@ -328,7 +336,7 @@ func (f *Supervisor) MarkRequirementNotified(id string) error { | |||||
| 328 | if result == nil || strings.TrimSpace(result.TurnID) != expectedTurnID { | 336 | if result == nil || strings.TrimSpace(result.TurnID) != expectedTurnID { | ||
| 329 | return fmt.Errorf("subagents: durable required result does not match %s", expectedTurnID) | 337 | return fmt.Errorf("subagents: durable required result does not match %s", expectedTurnID) | ||
| 330 | } | 338 | } | ||
| 331 | - | marked, err := a.markRequirementNotified() | 339 | + | marked, err := a.markRequirementNotified(expectedTurnID) |
| 332 | if err != nil { | 340 | if err != nil { | ||
| 333 | a.recordTrace(TraceEvent{Type: "result.delivery.failed", TurnID: expectedTurnID, Data: map[string]any{"ref": ResultRef(a.ID)}}) | 341 | a.recordTrace(TraceEvent{Type: "result.delivery.failed", TurnID: expectedTurnID, Data: map[string]any{"ref": ResultRef(a.ID)}}) | ||
| 334 | return fmt.Errorf("subagents: persist required result delivery: %w", err) | 342 | return fmt.Errorf("subagents: persist required result delivery: %w", err) | ||
M packages/agent/subagents/requirement_test.go +21 -0
| @@ -244,6 +244,27 @@ func TestRequirementNotificationRecordsDeliveryExactlyOnce(t *testing.T) { | |||
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | func TestRequirementNotificationRejectsResultSupersededByNewRequiredTurn(t *testing.T) { | |
| 248 | + | trace := NewMemoryTraceWriter() | |
| 249 | + | t.Cleanup(func() { _ = trace.Close() }) | |
| 250 | + | a := &Agent{ | |
| 251 | + | ID: "agent-1", | |
| 252 | + | trace: trace, | |
| 253 | + | result: &TurnResult{AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded}, | |
| 254 | + | resultRef: ResultRef("agent-1"), | |
| 255 | + | requirement: RequirementSnapshot{Required: true, State: RequirementSatisfied, TargetTurn: 1, ResultTurnID: "turn-1"}, | |
| 256 | + | } | |
| 257 | + | // A new required run begins after the caller observed turn-1 but before it | |
| 258 | + | // can publish delivery. The guarded transition must reject that stale turn. | |
| 259 | + | a.prepareRequired(2) | |
| 260 | + | if _, err := a.markRequirementNotified("turn-1"); err == nil { | |
| 261 | + | t.Fatal("stale result delivery was accepted") | |
| 262 | + | } | |
| 263 | + | if got := a.requirementSnapshot(); got.Notified || got.TargetTurn != 2 { | |
| 264 | + | t.Fatalf("requirement = %+v, want unnotified target turn 2", got) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 247 | 268 | func TestRequirementNotificationPersistenceFailureIsReported(t *testing.T) { | |
| 248 | 269 | trace := NewMemoryTraceWriter() | |
| 249 | 270 | t.Cleanup(func() { _ = trace.Close() }) | |
| @@ -244,6 +244,27 @@ func TestRequirementNotificationRecordsDeliveryExactlyOnce(t *testing.T) { | |||||
| 244 | } | 244 | } | ||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | + | func TestRequirementNotificationRejectsResultSupersededByNewRequiredTurn(t *testing.T) { | |||
| 248 | + | trace := NewMemoryTraceWriter() | |||
| 249 | + | t.Cleanup(func() { _ = trace.Close() }) | |||
| 250 | + | a := &Agent{ | |||
| 251 | + | ID: "agent-1", | |||
| 252 | + | trace: trace, | |||
| 253 | + | result: &TurnResult{AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded}, | |||
| 254 | + | resultRef: ResultRef("agent-1"), | |||
| 255 | + | requirement: RequirementSnapshot{Required: true, State: RequirementSatisfied, TargetTurn: 1, ResultTurnID: "turn-1"}, | |||
| 256 | + | } | |||
| 257 | + | // A new required run begins after the caller observed turn-1 but before it | |||
| 258 | + | // can publish delivery. The guarded transition must reject that stale turn. | |||
| 259 | + | a.prepareRequired(2) | |||
| 260 | + | if _, err := a.markRequirementNotified("turn-1"); err == nil { | |||
| 261 | + | t.Fatal("stale result delivery was accepted") | |||
| 262 | + | } | |||
| 263 | + | if got := a.requirementSnapshot(); got.Notified || got.TargetTurn != 2 { | |||
| 264 | + | t.Fatalf("requirement = %+v, want unnotified target turn 2", got) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | ||||
| 247 | func TestRequirementNotificationPersistenceFailureIsReported(t *testing.T) { | 268 | func TestRequirementNotificationPersistenceFailureIsReported(t *testing.T) { | ||
| 248 | trace := NewMemoryTraceWriter() | 269 | trace := NewMemoryTraceWriter() | ||
| 249 | t.Cleanup(func() { _ = trace.Close() }) | 270 | t.Cleanup(func() { _ = trace.Close() }) | ||
M packages/agent/subagents/runner.go +17 -1
| @@ -818,7 +818,9 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||
| 818 | 818 | break | |
| 819 | 819 | } | |
| 820 | 820 | message, _ := ev.Data["error"].(string) | |
| 821 | - | if message != "" { | |
| 821 | + | if result := a.Result(); result != nil && result.Error != nil && result.Error.Code == "result_persistence_failed" { | |
| 822 | + | a.setTurnState(TurnFailed, ev.TurnID) | |
| 823 | + | } else if message != "" { | |
| 822 | 824 | a.setTurnState(TurnFailed, ev.TurnID) | |
| 823 | 825 | } else { | |
| 824 | 826 | a.setTurnState(TurnSucceeded, ev.TurnID) | |
| @@ -988,6 +990,11 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 988 | 990 | } | |
| 989 | 991 | ||
| 990 | 992 | func traceErrorCode(data map[string]any) string { | |
| 993 | + | if structured, ok := data["error"].(map[string]any); ok { | |
| 994 | + | if code, _ := structured["code"].(string); isTraceErrorCode(code) { | |
| 995 | + | return code | |
| 996 | + | } | |
| 997 | + | } | |
| 991 | 998 | value, _ := data["error"].(string) | |
| 992 | 999 | value = strings.ToLower(value) | |
| 993 | 1000 | switch { | |
| @@ -1008,6 +1015,15 @@ func traceErrorCode(data map[string]any) string { | |||
| 1008 | 1015 | } | |
| 1009 | 1016 | } | |
| 1010 | 1017 | ||
| 1018 | + | func isTraceErrorCode(code string) bool { | |
| 1019 | + | switch code { | |
| 1020 | + | case "deadline_exceeded", "stream_idle_timeout", "cancelled", "quota", "rate_limit", "context_limit", "provider_error": | |
| 1021 | + | return true | |
| 1022 | + | default: | |
| 1023 | + | return false | |
| 1024 | + | } | |
| 1025 | + | } | |
| 1026 | + | ||
| 1011 | 1027 | // applyEventToSink translates an Event into Sink updates. Only a | |
| 1012 | 1028 | // few event types are interpreted; the rest still land in the | |
| 1013 | 1029 | // durable log via the caller. | |
| @@ -818,7 +818,9 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||||
| 818 | break | 818 | break | ||
| 819 | } | 819 | } | ||
| 820 | message, _ := ev.Data["error"].(string) | 820 | message, _ := ev.Data["error"].(string) | ||
| 821 | - | if message != "" { | 821 | + | if result := a.Result(); result != nil && result.Error != nil && result.Error.Code == "result_persistence_failed" { |
| 822 | + | a.setTurnState(TurnFailed, ev.TurnID) | |||
| 823 | + | } else if message != "" { | |||
| 822 | a.setTurnState(TurnFailed, ev.TurnID) | 824 | a.setTurnState(TurnFailed, ev.TurnID) | ||
| 823 | } else { | 825 | } else { | ||
| 824 | a.setTurnState(TurnSucceeded, ev.TurnID) | 826 | a.setTurnState(TurnSucceeded, ev.TurnID) | ||
| @@ -988,6 +990,11 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||||
| 988 | } | 990 | } | ||
| 989 | 991 | ||||
| 990 | func traceErrorCode(data map[string]any) string { | 992 | func traceErrorCode(data map[string]any) string { | ||
| 993 | + | if structured, ok := data["error"].(map[string]any); ok { | |||
| 994 | + | if code, _ := structured["code"].(string); isTraceErrorCode(code) { | |||
| 995 | + | return code | |||
| 996 | + | } | |||
| 997 | + | } | |||
| 991 | value, _ := data["error"].(string) | 998 | value, _ := data["error"].(string) | ||
| 992 | value = strings.ToLower(value) | 999 | value = strings.ToLower(value) | ||
| 993 | switch { | 1000 | switch { | ||
| @@ -1008,6 +1015,15 @@ func traceErrorCode(data map[string]any) string { | |||||
| 1008 | } | 1015 | } | ||
| 1009 | } | 1016 | } | ||
| 1010 | 1017 | ||||
| 1018 | + | func isTraceErrorCode(code string) bool { | |||
| 1019 | + | switch code { | |||
| 1020 | + | case "deadline_exceeded", "stream_idle_timeout", "cancelled", "quota", "rate_limit", "context_limit", "provider_error": | |||
| 1021 | + | return true | |||
| 1022 | + | default: | |||
| 1023 | + | return false | |||
| 1024 | + | } | |||
| 1025 | + | } | |||
| 1026 | + | ||||
| 1011 | // applyEventToSink translates an Event into Sink updates. Only a | 1027 | // applyEventToSink translates an Event into Sink updates. Only a | ||
| 1012 | // few event types are interpreted; the rest still land in the | 1028 | // few event types are interpreted; the rest still land in the | ||
| 1013 | // durable log via the caller. | 1029 | // durable log via the caller. | ||
M packages/agent/subagents/runner_test.go +31 -0
| @@ -785,6 +785,23 @@ func TestUpdateAgentFromEventSurfacesTurnResultPersistenceFailure(t *testing.T) | |||
| 785 | 785 | } | |
| 786 | 786 | } | |
| 787 | 787 | ||
| 788 | + | func TestUpdateAgentFromEventRetainsPersistenceFailureAfterTurnEnd(t *testing.T) { | |
| 789 | + | blockedStateDir := filepath.Join(t.TempDir(), "not-a-directory") | |
| 790 | + | if err := os.WriteFile(blockedStateDir, []byte("blocked"), 0o600); err != nil { | |
| 791 | + | t.Fatal(err) | |
| 792 | + | } | |
| 793 | + | a := &Agent{ID: "agent-1", stateDir: blockedStateDir} | |
| 794 | + | if err := updateAgentFromEvent(a, Event{Type: EventTurnResult, TurnID: "turn-1", Data: map[string]any{"status": "succeeded"}}); err == nil { | |
| 795 | + | t.Fatal("turn-result persistence failure was ignored") | |
| 796 | + | } | |
| 797 | + | if err := updateAgentFromEvent(a, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"step": 1}}); err != nil { | |
| 798 | + | t.Fatal(err) | |
| 799 | + | } | |
| 800 | + | if got := a.TurnState(); got != TurnFailed { | |
| 801 | + | t.Fatalf("turn state = %q, want %q", got, TurnFailed) | |
| 802 | + | } | |
| 803 | + | } | |
| 804 | + | ||
| 788 | 805 | func TestUpdateAgentFromEventRecordsDurableResultAvailability(t *testing.T) { | |
| 789 | 806 | trace := NewMemoryTraceWriter() | |
| 790 | 807 | t.Cleanup(func() { _ = trace.Close() }) | |
| @@ -936,6 +953,20 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) { | |||
| 936 | 953 | } | |
| 937 | 954 | } | |
| 938 | 955 | ||
| 956 | + | func TestRecordWorkerTracePreservesStructuredErrorCode(t *testing.T) { | |
| 957 | + | trace := NewMemoryTraceWriter() | |
| 958 | + | t.Cleanup(func() { _ = trace.Close() }) | |
| 959 | + | agent := &Agent{ID: "agent-1", trace: trace} | |
| 960 | + | recordWorkerTrace(agent, Event{Type: EventTurnResult, TurnID: "turn-1", Data: map[string]any{"status": "failed", "error": map[string]any{"code": "stream_idle_timeout"}}}) | |
| 961 | + | if err := trace.Flush(); err != nil { | |
| 962 | + | t.Fatal(err) | |
| 963 | + | } | |
| 964 | + | events := trace.Events() | |
| 965 | + | if len(events) != 1 || events[0].Data["error_code"] != "stream_idle_timeout" { | |
| 966 | + | t.Fatalf("trace events = %#v", events) | |
| 967 | + | } | |
| 968 | + | } | |
| 969 | + | ||
| 939 | 970 | func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) { | |
| 940 | 971 | trace := NewMemoryTraceWriter() | |
| 941 | 972 | t.Cleanup(func() { _ = trace.Close() }) | |
| @@ -785,6 +785,23 @@ func TestUpdateAgentFromEventSurfacesTurnResultPersistenceFailure(t *testing.T) | |||||
| 785 | } | 785 | } | ||
| 786 | } | 786 | } | ||
| 787 | 787 | ||||
| 788 | + | func TestUpdateAgentFromEventRetainsPersistenceFailureAfterTurnEnd(t *testing.T) { | |||
| 789 | + | blockedStateDir := filepath.Join(t.TempDir(), "not-a-directory") | |||
| 790 | + | if err := os.WriteFile(blockedStateDir, []byte("blocked"), 0o600); err != nil { | |||
| 791 | + | t.Fatal(err) | |||
| 792 | + | } | |||
| 793 | + | a := &Agent{ID: "agent-1", stateDir: blockedStateDir} | |||
| 794 | + | if err := updateAgentFromEvent(a, Event{Type: EventTurnResult, TurnID: "turn-1", Data: map[string]any{"status": "succeeded"}}); err == nil { | |||
| 795 | + | t.Fatal("turn-result persistence failure was ignored") | |||
| 796 | + | } | |||
| 797 | + | if err := updateAgentFromEvent(a, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"step": 1}}); err != nil { | |||
| 798 | + | t.Fatal(err) | |||
| 799 | + | } | |||
| 800 | + | if got := a.TurnState(); got != TurnFailed { | |||
| 801 | + | t.Fatalf("turn state = %q, want %q", got, TurnFailed) | |||
| 802 | + | } | |||
| 803 | + | } | |||
| 804 | + | ||||
| 788 | func TestUpdateAgentFromEventRecordsDurableResultAvailability(t *testing.T) { | 805 | func TestUpdateAgentFromEventRecordsDurableResultAvailability(t *testing.T) { | ||
| 789 | trace := NewMemoryTraceWriter() | 806 | trace := NewMemoryTraceWriter() | ||
| 790 | t.Cleanup(func() { _ = trace.Close() }) | 807 | t.Cleanup(func() { _ = trace.Close() }) | ||
| @@ -936,6 +953,20 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) { | |||||
| 936 | } | 953 | } | ||
| 937 | } | 954 | } | ||
| 938 | 955 | ||||
| 956 | + | func TestRecordWorkerTracePreservesStructuredErrorCode(t *testing.T) { | |||
| 957 | + | trace := NewMemoryTraceWriter() | |||
| 958 | + | t.Cleanup(func() { _ = trace.Close() }) | |||
| 959 | + | agent := &Agent{ID: "agent-1", trace: trace} | |||
| 960 | + | recordWorkerTrace(agent, Event{Type: EventTurnResult, TurnID: "turn-1", Data: map[string]any{"status": "failed", "error": map[string]any{"code": "stream_idle_timeout"}}}) | |||
| 961 | + | if err := trace.Flush(); err != nil { | |||
| 962 | + | t.Fatal(err) | |||
| 963 | + | } | |||
| 964 | + | events := trace.Events() | |||
| 965 | + | if len(events) != 1 || events[0].Data["error_code"] != "stream_idle_timeout" { | |||
| 966 | + | t.Fatalf("trace events = %#v", events) | |||
| 967 | + | } | |||
| 968 | + | } | |||
| 969 | + | ||||
| 939 | func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) { | 970 | func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) { | ||
| 940 | trace := NewMemoryTraceWriter() | 971 | trace := NewMemoryTraceWriter() | ||
| 941 | t.Cleanup(func() { _ = trace.Close() }) | 972 | t.Cleanup(func() { _ = trace.Close() }) | ||