detached 7070f92

fix(subagents): preserve lifecycle facts

commit
7070f923ff297813d82169c9b8872ce759ba4e89
author
brice · · 2026-08-13 03:57 UTC
parent
f87ad92
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
272272
273273 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.
274274
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.
276276
277277 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.
278278
@@ -439,7 +439,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
439439 - **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.
440440 - **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.
441441 - **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.
443443 - **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.
444444 - **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.
445445 - **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
502502
503503 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.
504504
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.
506506
507507 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.
508508
@@ -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
6262 zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result"
6363 ```
6464
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.
6666
6767 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.
6868
@@ -174,7 +174,7 @@ zut debug trace <bundle>
174174
175175 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.
176176
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.
178178
179179 ## Resource policy
180180
@@ -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 policy179 ## 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 {
16421642 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
16431643 fact := view.Summary()
16441644 if primary, ok := view.Primary(); ok {
1645+ fact = truncateLineSafe(fact, 24)
16451646 if observation := view.ObservationFor(primary); observation != nil {
16461647 fact += " " + formatAge(observation.At)
16471648 } else {
16481649 fact += " " + formatAge(primary.StartedAt)
16491650 }
16501651 } else if view.LastEvent.Type != "" {
1652+ fact = truncateLineSafe(fact, 24)
16511653 fact += " " + formatAge(view.LastEvent.Timestamp)
16521654 }
16531655 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) {
4646 }
4747 }
4848
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+
4960 func TestSubagentsDialogTranscriptShowsProviderRequestDiagnostic(t *testing.T) {
5061 at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
5162 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,
581581 case <-changed:
582582 continue
583583 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) {
585591 return result, nil
586592 }
587593 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 continue582 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, nil591 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) {
146146 }
147147 }
148148
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+
149184 func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {
150185 a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}
151186 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 {
423423 case "result.delivered":
424424 result.Available, result.Delivered, result.Failed = true, true, false
425425 case "result.delivery.failed":
426- result.Available, result.Failed = true, true
426+ result.Available, result.Delivered, result.Failed = true, false, true
427427 }
428428 return &result
429429 }
@@ -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, false424 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, true426+ result.Available, result.Delivered, result.Failed = true, false, true
427 }427 }
428 return &result428 return &result
429 }429 }
M packages/agent/subagents/projection_test.go +10 -0
@@ -263,6 +263,16 @@ func TestProjectTraceNewAvailableResultResetsDelivery(t *testing.T) {
263263 }
264264 }
265265
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+
266276 func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {
267277 view := ProjectTrace([]TraceEvent{
268278 {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) {
103103 a.lifecycleMu.Unlock()
104104 }
105105
106-func (a *Agent) markRequirementNotified() (bool, error) {
106+func (a *Agent) markRequirementNotified(expectedTurnID string) (bool, error) {
107107 if a == nil {
108108 return false, nil
109109 }
110110 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+ }
111119 if !a.requirement.Required || a.requirement.Notified {
112120 a.lifecycleMu.Unlock()
113121 return false, nil
@@ -328,7 +336,7 @@ func (f *Supervisor) MarkRequirementNotified(id string) error {
328336 if result == nil || strings.TrimSpace(result.TurnID) != expectedTurnID {
329337 return fmt.Errorf("subagents: durable required result does not match %s", expectedTurnID)
330338 }
331- marked, err := a.markRequirementNotified()
339+ marked, err := a.markRequirementNotified(expectedTurnID)
332340 if err != nil {
333341 a.recordTrace(TraceEvent{Type: "result.delivery.failed", TurnID: expectedTurnID, Data: map[string]any{"ref": ResultRef(a.ID)}})
334342 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, nil108 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, nil121 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) {
244244 }
245245 }
246246
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+
247268 func TestRequirementNotificationPersistenceFailureIsReported(t *testing.T) {
248269 trace := NewMemoryTraceWriter()
249270 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 {
818818 break
819819 }
820820 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 != "" {
822824 a.setTurnState(TurnFailed, ev.TurnID)
823825 } else {
824826 a.setTurnState(TurnSucceeded, ev.TurnID)
@@ -988,6 +990,11 @@ func recordWorkerTrace(agent *Agent, ev Event) {
988990 }
989991
990992 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+ }
991998 value, _ := data["error"].(string)
992999 value = strings.ToLower(value)
9931000 switch {
@@ -1008,6 +1015,15 @@ func traceErrorCode(data map[string]any) string {
10081015 }
10091016 }
10101017
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+
10111027 // applyEventToSink translates an Event into Sink updates. Only a
10121028 // few event types are interpreted; the rest still land in the
10131029 // durable log via the caller.
@@ -818,7 +818,9 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
818 break818 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 a1027 // applyEventToSink translates an Event into Sink updates. Only a
1012 // few event types are interpreted; the rest still land in the1028 // 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)
785785 }
786786 }
787787
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+
788805 func TestUpdateAgentFromEventRecordsDurableResultAvailability(t *testing.T) {
789806 trace := NewMemoryTraceWriter()
790807 t.Cleanup(func() { _ = trace.Close() })
@@ -936,6 +953,20 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) {
936953 }
937954 }
938955
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+
939970 func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) {
940971 trace := NewMemoryTraceWriter()
941972 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() })

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close