detached 2d66a41

merge: integrate origin/main

commit
2d66a4142c96a48ea51796bcf0340ed5adac818c
author
brice · · 2026-08-08 09:40 UTC
committer
brice · · 2026-08-08 09:40 UTC
merge of
4c138a0 + 06b32e8 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
46 files · +3047 -528
M README.md +2 -2
@@ -390,7 +390,7 @@ Background subagents that run alongside your main session. Each one is a separat
390 390
391 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.391 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
392 392
393-**Auto-subagents.** With `/settings` -> auto-subagents on, the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only completion signal. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle from `/settings`.393+**Auto-subagents.** With `/settings` -> auto-subagents on, the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only completion signal. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle from `/settings`. When off, the permitted canonical subagent tools remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract or profile manifest.
394 394
395 ### Named subagent profiles395 ### Named subagent profiles
396 396
@@ -404,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
404 - **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.404 - **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.
405 - **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.405 - **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.
406 - **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`.406 - **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`.
407-- **auto-subagents** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven: `[auto-subagents update]` completion updates are the only signal; do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.407+- **auto-subagents** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract or profile manifest. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven: `[auto-subagents update]` completion updates are the only signal; do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
408 - **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.408 - **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.
409 - **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.409 - **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.
410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
M docs/subagents.md +10 -4
@@ -54,7 +54,9 @@ Other frontmatter from another agent host is ignored when zut does not have an e
54 54
55 ## Selecting a profile55 ## Selecting a profile
56 56
57-When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results. This is a prompt-level behavior contract; the normal tool registry is not narrowed by this setting.57+When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results.
58+
59+When auto-subagents is disabled, the canonical subagent tools remain available subject to launch-time policy. The primary may use them when the user requests delegation or an active skill workflow requires delegation. It continues to perform work itself otherwise and does not receive the profile manifest or strict orchestrator contract. The setting changes prompting, not tool availability.
58 60
59 When `subagent_spawn` is available, the orchestrator receives a compact `[subagents_list]` section in its system prompt and should select the profile whose description best matches each worker task. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active, profile metadata is omitted, and the primary must report that delegation is unavailable rather than implementing directly. If no profile fits when spawning is available, omit `agent` to use a general worker:61 When `subagent_spawn` is available, the orchestrator receives a compact `[subagents_list]` section in its system prompt and should select the profile whose description best matches each worker task. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active, profile metadata is omitted, and the primary must report that delegation is unavailable rather than implementing directly. If no profile fits when spawning is available, omit `agent` to use a general worker:
60 62
@@ -123,12 +125,16 @@ subagent://<id>/result
123 subagent://<id>/patch125 subagent://<id>/patch
124 ```126 ```
125 127
128+`events.jsonl` is appended as the worker emits events, including partial `message.delta` output. The dashboard and `subagent://<id>/history` replay those deltas, so output received before a worker finishes remains recoverable rather than waiting for a final assistant message.
129+
130+When a subagent deadline expires, the supervisor requests worker shutdown and allows the configured grace period for the worker to cancel its turn and write its session and result. The terminal result remains a failure with error code `deadline_exceeded`, preserves any output already received, and directs the caller to its history. If the worker does not stop during that grace period, the supervisor forcefully cancels it to preserve the timeout limit.
131+
126 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.132 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
127 133
128-When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.134+When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.
129 135
130-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 send a new follow-up to an idle worker or to restart a terminal worker from its retained session with that follow-up as the initial turn. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.136+Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
131 137
132 ## Resource policy138 ## Resource policy
133 139
134-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.140+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with a fresh budget without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
M packages/agent/args.go +26 -1
@@ -139,8 +139,13 @@ type Args struct {
139 // --subagent-worker.139 // --subagent-worker.
140 SubagentWorker string140 SubagentWorker string
141 141
142- // SubagentMaxTurns limits prompt-level turns in worker mode.142+ // SubagentMaxTurns limits message turns in one worker run.
143 SubagentMaxTurns int143 SubagentMaxTurns int
144+ // SubagentLifetimeTurns and SubagentRunTurns are supervisor-provided
145+ // counters used to resume a worker without losing observability or its
146+ // current-run budget. They are internal worker flags, not user policy.
147+ SubagentLifetimeTurns int
148+ SubagentRunTurns int
144 149
145 // Subagent selects a named markdown profile for a subagent child.150 // Subagent selects a named markdown profile for a subagent child.
146 // It is intentionally an internal child flag; the parent subagent tool151 // It is intentionally an internal child flag; the parent subagent tool
@@ -371,6 +376,26 @@ func ParseArgs(in []string) (Args, error) {
371 return a, fmt.Errorf("--max-turns must be a positive integer")376 return a, fmt.Errorf("--max-turns must be a positive integer")
372 }377 }
373 a.SubagentMaxTurns = n378 a.SubagentMaxTurns = n
379+ case "--subagent-lifetime-turns":
380+ v, err := want(&i, arg)
381+ if err != nil {
382+ return a, err
383+ }
384+ var n int
385+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
386+ return a, fmt.Errorf("--subagent-lifetime-turns must be a non-negative integer")
387+ }
388+ a.SubagentLifetimeTurns = n
389+ case "--subagent-run-turns":
390+ v, err := want(&i, arg)
391+ if err != nil {
392+ return a, err
393+ }
394+ var n int
395+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
396+ return a, fmt.Errorf("--subagent-run-turns must be a non-negative integer")
397+ }
398+ a.SubagentRunTurns = n
374 default:399 default:
375 if strings.HasPrefix(arg, "-") && arg != "-" {400 if strings.HasPrefix(arg, "-") && arg != "-" {
376 return a, fmt.Errorf("unknown flag %q", arg)401 return a, fmt.Errorf("unknown flag %q", arg)
M packages/agent/build.go +2 -0
@@ -870,6 +870,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
870 autoSubagentsStopToolAllowed(args),870 autoSubagentsStopToolAllowed(args),
871 autoSubagentsResumeToolAllowed(args),871 autoSubagentsResumeToolAllowed(args),
872 ))872 ))
873+ } else if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) {
874+ append_ = append(append_, OnDemandSubagentsSystemAddendum)
873 }875 }
874 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {876 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
875 append_ = append(append_, selectedProfile.SystemPrompt)877 append_ = append(append_, selectedProfile.SystemPrompt)
M packages/agent/build_test.go +8 -0
@@ -501,6 +501,14 @@ func TestResolveOmitsAutoSubagentsOrchestratorContractWhenDisabled(t *testing.T)
501 t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)501 t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
502 }502 }
503 }503 }
504+ for _, required := range []string{
505+ "user asks you to delegate",
506+ "active skill workflow requires delegation",
507+ } {
508+ if !strings.Contains(r.SystemPrompt, required) {
509+ t.Fatalf("disabled system prompt omits permitted delegation path %q:\n%s", required, r.SystemPrompt)
510+ }
511+ }
504 }512 }
505 513
506 func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {514 func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
M packages/agent/cli.go +32 -37
@@ -1496,23 +1496,17 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1496 return findSubagentProfile(r.CWD, name)1496 return findSubagentProfile(r.CWD, name)
1497 }1497 }
1498 1498
1499- // Inject the auto-subagent tools only when /settings -> auto-subagents1499+ // Register the canonical subagent tools whenever launch-time policy permits
1500- // is currently enabled. Registering them unconditionally leaves the model1500+ // them. The auto-subagents setting controls strict orchestration guidance,
1501- // trying to call them (and getting a polite error) even when the user has1501+ // not explicit user-requested delegation.
1502- // switched the feature off. The /settings toggle live-mutates the running
1503- // agent's registry separately so flipping the flag mid-session takes effect
1504- // on the next turn.
1505 injectSubagentTools := func(reg core.Registry) core.Registry {1502 injectSubagentTools := func(reg core.Registry) core.Registry {
1506 if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {1503 if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1507 return reg1504 return reg
1508 }1505 }
1509- if !AutoSubagentsEnabled() {
1510- return reg
1511- }
1512 if autoSubagentsToolAllowed(args) {1506 if autoSubagentsToolAllowed(args) {
1513 canonical := &tools.SubagentSpawnTool{1507 canonical := &tools.SubagentSpawnTool{
1514 Supervisor: subagentsMgr,1508 Supervisor: subagentsMgr,
1515- Enabled: AutoSubagentsEnabled,1509+ Enabled: func() bool { return true },
1516 DefaultModel: func() string {1510 DefaultModel: func() string {
1517 persistMu.Lock()1511 persistMu.Lock()
1518 defer persistMu.Unlock()1512 defer persistMu.Unlock()
@@ -1532,14 +1526,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1532 if autoSubagentsStatusToolAllowed(args) {1526 if autoSubagentsStatusToolAllowed(args) {
1533 statusTool := &tools.SubagentStatusTool{1527 statusTool := &tools.SubagentStatusTool{
1534 Supervisor: subagentsMgr,1528 Supervisor: subagentsMgr,
1535- Enabled: AutoSubagentsEnabled,1529+ Enabled: func() bool { return true },
1536 }1530 }
1537 reg[statusTool.Name()] = statusTool1531 reg[statusTool.Name()] = statusTool
1538 }1532 }
1539 if autoSubagentsStopToolAllowed(args) {1533 if autoSubagentsStopToolAllowed(args) {
1540 stopTool := &tools.SubagentStopTool{1534 stopTool := &tools.SubagentStopTool{
1541 Supervisor: subagentsMgr,1535 Supervisor: subagentsMgr,
1542- Enabled: AutoSubagentsEnabled,1536+ Enabled: func() bool { return true },
1543 OnStopRequested: onStopRequestedSupervisor,1537 OnStopRequested: onStopRequestedSupervisor,
1544 }1538 }
1545 reg[stopTool.Name()] = stopTool1539 reg[stopTool.Name()] = stopTool
@@ -1547,7 +1541,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1547 if autoSubagentsResumeToolAllowed(args) {1541 if autoSubagentsResumeToolAllowed(args) {
1548 resumeTool := &tools.SubagentResumeTool{1542 resumeTool := &tools.SubagentResumeTool{
1549 Supervisor: subagentsMgr,1543 Supervisor: subagentsMgr,
1550- Enabled: AutoSubagentsEnabled,1544+ Enabled: func() bool { return true },
1551 OnResumed: onResumedSupervisor,1545 OnResumed: onResumedSupervisor,
1552 }1546 }
1553 reg[resumeTool.Name()] = resumeTool1547 reg[resumeTool.Name()] = resumeTool
@@ -2341,30 +2335,31 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2341 autoSubagentsStopToolAllowedForSession,2335 autoSubagentsStopToolAllowedForSession,
2342 autoSubagentsResumeToolAllowedForSession,2336 autoSubagentsResumeToolAllowedForSession,
2343 ),2337 ),
2344- SubagentsSystemAddendum: subagentsAddendum,2338+ OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2345- SettingsStore: configSettingsStore{},2339+ SubagentsSystemAddendum: subagentsAddendum,
2346- Model: r.Model,2340+ SettingsStore: configSettingsStore{},
2347- Provider: r.Provider,2341+ Model: r.Model,
2348- AuthMethod: r.AuthMethod,2342+ Provider: r.Provider,
2349- BaseURL: r.BaseURL,2343+ AuthMethod: r.AuthMethod,
2350- Reasoning: r.Reasoning,2344+ BaseURL: r.BaseURL,
2351- SystemPrompt: r.SystemPrompt,2345+ Reasoning: r.Reasoning,
2352- Tools: r.ToolRegistry,2346+ SystemPrompt: r.SystemPrompt,
2353- MaxSteps: r.MaxSteps,2347+ Tools: r.ToolRegistry,
2354- CWD: r.CWD,2348+ MaxSteps: r.MaxSteps,
2355- StartupAgentName: args.AgentName,2349+ CWD: r.CWD,
2356- StartupContextPaths: instructionContextPaths(r.ContextFiles),2350+ StartupAgentName: args.AgentName,
2357- StartupExtensionNames: startupExtensionNames(extMgr.All()),2351+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2358- StartupExtensionErrors: startupExtensionErrors,2352+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2359- StartupSkillNames: startupSkillNames(startupSkills),2353+ StartupExtensionErrors: startupExtensionErrors,
2360- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,2354+ StartupSkillNames: startupSkillNames(startupSkills),
2361- ZutHome: ZutHome(),2355+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2362- SessionsRoot: agentSessionsRoot(ZutHome(), args),2356+ ZutHome: ZutHome(),
2363- Version: version,2357+ SessionsRoot: agentSessionsRoot(ZutHome(), args),
2364- UpdateInfoChan: updateCh,2358+ Version: version,
2365- Sandbox: sharedSandbox,2359+ UpdateInfoChan: updateCh,
2366- Agent: ag,2360+ Sandbox: sharedSandbox,
2367- InitialCompactHandoff: currentCompactHandoff(),2361+ Agent: ag,
2362+ InitialCompactHandoff: currentCompactHandoff(),
2368 InitialSessionTitle: func() string {2363 InitialSessionTitle: func() string {
2369 if sess == nil || sessionTitlePending {2364 if sess == nil || sessionTitlePending {
2370 return ""2365 return ""
M packages/agent/modes/auto_subagents_policy_test.go +28 -2
@@ -155,7 +155,7 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
155 dirty: make(chan struct{}, 1),155 dirty: make(chan struct{}, 1),
156 }156 }
157 157
158- iv.applyAutoSubagentsTool(true)158+ iv.applyAutoSubagentsTool()
159 if _, ok := iv.agent.Tools["subagent_spawn"]; ok {159 if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
160 t.Fatal("subagent_spawn registered despite launch-time policy")160 t.Fatal("subagent_spawn registered despite launch-time policy")
161 }161 }
@@ -220,7 +220,7 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
220 dirty: make(chan struct{}, 1),220 dirty: make(chan struct{}, 1),
221 }221 }
222 222
223- iv.applyAutoSubagentsTool(true)223+ iv.applyAutoSubagentsTool()
224 _, gotSpawn := iv.agent.Tools["subagent_spawn"]224 _, gotSpawn := iv.agent.Tools["subagent_spawn"]
225 _, gotStatus := iv.agent.Tools["subagent_status"]225 _, gotStatus := iv.agent.Tools["subagent_status"]
226 _, gotStop := iv.agent.Tools["subagent_stop"]226 _, gotStop := iv.agent.Tools["subagent_stop"]
@@ -232,6 +232,32 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
232 }232 }
233 }233 }
234 234
235+func TestAutoSubagentsDisabledKeepsToolsForExplicitUserRequests(t *testing.T) {
236+ enabled := false
237+ allowed := true
238+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
239+ t.Cleanup(supervisor.StopAll)
240+ iv := NewInteractive(InteractiveConfig{
241+ Agent: &core.Agent{Tools: core.Registry{}},
242+ AutoSubagentsEnabled: &enabled,
243+ AutoSubagentsToolAllowed: &allowed,
244+ AutoSubagentsSystemAddendum: "primary-agent orchestrator",
245+ Supervisor: supervisor,
246+ })
247+
248+ spawn, ok := iv.agent.ToolsSnapshot()["subagent_spawn"].(*tools.SubagentSpawnTool)
249+ if !ok {
250+ t.Fatal("subagent_spawn is unavailable when automatic subagents are disabled")
251+ }
252+ if spawn.Enabled == nil || !spawn.Enabled() {
253+ t.Fatal("subagent_spawn is disabled when a user explicitly requests delegation")
254+ }
255+ system, _ := iv.agent.PromptConfig()
256+ if strings.Contains(system, "primary-agent orchestrator") {
257+ t.Fatalf("disabled automatic subagents retained the orchestrator contract: %q", system)
258+ }
259+}
260+
235 type serializedAutoSubagentsClient struct {261 type serializedAutoSubagentsClient struct {
236 started chan struct{}262 started chan struct{}
237 request chan provider.Request263 request chan provider.Request
M packages/agent/modes/auto_subagents_test.go +11 -4
@@ -13,13 +13,17 @@ import (
13 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
14 )14 )
15 15
16+const autoSubagentsTestTimeout = 10 * time.Second
17+
16 func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {18 func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
19+ const onDemand = "delegate only on an explicit user request"
17 iv := &Interactive{20 iv := &Interactive{
18 agent: &core.Agent{System: "base system"},21 agent: &core.Agent{System: "base system"},
19 cfg: InteractiveConfig{22 cfg: InteractiveConfig{
20- AutoSubagentsSystemAddendum: "auto-subagents guidance",23+ AutoSubagentsSystemAddendum: "auto-subagents guidance",
21- SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",24+ OnDemandSubagentsSystemAddendum: onDemand,
22- Supervisor: &subagents.Supervisor{},25+ SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
26+ Supervisor: &subagents.Supervisor{},
23 },27 },
24 }28 }
25 29
@@ -37,6 +41,9 @@ func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t
37 if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") {41 if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") {
38 t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System)42 t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System)
39 }43 }
44+ if !strings.Contains(iv.agent.System, onDemand) {
45+ t.Fatalf("disabled system prompt omitted on-demand delegation guidance: %q", iv.agent.System)
46+ }
40 }47 }
41 48
42 func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {49 func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
@@ -327,7 +334,7 @@ func newQueuedAutoSubagentsInteractive() *Interactive {
327 334
328 func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {335 func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {
329 t.Helper()336 t.Helper()
330- deadline := time.NewTimer(time.Second)337+ deadline := time.NewTimer(autoSubagentsTestTimeout)
331 defer deadline.Stop()338 defer deadline.Stop()
332 ticker := time.NewTicker(time.Millisecond)339 ticker := time.NewTicker(time.Millisecond)
333 defer ticker.Stop()340 defer ticker.Stop()
M packages/agent/modes/interactive.go +67 -85
@@ -179,12 +179,15 @@ type InteractiveConfig struct {
179 // ExtensionThemes returns themes bundled with loaded extensions.179 // ExtensionThemes returns themes bundled with loaded extensions.
180 ExtensionThemes func() []tui.ThemeOption180 ExtensionThemes func() []tui.ThemeOption
181 181
182- // AutoSubagentsSystemAddendum is the system-prompt block that gets182+ // AutoSubagentsSystemAddendum is the strict orchestrator block that gets
183- // appended/stripped when the user toggles auto-subagents at runtime.183+ // appended when the user enables auto-subagents at runtime. Plumbed in from
184- // Plumbed in from the cli so this package doesn't have to import184+ // the cli so this package doesn't have to import agent (cycle).
185- // agent (cycle).
186 AutoSubagentsSystemAddendum string185 AutoSubagentsSystemAddendum string
187 186
187+ // OnDemandSubagentsSystemAddendum limits delegation to explicit user
188+ // requests while auto-subagents is disabled.
189+ OnDemandSubagentsSystemAddendum string
190+
188 // SubagentsSystemAddendum is the metadata-only [subagents_list]191 // SubagentsSystemAddendum is the metadata-only [subagents_list]
189 // block that is added or removed together with auto-subagents.192 // block that is added or removed together with auto-subagents.
190 SubagentsSystemAddendum string193 SubagentsSystemAddendum string
@@ -974,9 +977,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
974 baseURL, _, err := cfg.LlamaCPPConfig()977 baseURL, _, err := cfg.LlamaCPPConfig()
975 i.llamaConfigured = err == nil && baseURL != ""978 i.llamaConfigured = err == nil && baseURL != ""
976 }979 }
977- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {980+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg, cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled)
978- i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
979- }
980 if cfg.Agent != nil {981 if cfg.Agent != nil {
981 i.agent = cfg.Agent982 i.agent = cfg.Agent
982 i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())983 i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
@@ -1003,9 +1004,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
1003 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)1004 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
1004 i.titleGenerationStarted = i.titleRealPromptSeen1005 i.titleGenerationStarted = i.titleRealPromptSeen
1005 }1006 }
1006- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {1007+ i.applyAutoSubagentsTool()
1007- i.applyAutoSubagentsTool(true)
1008- }
1009 return i1008 return i
1010 }1009 }
1011 1010
@@ -1343,10 +1342,9 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1343 1342
1344 func (i *Interactive) invalidate() {1343 func (i *Interactive) invalidate() {
1345 i.renderRevision.Add(1)1344 i.renderRevision.Add(1)
1346- if scheduler := i.renderScheduler.Load(); scheduler != nil {1345+ // Keep ordinary state changes on the main-loop throttle. The scheduler
1347- scheduler.request(false, false)1346+ // owns terminal output, but scheduling it here would bypass
1348- return1347+ // redrawMinInterval and paint every intermediate tool-event frame.
1349- }
1350 select {1348 select {
1351 case i.dirty <- struct{}{}:1349 case i.dirty <- struct{}{}:
1352 default:1350 default:
@@ -2224,11 +2222,6 @@ func (i *Interactive) redraw() {
2224 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 02222 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
2225 2223
2226 chatRows := rows - len(bottom)2224 chatRows := rows - len(bottom)
2227- if rightBarActive {
2228- // DrawRightBar keeps the renderer-owned bottom margin inside the
2229- // fixed frame, so reserve that row before selecting visible chat.
2230- chatRows--
2231- }
2232 if chatRows < 1 {2225 if chatRows < 1 {
2233 chatRows = 12226 chatRows = 1
2234 }2227 }
@@ -4018,11 +4011,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
4018 i.agent = ag4011 i.agent = ag
4019 i.cfg.CWD = cwd4012 i.cfg.CWD = cwd
4020 i.cfg.SubagentsSystemAddendum = subagentsAddendum4013 i.cfg.SubagentsSystemAddendum = subagentsAddendum
4021- if i.autoSubagentsEnabledLocked() {4014+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
4022- i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
4023- } else {
4024- i.managedAutoSubagentsAddenda = nil
4025- }
4026 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)4015 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
4027 i.view.StartupContextPaths = nil4016 i.view.StartupContextPaths = nil
4028 i.view.InvalidateRenderCache()4017 i.view.InvalidateRenderCache()
@@ -5244,14 +5233,12 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
5244 return5233 return
5245 }5234 }
5246 }5235 }
5247- // Add/remove the auto-subagent tools on the live agent so the5236+ // The subagent tools remain available for explicit user requests.
5248- // model's tools[] list reflects the toggle on the next turn.5237+ // Swap only the system-prompt guidance between strict orchestration and
5249- // Without this the tools stay advertised after a disable and5238+ // on-demand delegation.
5250- // the model keeps trying to call them.5239+ i.applyAutoSubagentsTool()
5251- i.applyAutoSubagentsTool(value)5240+ // Also swap the system-prompt addendum so the model knows whether it
5252- // Also swap the system-prompt addendum in/out so the model5241+ // should orchestrate proactively or delegate only on user request.
5253- // knows to use the tools proactively (or stops referencing them
5254- // after a disable).
5255 i.applyAutoSubagentsSystemPrompt(value)5242 i.applyAutoSubagentsSystemPrompt(value)
5256 i.mu.Lock()5243 i.mu.Lock()
5257 i.statusOK = "auto-subagents " + onOff(value)5244 i.statusOK = "auto-subagents " + onOff(value)
@@ -6909,7 +6896,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6909 // dynamically-registered tools need to be reattached. The apply6896 // dynamically-registered tools need to be reattached. The apply
6910 // helpers are no-ops when their feature is inactive, so the6897 // helpers are no-ops when their feature is inactive, so the
6911 // cross-provider path still works on a vanilla setup.6898 // cross-provider path still works on a vanilla setup.
6912- i.applyAutoSubagentsTool(i.autoSubagentsEnabled())6899+ i.applyAutoSubagentsTool()
6913 i.applyTelegramTools(i.telegramBridge != nil)6900 i.applyTelegramTools(i.telegramBridge != nil)
6914 if i.cfg.PersistModel != nil {6901 if i.cfg.PersistModel != nil {
6915 i.cfg.PersistModel(p, md)6902 i.cfg.PersistModel(p, md)
@@ -6952,7 +6939,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
6952 if oldAgent != nil {6939 if oldAgent != nil {
6953 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())6940 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6954 }6941 }
6955- i.applyAutoSubagentsTool(i.autoSubagentsEnabled())6942+ i.applyAutoSubagentsTool()
6956 i.applyTelegramTools(i.telegramBridge != nil)6943 i.applyTelegramTools(i.telegramBridge != nil)
6957 // Authentication can change the provider used by the live6944 // Authentication can change the provider used by the live
6958 // agent. Persist it on the active session just like /model.6945 // agent. Persist it on the active session just like /model.
@@ -8851,8 +8838,18 @@ func truncateForSummary(s string, n int) string {
8851 }8838 }
8852 8839
8853 // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents8840 // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
8854-// toggle, in the same order Resolve appends them to a new agent.8841+// setting, in the same order Resolve appends them to a new agent.
8855-func autoSubagentsAddenda(cfg InteractiveConfig) []string {8842+func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
8843+ if !orchestrating {
8844+ if cfg.Supervisor == nil || !autoSubagentsAnyToolAllowedConfig(cfg) {
8845+ return nil
8846+ }
8847+ if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
8848+ return []string{addendum}
8849+ }
8850+ return nil
8851+ }
8852+
8856 addenda := make([]string, 0, 2)8853 addenda := make([]string, 0, 2)
8857 if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {8854 if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
8858 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {8855 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
@@ -8865,15 +8862,6 @@ func autoSubagentsAddenda(cfg InteractiveConfig) []string {
8865 return addenda8862 return addenda
8866 }8863 }
8867 8864
8868-func containsAutoSubagentsAddendum(addenda []string, want string) bool {
8869- for _, addendum := range addenda {
8870- if addendum == want {
8871- return true
8872- }
8873- }
8874- return false
8875-}
8876-
8877 // removeLastAutoSubagentsAddendum removes one occurrence of a block known to8865 // removeLastAutoSubagentsAddendum removes one occurrence of a block known to
8878 // have been appended by auto-subagents. Resolve appends owned blocks at the8866 // have been appended by auto-subagents. Resolve appends owned blocks at the
8879 // end, so removing the last occurrence preserves an identical base block.8867 // end, so removing the last occurrence preserves an identical base block.
@@ -8885,11 +8873,10 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
8885 return system[:idx] + system[idx+len(addendum):], true8873 return system[:idx] + system[idx+len(addendum):], true
8886 }8874 }
8887 8875
8888-// applyAutoSubagentsSystemPrompt appends (active=true) or strips8876+// applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by the
8889-// (active=false) the auto-subagents prompt blocks on the running agent.8877+// auto-subagents setting on the running agent. The profile manifest belongs
8890-// The profile manifest and delegation guidance are managed together so8878+// only to strict orchestration; disabled mode keeps on-demand guidance.
8891-// toggling auto-subagents never leaves the model with names but no tool.8879+func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
8892-func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
8893 // i.mu is sufficient here: this path updates the existing agent's system8880 // i.mu is sufficient here: this path updates the existing agent's system
8894 // prompt and managed addenda in place; it does not replace the agent or8881 // prompt and managed addenda in place; it does not replace the agent or
8895 // its tool registry, so agentMu is not required.8882 // its tool registry, so agentMu is not required.
@@ -8898,22 +8885,6 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
8898 if i.agent == nil {8885 if i.agent == nil {
8899 return8886 return
8900 }8887 }
8901- addenda := autoSubagentsAddenda(i.cfg)
8902- if active {
8903- sys, _ := i.agent.PromptConfig()
8904- for _, addendum := range addenda {
8905- if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
8906- continue
8907- }
8908- if sys != "" && !strings.HasSuffix(sys, "\n\n") {
8909- sys += "\n\n"
8910- }
8911- sys += addendum
8912- i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
8913- }
8914- i.agent.SetSystemPrompt(sys)
8915- return
8916- }
8917 8888
8918 sys, _ := i.agent.PromptConfig()8889 sys, _ := i.agent.PromptConfig()
8919 changed := false8890 changed := false
@@ -8923,16 +8894,33 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
8923 changed = changed || removed8894 changed = changed || removed
8924 }8895 }
8925 i.managedAutoSubagentsAddenda = nil8896 i.managedAutoSubagentsAddenda = nil
8926- if changed {8897+
8927- i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")8898+ addenda := autoSubagentsAddenda(i.cfg, orchestrating)
8899+ if changed && len(addenda) > 0 {
8900+ sys = strings.TrimRight(sys, "\n")
8928 }8901 }
8902+ for _, addendum := range addenda {
8903+ if sys != "" {
8904+ sys += "\n\n"
8905+ }
8906+ sys += addendum
8907+ i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
8908+ changed = true
8909+ }
8910+ if !changed {
8911+ return
8912+ }
8913+ if len(addenda) == 0 {
8914+ sys = strings.TrimRight(sys, "\n") + "\n"
8915+ }
8916+ i.agent.SetSystemPrompt(sys)
8929 }8917 }
8930 8918
8931-// applyAutoSubagentsTool registers (active=true) or removes (active=false)8919+// applyAutoSubagentsTool registers the canonical subagent tools whenever
8932-// the auto-subagent tools on the running agent so the model only sees them8920+// launch-time policy permits them. Auto-subagents controls prompt behavior,
8933-// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'8921+// while the tools remain available for explicit user-requested delegation.
8934-// snapshot+mutate pattern so extension tools and /reload-ext additions8922+// Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
8935-// survive a toggle.8923+// /reload-ext additions survive a settings change.
8936 func (i *Interactive) autoSubagentsAvailable() bool {8924 func (i *Interactive) autoSubagentsAvailable() bool {
8937 return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)8925 return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
8938 }8926 }
@@ -8998,17 +8986,11 @@ func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
8998 return autoSubagentsResumeToolAllowedConfig(i.cfg)8986 return autoSubagentsResumeToolAllowedConfig(i.cfg)
8999 }8987 }
9000 8988
9001-func (i *Interactive) autoSubagentsEnabled() bool {
9002- i.mu.Lock()
9003- defer i.mu.Unlock()
9004- return i.autoSubagentsEnabledLocked()
9005-}
9006-
9007 func (i *Interactive) autoSubagentsEnabledLocked() bool {8989 func (i *Interactive) autoSubagentsEnabledLocked() bool {
9008 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()8990 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
9009 }8991 }
9010 8992
9011-func (i *Interactive) applyAutoSubagentsTool(active bool) {8993+func (i *Interactive) applyAutoSubagentsTool() {
9012 i.agentMu.Lock()8994 i.agentMu.Lock()
9013 defer i.agentMu.Unlock()8995 defer i.agentMu.Unlock()
9014 i.mu.Lock()8996 i.mu.Lock()
@@ -9024,11 +9006,11 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
9024 }9006 }
9025 next[name] = t9007 next[name] = t
9026 }9008 }
9027- if active && i.autoSubagentsAvailable() {9009+ if i.autoSubagentsAvailable() {
9028 if i.autoSubagentsToolAllowed() {9010 if i.autoSubagentsToolAllowed() {
9029 canonical := &tools.SubagentSpawnTool{9011 canonical := &tools.SubagentSpawnTool{
9030 Supervisor: i.cfg.Supervisor,9012 Supervisor: i.cfg.Supervisor,
9031- Enabled: func() bool { return i.autoSubagentsEnabled() },9013+ Enabled: func() bool { return true },
9032 DefaultModel: func() string { return i.cfg.Model },9014 DefaultModel: func() string { return i.cfg.Model },
9033 DefaultProvider: func() string { return i.cfg.Provider },9015 DefaultProvider: func() string { return i.cfg.Provider },
9034 DefaultReasoning: func() string { return i.cfg.Reasoning },9016 DefaultReasoning: func() string { return i.cfg.Reasoning },
@@ -9040,14 +9022,14 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
9040 if i.autoSubagentsStatusToolAllowed() {9022 if i.autoSubagentsStatusToolAllowed() {
9041 statusTool := &tools.SubagentStatusTool{9023 statusTool := &tools.SubagentStatusTool{
9042 Supervisor: i.cfg.Supervisor,9024 Supervisor: i.cfg.Supervisor,
9043- Enabled: func() bool { return i.autoSubagentsEnabled() },9025+ Enabled: func() bool { return true },
9044 }9026 }
9045 next[statusTool.Name()] = statusTool9027 next[statusTool.Name()] = statusTool
9046 }9028 }
9047 if i.autoSubagentsStopToolAllowed() {9029 if i.autoSubagentsStopToolAllowed() {
9048 stopTool := &tools.SubagentStopTool{9030 stopTool := &tools.SubagentStopTool{
9049 Supervisor: i.cfg.Supervisor,9031 Supervisor: i.cfg.Supervisor,
9050- Enabled: func() bool { return i.autoSubagentsEnabled() },9032+ Enabled: func() bool { return true },
9051 OnStopRequested: i.TrackStoppedSubagentWorker,9033 OnStopRequested: i.TrackStoppedSubagentWorker,
9052 }9034 }
9053 next[stopTool.Name()] = stopTool9035 next[stopTool.Name()] = stopTool
@@ -9055,7 +9037,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
9055 if i.autoSubagentsResumeToolAllowed() {9037 if i.autoSubagentsResumeToolAllowed() {
9056 resumeTool := &tools.SubagentResumeTool{9038 resumeTool := &tools.SubagentResumeTool{
9057 Supervisor: i.cfg.Supervisor,9039 Supervisor: i.cfg.Supervisor,
9058- Enabled: func() bool { return i.autoSubagentsEnabled() },9040+ Enabled: func() bool { return true },
9059 OnResumed: i.TrackResumedSubagentWorker,9041 OnResumed: i.TrackResumedSubagentWorker,
9060 }9042 }
9061 next[resumeTool.Name()] = resumeTool9043 next[resumeTool.Name()] = resumeTool
M packages/agent/modes/interactive_render_test.go +28 -0
@@ -65,6 +65,34 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
65 }65 }
66 }66 }
67 67
68+func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
69+ scheduler := newLatestFrameScheduler()
70+ i := &Interactive{
71+ dirty: make(chan struct{}, 1),
72+ toolCalls: make(map[string]*tui.ToolCallView),
73+ toolGate: make(map[string]int),
74+ }
75+ i.renderScheduler.Store(scheduler)
76+
77+ i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
78+ i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`})
79+ i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"})
80+
81+ if got := len(scheduler.wake); got != 0 {
82+ t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got)
83+ }
84+ select {
85+ case <-i.dirty:
86+ default:
87+ t.Fatal("tool events did not wake the throttled redraw path")
88+ }
89+ select {
90+ case <-i.dirty:
91+ t.Fatal("tool event burst queued more than one throttled redraw")
92+ default:
93+ }
94+}
95+
68 func TestStableChatCacheTracksViewInvalidation(t *testing.T) {96 func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
69 i := &Interactive{97 i := &Interactive{
70 view: &tui.View{98 view: &tui.View{
M packages/agent/modes/session_dialog_test.go +8 -6
@@ -13,6 +13,8 @@ import (
13 "github.com/bnema/zut/packages/tui"13 "github.com/bnema/zut/packages/tui"
14 )14 )
15 15
16+const sessionDialogTestTimeout = 10 * time.Second
17+
16 func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {18 func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
17 row := formatSessionRowPlain(core.SessionSummary{19 row := formatSessionRowPlain(core.SessionSummary{
18 Provider: "test\x1b]0;bad\a",20 Provider: "test\x1b]0;bad\a",
@@ -149,12 +151,12 @@ func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
149 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)151 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
150 }152 }
151 d.ApplyLoad(event)153 d.ApplyLoad(event)
152- case <-time.After(time.Second):154+ case <-time.After(sessionDialogTestTimeout):
153 t.Fatal("timed out waiting for sessionLoadStarted")155 t.Fatal("timed out waiting for sessionLoadStarted")
154 }156 }
155 select {157 select {
156 case <-entered:158 case <-entered:
157- case <-time.After(time.Second):159+ case <-time.After(sessionDialogTestTimeout):
158 t.Fatal("timed out waiting for the session worker")160 t.Fatal("timed out waiting for the session worker")
159 }161 }
160 162
@@ -173,7 +175,7 @@ func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
173 t.Fatal("canceled load emitted sessionLoadFinished")175 t.Fatal("canceled load emitted sessionLoadFinished")
174 }176 }
175 d.ApplyLoad(event)177 d.ApplyLoad(event)
176- case <-time.After(time.Second):178+ case <-time.After(sessionDialogTestTimeout):
177 t.Fatal("timed out waiting for canceled session load to close")179 t.Fatal("timed out waiting for canceled session load to close")
178 }180 }
179 }181 }
@@ -221,12 +223,12 @@ func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T)
221 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)223 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
222 }224 }
223 d.ApplyLoad(event)225 d.ApplyLoad(event)
224- case <-time.After(time.Second):226+ case <-time.After(sessionDialogTestTimeout):
225 t.Fatal("timed out waiting for the first load to start")227 t.Fatal("timed out waiting for the first load to start")
226 }228 }
227 select {229 select {
228 case <-entered:230 case <-entered:
229- case <-time.After(time.Second):231+ case <-time.After(sessionDialogTestTimeout):
230 t.Fatal("timed out waiting for the first worker")232 t.Fatal("timed out waiting for the first worker")
231 }233 }
232 234
@@ -253,7 +255,7 @@ func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T)
253 t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)255 t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
254 }256 }
255 d.ApplyLoad(event)257 d.ApplyLoad(event)
256- case <-time.After(time.Second):258+ case <-time.After(sessionDialogTestTimeout):
257 t.Fatal("timed out waiting for third load after releasing the first")259 t.Fatal("timed out waiting for third load after releasing the first")
258 }260 }
259 cancelThird()261 cancelThird()
M packages/agent/modes/subagents_slash_test.go +13 -13
@@ -290,20 +290,20 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
290 if err != nil {290 if err != nil {
291 t.Fatal(err)291 t.Fatal(err)
292 }292 }
293+ watcherDone := make(chan struct{})
294+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
293 iv.runSubagents(context.Background(), []string{"wait", a.ID})295 iv.runSubagents(context.Background(), []string{"wait", a.ID})
294- deadline := time.After(time.Second)296+ select {
295- for {297+ case <-watcherDone:
296- iv.mu.Lock()298+ case <-time.After(5 * time.Second):
297- status := iv.statusOK299+ t.Fatal("timed out waiting for wait watcher to report completion")
298- iv.mu.Unlock()300+ }
299- if status == "completed "+a.ID {301+
300- return302+ iv.mu.Lock()
301- }303+ status := iv.statusOK
302- select {304+ iv.mu.Unlock()
303- case <-deadline:305+ if status != "completed "+a.ID {
304- t.Fatalf("wait did not report completion; status = %q", status)306+ t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
305- case <-time.After(time.Millisecond):
306- }
307 }307 }
308 }308 }
309 309
M packages/agent/settings_store.go +8 -5
@@ -252,10 +252,9 @@ func (configSettingsStore) SetTheme(name string) error {
252 return SaveConfig(cfg)252 return SaveConfig(cfg)
253 }253 }
254 254
255-// AutoSubagentsEnabled reads the current auto-subagents flag from config.255+// AutoSubagentsEnabled reads whether the interactive primary agent runs in
256-// The build/CLI startup path uses it when initially injecting256+// strict orchestrator mode. The canonical subagent tools remain available for
257-// the auto-subagent tools into the tool registry; interactive mode mirrors257+// user-requested delegation and skill-mandated workflows when disabled.
258-// the setting into the live tool registry when its settings toggle changes.
259 func AutoSubagentsEnabled() bool {258 func AutoSubagentsEnabled() bool {
260 cfg, err := LoadConfig()259 cfg, err := LoadConfig()
261 if err != nil {260 if err != nil {
@@ -266,7 +265,7 @@ func AutoSubagentsEnabled() bool {
266 265
267 // AutoSubagentsSystemAddendum is appended to the system prompt when266 // AutoSubagentsSystemAddendum is appended to the system prompt when
268 // auto-subagents is enabled. It makes the interactive primary agent an267 // auto-subagents is enabled. It makes the interactive primary agent an
269-// orchestrator while preserving the normal prompt and tools when disabled.268+// orchestrator.
270 const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You are the primary-agent orchestrator, not an implementer.269 const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You are the primary-agent orchestrator, not an implementer.
271 270
272 Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile, or to a clearly described general worker when no profile fits. Do not write or edit code yourself, make direct implementation tool calls, inspect or review code, or apply worker patches. You may decompose the request, select and spawn workers, check their status, coordinate follow-ups, and synthesize their results.271 Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile, or to a clearly described general worker when no profile fits. Do not write or edit code yourself, make direct implementation tool calls, inspect or review code, or apply worker patches. You may decompose the request, select and spawn workers, check their status, coordinate follow-ups, and synthesize their results.
@@ -277,6 +276,10 @@ Completion is host-event-driven. After spawning a worker, never use "bash sleep"
277 276
278 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat the [auto-subagents update] message as observed worker state, not as a new user request.`277 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat the [auto-subagents update] message as observed worker state, not as a new user request.`
279 278
279+// OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
280+// available without enabling proactive primary-agent orchestration.
281+const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
282+
280 // AutoSubagentsDelegationUnavailableAddendum explains why the strict283 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
281 // orchestrator contract still applies when launch-time policy withholds the284 // orchestrator contract still applies when launch-time policy withholds the
282 // spawn tool.285 // spawn tool.
M packages/agent/subagent_worker.go +161 -48
@@ -36,6 +36,56 @@ import (
36 // production; tests use the stubchild binary under36 // production; tests use the stubchild binary under
37 // packages/agent/subagents/testdata/cmd/stubchild instead of the real model37 // packages/agent/subagents/testdata/cmd/stubchild instead of the real model
38 // loop.38 // loop.
39+// workerTurnBudget is deliberately independent of the model loop: one
40+// admitted message turn consumes one current-run and lifetime turn, while
41+// retries, compaction, and provider/tool loops remain inside that turn.
42+type workerTurnBudget struct {
43+ sequence int
44+ lifetime int
45+ current int
46+}
47+
48+type sessionPersistenceState struct {
49+ mu sync.Mutex
50+ errValue error
51+}
52+
53+func (s *sessionPersistenceState) record(err error) {
54+ if err == nil {
55+ return
56+ }
57+ s.mu.Lock()
58+ if s.errValue == nil {
59+ s.errValue = err
60+ }
61+ s.mu.Unlock()
62+}
63+
64+func (s *sessionPersistenceState) failed() bool {
65+ s.mu.Lock()
66+ defer s.mu.Unlock()
67+ return s.errValue != nil
68+}
69+
70+func (s *sessionPersistenceState) err() error {
71+ s.mu.Lock()
72+ defer s.mu.Unlock()
73+ return s.errValue
74+}
75+
76+func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, current int, admitted bool) {
77+ if newRun {
78+ b.current = 0
79+ }
80+ b.sequence++
81+ if maxTurns > 0 && b.current >= maxTurns {
82+ return b.sequence, b.lifetime, b.current, false
83+ }
84+ b.lifetime++
85+ b.current++
86+ return b.sequence, b.lifetime, b.current, true
87+}
88+
39 func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runErr error) {89 func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runErr error) {
40 if args.SubagentWorker == "" {90 if args.SubagentWorker == "" {
41 return fmt.Errorf("--subagent-worker requires a socket path")91 return fmt.Errorf("--subagent-worker requires a socket path")
@@ -78,6 +128,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
78 if err != nil {128 if err != nil {
79 return err129 return err
80 }130 }
131+ var sessionPersistence sessionPersistenceState
81 if sess != nil {132 if sess != nil {
82 var providerName, model string133 var providerName, model string
83 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)134 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
@@ -86,6 +137,23 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
86 }137 }
87 r.Provider, r.Model = providerName, model138 r.Provider, r.Model = providerName, model
88 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }139 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
140+ // A prompt is acknowledged by the supervisor only after this callback
141+ // has appended and synced the matching user message. This closes the
142+ // crash window between accepting a follow-up and writing session.json.
143+ ag.OnMessageAppended = func(message provider.Message) {
144+ if err := sess.AppendMessage(message); err != nil {
145+ sessionPersistence.record(err)
146+ return
147+ }
148+ if err := sess.Sync(); err != nil {
149+ sessionPersistence.record(err)
150+ }
151+ }
152+ ag.OnUsage = func(usage provider.Usage) {
153+ if err := sess.AppendUsage(usage, usage); err != nil {
154+ sessionPersistence.record(err)
155+ }
156+ }
89 defer func() { runErr = joinSessionCloseError(runErr, sess) }()157 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
90 }158 }
91 announceSession(extMgr, sess)159 announceSession(extMgr, sess)
@@ -122,9 +190,12 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
122 em := newSubagentEmitter(os.Stdout, logMirror)190 em := newSubagentEmitter(os.Stdout, logMirror)
123 em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))191 em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))
124 em.emit("agent.ready", map[string]any{192 em.emit("agent.ready", map[string]any{
125- "version": version,193+ "version": version,
126- "cwd": r.CWD,194+ "cwd": r.CWD,
127- "model": r.Model,195+ "model": r.Model,
196+ "lifetime_turns": args.SubagentLifetimeTurns,
197+ "current_run_turns": args.SubagentRunTurns,
198+ "max_turns": args.SubagentMaxTurns,
128 })199 })
129 200
130 // Heartbeats make a live-but-detached child distinguishable from a201 // Heartbeats make a live-but-detached child distinguishable from a
@@ -154,63 +225,82 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
154 // Keep a per-turn cancel so the "cancel" inbox message can interrupt225 // Keep a per-turn cancel so the "cancel" inbox message can interrupt
155 // an in-flight turn without tearing down the whole daemon.226 // an in-flight turn without tearing down the whole daemon.
156 var (227 var (
157- mu sync.Mutex228+ mu sync.Mutex
158- cancelFn context.CancelFunc229+ cancelFn context.CancelFunc
159- busyTurn bool230+ busyTurn bool
160- turnNo = initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG"))231+ budget = workerTurnBudget{
232+ sequence: initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG")),
233+ lifetime: args.SubagentLifetimeTurns,
234+ current: args.SubagentRunTurns,
235+ }
161 turnWG sync.WaitGroup236 turnWG sync.WaitGroup
162 closing bool237 closing bool
238+ turnPending bool
239+ seenCommands = make(map[string]struct{})
163 shutdown = make(chan struct{})240 shutdown = make(chan struct{})
164 shutdownOnce sync.Once241 shutdownOnce sync.Once
165 )242 )
166 maxOutputBytes, maxOutputLines := workerOutputLimits()243 maxOutputBytes, maxOutputLines := workerOutputLimits()
167 244
168- runOne := func(prompt string) {245+ runOne := func(prompt string, newRun bool, commandID string) {
169 mu.Lock()246 mu.Lock()
170 // launchTurn admits the goroutine to turnWG before it starts so247 // launchTurn admits the goroutine to turnWG before it starts so
171 // shutdown can join it. A shutdown can win the scheduling race248 // shutdown can join it. A shutdown can win the scheduling race
172 // after that admission but before this goroutine acquires mu; in249 // after that admission but before this goroutine acquires mu; in
173 // that case do not start a new Prompt after waitForActiveTurn has250 // that case do not start a new Prompt after waitForActiveTurn has
174 // declared the worker closing.251 // declared the worker closing.
175- if closing {252+ if closing || busyTurn {
176 mu.Unlock()253 mu.Unlock()
254+ em.emit("error", map[string]any{"code": "turn_rejected", "reason": "busy", "command_id": commandID, "message": "worker is busy"})
177 return255 return
178 }256 }
179- if args.SubagentMaxTurns > 0 && turnNo >= args.SubagentMaxTurns {257+ busyTurn = true
180- // Consume a unique rejected attempt so repeated prompts after258+ turnPending = false
181- // the limit cannot reuse the same turn id in the event log.259+ step, lifetime, currentRun, admitted := budget.start(args.SubagentMaxTurns, newRun)
182- turnNo++260+ if !admitted {
183- step := turnNo261+ busyTurn = false
184 mu.Unlock()262 mu.Unlock()
185 turnID := fmt.Sprintf("turn-%d", step)263 turnID := fmt.Sprintf("turn-%d", step)
186- errPayload := map[string]any{"code": "max_turns", "message": "maximum subagent turns reached"}264+ errPayload := map[string]any{"code": "turn_rejected", "reason": "max_turns", "command_id": commandID, "message": "maximum subagent turns reached"}
265+ em.emit("error", errPayload)
187 em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload})266 em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload})
188 em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload})267 em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload})
189 em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]})268 em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]})
190- em.emit("agent.idle", map[string]any{"turn_id": turnID})269+ // Max-turn rejection ends the current run, but the worker remains
191- return270+ // alive and can accept a queued follow-up as a fresh run.
192- }271+ em.emit("agent.idle", map[string]any{
193- if busyTurn {272+ "turn_id": turnID,
194- // Drop concurrent turns rather than queuing. The273+ "lifetime_turns": lifetime,
195- // supervisor protocol assumes one outstanding turn per274+ "current_run_turns": currentRun,
196- // agent; if a user really wants to interrupt and start275+ })
197- // another, they should send "cancel" first.
198- mu.Unlock()
199- em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"})
200 return276 return
201 }277 }
202- busyTurn = true278+ turnID := fmt.Sprintf("turn-%d", step)
203- turnNo++
204- step := turnNo
205 c, cancel := context.WithCancel(ctx)279 c, cancel := context.WithCancel(ctx)
206 cancelFn = cancel280 cancelFn = cancel
207 mu.Unlock()281 mu.Unlock()
208 282
209- turnID := fmt.Sprintf("turn-%d", step)283+ turnStarted := false
210- em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
211-
212 sink := func(ev core.AgentEvent) {284 sink := func(ev core.AgentEvent) {
213- em.emit(ev.Type(), subagentEventData(ev))285+ data := subagentEventData(ev)
286+ if ev.Type() == "user_message" && !turnStarted {
287+ // Prompt invokes OnMessageAppended before delivering this sink
288+ // event, so the session row is durable before the lifecycle
289+ // acknowledgement is written.
290+ em.emit(ev.Type(), data)
291+ if !sessionPersistence.failed() {
292+ em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID, "command_id": commandID, "lifetime_turns": lifetime, "current_run_turns": currentRun})
293+ turnStarted = true
294+ }
295+ return
296+ }
297+ if ev.Type() == "turn_start" {
298+ // A provider/model-loop turn is nested inside the one delegated
299+ // message turn admitted above. Mark it on the wire so the
300+ // supervisor cannot mistake it for a new worker turn.
301+ data["nested_turn"] = true
302+ }
303+ em.emit(ev.Type(), data)
214 }304 }
215 305
216 baseline := workerTranscriptState{306 baseline := workerTranscriptState{
@@ -232,13 +322,21 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
232 AutoCompactThreshold: cfg.AutoCompactThreshold,322 AutoCompactThreshold: cfg.AutoCompactThreshold,
233 })323 })
234 output := finalAssistantText(ag.Messages()[recovery.OutputStart:])324 output := finalAssistantText(ag.Messages()[recovery.OutputStart:])
235- if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {325+ // Message and usage callbacks persist ordinary turns incrementally. A
236- if err != nil {326+ // compacted turn still needs one atomic replacement checkpoint so the
237- err = errors.Join(err, persistErr)327+ // durable transcript cannot mix generations.
238- } else {328+ if recovery.Compacted || ag.OnMessageAppended == nil {
239- err = persistErr329+ if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {
330+ if err != nil {
331+ err = errors.Join(err, persistErr)
332+ } else {
333+ err = persistErr
334+ }
240 }335 }
241 }336 }
337+ if sessionErr := sessionPersistence.err(); err == nil && sessionErr != nil {
338+ err = sessionErr
339+ }
242 340
243 resultStatus := "succeeded"341 resultStatus := "succeeded"
244 if err != nil {342 if err != nil {
@@ -273,24 +371,26 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
273 // The supervisor treats agent.idle as permission to start a follow-up.371 // The supervisor treats agent.idle as permission to start a follow-up.
274 // Publish it only after the busy gate has opened, otherwise a manager372 // Publish it only after the busy gate has opened, otherwise a manager
275 // can send a turn that the inbox loop drops as still busy.373 // can send a turn that the inbox loop drops as still busy.
276- em.emit("agent.idle", map[string]any{"turn_id": turnID})374+ em.emit("agent.idle", map[string]any{"turn_id": turnID, "lifetime_turns": lifetime, "current_run_turns": currentRun})
277 }375 }
278 376
279- launchTurn := func(prompt string) {377+ launchTurn := func(prompt string, newRun bool, commandID string) bool {
280 mu.Lock()378 mu.Lock()
281- if closing {379+ if closing || busyTurn || turnPending {
282 mu.Unlock()380 mu.Unlock()
283- return381+ em.emit("error", map[string]any{"code": "turn_rejected", "reason": "busy", "command_id": commandID, "message": "worker is busy"})
382+ return false
284 }383 }
285- // Register the goroutine before launching it. Shutdown can then384+ // Reserve the worker before launching the goroutine. A second command
286- // close the gate and wait for every turn that was admitted, even385+ // cannot be accepted in the gap before runOne acquires mu.
287- // if runOne has not acquired mu and set busyTurn yet.386+ turnPending = true
288 turnWG.Add(1)387 turnWG.Add(1)
289 mu.Unlock()388 mu.Unlock()
290 go func() {389 go func() {
291 defer turnWG.Done()390 defer turnWG.Done()
292- runOne(prompt)391+ runOne(prompt, newRun, commandID)
293 }()392 }()
393+ return true
294 }394 }
295 395
296 waitForActiveTurn := func(cancel bool) {396 waitForActiveTurn := func(cancel bool) {
@@ -315,7 +415,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
315 // "starts working" the moment it boots, matching what users415 // "starts working" the moment it boots, matching what users
316 // expect from `/subagents new <task>`.416 // expect from `/subagents new <task>`.
317 if args.Prompt != "" {417 if args.Prompt != "" {
318- launchTurn(args.Prompt)418+ launchTurn(args.Prompt, false, "")
319 }419 }
320 420
321 // Inbox loop: one supervisor message at a time. We don't spawn421 // Inbox loop: one supervisor message at a time. We don't spawn
@@ -361,10 +461,20 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
361 case subagents.CommandTurnStart:461 case subagents.CommandTurnStart:
362 var payload subagents.TurnStartPayload462 var payload subagents.TurnStartPayload
363 if err := command.DecodePayload(&payload); err != nil {463 if err := command.DecodePayload(&payload); err != nil {
364- em.emit("error", map[string]any{"message": "invalid turn.start payload"})464+ em.emit("error", map[string]any{"code": "turn_rejected", "reason": "invalid_payload", "command_id": command.MessageID, "message": "invalid turn.start payload"})
365 continue465 continue
366 }466 }
367- launchTurn(payload.Prompt)467+ mu.Lock()
468+ _, duplicate := seenCommands[command.MessageID]
469+ mu.Unlock()
470+ if duplicate && command.MessageID != "" {
471+ continue
472+ }
473+ if launchTurn(payload.Prompt, payload.NewRun, command.MessageID) && command.MessageID != "" {
474+ mu.Lock()
475+ seenCommands[command.MessageID] = struct{}{}
476+ mu.Unlock()
477+ }
368 default:478 default:
369 // Unknown commands are rejected rather than interpreted as479 // Unknown commands are rejected rather than interpreted as
370 // child-originated control requests.480 // child-originated control requests.
@@ -625,6 +735,9 @@ func initialTurnNumber(path string) int {
625 }735 }
626 maxStep := 0736 maxStep := 0
627 for _, ev := range events {737 for _, ev := range events {
738+ if !subagents.IsDelegatedTurnStart(ev) {
739+ continue
740+ }
628 if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep {741 if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep {
629 maxStep = int(step)742 maxStep = int(step)
630 }743 }
M packages/agent/subagent_worker_test.go +46 -0
@@ -857,3 +857,49 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
857 t.Errorf("payload step field missing or wrong: %v", object["payload"])857 t.Errorf("payload step field missing or wrong: %v", object["payload"])
858 }858 }
859 }859 }
860+
861+func TestInitialTurnNumberIgnoresNestedProviderTurns(t *testing.T) {
862+ path := filepath.Join(t.TempDir(), "events.jsonl")
863+ log, err := subagents.OpenEventLog(path)
864+ if err != nil {
865+ t.Fatal(err)
866+ }
867+ if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
868+ "step": float64(1), "lifetime_turns": 1, "current_run_turns": 1,
869+ })); err != nil {
870+ t.Fatal(err)
871+ }
872+ if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
873+ "step": float64(99), "nested_turn": true,
874+ })); err != nil {
875+ t.Fatal(err)
876+ }
877+ if err := log.Close(); err != nil {
878+ t.Fatal(err)
879+ }
880+ if got := initialTurnNumber(path); got != 1 {
881+ t.Fatalf("initial turn number = %d, want 1", got)
882+ }
883+}
884+
885+func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {
886+ var budget workerTurnBudget
887+
888+ if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 1 || current != 1 {
889+ t.Fatalf("first turn = (%d, %d, %v), want (1, 1, true)", lifetime, current, admitted)
890+ }
891+ // Provider retries, model loops, and compaction stay inside the admitted
892+ // turn and therefore do not call start or consume another allowance.
893+ if budget.lifetime != 1 || budget.current != 1 {
894+ t.Fatalf("nested work changed budget to (lifetime=%d, current=%d)", budget.lifetime, budget.current)
895+ }
896+ if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 2 || current != 2 {
897+ t.Fatalf("second turn = (%d, %d, %v), want (2, 2, true)", lifetime, current, admitted)
898+ }
899+ if _, lifetime, current, admitted := budget.start(2, false); admitted || lifetime != 2 || current != 2 {
900+ t.Fatalf("exhausted turn = (%d, %d, %v), want (2, 2, false)", lifetime, current, admitted)
901+ }
902+ if _, lifetime, current, admitted := budget.start(2, true); !admitted || lifetime != 3 || current != 1 {
903+ t.Fatalf("explicit resumed turn = (%d, %d, %v), want (3, 1, true)", lifetime, current, admitted)
904+ }
905+}
A packages/agent/subagents/ack_test.go +65 -0
@@ -0,0 +1,65 @@
1+package subagents
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestResumePromptAcknowledgementRequiresDurableUserMessageAndTurnStart(t *testing.T) {
9+ acceptedAt := time.Now().Add(-time.Second)
10+ prompt := "follow-up prompt"
11+ user := NewEvent("user_message", map[string]any{
12+ "content": []any{map[string]any{"type": "text", "text": prompt}},
13+ })
14+ started := NewEvent(EventTurnStarted, map[string]any{
15+ "lifetime_turns": 2,
16+ "current_run_turns": 1,
17+ })
18+
19+ if resumePromptAcknowledged([]Event{started}, prompt, acceptedAt) {
20+ t.Fatal("turn.started alone acknowledged a pending prompt")
21+ }
22+ if resumePromptAcknowledged([]Event{user}, prompt, acceptedAt) {
23+ t.Fatal("crash point after durable user message acknowledged before turn.started")
24+ }
25+ if !resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
26+ t.Fatal("matching durable user message followed by delegated turn.started was not acknowledged")
27+ }
28+}
29+
30+func TestResumePromptAcknowledgementIgnoresNestedTurnStart(t *testing.T) {
31+ acceptedAt := time.Now().Add(-time.Second)
32+ prompt := "follow-up prompt"
33+ user := NewEvent("user_message", map[string]any{
34+ "content": []any{map[string]any{"type": "text", "text": prompt}},
35+ })
36+ nested := NewEvent(EventTurnStarted, map[string]any{"nested_turn": true})
37+ if resumePromptAcknowledged([]Event{user, nested}, prompt, acceptedAt) {
38+ t.Fatal("nested turn.started acknowledged a pending delegated prompt")
39+ }
40+}
41+
42+func TestResumePromptAcknowledgementIgnoresStaleMatchingUserMessage(t *testing.T) {
43+ acceptedAt := time.Now()
44+ prompt := "follow-up prompt"
45+ user := NewEvent("user_message", map[string]any{
46+ "content": []any{map[string]any{"type": "text", "text": prompt}},
47+ })
48+ user.Time = acceptedAt.Add(-time.Millisecond)
49+ started := NewEvent(EventTurnStarted, map[string]any{"lifetime_turns": 2, "current_run_turns": 1})
50+ if resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
51+ t.Fatal("stale matching user message acknowledged a pending prompt")
52+ }
53+}
54+
55+func TestResumePromptAcknowledgementIgnoresAmbiguousUserMessage(t *testing.T) {
56+ acceptedAt := time.Now().Add(-time.Second)
57+ prompt := "follow-up prompt"
58+ user := NewEvent("user_message", map[string]any{
59+ "content": []any{map[string]any{"type": "text", "text": "older prompt"}},
60+ })
61+ started := NewEvent(EventTurnStarted, map[string]any{"lifetime_turns": 2, "current_run_turns": 1})
62+ if resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
63+ t.Fatal("ambiguous user message acknowledged a pending prompt")
64+ }
65+}
M packages/agent/subagents/agent.go +377 -43
@@ -7,6 +7,8 @@ import (
7 "strings"7 "strings"
8 "sync"8 "sync"
9 "time"9 "time"
10+
11+ "github.com/google/uuid"
10 )12 )
11 13
12 // Agent is one supervised task. Public fields are immutable after14 // Agent is one supervised task. Public fields are immutable after
@@ -17,6 +19,18 @@ type turnEndNotice struct {
17 errMsg string19 errMsg string
18 }20 }
19 21
22+type queuedResumePrompt struct {
23+ Prompt string `json:"prompt"`
24+ AcceptedAt time.Time `json:"accepted_at"`
25+ CommandID string `json:"command_id,omitempty"`
26+}
27+
28+type resumePromptSnapshot struct {
29+ Prompt string
30+ AcceptedAt time.Time
31+ CommandID string
32+}
33+
20 type Agent struct {34 type Agent struct {
21 ID string35 ID string
22 Task string36 Task string
@@ -36,6 +50,14 @@ type Agent struct {
36 WorkspaceBase string50 WorkspaceBase string
37 WorkspaceCapture CaptureMode51 WorkspaceCapture CaptureMode
38 MaxTurns int52 MaxTurns int
53+
54+ // LifetimeTurns counts every accepted message turn for this worker across
55+ // all explicit runs. CurrentRunTurns resets only when an explicit
56+ // subagent_resume follow-up starts a new run; retries, model loops,
57+ // compaction, and cancellation do not reset it.
58+ LifetimeTurns int
59+ CurrentRunTurns int
60+
39 // Timeout is the effective per-agent lifetime, persisted so a resumed61 // Timeout is the effective per-agent lifetime, persisted so a resumed
40 // worker keeps the timeout selected at its spawn boundary.62 // worker keeps the timeout selected at its spawn boundary.
41 Timeout time.Duration63 Timeout time.Duration
@@ -80,12 +102,15 @@ type Agent struct {
80 // Not persisted — every Resume sets it explicitly.102 // Not persisted — every Resume sets it explicitly.
81 Resuming bool103 Resuming bool
82 104
83- // resumePromptText is an optional manager follow-up passed to a resumed105+ // resumePromptText is the manager follow-up currently handed to the
84- // child as its initial prompt. It remains in durable metadata until the106+ // child. It remains in durable metadata until the child reports
85- // child reports turn.started, so a host exit while the worker is queued107+ // turn.started, so a host exit while the worker is queued cannot lose it.
86- // cannot lose it. Access it through ResumePromptInfo.108+ // Additional follow-ups accepted while the child is busy stay in
87- resumePromptText string109+ // resumePromptQueue until the next agent idle turn.
88- resumePromptAt time.Time110+ resumePromptText string
111+ resumePromptAt time.Time
112+ resumePromptID string
113+ resumePromptQueue []queuedResumePrompt
89 114
90 // InboxPath is the unix socket the child agent listens on for115 // InboxPath is the unix socket the child agent listens on for
91 // follow-up prompts and control messages. The supervisor opens116 // follow-up prompts and control messages. The supervisor opens
@@ -116,16 +141,20 @@ type Agent struct {
116 // Supervisor.Spawn so callers do not need to manage the socket directly.141 // Supervisor.Spawn so callers do not need to manage the socket directly.
117 inbox *Inbox142 inbox *Inbox
118 143
119- mu sync.Mutex144+ mu sync.Mutex
120- status Status145+ status Status
121- activity string146+ activity string
122- transcript []string147+ transcript []string
123- lastAssistant string148+ lastAssistant string
124- finished time.Time149+ streamingAssistantText string
125- lastErr error150+ streamingAssistantTruncated bool
126- transcriptLoaded bool151+ streamingAssistantStart int
127- legacyEventState bool152+ streamingAssistantLines int
128- transcriptMu sync.Mutex153+ finished time.Time
154+ lastErr error
155+ transcriptLoaded bool
156+ legacyEventState bool
157+ transcriptMu sync.Mutex
129 158
130 lifecycleMu sync.Mutex159 lifecycleMu sync.Mutex
131 processState ProcessState160 processState ProcessState
@@ -142,10 +171,12 @@ type Agent struct {
142 outputTruncated bool171 outputTruncated bool
143 maxOutputBytes int172 maxOutputBytes int
144 maxOutputLines int173 maxOutputLines int
145- persistFn func(*Agent)174+ persistFn func(*Agent) error
175+ persistMu sync.Mutex
146 stateDir string176 stateDir string
147 workspaceCleanup func() error177 workspaceCleanup func() error
148 workspaceCapture func() (WorkspaceCapture, error)178 workspaceCapture func() (WorkspaceCapture, error)
179+ onTurnIdle func()
149 180
150 // OnTurnEnd, if set, fires once per prompt-level turn_end event181 // OnTurnEnd, if set, fires once per prompt-level turn_end event
151 // emitted by the subagent daemon wrapper. Provider/tool-loop182 // emitted by the subagent daemon wrapper. Provider/tool-loop
@@ -230,6 +261,66 @@ func (a *Agent) AttemptValue() int {
230 return a.Attempt261 return a.Attempt
231 }262 }
232 263
264+// LifetimeTurnsValue returns the number of accepted message turns across all
265+// explicit runs of this worker.
266+func (a *Agent) LifetimeTurnsValue() int {
267+ a.lifecycleMu.Lock()
268+ defer a.lifecycleMu.Unlock()
269+ return a.LifetimeTurns
270+}
271+
272+// CurrentRunTurnsValue returns the number of accepted message turns in the
273+// current explicit run.
274+func (a *Agent) CurrentRunTurnsValue() int {
275+ a.lifecycleMu.Lock()
276+ defer a.lifecycleMu.Unlock()
277+ return a.CurrentRunTurns
278+}
279+
280+func (a *Agent) setTurnCounts(lifetime, currentRun int) {
281+ if lifetime < 0 {
282+ lifetime = 0
283+ }
284+ if currentRun < 0 {
285+ currentRun = 0
286+ }
287+ a.lifecycleMu.Lock()
288+ a.LifetimeTurns = lifetime
289+ a.CurrentRunTurns = currentRun
290+ a.updatedAt = time.Now()
291+ a.lifecycleMu.Unlock()
292+}
293+
294+func (a *Agent) incrementTurnCounts() (lifetime, currentRun int) {
295+ a.lifecycleMu.Lock()
296+ a.LifetimeTurns++
297+ a.CurrentRunTurns++
298+ a.updatedAt = time.Now()
299+ lifetime = a.LifetimeTurns
300+ currentRun = a.CurrentRunTurns
301+ a.lifecycleMu.Unlock()
302+ return lifetime, currentRun
303+}
304+
305+func (a *Agent) resetCurrentRunTurns() (previous int) {
306+ a.lifecycleMu.Lock()
307+ previous = a.CurrentRunTurns
308+ a.CurrentRunTurns = 0
309+ a.updatedAt = time.Now()
310+ a.lifecycleMu.Unlock()
311+ return previous
312+}
313+
314+func (a *Agent) setCurrentRunTurns(currentRun int) {
315+ if currentRun < 0 {
316+ currentRun = 0
317+ }
318+ a.lifecycleMu.Lock()
319+ a.CurrentRunTurns = currentRun
320+ a.updatedAt = time.Now()
321+ a.lifecycleMu.Unlock()
322+}
323+
233 // ProcessPIDValue returns the current worker pid, when known.324 // ProcessPIDValue returns the current worker pid, when known.
234 func (a *Agent) ProcessPIDValue() int {325 func (a *Agent) ProcessPIDValue() int {
235 a.lifecycleMu.Lock()326 a.lifecycleMu.Lock()
@@ -400,13 +491,45 @@ func (a *Agent) ResumePromptInfo() (string, time.Time) {
400 return a.resumePromptText, a.resumePromptAt491 return a.resumePromptText, a.resumePromptAt
401 }492 }
402 493
403-func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) (string, time.Time) {494+func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) resumePromptSnapshot {
404 a.mu.Lock()495 a.mu.Lock()
405 defer a.mu.Unlock()496 defer a.mu.Unlock()
406- previousPrompt, previousAcceptedAt := a.resumePromptText, a.resumePromptAt497+ previous := resumePromptSnapshot{
498+ Prompt: a.resumePromptText,
499+ AcceptedAt: a.resumePromptAt,
500+ CommandID: a.resumePromptID,
501+ }
407 a.resumePromptText = prompt502 a.resumePromptText = prompt
408 a.resumePromptAt = acceptedAt503 a.resumePromptAt = acceptedAt
409- return previousPrompt, previousAcceptedAt504+ a.resumePromptID = uuid.NewString()
505+ return previous
506+}
507+
508+func (a *Agent) restoreResumePrompt(previous resumePromptSnapshot) {
509+ a.mu.Lock()
510+ defer a.mu.Unlock()
511+ a.resumePromptText = previous.Prompt
512+ a.resumePromptAt = previous.AcceptedAt
513+ a.resumePromptID = previous.CommandID
514+ if a.resumePromptText != "" && a.resumePromptID == "" {
515+ a.resumePromptID = uuid.NewString()
516+ }
517+}
518+
519+func (a *Agent) resumePromptState() resumePromptSnapshot {
520+ a.mu.Lock()
521+ defer a.mu.Unlock()
522+ return resumePromptSnapshot{
523+ Prompt: a.resumePromptText,
524+ AcceptedAt: a.resumePromptAt,
525+ CommandID: a.resumePromptID,
526+ }
527+}
528+
529+func (a *Agent) resumePromptCommandID() string {
530+ a.mu.Lock()
531+ defer a.mu.Unlock()
532+ return a.resumePromptID
410 }533 }
411 534
412 func (a *Agent) clearResumePrompt() bool {535 func (a *Agent) clearResumePrompt() bool {
@@ -417,9 +540,110 @@ func (a *Agent) clearResumePrompt() bool {
417 }540 }
418 a.resumePromptText = ""541 a.resumePromptText = ""
419 a.resumePromptAt = time.Time{}542 a.resumePromptAt = time.Time{}
543+ a.resumePromptID = ""
544+ return true
545+}
546+
547+func (a *Agent) queueResumePrompt(prompt string, acceptedAt time.Time) string {
548+ commandID := uuid.NewString()
549+ a.mu.Lock()
550+ a.resumePromptQueue = append(a.resumePromptQueue, queuedResumePrompt{Prompt: prompt, AcceptedAt: acceptedAt, CommandID: commandID})
551+ a.mu.Unlock()
552+ return commandID
553+}
554+
555+func (a *Agent) removeQueuedResumePrompt(commandID string) bool {
556+ if commandID == "" {
557+ return false
558+ }
559+ a.mu.Lock()
560+ defer a.mu.Unlock()
561+ for i := len(a.resumePromptQueue) - 1; i >= 0; i-- {
562+ if a.resumePromptQueue[i].CommandID == commandID {
563+ a.resumePromptQueue = append(a.resumePromptQueue[:i], a.resumePromptQueue[i+1:]...)
564+ return true
565+ }
566+ }
567+ return false
568+}
569+
570+func (a *Agent) resumePromptQueueSnapshot() []queuedResumePrompt {
571+ a.mu.Lock()
572+ defer a.mu.Unlock()
573+ return append([]queuedResumePrompt(nil), a.resumePromptQueue...)
574+}
575+
576+func (a *Agent) hasQueuedResumePrompt() bool {
577+ a.mu.Lock()
578+ defer a.mu.Unlock()
579+ return len(a.resumePromptQueue) != 0
580+}
581+
582+// startQueuedResumePrompt moves the oldest active follow-up into the
583+// in-flight prompt slot. The caller must persist the returned state before
584+// delivering the command so a host exit cannot lose the prompt.
585+func (a *Agent) startQueuedResumePrompt() (string, time.Time, string, bool) {
586+ a.mu.Lock()
587+ defer a.mu.Unlock()
588+ if a.resumePromptText != "" || len(a.resumePromptQueue) == 0 {
589+ return "", time.Time{}, "", false
590+ }
591+ queued := a.resumePromptQueue[0]
592+ a.resumePromptQueue = a.resumePromptQueue[1:]
593+ a.resumePromptText = queued.Prompt
594+ a.resumePromptAt = queued.AcceptedAt
595+ a.resumePromptID = queued.CommandID
596+ if a.resumePromptID == "" {
597+ a.resumePromptID = uuid.NewString()
598+ }
599+ return queued.Prompt, queued.AcceptedAt, a.resumePromptID, true
600+}
601+
602+func (a *Agent) restoreResumePromptToQueue(prompt string, acceptedAt time.Time) bool {
603+ a.mu.Lock()
604+ defer a.mu.Unlock()
605+ if a.resumePromptText != prompt || !a.resumePromptAt.Equal(acceptedAt) {
606+ return false
607+ }
608+ commandID := a.resumePromptID
609+ if commandID == "" {
610+ commandID = uuid.NewString()
611+ }
612+ a.resumePromptText = ""
613+ a.resumePromptAt = time.Time{}
614+ a.resumePromptID = ""
615+ a.resumePromptQueue = append([]queuedResumePrompt{{Prompt: prompt, AcceptedAt: acceptedAt, CommandID: commandID}}, a.resumePromptQueue...)
420 return true616 return true
421 }617 }
422 618
619+func (a *Agent) rejectActiveResumePrompt() bool {
620+ a.mu.Lock()
621+ defer a.mu.Unlock()
622+ if a.resumePromptText == "" {
623+ return false
624+ }
625+ a.resumePromptQueue = append([]queuedResumePrompt{{Prompt: a.resumePromptText, AcceptedAt: a.resumePromptAt, CommandID: a.resumePromptID}}, a.resumePromptQueue...)
626+ a.resumePromptText = ""
627+ a.resumePromptAt = time.Time{}
628+ a.resumePromptID = ""
629+ return true
630+}
631+
632+func (a *Agent) setOnTurnIdle(fn func()) {
633+ a.mu.Lock()
634+ a.onTurnIdle = fn
635+ a.mu.Unlock()
636+}
637+
638+func (a *Agent) notifyTurnIdle() {
639+ a.mu.Lock()
640+ fn := a.onTurnIdle
641+ a.mu.Unlock()
642+ if fn != nil {
643+ fn()
644+ }
645+}
646+
423 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {647 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
424 a.mu.Lock()648 a.mu.Lock()
425 a.OnTurnEnd = fn649 a.OnTurnEnd = fn
@@ -433,6 +657,20 @@ func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
433 }657 }
434 }658 }
435 659
660+func (a *Agent) recordPersistenceError(err error) {
661+ if a == nil || err == nil {
662+ return
663+ }
664+ a.mu.Lock()
665+ if a.lastErr == nil {
666+ a.lastErr = fmt.Errorf("subagent metadata persistence: %w", err)
667+ }
668+ if a.status != StatusDone && a.status != StatusFailed && a.status != StatusKilled {
669+ a.activity = "metadata error: " + truncate(err.Error(), 120)
670+ }
671+ a.mu.Unlock()
672+}
673+
436 func (a *Agent) setActivity(msg string) {674 func (a *Agent) setActivity(msg string) {
437 a.mu.Lock()675 a.mu.Lock()
438 a.activity = strings.TrimSpace(msg)676 a.activity = strings.TrimSpace(msg)
@@ -441,25 +679,114 @@ func (a *Agent) setActivity(msg string) {
441 }679 }
442 680
443 func (a *Agent) appendTranscript(chunk string) {681 func (a *Agent) appendTranscript(chunk string) {
444- a.appendTranscriptLocked(chunk, "", false)682+ a.appendTranscriptChunk(chunk, "", false)
445 }683 }
446 684
447 func (a *Agent) appendUserMessage(text string) {685 func (a *Agent) appendUserMessage(text string) {
448- a.appendTranscriptLocked(text, "user: ", false)686+ a.appendTranscriptChunk(text, "user: ", false)
449 }687 }
450 688
451 func (a *Agent) appendAssistantMessage(text string) {689 func (a *Agent) appendAssistantMessage(text string) {
452- a.appendTranscriptLocked(text, "", true)690+ if text == "" {
691+ return
692+ }
693+ a.mu.Lock()
694+ if a.streamingAssistantText != "" {
695+ a.replaceStreamingAssistantLocked(text)
696+ a.clearStreamingAssistantLocked()
697+ } else {
698+ a.appendTranscriptLinesLocked(text, "", true)
699+ a.boundTranscriptLocked()
700+ }
701+ a.mu.Unlock()
702+ a.markActivity(time.Now())
703+}
704+
705+// appendAssistantDelta exposes an in-progress assistant response in the
706+// dashboard without duplicating it when the final assistant_message arrives.
707+// The JSONL event log remains the durable source; this maintains its live and
708+// replayed in-memory projection.
709+func (a *Agent) appendAssistantDelta(text string) {
710+ if text == "" {
711+ return
712+ }
713+ a.mu.Lock()
714+ if !a.streamingAssistantTruncated {
715+ value := a.streamingAssistantText + text
716+ byteCap, lineCap := a.outputLimits()
717+ a.streamingAssistantText = boundInlineText(value, byteCap, lineCap)
718+ a.streamingAssistantTruncated = a.streamingAssistantText != value
719+ }
720+ a.replaceStreamingAssistantLocked(a.streamingAssistantText)
721+ a.mu.Unlock()
722+ a.markActivity(time.Now())
723+}
724+
725+func (a *Agent) appendTranscriptChunk(chunk, linePrefix string, assistant bool) {
726+ if strings.TrimRight(chunk, "\n") == "" {
727+ return
728+ }
729+ a.mu.Lock()
730+ a.appendTranscriptLinesLocked(chunk, linePrefix, assistant)
731+ a.boundTranscriptLocked()
732+ a.mu.Unlock()
733+ a.markActivity(time.Now())
453 }734 }
454 735
455-func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {736+func (a *Agent) resetStreamingAssistant() {
737+ a.mu.Lock()
738+ a.clearStreamingAssistantLocked()
739+ a.mu.Unlock()
740+}
741+
742+func (a *Agent) replaceStreamingAssistantLocked(text string) {
743+ a.removeStreamingAssistantLocked()
744+ a.streamingAssistantStart = len(a.transcript)
745+ a.streamingAssistantLines = a.appendTranscriptLinesLocked(text, "", true)
746+ a.boundTranscriptLocked()
747+}
748+
749+func (a *Agent) removeStreamingAssistantLocked() {
750+ if a.streamingAssistantLines == 0 {
751+ return
752+ }
753+ start := a.streamingAssistantStart
754+ if start < 0 {
755+ start = 0
756+ }
757+ if start > len(a.transcript) {
758+ start = len(a.transcript)
759+ }
760+ end := start + a.streamingAssistantLines
761+ if end > len(a.transcript) {
762+ end = len(a.transcript)
763+ }
764+ for _, line := range a.transcript[start:end] {
765+ a.outputBytes -= len(line) + 1
766+ a.outputLines--
767+ }
768+ a.transcript = append(a.transcript[:start], a.transcript[end:]...)
769+ a.streamingAssistantLines = 0
770+ if a.outputLines == 0 {
771+ a.outputBytes = 0
772+ }
773+}
774+
775+func (a *Agent) clearStreamingAssistantLocked() {
776+ a.streamingAssistantText = ""
777+ a.streamingAssistantTruncated = false
778+ a.streamingAssistantStart = 0
779+ a.streamingAssistantLines = 0
780+}
781+
782+func (a *Agent) appendTranscriptLinesLocked(chunk, linePrefix string, assistant bool) int {
456 message := chunk783 message := chunk
457 chunk = strings.TrimRight(chunk, "\n")784 chunk = strings.TrimRight(chunk, "\n")
458 if chunk == "" {785 if chunk == "" {
459- return786+ return 0
460 }787 }
461- a.mu.Lock()788+ lines := strings.Split(chunk, "\n")
462- for _, line := range strings.Split(chunk, "\n") {789+ for _, line := range lines {
463 line = linePrefix + line790 line = linePrefix + line
464 a.transcript = append(a.transcript, line)791 a.transcript = append(a.transcript, line)
465 a.outputBytes += len(line) + 1792 a.outputBytes += len(line) + 1
@@ -468,35 +795,42 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
468 if assistant {795 if assistant {
469 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)796 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
470 }797 }
471- // Bound the inline dashboard copy. The durable event log and session798+ return len(lines)
472- // remain available through the logical history reference.799+}
473- lineCap := a.maxOutputLines800+
474- if lineCap <= 0 {801+func (a *Agent) outputLimits() (byteCap, lineCap int) {
475- lineCap = 2_000802+ byteCap = a.maxOutputBytes
476- }
477- byteCap := a.maxOutputBytes
478 if byteCap <= 0 {803 if byteCap <= 0 {
479 byteCap = 500_000804 byteCap = 500_000
480 }805 }
481- for a.outputLines > lineCap {806+ lineCap = a.maxOutputLines
482- line := a.transcript[0]807+ if lineCap <= 0 {
483- a.transcript = a.transcript[1:]808+ lineCap = 2_000
484- a.outputBytes -= len(line) + 1
485- a.outputLines--
486- a.outputTruncated = true
487 }809 }
488- for a.outputLines > 0 && a.outputBytes > byteCap {810+ return byteCap, lineCap
811+}
812+
813+func (a *Agent) boundTranscriptLocked() {
814+ // Bound the inline dashboard copy. The durable event log and session
815+ // remain available through the logical history reference.
816+ byteCap, lineCap := a.outputLimits()
817+ for a.outputLines > lineCap || (a.outputLines > 0 && a.outputBytes > byteCap) {
489 line := a.transcript[0]818 line := a.transcript[0]
490 a.transcript = a.transcript[1:]819 a.transcript = a.transcript[1:]
491 a.outputBytes -= len(line) + 1820 a.outputBytes -= len(line) + 1
492 a.outputLines--821 a.outputLines--
822+ if a.streamingAssistantLines > 0 {
823+ if a.streamingAssistantStart > 0 {
824+ a.streamingAssistantStart--
825+ } else {
826+ a.streamingAssistantLines--
827+ }
828+ }
493 a.outputTruncated = true829 a.outputTruncated = true
494 }830 }
495 if a.outputLines == 0 {831 if a.outputLines == 0 {
496 a.outputBytes = 0832 a.outputBytes = 0
497 }833 }
498- a.mu.Unlock()
499- a.markActivity(time.Now())
500 }834 }
501 835
502 // newAgentID returns a short, mostly-collision-free identifier of the836 // newAgentID returns a short, mostly-collision-free identifier of the
M packages/agent/subagents/agent_test.go +31 -1
@@ -1,6 +1,9 @@
1 package subagents1 package subagents
2 2
3-import "testing"3+import (
4+ "sync"
5+ "testing"
6+)
4 7
5 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {8 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
6 a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}9 a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
@@ -47,3 +50,30 @@ func TestAgentTranscriptLineCapAccounting(t *testing.T) {
47 t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)50 t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
48 }51 }
49 }52 }
53+
54+func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
55+ a := &Agent{maxOutputBytes: 8, maxOutputLines: 1}
56+ start := make(chan struct{})
57+ var wg sync.WaitGroup
58+ wg.Add(2)
59+ go func() {
60+ defer wg.Done()
61+ <-start
62+ for range 10_000 {
63+ a.appendTranscript("overflow")
64+ }
65+ }()
66+ go func() {
67+ defer wg.Done()
68+ <-start
69+ for range 10_000 {
70+ _ = a.Snapshot()
71+ }
72+ }()
73+ close(start)
74+ wg.Wait()
75+
76+ if !a.Snapshot().OutputTruncated {
77+ t.Fatal("snapshot did not report transcript truncation")
78+ }
79+}
M packages/agent/subagents/inbox.go +33 -12
@@ -112,34 +112,49 @@ func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error {
112 }112 }
113 defer conn.SetWriteDeadline(time.Time{})113 defer conn.SetWriteDeadline(time.Time{})
114 }114 }
115- if _, err := conn.Write(data); err != nil {115+ n, writeErr := conn.Write(data)
116- // Drop the connection so the next call redials. The previous error is116+ if writeErr != nil {
117- // more informative than the redial's would be, so surface this one.117+ // Drop the connection so the next call redials. Once any bytes have
118+ // crossed the socket boundary, the outcome is ambiguous; returning the
119+ // caller's context error would invite a duplicate command with a new
120+ // identity.
118 stopCancel()121 stopCancel()
119- b.mu.Lock()122+ b.dropConnection(conn)
120- if b.conn == conn {123+ if n > 0 {
121- _ = b.conn.Close()124+ return fmt.Errorf("%w: wrote %d of %d bytes: %v", ErrDeliveryUnknown, n, len(data), writeErr)
122- b.conn = nil
123 }125 }
124- b.mu.Unlock()
125 if ctxErr := ctx.Err(); ctxErr != nil {126 if ctxErr := ctx.Err(); ctxErr != nil {
126 return ctxErr127 return ctxErr
127 }128 }
128- return err129+ return writeErr
130+ }
131+ if n != len(data) {
132+ stopCancel()
133+ b.dropConnection(conn)
134+ return fmt.Errorf("%w: wrote %d of %d bytes", ErrDeliveryUnknown, n, len(data))
129 }135 }
130 if !stopCancel() {136 if !stopCancel() {
131- // Cancellation closed this connection after the write completed. Drop it137+ // Cancellation closed this connection after the complete command was
132- // so the next command redials instead of reusing a closed stream.138+ // written. The worker can parse the newline-delimited command, so the
139+ // delivery succeeded even though the context is now canceled.
133 b.mu.Lock()140 b.mu.Lock()
134 if b.conn == conn {141 if b.conn == conn {
135 b.conn = nil142 b.conn = nil
136 }143 }
137 b.mu.Unlock()144 b.mu.Unlock()
138- return ctx.Err()
139 }145 }
140 return nil146 return nil
141 }147 }
142 148
149+func (b *Inbox) dropConnection(conn net.Conn) {
150+ b.mu.Lock()
151+ defer b.mu.Unlock()
152+ if b.conn == conn {
153+ _ = b.conn.Close()
154+ b.conn = nil
155+ }
156+}
157+
143 func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) {158 func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) {
144 b.mu.Lock()159 b.mu.Lock()
145 if b.send == nil {160 if b.send == nil {
@@ -179,6 +194,12 @@ func (b *Inbox) Close() error {
179 // the TUI surfaces it as "agent <id> not listening yet".194 // the TUI surfaces it as "agent <id> not listening yet".
180 var ErrNotReady = errors.New("subagent: agent inbox not ready")195 var ErrNotReady = errors.New("subagent: agent inbox not ready")
181 196
197+// ErrDeliveryUnknown means a command write made progress but did not finish.
198+// Callers must not replace the command with a fresh identity: the worker may
199+// have received enough bytes to parse it, so retries must reuse the same
200+// message id and be handled idempotently.
201+var ErrDeliveryUnknown = errors.New("subagent: command delivery unknown")
202+
182 // dialUnixContext retries a Unix-socket connection briefly so the happy path203 // dialUnixContext retries a Unix-socket connection briefly so the happy path
183 // "child just started, supervisor sends right away" works without forcing the204 // "child just started, supervisor sends right away" works without forcing the
184 // caller to poll. Errors collapse onto ErrNotReady whenever the failure means205 // caller to poll. Errors collapse onto ErrNotReady whenever the failure means
M packages/agent/subagents/inbox_test.go +60 -0
@@ -127,6 +127,32 @@ func (c *blockingWriteConn) Close() error {
127 return c.Conn.Close()127 return c.Conn.Close()
128 }128 }
129 129
130+type cancelAfterWriteConn struct {
131+ net.Conn
132+ cancel context.CancelFunc
133+}
134+
135+func (c *cancelAfterWriteConn) Write(data []byte) (int, error) {
136+ c.cancel()
137+ return len(data), nil
138+}
139+
140+type partialWriteConn struct {
141+ net.Conn
142+ withError bool
143+}
144+
145+func (c *partialWriteConn) Write(data []byte) (int, error) {
146+ written := len(data) / 2
147+ if written == 0 {
148+ written = 1
149+ }
150+ if c.withError {
151+ return written, errors.New("partial write")
152+ }
153+ return written, nil
154+}
155+
130 type notifyingContext struct {156 type notifyingContext struct {
131 context.Context157 context.Context
132 doneCalled chan struct{}158 doneCalled chan struct{}
@@ -138,6 +164,40 @@ func (c *notifyingContext) Done() <-chan struct{} {
138 return c.Context.Done()164 return c.Context.Done()
139 }165 }
140 166
167+func TestInboxSendContextTreatsCompleteWriteBeforeCancellationAsDelivered(t *testing.T) {
168+ local, remote := net.Pipe()
169+ defer remote.Close()
170+ ctx, cancel := context.WithCancel(context.Background())
171+ inbox := NewInbox("unused")
172+ inbox.conn = &cancelAfterWriteConn{Conn: local, cancel: cancel}
173+ defer inbox.Close()
174+
175+ if err := inbox.sendBytesContext(ctx, []byte("follow-up\n")); err != nil {
176+ t.Fatalf("complete write after cancellation = %v, want success", err)
177+ }
178+}
179+
180+func TestInboxSendContextTreatsPartialWritesAsUnknownDelivery(t *testing.T) {
181+ for _, withError := range []bool{false, true} {
182+ name := "short write"
183+ if withError {
184+ name = "partial write error"
185+ }
186+ t.Run(name, func(t *testing.T) {
187+ local, remote := net.Pipe()
188+ defer remote.Close()
189+ inbox := NewInbox("unused")
190+ inbox.conn = &partialWriteConn{Conn: local, withError: withError}
191+ defer inbox.Close()
192+
193+ err := inbox.sendBytesContext(context.Background(), []byte("follow-up\n"))
194+ if !errors.Is(err, ErrDeliveryUnknown) {
195+ t.Fatalf("partial write error = %v, want ErrDeliveryUnknown", err)
196+ }
197+ })
198+ }
199+}
200+
141 func TestInboxSendContextCancelsBlockedWrite(t *testing.T) {201 func TestInboxSendContextCancelsBlockedWrite(t *testing.T) {
142 local, remote := net.Pipe()202 local, remote := net.Pipe()
143 defer remote.Close()203 defer remote.Close()
M packages/agent/subagents/persist.go +324 -82
@@ -22,6 +22,8 @@ import (
22 "sort"22 "sort"
23 "strings"23 "strings"
24 "time"24 "time"
25+
26+ "github.com/google/uuid"
25 )27 )
26 28
27 // agentMeta is the durable identity record for one agent. Only fields29 // agentMeta is the durable identity record for one agent. Only fields
@@ -54,6 +56,8 @@ type agentMeta struct {
54 WorkspaceBase string `json:"workspace_base,omitempty"`56 WorkspaceBase string `json:"workspace_base,omitempty"`
55 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`57 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
56 MaxTurns int `json:"max_turns,omitempty"`58 MaxTurns int `json:"max_turns,omitempty"`
59+ LifetimeTurns int `json:"lifetime_turns,omitempty"`
60+ CurrentRunTurns int `json:"current_run_turns,omitempty"`
57 Timeout time.Duration `json:"timeout,omitempty"`61 Timeout time.Duration `json:"timeout,omitempty"`
58 Tools []string `json:"tools,omitempty"`62 Tools []string `json:"tools,omitempty"`
59 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`63 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`
@@ -77,8 +81,10 @@ type agentMeta struct {
77 // ResumePrompt is a follow-up accepted by subagent_resume but not yet81 // ResumePrompt is a follow-up accepted by subagent_resume but not yet
78 // acknowledged by the child with a turn.started event. It is durable so a82 // acknowledged by the child with a turn.started event. It is durable so a
79 // host exit while the worker is queued does not lose the manager signal.83 // host exit while the worker is queued does not lose the manager signal.
80- ResumePrompt string `json:"resume_prompt,omitempty"`84+ ResumePrompt string `json:"resume_prompt,omitempty"`
81- ResumePromptAt time.Time `json:"resume_prompt_at,omitempty"`85+ ResumePromptAt time.Time `json:"resume_prompt_at,omitempty"`
86+ ResumePromptID string `json:"resume_prompt_id,omitempty"`
87+ ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"`
82 88
83 // SessionID, when non-empty, scopes the agent to a particular89 // SessionID, when non-empty, scopes the agent to a particular
84 // host zut session: the dashboard only shows agents whose90 // host zut session: the dashboard only shows agents whose
@@ -96,6 +102,12 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
96 // write is atomic (tmp + rename) so a crash mid-write can't leave a102 // write is atomic (tmp + rename) so a crash mid-write can't leave a
97 // half-parsable file that fails Reload.103 // half-parsable file that fails Reload.
98 func writeAgentMeta(stateDir string, a *Agent) error {104 func writeAgentMeta(stateDir string, a *Agent) error {
105+ if a == nil {
106+ return errors.New("subagent meta: nil agent")
107+ }
108+ a.persistMu.Lock()
109+ defer a.persistMu.Unlock()
110+
99 fastMode := a.FastMode111 fastMode := a.FastMode
100 webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)112 webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)
101 a.mu.Lock()113 a.mu.Lock()
@@ -104,6 +116,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
104 finished := a.finished116 finished := a.finished
105 resumePrompt := a.resumePromptText117 resumePrompt := a.resumePromptText
106 resumePromptAt := a.resumePromptAt118 resumePromptAt := a.resumePromptAt
119+ resumePromptID := a.resumePromptID
120+ resumeQueue := append([]queuedResumePrompt(nil), a.resumePromptQueue...)
107 errText := ""121 errText := ""
108 if a.lastErr != nil {122 if a.lastErr != nil {
109 errText = a.lastErr.Error()123 errText = a.lastErr.Error()
@@ -113,6 +127,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
113 processState := a.processState127 processState := a.processState
114 turnState := a.turnState128 turnState := a.turnState
115 currentTurnID := a.currentTurnID129 currentTurnID := a.currentTurnID
130+ lifetimeTurns := a.LifetimeTurns
131+ currentRunTurns := a.CurrentRunTurns
116 attempt := a.Attempt132 attempt := a.Attempt
117 processPID := a.ProcessPID133 processPID := a.ProcessPID
118 updatedAt := a.updatedAt134 updatedAt := a.updatedAt
@@ -143,6 +159,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
143 WorkspaceBase: a.WorkspaceBase,159 WorkspaceBase: a.WorkspaceBase,
144 WorkspaceCapture: a.WorkspaceCapture,160 WorkspaceCapture: a.WorkspaceCapture,
145 MaxTurns: a.MaxTurns,161 MaxTurns: a.MaxTurns,
162+ LifetimeTurns: lifetimeTurns,
163+ CurrentRunTurns: currentRunTurns,
146 Timeout: a.Timeout,164 Timeout: a.Timeout,
147 Tools: append([]string(nil), a.Tools...),165 Tools: append([]string(nil), a.Tools...),
148 WebSearchPolicy: webSearchPolicy,166 WebSearchPolicy: webSearchPolicy,
@@ -165,6 +183,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
165 SessionPath: a.SessionPath,183 SessionPath: a.SessionPath,
166 ResumePrompt: resumePrompt,184 ResumePrompt: resumePrompt,
167 ResumePromptAt: resumePromptAt,185 ResumePromptAt: resumePromptAt,
186+ ResumePromptID: resumePromptID,
187+ ResumeQueue: resumeQueue,
168 SessionID: a.SessionID,188 SessionID: a.SessionID,
169 }189 }
170 b, err := json.MarshalIndent(m, "", " ")190 b, err := json.MarshalIndent(m, "", " ")
@@ -175,14 +195,50 @@ func writeAgentMeta(stateDir string, a *Agent) error {
175 return fmt.Errorf("subagents meta dir: %w", err)195 return fmt.Errorf("subagents meta dir: %w", err)
176 }196 }
177 final := metaPath(stateDir)197 final := metaPath(stateDir)
178- tmp := final + ".tmp"198+ // Use a unique temporary file while holding the per-agent persistence
179- if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {199+ // lock. A fixed meta.json.tmp is vulnerable to a concurrent writer (or a
200+ // stale file after a crash) replacing another writer's snapshot.
201+ tmpFile, err := os.CreateTemp(stateDir, ".meta.json.tmp-*")
202+ if err != nil {
203+ return fmt.Errorf("subagents meta temp: %w", err)
204+ }
205+ tmp := tmpFile.Name()
206+ removeTemp := true
207+ defer func() {
208+ _ = tmpFile.Close()
209+ if removeTemp {
210+ _ = os.Remove(tmp)
211+ }
212+ }()
213+ if err := tmpFile.Chmod(0o600); err != nil {
214+ return fmt.Errorf("subagents meta permissions: %w", err)
215+ }
216+ if _, err := tmpFile.Write(append(b, '\n')); err != nil {
180 return fmt.Errorf("subagents meta write: %w", err)217 return fmt.Errorf("subagents meta write: %w", err)
181 }218 }
219+ if err := tmpFile.Sync(); err != nil {
220+ return fmt.Errorf("subagents meta sync: %w", err)
221+ }
222+ if err := tmpFile.Close(); err != nil {
223+ return fmt.Errorf("subagents meta close: %w", err)
224+ }
182 if err := os.Rename(tmp, final); err != nil {225 if err := os.Rename(tmp, final); err != nil {
183- _ = os.Remove(tmp)
184 return fmt.Errorf("subagent meta rename: %w", err)226 return fmt.Errorf("subagent meta rename: %w", err)
185 }227 }
228+ removeTemp = false
229+ // Sync the containing directory so the rename itself survives a host
230+ // crash, not just the bytes in the temporary file.
231+ dir, err := os.Open(stateDir)
232+ if err != nil {
233+ return fmt.Errorf("subagents meta directory open: %w", err)
234+ }
235+ if err := dir.Sync(); err != nil {
236+ _ = dir.Close()
237+ return fmt.Errorf("subagents meta directory sync: %w", err)
238+ }
239+ if err := dir.Close(); err != nil {
240+ return fmt.Errorf("subagents meta directory close: %w", err)
241+ }
186 return nil242 return nil
187 }243 }
188 244
@@ -420,57 +476,61 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
420 agentStateDir = filepath.Dir(m.EventLogPath)476 agentStateDir = filepath.Dir(m.EventLogPath)
421 }477 }
422 a := &Agent{478 a := &Agent{
423- ID: m.ID,479+ ID: m.ID,
424- Task: m.Task,480+ Task: m.Task,
425- OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),481+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
426- Dir: dir,482+ Dir: dir,
427- RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),483+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
428- Started: m.Started,484+ Started: m.Started,
429- ParentID: m.ParentID,485+ ParentID: m.ParentID,
430- BatchID: m.BatchID,486+ BatchID: m.BatchID,
431- RootSessionID: m.RootSessionID,487+ RootSessionID: m.RootSessionID,
432- Model: m.Model,488+ Model: m.Model,
433- Provider: m.Provider,489+ Provider: m.Provider,
434- BaseURL: m.BaseURL,490+ BaseURL: m.BaseURL,
435- InsecureTLS: m.InsecureTLS,491+ InsecureTLS: m.InsecureTLS,
436- Reasoning: m.Reasoning,492+ Reasoning: m.Reasoning,
437- FastMode: fastMode,493+ FastMode: fastMode,
438- Subagent: m.Subagent,494+ Subagent: m.Subagent,
439- WorkspaceMode: m.WorkspaceMode,495+ WorkspaceMode: m.WorkspaceMode,
440- WorkspacePath: m.WorkspacePath,496+ WorkspacePath: m.WorkspacePath,
441- WorkspaceBase: m.WorkspaceBase,497+ WorkspaceBase: m.WorkspaceBase,
442- WorkspaceCapture: m.WorkspaceCapture,498+ WorkspaceCapture: m.WorkspaceCapture,
443- MaxTurns: m.MaxTurns,499+ MaxTurns: m.MaxTurns,
444- Timeout: m.Timeout,500+ LifetimeTurns: m.LifetimeTurns,
445- Tools: append([]string(nil), m.Tools...),501+ CurrentRunTurns: m.CurrentRunTurns,
446- WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),502+ Timeout: m.Timeout,
447- Attempt: m.Attempt,503+ Tools: append([]string(nil), m.Tools...),
448- ProcessPID: m.ProcessPID,504+ WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
449- InboxPath: m.InboxPath,505+ Attempt: m.Attempt,
450- EventLogPath: m.EventLogPath,506+ ProcessPID: m.ProcessPID,
451- SessionPath: m.SessionPath,507+ InboxPath: m.InboxPath,
452- resumePromptText: m.ResumePrompt,508+ EventLogPath: m.EventLogPath,
453- resumePromptAt: m.ResumePromptAt,509+ SessionPath: m.SessionPath,
454- SessionID: m.SessionID,510+ resumePromptText: m.ResumePrompt,
455- inbox: NewInbox(m.InboxPath),511+ resumePromptAt: m.ResumePromptAt,
456- status: status,512+ resumePromptID: m.ResumePromptID,
457- activity: activity,513+ resumePromptQueue: append([]queuedResumePrompt(nil), m.ResumeQueue...),
458- finished: m.Finished,514+ SessionID: m.SessionID,
459- lastErr: lastErr,515+ inbox: NewInbox(m.InboxPath),
460- legacyEventState: legacyEventState,516+ status: status,
461- processState: processState,517+ activity: activity,
462- turnState: turnState,518+ finished: m.Finished,
463- currentTurnID: m.CurrentTurnID,519+ lastErr: lastErr,
464- updatedAt: m.UpdatedAt,520+ legacyEventState: legacyEventState,
465- lastActivity: lastActivity,521+ processState: processState,
466- stateDir: agentStateDir,522+ turnState: turnState,
467- resultRef: m.ResultRef,523+ currentTurnID: m.CurrentTurnID,
468- patchRef: m.PatchRef,524+ updatedAt: m.UpdatedAt,
469- changedFiles: append([]string(nil), m.ChangedFiles...),525+ lastActivity: lastActivity,
470- maxOutputBytes: f.cfg.Policy.MaxOutputBytes,526+ stateDir: agentStateDir,
471- maxOutputLines: f.cfg.Policy.MaxOutputLines,527+ resultRef: m.ResultRef,
472- done: make(chan struct{}),528+ patchRef: m.PatchRef,
473- turnResults: make(chan *TurnResult, 16),529+ changedFiles: append([]string(nil), m.ChangedFiles...),
530+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
531+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
532+ done: make(chan struct{}),
533+ turnResults: make(chan *TurnResult, 16),
474 }534 }
475 if a.updatedAt.IsZero() {535 if a.updatedAt.IsZero() {
476 a.updatedAt = lastActivity536 a.updatedAt = lastActivity
@@ -542,7 +602,7 @@ func (a *Agent) loadTranscript() error {
542 if err != nil {602 if err != nil {
543 return fmt.Errorf("read subagent transcript: %w", err)603 return fmt.Errorf("read subagent transcript: %w", err)
544 }604 }
545- if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, acceptedAt) {605+ if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, prompt, acceptedAt) {
546 a.clearResumePrompt()606 a.clearResumePrompt()
547 }607 }
548 a.mu.Lock()608 a.mu.Lock()
@@ -583,12 +643,23 @@ func (a *Agent) loadTranscript() error {
583 // Status precedence: explicit lifecycle events (agent_stopped) win643 // Status precedence: explicit lifecycle events (agent_stopped) win
584 // over inferred ones (assistant_message → idle). If the log contains644 // over inferred ones (assistant_message → idle). If the log contains
585 // no terminator we keep status=StatusDetached so the user can resume.645 // no terminator we keep status=StatusDetached so the user can resume.
586-func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {646+func resumePromptAcknowledged(events []Event, prompt string, acceptedAt time.Time) bool {
587- if acceptedAt.IsZero() {647+ if prompt == "" || acceptedAt.IsZero() {
588 return false648 return false
589 }649 }
650+ userDurable := false
590 for _, ev := range events {651 for _, ev := range events {
591- if (ev.Type == EventTurnStarted || ev.Type == "turn_start") && !ev.Time.Before(acceptedAt) {652+ if ev.Time.Before(acceptedAt) {
653+ continue
654+ }
655+ if ev.Type == "user_message" && userMessageText(ev) == prompt {
656+ // The worker emits this event only after the matching session
657+ // message has been appended and flushed. A crash here leaves the
658+ // prompt pending, which is intentionally safe to replay.
659+ userDurable = true
660+ continue
661+ }
662+ if userDurable && isDelegatedTurnStart(ev) {
592 return true663 return true
593 }664 }
594 }665 }
@@ -598,15 +669,13 @@ func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
598 func replayEventsIntoAgent(a *Agent, evs []Event) {669 func replayEventsIntoAgent(a *Agent, evs []Event) {
599 terminal := false670 terminal := false
600 for _, ev := range evs {671 for _, ev := range evs {
672+ replayEventTranscript(a, ev)
601 switch ev.Type {673 switch ev.Type {
602- case EventTurnStarted, "turn_start":674+ case EventTurnStarted:
603- turnID := ev.TurnID675+ if !isDelegatedTurnStart(ev) {
604- if turnID == "" {676+ continue
605- if step, ok := ev.Data["step"].(float64); ok {
606- turnID = fmt.Sprintf("turn-%d", int(step))
607- }
608 }677 }
609- a.setTurnState(TurnRunning, turnID)678+ a.setTurnState(TurnRunning, eventTurnID(ev))
610 case EventTurnResult, "turn_result":679 case EventTurnResult, "turn_result":
611 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {680 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
612 a.setResult(result)681 a.setResult(result)
@@ -627,8 +696,6 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
627 a.status = StatusDone696 a.status = StatusDone
628 a.activity = "done (offline)"697 a.activity = "done (offline)"
629 a.mu.Unlock()698 a.mu.Unlock()
630- case "assistant_message", "user_message", "stdout", "stderr", "error":
631- replayEventTranscript(a, ev)
632 case "agent_stopped":699 case "agent_stopped":
633 terminal = true700 terminal = true
634 reason, _ := ev.Data["reason"].(string)701 reason, _ := ev.Data["reason"].(string)
@@ -677,7 +744,14 @@ func replayTranscriptIntoAgent(a *Agent, evs []Event) {
677 }744 }
678 745
679 func replayEventTranscript(a *Agent, ev Event) {746 func replayEventTranscript(a *Agent, ev Event) {
747+ if isAssistantStreamBoundary(ev.Type) {
748+ a.resetStreamingAssistant()
749+ }
680 switch ev.Type {750 switch ev.Type {
751+ case "message.delta":
752+ if delta, _ := ev.Data["delta"].(string); delta != "" {
753+ a.appendAssistantDelta(delta)
754+ }
681 case "assistant_message", "user_message":755 case "assistant_message", "user_message":
682 var text []string756 var text []string
683 if c, ok := ev.Data["content"].([]any); ok {757 if c, ok := ev.Data["content"].([]any); ok {
@@ -735,9 +809,16 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro
735 809
736 // ResumeWithPrompt continues an agent with a manager follow-up while retaining810 // ResumeWithPrompt continues an agent with a manager follow-up while retaining
737 // its existing session. An idle live worker receives the turn through its811 // its existing session. An idle live worker receives the turn through its
738-// inbox; a terminal worker is restarted with the follow-up as its initial812+// inbox; an active live worker queues the follow-up for the next idle turn; a
739-// prompt, avoiding a race with inbox listener startup.813+// terminal worker is restarted with the follow-up as its initial prompt,
814+// avoiding a race with inbox listener startup.
740 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {815 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
816+ if ctx == nil {
817+ ctx = context.Background()
818+ }
819+ if err := ctx.Err(); err != nil {
820+ return nil, err
821+ }
741 if strings.TrimSpace(prompt) == "" {822 if strings.TrimSpace(prompt) == "" {
742 return nil, fmt.Errorf("subagent: follow-up prompt is required")823 return nil, fmt.Errorf("subagent: follow-up prompt is required")
743 }824 }
@@ -750,34 +831,82 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
750 }831 }
751 status := existing.Status()832 status := existing.Status()
752 if status == StatusRunning {833 if status == StatusRunning {
753- if existing.ProcessState() != ProcessAlive || existing.TurnState() != TurnIdle {834+ if existing.ProcessState() != ProcessAlive {
754 f.operationMu.Unlock()835 f.operationMu.Unlock()
755- return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID)836+ return nil, fmt.Errorf("subagent: agent %s is still working; wait for its worker to become available before sending a follow-up", existing.ID)
756 }837 }
757 if existing.inbox == nil {838 if existing.inbox == nil {
758 f.operationMu.Unlock()839 f.operationMu.Unlock()
759 return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID)840 return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID)
760 }841 }
842+ if err := ctx.Err(); err != nil {
843+ f.operationMu.Unlock()
844+ return nil, err
845+ }
846+ if existing.TurnState() != TurnRunning && existing.hasQueuedResumePrompt() {
847+ f.operationMu.Unlock()
848+ return nil, fmt.Errorf("subagent: agent %s already has a follow-up pending delivery", existing.ID)
849+ }
850+ if existing.TurnState() == TurnRunning {
851+ // Active turns cannot accept a command through the worker inbox yet.
852+ // Keep the prompt durable and let the idle transition deliver it in
853+ // order after the current message turn finishes.
854+ acceptedAt := f.cfg.Now()
855+ commandID := existing.queueResumePrompt(prompt, acceptedAt)
856+ if err := f.persistAgent(existing); err != nil {
857+ existing.removeQueuedResumePrompt(commandID)
858+ f.operationMu.Unlock()
859+ return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)
860+ }
861+ f.operationMu.Unlock()
862+ return existing, nil
863+ }
864+ if existing.TurnState() != TurnIdle {
865+ f.operationMu.Unlock()
866+ return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID)
867+ }
761 turnID := existing.CurrentTurnID()868 turnID := existing.CurrentTurnID()
869+ previousRunTurns := existing.resetCurrentRunTurns()
762 // Reserve the worker before writing to its inbox. A second manager call870 // Reserve the worker before writing to its inbox. A second manager call
763 // must not enqueue another turn in the small interval before the worker871 // must not enqueue another turn in the small interval before the worker
764 // reports its turn.start event. Persist the prompt first so a host exit872 // reports its turn.start event. Persist the prompt first so a host exit
765 // before that event can resume and redeliver it.873 // before that event can resume and redeliver it.
766- previousPrompt, previousPromptAt := existing.setResumePrompt(prompt, f.cfg.Now())874+ previousPrompt := existing.setResumePrompt(prompt, f.cfg.Now())
767 existing.setTurnState(TurnQueued, turnID)875 existing.setTurnState(TurnQueued, turnID)
768 inbox := existing.inbox876 inbox := existing.inbox
769- command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt})877+ command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt, NewRun: true})
770- f.persistAgent(existing)878+ command.MessageID = existing.resumePromptCommandID()
879+ if err := f.persistAgent(existing); err != nil {
880+ existing.setTurnState(TurnIdle, turnID)
881+ existing.setCurrentRunTurns(previousRunTurns)
882+ existing.restoreResumePrompt(previousPrompt)
883+ f.operationMu.Unlock()
884+ return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)
885+ }
771 f.operationMu.Unlock()886 f.operationMu.Unlock()
772 887
773 if err := inbox.SendCommandContext(ctx, command); err != nil {888 if err := inbox.SendCommandContext(ctx, command); err != nil {
889+ retryUnknown := false
774 f.operationMu.Lock()890 f.operationMu.Lock()
775 if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID {891 if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID {
776 existing.setTurnState(TurnIdle, turnID)892 existing.setTurnState(TurnIdle, turnID)
777- existing.setResumePrompt(previousPrompt, previousPromptAt)893+ existing.setCurrentRunTurns(previousRunTurns)
778- f.persistAgent(existing)894+ if errors.Is(err, ErrDeliveryUnknown) {
895+ existing.rejectActiveResumePrompt()
896+ retryUnknown = true
897+ } else {
898+ existing.restoreResumePrompt(previousPrompt)
899+ }
900+ if persistErr := f.persistAgent(existing); persistErr != nil {
901+ err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)
902+ }
779 }903 }
780 f.operationMu.Unlock()904 f.operationMu.Unlock()
905+ if retryUnknown {
906+ // The command keeps its durable identity. A retry is safe whether
907+ // the worker parsed the first write or only received a prefix.
908+ f.dispatchQueuedResume(ctx, existing)
909+ }
781 return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err)910 return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err)
782 }911 }
783 return existing, nil912 return existing, nil
@@ -787,6 +916,77 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
787 return f.resume(ctx, id, true, prompt)916 return f.resume(ctx, id, true, prompt)
788 }917 }
789 918
919+// dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a
920+// manager request, an idle transition has no caller context to propagate.
921+func (f *Supervisor) dispatchQueuedResumeWithTimeout(a *Agent) {
922+ timeout := f.cfg.Policy.ReconnectTimeout
923+ if timeout <= 0 {
924+ timeout = 5 * time.Second
925+ }
926+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
927+ defer cancel()
928+ f.dispatchQueuedResume(ctx, a)
929+}
930+
931+// dispatchQueuedResume delivers the oldest follow-up accepted during an
932+// active turn after the worker reports that its inbox can accept a new turn.
933+// The operation lock serializes this transition with manager resume calls and
934+// the agent's durable prompt state prevents a host exit from losing it.
935+func (f *Supervisor) dispatchQueuedResume(ctx context.Context, a *Agent) {
936+ if ctx == nil {
937+ ctx = context.Background()
938+ }
939+ if a == nil {
940+ return
941+ }
942+ f.operationMu.Lock()
943+ if a.Status() != StatusRunning || a.ProcessState() != ProcessAlive || a.TurnState() != TurnIdle || a.inbox == nil {
944+ f.operationMu.Unlock()
945+ return
946+ }
947+ prompt, acceptedAt, commandID, ok := a.startQueuedResumePrompt()
948+ if !ok {
949+ f.operationMu.Unlock()
950+ return
951+ }
952+ turnID := a.CurrentTurnID()
953+ previousRunTurns := a.resetCurrentRunTurns()
954+ a.setTurnState(TurnQueued, turnID)
955+ inbox := a.inbox
956+ command := NewCommand(CommandTurnStart, a.ID, turnID, TurnStartPayload{Prompt: prompt, NewRun: true})
957+ command.MessageID = commandID
958+ if err := f.persistAgent(a); err != nil {
959+ a.restoreResumePromptToQueue(prompt, acceptedAt)
960+ a.setCurrentRunTurns(previousRunTurns)
961+ a.setTurnState(TurnIdle, turnID)
962+ f.operationMu.Unlock()
963+ return
964+ }
965+ f.operationMu.Unlock()
966+
967+ if err := inbox.SendCommandContext(ctx, command); err == nil {
968+ return
969+ }
970+
971+ // A failed delivery must put the prompt back at the head of the queue.
972+ // Keep the worker idle so a later retry, or an explicit resume, can send
973+ // it without skipping any subsequent follow-ups.
974+ f.operationMu.Lock()
975+ if a.Status() == StatusRunning && a.ProcessState() == ProcessAlive && a.TurnState() == TurnQueued && a.CurrentTurnID() == turnID {
976+ if a.restoreResumePromptToQueue(prompt, acceptedAt) {
977+ a.setCurrentRunTurns(previousRunTurns)
978+ a.setTurnState(TurnIdle, turnID)
979+ if persistErr := f.persistAgent(a); persistErr != nil {
980+ // persistAgent records the failure on the agent. Keep the explicit
981+ // check here because this recovery write is the only durable copy
982+ // of the restored queue head.
983+ a.recordPersistenceError(persistErr)
984+ }
985+ }
986+ }
987+ f.operationMu.Unlock()
988+}
989+
790 // RestartTask intentionally runs the stored original task again in a fresh990 // RestartTask intentionally runs the stored original task again in a fresh
791 // worker attempt. Callers should use ResumeSession for normal recovery.991 // worker attempt. Callers should use ResumeSession for normal recovery.
792 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {992 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
@@ -852,10 +1052,45 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
852 }1052 }
853 now := f.cfg.Now()1053 now := f.cfg.Now()
854 fastMode := existing.FastMode1054 fastMode := existing.FastMode
855- pendingResumePrompt, pendingResumePromptAt := existing.ResumePromptInfo()1055+ lifetimeTurns := existing.LifetimeTurnsValue()
1056+ currentRunTurns := existing.CurrentRunTurnsValue()
1057+ pendingResume := existing.resumePromptState()
1058+ pendingResumePrompt, pendingResumePromptAt, pendingResumePromptID := pendingResume.Prompt, pendingResume.AcceptedAt, pendingResume.CommandID
1059+ pendingResumeQueue := existing.resumePromptQueueSnapshot()
1060+ if pendingResumePrompt != "" && pendingResumePromptID == "" {
1061+ pendingResumePromptID = uuid.NewString()
1062+ }
856 if resumePrompt != "" {1063 if resumePrompt != "" {
857- pendingResumePrompt = resumePrompt1064+ currentRunTurns = 0
858- pendingResumePromptAt = now1065+ if pendingResumePrompt == "" {
1066+ pendingResumePrompt = resumePrompt
1067+ pendingResumePromptAt = now
1068+ pendingResumePromptID = uuid.NewString()
1069+ } else {
1070+ pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1071+ }
1072+ }
1073+ if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
1074+ // A host can exit after accepting an active-worker follow-up but before
1075+ // dispatching it. Make the first durable queued prompt the resumed
1076+ // worker's initial turn rather than leaving the queue stranded.
1077+ queued := pendingResumeQueue[0]
1078+ pendingResumePrompt = queued.Prompt
1079+ pendingResumePromptAt = queued.AcceptedAt
1080+ pendingResumePromptID = queued.CommandID
1081+ if pendingResumePromptID == "" {
1082+ pendingResumePromptID = uuid.NewString()
1083+ }
1084+ pendingResumeQueue = pendingResumeQueue[1:]
1085+ }
1086+ if resuming && pendingResumePrompt != "" {
1087+ // A retained follow-up is an explicit new run, including a queued
1088+ // follow-up recovered after a host restart. Preserve lifetime turns but
1089+ // never carry the previous run's allowance into this one.
1090+ currentRunTurns = 0
1091+ }
1092+ if !resuming {
1093+ currentRunTurns = 0
859 }1094 }
860 m := agentMeta{1095 m := agentMeta{
861 ID: existing.ID, Task: existing.Task,1096 ID: existing.ID, Task: existing.Task,
@@ -868,7 +1103,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
868 Subagent: existing.Subagent,1103 Subagent: existing.Subagent,
869 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,1104 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
870 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,1105 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
871- MaxTurns: existing.MaxTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),1106+ MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
872 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),1107 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
873 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,1108 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
874 SessionID: existing.SessionID,1109 SessionID: existing.SessionID,
@@ -876,6 +1111,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
876 SessionPath: existing.SessionPath,1111 SessionPath: existing.SessionPath,
877 ResumePrompt: pendingResumePrompt,1112 ResumePrompt: pendingResumePrompt,
878 ResumePromptAt: pendingResumePromptAt,1113 ResumePromptAt: pendingResumePromptAt,
1114+ ResumePromptID: pendingResumePromptID,
1115+ ResumeQueue: pendingResumeQueue,
879 }1116 }
880 1117
881 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {1118 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
@@ -954,6 +1191,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
954 WorkspaceBase: m.WorkspaceBase,1191 WorkspaceBase: m.WorkspaceBase,
955 WorkspaceCapture: m.WorkspaceCapture,1192 WorkspaceCapture: m.WorkspaceCapture,
956 MaxTurns: maxTurns,1193 MaxTurns: maxTurns,
1194+ LifetimeTurns: m.LifetimeTurns,
1195+ CurrentRunTurns: m.CurrentRunTurns,
957 Timeout: timeout,1196 Timeout: timeout,
958 Attempt: m.Attempt,1197 Attempt: m.Attempt,
959 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,1198 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
@@ -965,6 +1204,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
965 Resuming: resuming,1204 Resuming: resuming,
966 resumePromptText: pendingResumePrompt,1205 resumePromptText: pendingResumePrompt,
967 resumePromptAt: pendingResumePromptAt,1206 resumePromptAt: pendingResumePromptAt,
1207+ resumePromptID: m.ResumePromptID,
1208+ resumePromptQueue: append([]queuedResumePrompt(nil), pendingResumeQueue...),
968 inbox: NewInbox(m.InboxPath),1209 inbox: NewInbox(m.InboxPath),
969 status: StatusPending,1210 status: StatusPending,
970 activity: "resuming",1211 activity: "resuming",
@@ -989,6 +1230,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
989 }1230 }
990 a.ctx, a.cancel = runCtx, cancel1231 a.ctx, a.cancel = runCtx, cancel
991 a.persistFn = f.persistAgent1232 a.persistFn = f.persistAgent
1233+ a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
992 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }1234 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
993 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }1235 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
994 a.runner = f.cfg.NewRunner(a)1236 a.runner = f.cfg.NewRunner(a)
M packages/agent/subagents/persist_test.go +32 -1
@@ -14,6 +14,34 @@ import (
14 "time"14 "time"
15 )15 )
16 16
17+func TestWriteAgentMetaSerializesConcurrentSnapshots(t *testing.T) {
18+ stateDir := t.TempDir()
19+ a := &Agent{ID: "concurrent-meta", Task: "retain queued prompts", stateDir: stateDir}
20+ var wg sync.WaitGroup
21+ for i := 0; i < 32; i++ {
22+ wg.Add(1)
23+ go func(i int) {
24+ defer wg.Done()
25+ a.setTurnCounts(i, i/2)
26+ a.queueResumePrompt("prompt", time.Now())
27+ if err := writeAgentMeta(stateDir, a); err != nil {
28+ t.Errorf("write metadata: %v", err)
29+ }
30+ }(i)
31+ }
32+ wg.Wait()
33+ if _, err := readAgentMeta(stateDir); err != nil {
34+ t.Fatalf("final metadata is not a complete JSON snapshot: %v", err)
35+ }
36+ matches, err := filepath.Glob(filepath.Join(stateDir, ".meta.json.tmp-*"))
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if len(matches) != 0 {
41+ t.Fatalf("temporary metadata files remain: %v", matches)
42+ }
43+}
44+
17 // TestSpawnWritesMetaJSON asserts the durability contract: every45 // TestSpawnWritesMetaJSON asserts the durability contract: every
18 // successful Spawn leaves a meta.json on disk with the agent's46 // successful Spawn leaves a meta.json on disk with the agent's
19 // identity bits. Without this, Reload on the next zut launch can't47 // identity bits. Without this, Reload on the next zut launch can't
@@ -662,7 +690,7 @@ func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) {
662 EventLogPath: filepath.Join(stateDir, "events.jsonl"),690 EventLogPath: filepath.Join(stateDir, "events.jsonl"),
663 SessionPath: filepath.Join(stateDir, "session.json"),691 SessionPath: filepath.Join(stateDir, "session.json"),
664 ResumePrompt: "I applied your review. What do you think now?",692 ResumePrompt: "I applied your review. What do you think now?",
665- ResumePromptAt: time.Now(),693+ ResumePromptAt: time.Now().Add(-time.Second),
666 }694 }
667 data, err := json.Marshal(m)695 data, err := json.Marshal(m)
668 if err != nil {696 if err != nil {
@@ -675,6 +703,9 @@ func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) {
675 if err != nil {703 if err != nil {
676 t.Fatal(err)704 t.Fatal(err)
677 }705 }
706+ if err := log.Append(NewEvent("user_message", map[string]any{"content": []any{map[string]any{"type": "text", "text": m.ResumePrompt}}})); err != nil {
707+ t.Fatal(err)
708+ }
678 if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil {709 if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil {
679 t.Fatal(err)710 t.Fatal(err)
680 }711 }
M packages/agent/subagents/protocol.go +4 -0
@@ -579,6 +579,10 @@ type AgentHeartbeatPayload struct {
579 }579 }
580 type TurnStartPayload struct {580 type TurnStartPayload struct {
581 Prompt string `json:"prompt"`581 Prompt string `json:"prompt"`
582+ // NewRun starts a fresh max_turns budget while retaining the worker's
583+ // existing session and transcript. It is set only for explicit
584+ // subagent_resume follow-ups.
585+ NewRun bool `json:"new_run,omitempty"`
582 }586 }
583 type TurnCancelPayload struct {587 type TurnCancelPayload struct {
584 Reason string `json:"reason,omitempty"`588 Reason string `json:"reason,omitempty"`
M packages/agent/subagents/result.go +26 -4
@@ -443,6 +443,9 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
443 return443 return
444 }444 }
445 attempt := a.AttemptValue()445 attempt := a.AttemptValue()
446+ a.mu.Lock()
447+ partialOutput := a.lastAssistant
448+ a.mu.Unlock()
446 result := a.Result()449 result := a.Result()
447 if result != nil {450 if result != nil {
448 if err := validateTurnResultAgent(result, a.ID); err != nil {451 if err := validateTurnResultAgent(result, a.ID); err != nil {
@@ -468,14 +471,20 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
468 result = nil471 result = nil
469 }472 }
470 }473 }
474+ if result != nil && result.Output == "" && partialOutput != "" &&
475+ (result.Status == ResultFailed || result.Status == ResultCanceled || status == StatusFailed || status == StatusKilled) {
476+ // A canceled or failed worker turn may emit its terminal result before
477+ // its provider turns accumulated text into a final assistant message.
478+ // The event stream has already projected that text onto the Agent, so
479+ // retain it in the durable result rather than returning an empty failure.
480+ result.Output = partialOutput
481+ }
471 if result == nil {482 if result == nil {
472 turnID := a.CurrentTurnID()483 turnID := a.CurrentTurnID()
473 if turnID == "" {484 if turnID == "" {
474 turnID = fmt.Sprintf("turn-%d", attempt)485 turnID = fmt.Sprintf("turn-%d", attempt)
475 }486 }
476- a.mu.Lock()487+ output := partialOutput
477- output := a.lastAssistant
478- a.mu.Unlock()
479 resultStatus := ResultSucceeded488 resultStatus := ResultSucceeded
480 switch {489 switch {
481 case status == StatusFailed && errors.Is(runErr, context.Canceled):490 case status == StatusFailed && errors.Is(runErr, context.Canceled):
@@ -512,7 +521,20 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
512 } else {521 } else {
513 result.Status = ResultFailed522 result.Status = ResultFailed
514 }523 }
515- if result.Error == nil {524+ if errors.Is(runErr, context.DeadlineExceeded) {
525+ // The parent deadline is authoritative for terminal categorization, so
526+ // it intentionally overrides any child result error, including
527+ // context_limit. The worker was first asked to stop cleanly, and any
528+ // streamed output remains in this result and HistoryRef(agentID).
529+ message := "subagent deadline exceeded; inspect history for received output"
530+ if result.Output != "" {
531+ message = "subagent deadline exceeded; partial output is preserved in the result and history"
532+ }
533+ result.Error = &ResultError{
534+ Code: "deadline_exceeded",
535+ Message: message,
536+ }
537+ } else if result.Error == nil {
516 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}538 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517 }539 }
518 }540 }
M packages/agent/subagents/result_test.go +52 -10
@@ -269,23 +269,35 @@ func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
269 269
270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271 tests := []struct {271 tests := []struct {
272- name string272+ name string
273- status Status273+ status Status
274- runErr error274+ runErr error
275- wantState TurnStatus275+ childCode string
276+ wantState TurnStatus
277+ wantCode string
278+ wantOutput string
276 }{279 }{
277- {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},280+ {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
278- {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},281+ {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
279- {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},282+ {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283+ {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
284+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
280 }285 }
281 for _, tt := range tests {286 for _, tt := range tests {
282 t.Run(tt.name, func(t *testing.T) {287 t.Run(tt.name, func(t *testing.T) {
283 stateDir := t.TempDir()288 stateDir := t.TempDir()
289+ childStatus := ResultSucceeded
290+ var childError *ResultError
291+ if tt.childCode != "" {
292+ childStatus = ResultFailed
293+ childError = &ResultError{Code: tt.childCode, Message: "child error"}
294+ }
284 agent := &Agent{295 agent := &Agent{
285- ID: "agent-1",296+ ID: "agent-1",
286- stateDir: stateDir,297+ stateDir: stateDir,
298+ lastAssistant: tt.wantOutput,
287 result: &TurnResult{299 result: &TurnResult{
288- AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,300+ AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
289 },301 },
290 }302 }
291 supervisor := &Supervisor{}303 supervisor := &Supervisor{}
@@ -297,6 +309,12 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
297 if result.Error == nil {309 if result.Error == nil {
298 t.Fatal("runner error was not recorded")310 t.Fatal("runner error was not recorded")
299 }311 }
312+ if result.Error.Code != tt.wantCode {
313+ t.Fatalf("result error code = %q, want %q", result.Error.Code, tt.wantCode)
314+ }
315+ if result.Output != tt.wantOutput {
316+ t.Fatalf("result output = %q, want %q", result.Output, tt.wantOutput)
317+ }
300 durable, err := readTurnResult(stateDir)318 durable, err := readTurnResult(stateDir)
301 if err != nil {319 if err != nil {
302 t.Fatal(err)320 t.Fatal(err)
@@ -304,6 +322,30 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
304 if durable.Status != tt.wantState {322 if durable.Status != tt.wantState {
305 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)323 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
306 }324 }
325+ if durable.Output != tt.wantOutput {
326+ t.Fatalf("durable result output = %q, want %q", durable.Output, tt.wantOutput)
327+ }
328+ if durable.Error == nil || durable.Error.Code != tt.wantCode {
329+ t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
330+ }
307 })331 })
308 }332 }
309 }333 }
334+
335+func TestEnsureResultDoesNotBackfillSuccessfulEmptyOutput(t *testing.T) {
336+ stateDir := t.TempDir()
337+ agent := &Agent{
338+ ID: "agent-1",
339+ stateDir: stateDir,
340+ lastAssistant: "streamed text",
341+ result: &TurnResult{
342+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
343+ },
344+ }
345+
346+ (&Supervisor{}).ensureResult(agent, StatusDone, nil)
347+
348+ if result := agent.Result(); result == nil || result.Output != "" {
349+ t.Fatalf("successful result = %#v, want intentionally empty output", result)
350+ }
351+}
M packages/agent/subagents/resume_prompt_test.go +149 -0
@@ -2,12 +2,151 @@ package subagents
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "path/filepath"6 "path/filepath"
6 "runtime"7 "runtime"
7 "testing"8 "testing"
8 "time"9 "time"
9 )10 )
10 11
12+func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
13+ if runtime.GOOS == "windows" {
14+ t.Skip("subagent inbox transport uses Unix-domain sockets")
15+ }
16+
17+ root := shortSocketDir(t)
18+ ready := make(chan struct{}, 1)
19+ commands := make(chan Envelope, 2)
20+ f := New(Config{
21+ Root: root, RepoRoot: root,
22+ NewRunner: func(a *Agent) Runner {
23+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
24+ listener, err := Listen(a.InboxPath)
25+ if err != nil {
26+ return err
27+ }
28+ defer listener.Close()
29+ a.setProcessState(ProcessAlive)
30+ a.setTurnState(TurnRunning, "turn-1")
31+ ready <- struct{}{}
32+ for {
33+ select {
34+ case <-ctx.Done():
35+ return ctx.Err()
36+ case line, ok := <-listener.Lines():
37+ if !ok {
38+ return nil
39+ }
40+ command, err := ParseCommand(line)
41+ if err != nil {
42+ return err
43+ }
44+ if command.Type == CommandAgentShutdown {
45+ return nil
46+ }
47+ commands <- command
48+ }
49+ }
50+ })
51+ },
52+ })
53+ defer f.StopAll()
54+
55+ a, err := f.Spawn(context.Background(), "review the implementation")
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ select {
60+ case <-ready:
61+ case <-time.After(time.Second):
62+ t.Fatal("active worker did not open its inbox")
63+ }
64+ canceled, cancel := context.WithCancel(context.Background())
65+ cancel()
66+ if _, err := f.ResumeWithPrompt(canceled, a.ID, "must not be accepted"); !errors.Is(err, context.Canceled) {
67+ t.Fatalf("canceled active follow-up error = %v, want context canceled", err)
68+ }
69+
70+ const (
71+ firstFollowUp = "Check the parser behavior."
72+ secondFollowUp = "Then check the error handling."
73+ )
74+ if _, err := f.ResumeWithPrompt(context.Background(), a.ID, firstFollowUp); err != nil {
75+ t.Fatalf("queue first active follow-up: %v", err)
76+ }
77+ if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {
78+ t.Fatalf("queue second active follow-up: %v", err)
79+ }
80+ select {
81+ case command := <-commands:
82+ t.Fatalf("active follow-up was delivered before idle: %v", command.Type)
83+ default:
84+ }
85+
86+ persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if persisted.ResumePrompt != "" || len(persisted.ResumeQueue) != 2 {
91+ t.Fatalf("active follow-ups persisted as prompt %q queue %d, want empty prompt and queue 2", persisted.ResumePrompt, len(persisted.ResumeQueue))
92+ }
93+ if firstID, secondID := persisted.ResumeQueue[0].CommandID, persisted.ResumeQueue[1].CommandID; firstID == "" || secondID == "" || firstID == secondID {
94+ t.Fatalf("queued follow-up command IDs = %q, %q; want distinct non-empty IDs", firstID, secondID)
95+ }
96+
97+ if err := updateAgentFromEvent(a, NewEvent(EventAgentIdle, map[string]any{"turn_id": "turn-1"})); err != nil {
98+ t.Fatalf("apply first idle event: %v", err)
99+ }
100+ select {
101+ case command := <-commands:
102+ var payload TurnStartPayload
103+ if command.Type != CommandTurnStart || command.DecodePayload(&payload) != nil || payload.Prompt != firstFollowUp {
104+ t.Fatalf("first idle delivery = type %q prompt %q, want turn start %q", command.Type, payload.Prompt, firstFollowUp)
105+ }
106+ if !payload.NewRun {
107+ t.Fatal("first queued follow-up did not request a fresh run budget")
108+ }
109+ case <-time.After(time.Second):
110+ t.Fatal("first active follow-up was not delivered at idle")
111+ }
112+ if got := a.TurnState(); got != TurnQueued {
113+ t.Fatalf("turn state after first queued delivery = %s, want %s", got, TurnQueued)
114+ }
115+
116+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
117+ "turn_id": "turn-2", "step": float64(2), "lifetime_turns": 2, "current_run_turns": 1,
118+ })); err != nil {
119+ t.Fatalf("apply second turn started event: %v", err)
120+ }
121+ if err := updateAgentFromEvent(a, NewEvent(EventAgentIdle, map[string]any{"turn_id": "turn-2"})); err != nil {
122+ t.Fatalf("apply second idle event: %v", err)
123+ }
124+ select {
125+ case command := <-commands:
126+ var payload TurnStartPayload
127+ if command.Type != CommandTurnStart || command.DecodePayload(&payload) != nil || payload.Prompt != secondFollowUp {
128+ t.Fatalf("second idle delivery = type %q prompt %q, want turn start %q", command.Type, payload.Prompt, secondFollowUp)
129+ }
130+ case <-time.After(time.Second):
131+ t.Fatal("second active follow-up was not delivered at the next idle turn")
132+ }
133+
134+ persisted, err = readAgentMeta(filepath.Join(root, "agents", a.ID))
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if persisted.ResumePrompt != secondFollowUp || len(persisted.ResumeQueue) != 0 {
139+ t.Fatalf("second follow-up persistence = prompt %q queue %d, want prompt %q and empty queue", persisted.ResumePrompt, len(persisted.ResumeQueue), secondFollowUp)
140+ }
141+ if persisted.ResumePromptID == "" {
142+ t.Fatal("promoted follow-up lost its command ID")
143+ }
144+ if err := f.Stop(a.ID); err != nil {
145+ t.Fatal(err)
146+ }
147+ a.Wait()
148+}
149+
11 func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {150 func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
12 if runtime.GOOS == "windows" {151 if runtime.GOOS == "windows" {
13 t.Skip("subagent inbox transport uses Unix-domain sockets")152 t.Skip("subagent inbox transport uses Unix-domain sockets")
@@ -60,6 +199,7 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
60 case <-time.After(time.Second):199 case <-time.After(time.Second):
61 t.Fatal("idle worker did not open its inbox")200 t.Fatal("idle worker did not open its inbox")
62 }201 }
202+ a.setTurnCounts(4, 2)
63 203
64 const followUp = "I applied your review. What do you think now?"204 const followUp = "I applied your review. What do you think now?"
65 continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)205 continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)
@@ -72,6 +212,12 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
72 if got := a.TurnState(); got != TurnQueued {212 if got := a.TurnState(); got != TurnQueued {
73 t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued)213 t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued)
74 }214 }
215+ if got := a.CurrentRunTurnsValue(); got != 0 {
216+ t.Fatalf("current-run turns after explicit resume = %d, want 0 before turn starts", got)
217+ }
218+ if got := a.LifetimeTurnsValue(); got != 4 {
219+ t.Fatalf("lifetime turns after explicit resume = %d, want 4", got)
220+ }
75 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil {221 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil {
76 t.Fatal("second follow-up succeeded while the first was queued")222 t.Fatal("second follow-up succeeded while the first was queued")
77 }223 }
@@ -87,6 +233,9 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
87 if payload.Prompt != followUp {233 if payload.Prompt != followUp {
88 t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp)234 t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp)
89 }235 }
236+ if !payload.NewRun {
237+ t.Fatal("idle follow-up did not request a fresh run budget")
238+ }
90 case <-time.After(time.Second):239 case <-time.After(time.Second):
91 t.Fatal("idle worker did not receive the follow-up")240 t.Fatal("idle worker did not receive the follow-up")
92 }241 }
M packages/agent/subagents/runner.go +246 -18
@@ -5,7 +5,9 @@ import (
5 "bytes"5 "bytes"
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8+ "errors"
8 "fmt"9 "fmt"
10+ "math"
9 "os"11 "os"
10 "os/exec"12 "os/exec"
11 "path/filepath"13 "path/filepath"
@@ -52,6 +54,11 @@ type execRunner struct {
52 // would litter the user's repo — every agent's Dir points54 // would litter the user's repo — every agent's Dir points
53 // at it directly.55 // at it directly.
54 SessionPath string56 SessionPath string
57+
58+ // GracePeriod is the time a deadline-expired worker gets to handle an
59+ // inbox shutdown and write its session/result before the process group is
60+ // force-killed. Zero uses the standard ten-second cancellation grace.
61+ GracePeriod time.Duration
55 }62 }
56 63
57 // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.64 // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.
@@ -73,6 +80,9 @@ type subagentWorkerArgsOpts struct {
73 FastModeSet bool80 FastModeSet bool
74 Subagent string81 Subagent string
75 MaxTurns int82 MaxTurns int
83+ LifetimeTurns int
84+ RunTurns int
85+ CountersSet bool
76 Tools []string86 Tools []string
77 WebSearchPolicy WebSearchPolicy87 WebSearchPolicy WebSearchPolicy
78 }88 }
@@ -108,6 +118,9 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
108 FastModeSet: true,118 FastModeSet: true,
109 Subagent: a.Subagent,119 Subagent: a.Subagent,
110 MaxTurns: a.MaxTurns,120 MaxTurns: a.MaxTurns,
121+ LifetimeTurns: a.LifetimeTurnsValue(),
122+ RunTurns: a.CurrentRunTurnsValue(),
123+ CountersSet: true,
111 Tools: a.Tools,124 Tools: a.Tools,
112 WebSearchPolicy: a.WebSearchPolicy,125 WebSearchPolicy: a.WebSearchPolicy,
113 })126 })
@@ -151,6 +164,12 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
151 if opts.MaxTurns > 0 {164 if opts.MaxTurns > 0 {
152 args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))165 args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
153 }166 }
167+ if opts.CountersSet {
168+ args = append(args,
169+ "--subagent-lifetime-turns", fmt.Sprint(opts.LifetimeTurns),
170+ "--subagent-run-turns", fmt.Sprint(opts.RunTurns),
171+ )
172+ }
154 webSearchPolicy := childWebSearchPolicy(opts.WebSearchPolicy, opts.Subagent, opts.Tools)173 webSearchPolicy := childWebSearchPolicy(opts.WebSearchPolicy, opts.Subagent, opts.Tools)
155 // Always propagate the final capability decision, including deny, so a174 // Always propagate the final capability decision, including deny, so a
156 // child with no ordinary --tools list cannot re-enable web search from its175 // child with no ordinary --tools list cannot re-enable web search from its
@@ -302,7 +321,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
302 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)321 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
303 }322 }
304 323
305- cmd := exec.CommandContext(ctx, args[0], args[1:]...)324+ // Do not bind the process directly to ctx: CommandContext sends an
325+ // immediate SIGKILL at the deadline, which prevents the worker from
326+ // handling cancellation and persisting the session it has built so far.
327+ // A deadline watcher below requests the worker's graceful inbox shutdown
328+ // first, then force-stops it only after the bounded grace period. Use a
329+ // non-canceling CommandContext so configureWorkerProcess can retain its
330+ // Cmd.Cancel fallback without tying it to the worker lifetime context.
331+ cmd := exec.CommandContext(context.Background(), args[0], args[1:]...)
306 cmd.Dir = r.agent.Dir332 cmd.Dir = r.agent.Dir
307 cmd.Env = append(workerEnvironment(r.agent.Provider),333 cmd.Env = append(workerEnvironment(r.agent.Provider),
308 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,334 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,
@@ -353,6 +379,10 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
353 r.agent.persistFn(r.agent)379 r.agent.persistFn(r.agent)
354 }380 }
355 381
382+ runnerDone := make(chan struct{})
383+ defer close(runnerDone)
384+ go r.stopOnContextDone(ctx, cmd, runnerDone)
385+
356 var logErrMu sync.Mutex386 var logErrMu sync.Mutex
357 var logErr error387 var logErr error
358 var stopOnLogErr sync.Once388 var stopOnLogErr sync.Once
@@ -397,7 +427,9 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
397 if trimmed != "" && !truncated {427 if trimmed != "" && !truncated {
398 if ev, ok := parseEventLine(trimmed); ok {428 if ev, ok := parseEventLine(trimmed); ok {
399 appendLog(ev)429 appendLog(ev)
400- updateAgentFromEvent(r.agent, ev)430+ if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil {
431+ sink.Transcript("error: metadata persistence failed: " + persistErr.Error())
432+ }
401 applyEventToSink(ev, sink)433 applyEventToSink(ev, sink)
402 // Fan prompt-level task completions up to any434 // Fan prompt-level task completions up to any
403 // subscriber on the supervised Agent. The child435 // subscriber on the supervised Agent. The child
@@ -460,12 +492,16 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
460 if ee, ok := err.(*exec.ExitError); ok {492 if ee, ok := err.(*exec.ExitError); ok {
461 exit = ee.ExitCode()493 exit = ee.ExitCode()
462 }494 }
463- if err != nil && ctx.Err() != nil {495+ if ctxErr := ctx.Err(); ctxErr != nil {
464- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))496+ reason := "cancelled"
497+ if errors.Is(ctxErr, context.DeadlineExceeded) {
498+ reason = "deadline"
499+ }
500+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
465 if logErr := firstLogErr(); logErr != nil {501 if logErr := firstLogErr(); logErr != nil {
466 return fmt.Errorf("subagent event log: %w", logErr)502 return fmt.Errorf("subagent event log: %w", logErr)
467 }503 }
468- return ctx.Err()504+ return ctxErr
469 }505 }
470 if err != nil {506 if err != nil {
471 appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))507 appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
@@ -482,6 +518,48 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
482 return nil518 return nil
483 }519 }
484 520
521+// stopOnContextDone stops a worker when its run context ends. A deadline gets
522+// one bounded chance to process an inbox shutdown and persist its session;
523+// explicit cancellation force-stops immediately so Run cannot remain blocked.
524+func (r *execRunner) stopOnContextDone(ctx context.Context, cmd *exec.Cmd, runnerDone <-chan struct{}) {
525+ select {
526+ case <-runnerDone:
527+ return
528+ case <-ctx.Done():
529+ }
530+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
531+ if cmd.Cancel != nil {
532+ _ = cmd.Cancel()
533+ return
534+ }
535+ _ = killProcessGroup(cmd)
536+ return
537+ }
538+
539+ grace := r.GracePeriod
540+ if grace <= 0 {
541+ grace = 10 * time.Second
542+ }
543+ shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
544+ defer cancel()
545+ if r.agent != nil && r.agent.inbox != nil {
546+ _ = r.agent.inbox.SendCommandContext(shutdownCtx, NewCommand(
547+ CommandAgentShutdown, r.agent.ID, r.agent.CurrentTurnID(), AgentShutdownPayload{},
548+ ))
549+ }
550+
551+ select {
552+ case <-runnerDone:
553+ return
554+ case <-shutdownCtx.Done():
555+ }
556+ if cmd.Cancel != nil {
557+ _ = cmd.Cancel()
558+ return
559+ }
560+ _ = killProcessGroup(cmd)
561+}
562+
485 func workerEnvironment(provider string) []string {563 func workerEnvironment(provider string) []string {
486 // Provider credentials are resolved by the supervisor and transferred564 // Provider credentials are resolved by the supervisor and transferred
487 // over the child's stdin. Do not inherit API-key/token variables into the565 // over the child's stdin. Do not inherit API-key/token variables into the
@@ -594,9 +672,71 @@ func parseEventLine(line string) (Event, bool) {
594 }, true672 }, true
595 }673 }
596 674
597-func updateAgentFromEvent(a *Agent, ev Event) {675+func eventMatchesPendingResume(a *Agent, ev Event) bool {
598 if a == nil {676 if a == nil {
599- return677+ return false
678+ }
679+ commandID, _ := ev.Data["command_id"].(string)
680+ if commandID == "" {
681+ // Protocol-v1 turn.started events predate command identities, but a
682+ // commandless rejection cannot be safely associated with the current
683+ // pending prompt and must not consume it.
684+ code, _ := ev.Data["code"].(string)
685+ return code != "turn_rejected"
686+ }
687+ expected := a.resumePromptCommandID()
688+ return expected == "" || expected == commandID
689+}
690+
691+func eventCounter(data map[string]any, key string) (int, bool) {
692+ value, ok := data[key]
693+ if !ok {
694+ return 0, false
695+ }
696+ maxInt := int(^uint(0) >> 1)
697+ valid := func(value int64) (int, bool) {
698+ if value < 0 || value > int64(maxInt) {
699+ return 0, false
700+ }
701+ return int(value), true
702+ }
703+ switch value := value.(type) {
704+ case int:
705+ if value < 0 {
706+ return 0, false
707+ }
708+ return value, true
709+ case int64:
710+ return valid(value)
711+ case uint:
712+ if uint64(value) > uint64(maxInt) {
713+ return 0, false
714+ }
715+ return int(value), true
716+ case uint64:
717+ if value > uint64(maxInt) {
718+ return 0, false
719+ }
720+ return int(value), true
721+ case float64:
722+ if value < 0 || math.Trunc(value) != value || value >= float64(maxInt)+1 {
723+ return 0, false
724+ }
725+ return int(value), true
726+ case json.Number:
727+ parsed, err := value.Int64()
728+ if err != nil {
729+ return 0, false
730+ }
731+ return valid(parsed)
732+ default:
733+ return 0, false
734+ }
735+}
736+
737+func updateAgentFromEvent(a *Agent, ev Event) error {
738+ if a == nil {
739+ return nil
600 }740 }
601 now := ev.Time741 now := ev.Time
602 if now.IsZero() {742 if now.IsZero() {
@@ -604,25 +744,56 @@ func updateAgentFromEvent(a *Agent, ev Event) {
604 }744 }
605 a.markActivity(now)745 a.markActivity(now)
606 persist := false746 persist := false
747+ notifyIdle := false
607 switch ev.Type {748 switch ev.Type {
608 case EventAgentReady, "agent_ready":749 case EventAgentReady, "agent_ready":
609 a.setProcessState(ProcessAlive)750 a.setProcessState(ProcessAlive)
610- a.setTurnState(TurnIdle, ev.TurnID)751+ if lifetime, ok := eventCounter(ev.Data, "lifetime_turns"); ok {
752+ if currentRun, currentOK := eventCounter(ev.Data, "current_run_turns"); currentOK {
753+ a.setTurnCounts(lifetime, currentRun)
754+ }
755+ }
756+ // A resumed worker may have a durable initial follow-up in its argv.
757+ // Keep it queued until the worker emits its own turn.started event;
758+ // treating readiness as idle here could dispatch another queued prompt
759+ // concurrently with that initial turn.
760+ if prompt, _ := a.ResumePromptInfo(); prompt == "" && a.TurnState() != TurnQueued {
761+ a.setTurnState(TurnIdle, ev.TurnID)
762+ notifyIdle = true
763+ }
611 persist = true764 persist = true
612 case EventAgentHeartbeat, "agent_heartbeat":765 case EventAgentHeartbeat, "agent_heartbeat":
613 a.setProcessState(ProcessAlive)766 a.setProcessState(ProcessAlive)
614 persist = true767 persist = true
615- case EventTurnStarted, "turn_start":768+ case EventTurnStarted:
616- turnID := ev.TurnID769+ if !isDelegatedTurnStart(ev) {
617- if turnID == "" {770+ // Provider/model-loop turn starts are nested inside one worker
618- if step, ok := ev.Data["step"].(float64); ok {771+ // message turn and are activity only.
619- turnID = fmt.Sprintf("turn-%d", int(step))772+ a.setProcessState(ProcessAlive)
620- }773+ break
621 }774 }
775+ if !eventMatchesPendingResume(a, ev) {
776+ break
777+ }
778+ turnID := eventTurnID(ev)
622 a.setProcessState(ProcessAlive)779 a.setProcessState(ProcessAlive)
780+ if lifetime, ok := eventCounter(ev.Data, "lifetime_turns"); ok {
781+ if currentRun, currentOK := eventCounter(ev.Data, "current_run_turns"); currentOK {
782+ a.setTurnCounts(lifetime, currentRun)
783+ } else {
784+ a.incrementTurnCounts()
785+ }
786+ } else {
787+ a.incrementTurnCounts()
788+ }
623 a.setTurnState(TurnRunning, turnID)789 a.setTurnState(TurnRunning, turnID)
624 a.clearResumePrompt()790 a.clearResumePrompt()
625 persist = true791 persist = true
792+ case "turn_start":
793+ // Provider/model-loop turn starts are nested inside one worker message
794+ // turn. They are activity only and must not consume or reset the
795+ // worker's lifetime or current-run budget.
796+ a.setProcessState(ProcessAlive)
626 case EventTurnResult, "turn_result":797 case EventTurnResult, "turn_result":
627 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {798 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
628 a.setResult(result)799 a.setResult(result)
@@ -653,17 +824,36 @@ func updateAgentFromEvent(a *Agent, ev Event) {
653 a.setTurnState(TurnFailed, ev.TurnID)824 a.setTurnState(TurnFailed, ev.TurnID)
654 persist = true825 persist = true
655 case EventAgentIdle, "agent_idle":826 case EventAgentIdle, "agent_idle":
827+ if lifetime, ok := eventCounter(ev.Data, "lifetime_turns"); ok {
828+ if currentRun, currentOK := eventCounter(ev.Data, "current_run_turns"); currentOK {
829+ a.setTurnCounts(lifetime, currentRun)
830+ }
831+ }
656 a.setTurnState(TurnIdle, ev.TurnID)832 a.setTurnState(TurnIdle, ev.TurnID)
833+ notifyIdle = true
657 persist = true834 persist = true
658 case EventAgentExited, "agent_exited", "agent_stopped":835 case EventAgentExited, "agent_exited", "agent_stopped":
659 a.setProcessState(ProcessExited)836 a.setProcessState(ProcessExited)
660 persist = true837 persist = true
838+ case "error":
839+ if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) {
840+ if a.rejectActiveResumePrompt() {
841+ reason, _ := ev.Data["reason"].(string)
842+ if reason == "max_turns" {
843+ a.setTurnState(TurnFailed, ev.TurnID)
844+ } else {
845+ a.setTurnState(TurnIdle, ev.TurnID)
846+ notifyIdle = true
847+ }
848+ persist = true
849+ }
850+ }
661 case "turn_end":851 case "turn_end":
662 // Provider/tool-loop turn_end events (for example stop=tool_use)852 // Provider/tool-loop turn_end events (for example stop=tool_use)
663 // do not carry the daemon's prompt step and are not terminal for853 // do not carry the daemon's prompt step and are not terminal for
664 // the delegated task. They remain in the event log, but must not854 // the delegated task. They remain in the event log, but must not
665 // overwrite the delegated turn state or trigger persistence.855 // overwrite the delegated turn state or trigger persistence.
666- if _, ok := ev.Data["step"].(float64); !ok {856+ if !isDelegatedTurnEnd(ev) {
667 break857 break
668 }858 }
669 if message, _ := ev.Data["error"].(string); message != "" {859 if message, _ := ev.Data["error"].(string); message != "" {
@@ -674,8 +864,15 @@ func updateAgentFromEvent(a *Agent, ev Event) {
674 persist = true864 persist = true
675 }865 }
676 if persist && a.persistFn != nil {866 if persist && a.persistFn != nil {
677- a.persistFn(a)867+ if err := a.persistFn(a); err != nil {
868+ a.recordPersistenceError(err)
869+ return err
870+ }
871+ }
872+ if notifyIdle {
873+ a.notifyTurnIdle()
678 }874 }
875+ return nil
679 }876 }
680 877
681 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent878 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent
@@ -686,7 +883,10 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
686 if a == nil || ev.Type != "turn_end" {883 if a == nil || ev.Type != "turn_end" {
687 return884 return
688 }885 }
689- step, ok := ev.Data["step"].(float64)886+ if !isDelegatedTurnEnd(ev) {
887+ return
888+ }
889+ step, ok := eventCounter(ev.Data, "step")
690 if !ok {890 if !ok {
691 return891 return
692 }892 }
@@ -700,7 +900,16 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
700 return900 return
701 }901 }
702 a.mu.Unlock()902 a.mu.Unlock()
703- go fn(int(step), errMsg)903+ go fn(step, errMsg)
904+}
905+
906+func isAssistantStreamBoundary(eventType string) bool {
907+ switch eventType {
908+ case EventTurnStarted, "turn_start", EventTurnResult, "turn_result", EventTurnFailed, "turn_failed", "turn_end":
909+ return true
910+ default:
911+ return false
912+ }
704 }913 }
705 914
706 // applyEventToSink translates an Event into Sink updates. Only a915 // applyEventToSink translates an Event into Sink updates. Only a
@@ -711,6 +920,17 @@ func applyEventToSink(ev Event, sink Sink) {
711 userMessage(string)920 userMessage(string)
712 assistantMessage(string)921 assistantMessage(string)
713 }922 }
923+ type streamingSink interface {
924+ assistantDelta(string)
925+ }
926+ type streamResetSink interface {
927+ resetStreamingAssistant()
928+ }
929+ if isAssistantStreamBoundary(ev.Type) {
930+ if streaming, ok := sink.(streamResetSink); ok {
931+ streaming.resetStreamingAssistant()
932+ }
933+ }
714 appendMessage := func(text string, assistant bool) {934 appendMessage := func(text string, assistant bool) {
715 if text == "" {935 if text == "" {
716 return936 return
@@ -731,6 +951,14 @@ func applyEventToSink(ev Event, sink Sink) {
731 }951 }
732 952
733 switch ev.Type {953 switch ev.Type {
954+ case "message.delta":
955+ if delta, _ := ev.Data["delta"].(string); delta != "" {
956+ if streaming, ok := sink.(streamingSink); ok {
957+ streaming.assistantDelta(delta)
958+ } else {
959+ sink.Transcript(delta)
960+ }
961+ }
734 case "assistant_message", "user_message":962 case "assistant_message", "user_message":
735 var text []string963 var text []string
736 if c, ok := ev.Data["content"].([]any); ok {964 if c, ok := ev.Data["content"].([]any); ok {
M packages/agent/subagents/runner_e2e_test.go +181 -19
@@ -12,25 +12,187 @@ import (
12 "time"12 "time"
13 )13 )
14 14
15-// TestRunnerEndToEndWithStubChild is the integration test for the15+type notifyingAgentSink struct {
16-// new daemon-mode runner. It compiles the stubchild binary in16+ agentSink
17-// testdata/cmd/stubchild, points an execRunner at it, and drives17+ deltas chan<- struct{}
18-// a Supervisor through one Spawn + one SendUserTurn + Stop cycle.18+}
19-//19+
20-// What this proves:20+func (s notifyingAgentSink) assistantDelta(text string) {
21-//21+ s.agentSink.assistantDelta(text)
22-// - The default argv shape (subagentWorkerArgs) is one the child can22+ select {
23-// actually parse — locks the shape against silent breakage.23+ case s.deltas <- struct{}{}:
24-// - The stdout JSONL parser ingests events and writes them to the24+ default:
25-// durable log.25+ }
26-// - applyEventToSink turns events into Activity / Transcript26+}
27-// updates the dashboard reads.27+
28-// - The supervisor inbox dials the child's socket and a follow-up28+const runnerProcessTimeout = 10 * time.Second
29-// line round-trips back as a user_message event.29+
30-// - Stop closes the inbox AND cancels the child's context so the30+type controlledDeadlineContext struct{ done chan struct{} }
31-// stub exits cleanly.31+
32-//32+func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {
33-// The test is skipped on platforms without unix sockets (Windows).33+ return time.Now().Add(time.Hour), true
34+}
35+func (c *controlledDeadlineContext) Done() <-chan struct{} { return c.done }
36+func (c *controlledDeadlineContext) Err() error {
37+ select {
38+ case <-c.done:
39+ return context.DeadlineExceeded
40+ default:
41+ return nil
42+ }
43+}
44+func (c *controlledDeadlineContext) Value(any) any { return nil }
45+func (c *controlledDeadlineContext) expire() {
46+ select {
47+ case <-c.done:
48+ default:
49+ close(c.done)
50+ }
51+}
52+
53+// TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput
54+// verifies that deadline expiry shuts a worker down through its inbox rather
55+// than killing it before its terminal events can be recorded.
56+func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *testing.T) {
57+ if runtime.GOOS == "windows" {
58+ t.Skip("unix sockets not supported")
59+ }
60+ if testing.Short() {
61+ t.Skip("skip end-to-end runner test in -short mode")
62+ }
63+
64+ exe := buildStubChild(t)
65+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
66+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
67+
68+ root := t.TempDir()
69+ inboxPath, err := inboxSocketPath(root, "deadline-test")
70+ if err != nil {
71+ t.Fatalf("inboxSocketPath: %v", err)
72+ }
73+ a := &Agent{
74+ ID: "deadline-test",
75+ Task: "long-running task",
76+ Dir: root,
77+ SessionPath: filepath.Join(root, "session.jsonl"),
78+ InboxPath: inboxPath,
79+ EventLogPath: filepath.Join(root, "events.jsonl"),
80+ }
81+ a.inbox = NewInbox(a.InboxPath)
82+ t.Cleanup(func() { _ = a.inbox.Close() })
83+
84+ r := &execRunner{
85+ agent: a,
86+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
87+ GracePeriod: time.Second,
88+ }
89+ ctx := &controlledDeadlineContext{done: make(chan struct{})}
90+ defer ctx.expire()
91+ deltas := make(chan struct{}, 1)
92+ runDone := make(chan error, 1)
93+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
94+ select {
95+ case <-deltas:
96+ case <-time.After(runnerProcessTimeout):
97+ t.Fatal("worker did not emit its initial delta")
98+ }
99+ ctx.expire()
100+ select {
101+ case err := <-runDone:
102+ if !errors.Is(err, context.DeadlineExceeded) {
103+ t.Fatalf("Run error = %v, want context deadline exceeded", err)
104+ }
105+ case <-time.After(runnerProcessTimeout):
106+ t.Fatal("Run did not return after deadline expiry")
107+ }
108+
109+ events, err := ReadEventLog(a.EventLogPath)
110+ if err != nil {
111+ t.Fatalf("ReadEventLog: %v", err)
112+ }
113+ var sawDelta, sawGracefulTurnEnd bool
114+ for _, event := range events {
115+ if event.Type == "message.delta" && event.Data["delta"] == "partial answer" {
116+ sawDelta = true
117+ }
118+ if event.Type == "turn_end" && event.Data["stop"] == "cancelled" {
119+ sawGracefulTurnEnd = true
120+ }
121+ }
122+ if !sawDelta || !sawGracefulTurnEnd {
123+ t.Fatalf("deadline did not preserve a graceful partial turn: %s", formatEvents(events))
124+ }
125+
126+ snapshot := a.Snapshot()
127+ if snapshot.LastAssistant != "partial answer" {
128+ t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
129+ }
130+ replayed := &Agent{}
131+ for _, event := range events {
132+ replayEventTranscript(replayed, event)
133+ }
134+ if snapshot := replayed.Snapshot(); snapshot.LastAssistant != "partial answer" {
135+ t.Fatalf("replayed partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
136+ }
137+}
138+
139+func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
140+ if runtime.GOOS == "windows" {
141+ t.Skip("unix sockets not supported")
142+ }
143+ if testing.Short() {
144+ t.Skip("skip end-to-end runner test in -short mode")
145+ }
146+
147+ exe := buildStubChild(t)
148+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
149+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
150+
151+ root := t.TempDir()
152+ inboxPath, err := inboxSocketPath(root, "cancel-test")
153+ if err != nil {
154+ t.Fatalf("inboxSocketPath: %v", err)
155+ }
156+ a := &Agent{
157+ ID: "cancel-test",
158+ Task: "blocked task",
159+ Dir: root,
160+ SessionPath: filepath.Join(root, "session.jsonl"),
161+ InboxPath: inboxPath,
162+ EventLogPath: filepath.Join(root, "events.jsonl"),
163+ }
164+ a.inbox = NewInbox(a.InboxPath)
165+ t.Cleanup(func() { _ = a.inbox.Close() })
166+
167+ r := &execRunner{
168+ agent: a,
169+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
170+ }
171+ ctx, cancel := context.WithCancel(context.Background())
172+ defer cancel()
173+ deltas := make(chan struct{}, 1)
174+ runDone := make(chan error, 1)
175+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
176+
177+ select {
178+ case <-deltas:
179+ case <-time.After(runnerProcessTimeout):
180+ t.Fatal("worker did not emit its initial delta")
181+ }
182+ cancel()
183+ select {
184+ case err := <-runDone:
185+ if !errors.Is(err, context.Canceled) {
186+ t.Fatalf("Run error = %v, want context canceled", err)
187+ }
188+ case <-time.After(runnerProcessTimeout):
189+ t.Fatal("Run did not return after context cancellation")
190+ }
191+}
192+
193+// TestRunnerEndToEndWithStubChild drives the daemon-mode runner through an
194+// initial turn, a follow-up, and a graceful stop using the stub child. It is
195+// skipped on platforms without Unix sockets.
34 func TestRunnerEndToEndWithStubChild(t *testing.T) {196 func TestRunnerEndToEndWithStubChild(t *testing.T) {
35 if runtime.GOOS == "windows" {197 if runtime.GOOS == "windows" {
36 t.Skip("unix sockets not supported")198 t.Skip("unix sockets not supported")
M packages/agent/subagents/runner_test.go +268 -2
@@ -58,6 +58,106 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
58 os.Exit(0)58 os.Exit(0)
59 }59 }
60 60
61+// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62+// that the durable event stream has the same visible result while live and
63+// after history replay.
64+func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65+ events := []Event{
66+ NewEvent("message.delta", map[string]any{"delta": "partial "}),
67+ NewEvent("message.delta", map[string]any{"delta": "answer"}),
68+ NewEvent("assistant_message", map[string]any{"content": []any{
69+ map[string]any{"type": "text", "text": "partial answer"},
70+ }}),
71+ }
72+
73+ live := &Agent{transcriptLoaded: true}
74+ for _, event := range events {
75+ applyEventToSink(event, agentSink{a: live})
76+ }
77+ if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78+ t.Fatalf("live transcript = %q, want final assistant message once", got)
79+ }
80+ if got := live.Snapshot().LastAssistant; got != "partial answer" {
81+ t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82+ }
83+
84+ replayed := &Agent{transcriptLoaded: true}
85+ for _, event := range events {
86+ replayEventTranscript(replayed, event)
87+ }
88+ if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89+ t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90+ }
91+}
92+
93+func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94+ a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97+
98+ a.mu.Lock()
99+ streamed := a.streamingAssistantText
100+ truncated := a.streamingAssistantTruncated
101+ a.mu.Unlock()
102+ if !truncated {
103+ t.Fatal("streamed output was not marked truncated")
104+ }
105+ if got := len([]byte(streamed)); got > 32 {
106+ t.Fatalf("streamed output length = %d, want at most 32", got)
107+ }
108+ snapshot := a.Snapshot()
109+ if got := snapshot.LastAssistant; got != streamed {
110+ t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111+ }
112+ if !snapshot.OutputTruncated {
113+ t.Fatal("snapshot did not expose truncated streamed output")
114+ }
115+
116+ events := []Event{
117+ NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118+ NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119+ NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120+ }
121+ want := "first partial\nsecond partial"
122+ assertProjection := func(t *testing.T, agent *Agent) {
123+ t.Helper()
124+ if got := strings.Join(agent.Transcript(), "\n"); got != want {
125+ t.Fatalf("transcript = %q, want %q", got, want)
126+ }
127+ agent.mu.Lock()
128+ truncated := agent.streamingAssistantTruncated
129+ agent.mu.Unlock()
130+ if truncated {
131+ t.Fatal("fresh second-turn stream was incorrectly truncated")
132+ }
133+ }
134+
135+ live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136+ for _, event := range events {
137+ applyEventToSink(event, agentSink{a: live})
138+ }
139+ assertProjection(t, live)
140+
141+ replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142+ replayTranscriptIntoAgent(replayed, events)
143+ assertProjection(t, replayed)
144+
145+ legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146+ replayEventsIntoAgent(legacy, events)
147+ assertProjection(t, legacy)
148+
149+ truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150+ applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151+ applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152+ truncatedTurn.mu.Lock()
153+ streamText := truncatedTurn.streamingAssistantText
154+ streamTruncated := truncatedTurn.streamingAssistantTruncated
155+ truncatedTurn.mu.Unlock()
156+ if streamText != "" || streamTruncated {
157+ t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158+ }
159+}
160+
61 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {161 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
62 t.Setenv("OPENAI_API_KEY", "openai-secret")162 t.Setenv("OPENAI_API_KEY", "openai-secret")
63 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")163 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
@@ -383,11 +483,69 @@ func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
383 }483 }
384 }484 }
385 485
486+func TestReadyDoesNotMarkInitialDelegatedTurnIdle(t *testing.T) {
487+ a := &Agent{Task: "initial delegated task"}
488+ a.setTurnState(TurnQueued, "")
489+
490+ if err := updateAgentFromEvent(a, NewEvent(EventAgentReady, map[string]any{"lifetime_turns": 0, "current_run_turns": 0})); err != nil {
491+ t.Fatal(err)
492+ }
493+ if got := a.TurnState(); got != TurnQueued {
494+ t.Fatalf("ready changed turn state to %s, want %s", got, TurnQueued)
495+ }
496+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
497+ "turn_id": "turn-1",
498+ "lifetime_turns": 1,
499+ "current_run_turns": 1,
500+ })); err != nil {
501+ t.Fatal(err)
502+ }
503+ if got := a.TurnState(); got != TurnRunning {
504+ t.Fatalf("turn.started changed turn state to %s, want %s", got, TurnRunning)
505+ }
506+}
507+
508+func TestUpdateAgentFromEventIgnoresStaleRejectedFollowUp(t *testing.T) {
509+ a := &Agent{}
510+ a.setResumePrompt("current prompt", time.Now())
511+ if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
512+ "code": "turn_rejected", "reason": "busy", "command_id": "stale-command",
513+ })); err != nil {
514+ t.Fatal(err)
515+ }
516+ if prompt, _ := a.ResumePromptInfo(); prompt != "current prompt" {
517+ t.Fatalf("stale rejection changed active prompt to %q", prompt)
518+ }
519+}
520+
521+func TestUpdateAgentFromEventRecoversRejectedFollowUp(t *testing.T) {
522+ a := &Agent{}
523+ a.setResumePrompt("retry me", time.Now())
524+ a.setTurnState(TurnQueued, "turn-1")
525+ if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
526+ "code": "turn_rejected",
527+ "reason": "busy",
528+ "command_id": a.resumePromptCommandID(),
529+ })); err != nil {
530+ t.Fatal(err)
531+ }
532+ if got := a.TurnState(); got != TurnIdle {
533+ t.Fatalf("rejected turn state = %s, want %s", got, TurnIdle)
534+ }
535+ if prompt, _ := a.ResumePromptInfo(); prompt != "" {
536+ t.Fatalf("rejected prompt remained active: %q", prompt)
537+ }
538+ queued := a.resumePromptQueueSnapshot()
539+ if len(queued) != 1 || queued[0].Prompt != "retry me" {
540+ t.Fatalf("rejected prompt queue = %+v, want retry me", queued)
541+ }
542+}
543+
386 func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {544 func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
387 a := &Agent{}545 a := &Agent{}
388 a.setTurnState(TurnRunning, "turn-1")546 a.setTurnState(TurnRunning, "turn-1")
389 persisted := 0547 persisted := 0
390- a.persistFn = func(*Agent) { persisted++ }548+ a.persistFn = func(*Agent) error { persisted++; return nil }
391 549
392 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))550 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
393 551
@@ -405,7 +563,7 @@ func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
405 func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {563 func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
406 a := &Agent{}564 a := &Agent{}
407 persisted := 0565 persisted := 0
408- a.persistFn = func(*Agent) { persisted++ }566+ a.persistFn = func(*Agent) error { persisted++; return nil }
409 567
410 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))568 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
411 569
@@ -481,6 +639,114 @@ func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
481 }639 }
482 }640 }
483 641
642+func TestUpdateAgentFromEventDoesNotCountProviderTurnStarts(t *testing.T) {
643+ a := &Agent{}
644+ a.setTurnCounts(4, 2)
645+ a.setTurnState(TurnRunning, "turn-1")
646+ persisted := 0
647+ a.persistFn = func(*Agent) error { persisted++; return nil }
648+
649+ updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{"step": float64(7), "nested_turn": true}))
650+
651+ if got := a.LifetimeTurnsValue(); got != 4 {
652+ t.Fatalf("lifetime turns = %d, want 4", got)
653+ }
654+ if got := a.CurrentRunTurnsValue(); got != 2 {
655+ t.Fatalf("current-run turns = %d, want 2", got)
656+ }
657+ if got := a.TurnState(); got != TurnRunning {
658+ t.Fatalf("turn state = %s, want %s", got, TurnRunning)
659+ }
660+ if persisted != 0 {
661+ t.Fatalf("provider turn start persisted %d times, want 0", persisted)
662+ }
663+}
664+
665+func TestUpdateAgentFromEventPersistsCanonicalTurnCounters(t *testing.T) {
666+ stateDir := t.TempDir()
667+ a := &Agent{ID: "counter-agent", stateDir: stateDir}
668+ a.persistFn = func(agent *Agent) error {
669+ if err := writeAgentMeta(stateDir, agent); err != nil {
670+ t.Errorf("write agent metadata: %v", err)
671+ return err
672+ }
673+ return nil
674+ }
675+
676+ updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
677+ "turn_id": "turn-3",
678+ "lifetime_turns": 3,
679+ "current_run_turns": 1,
680+ }))
681+
682+ meta, err := readAgentMeta(stateDir)
683+ if err != nil {
684+ t.Fatal(err)
685+ }
686+ if meta.LifetimeTurns != 3 || meta.CurrentRunTurns != 1 {
687+ t.Fatalf("persisted counters = (%d, %d), want (3, 1)", meta.LifetimeTurns, meta.CurrentRunTurns)
688+ }
689+ if got := a.Snapshot(); got.LifetimeTurns != 3 || got.CurrentRunTurns != 1 {
690+ t.Fatalf("snapshot counters = (%d, %d), want (3, 1)", got.LifetimeTurns, got.CurrentRunTurns)
691+ }
692+}
693+
694+func TestRecordPersistenceErrorPreservesFirstFailure(t *testing.T) {
695+ a := &Agent{}
696+ a.recordPersistenceError(fmt.Errorf("first failure"))
697+ a.recordPersistenceError(fmt.Errorf("later failure"))
698+ if got := a.Snapshot().Err; !strings.Contains(got, "first failure") || strings.Contains(got, "later failure") {
699+ t.Fatalf("recorded persistence error = %q, want only first failure", got)
700+ }
701+}
702+
703+func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {
704+ a := &Agent{}
705+ a.setResumePrompt("old prompt", time.Now())
706+ oldID := a.resumePromptCommandID()
707+ previous := a.setResumePrompt("new prompt", time.Now())
708+ a.restoreResumePrompt(previous)
709+ if got := a.resumePromptCommandID(); got != oldID {
710+ t.Fatalf("restored command ID = %q, want %q", got, oldID)
711+ }
712+}
713+
714+func TestEventMatchesPendingResumeRejectsIdentityLessRejection(t *testing.T) {
715+ a := &Agent{resumePromptID: "command-1"}
716+ if eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected"}}) {
717+ t.Fatal("identity-less turn rejection matched a pending prompt")
718+ }
719+ if !eventMatchesPendingResume(a, Event{Type: EventTurnStarted, Data: map[string]any{}}) {
720+ t.Fatal("identity-less legacy turn.started did not match a pending prompt")
721+ }
722+ if !eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected", "command_id": "command-1"}}) {
723+ t.Fatal("matching turn rejection did not match a pending prompt")
724+ }
725+}
726+
727+func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
728+ cases := []struct {
729+ name string
730+ value any
731+ ok bool
732+ }{
733+ {name: "valid integer", value: float64(3), ok: true},
734+ {name: "negative integer", value: int64(-1)},
735+ {name: "fractional float", value: 1.5},
736+ {name: "negative float", value: -1.0},
737+ {name: "fractional json number", value: json.Number("1.5")},
738+ {name: "overflow float", value: float64(^uint(0) >> 1)},
739+ }
740+ for _, tc := range cases {
741+ t.Run(tc.name, func(t *testing.T) {
742+ got, ok := eventCounter(map[string]any{"counter": tc.value}, "counter")
743+ if ok != tc.ok {
744+ t.Fatalf("eventCounter ok = %t, want %t (value %v, got %d)", ok, tc.ok, tc.value, got)
745+ }
746+ })
747+ }
748+}
749+
484 func indexOf(xs []string, want string) int {750 func indexOf(xs []string, want string) int {
485 for i, x := range xs {751 for i, x := range xs {
486 if x == want {752 if x == want {
M packages/agent/subagents/scheduler.go +17 -6
@@ -2,6 +2,7 @@ package subagents
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -184,7 +185,13 @@ func (f *Supervisor) schedule() {
184 a.activity = "starting"185 a.activity = "starting"
185 a.mu.Unlock()186 a.mu.Unlock()
186 a.setProcessState(ProcessStarting)187 a.setProcessState(ProcessStarting)
187- a.setTurnState(TurnIdle, "")188+ prompt, _ := a.ResumePromptInfo()
189+ initialTurn := (!a.Resuming && strings.TrimSpace(a.Task) != "") || prompt != ""
190+ if initialTurn {
191+ a.setTurnState(TurnQueued, "")
192+ } else {
193+ a.setTurnState(TurnIdle, "")
194+ }
188 a.incrementAttempt()195 a.incrementAttempt()
189 196
190 f.active++197 f.active++
@@ -374,13 +381,17 @@ func (f *Supervisor) releaseCapacity(a *Agent) {
374 f.schedule()381 f.schedule()
375 }382 }
376 383
377-func (f *Supervisor) persistAgent(a *Agent) {384+func (f *Supervisor) persistAgent(a *Agent) error {
378 if a == nil {385 if a == nil {
379- return386+ return errors.New("subagents: nil agent")
380 }387 }
381 stateDir := a.stateDirectory(f.cfg.Root)388 stateDir := a.stateDirectory(f.cfg.Root)
382- if _, err := os.Stat(stateDir); err != nil {389+ if err := writeAgentMeta(stateDir, a); err != nil {
383- return390+ // Lifecycle callbacks cannot all return an error to their caller. Keep
391+ // the failure visible in the agent snapshot instead of silently
392+ // allowing a queued prompt or counter to disappear on restart.
393+ a.recordPersistenceError(err)
394+ return err
384 }395 }
385- _ = writeAgentMeta(stateDir, a)396+ return nil
386 }397 }
M packages/agent/subagents/supervisor.go +14 -3
@@ -155,7 +155,11 @@ func New(cfg Config) *Supervisor {
155 }155 }
156 if cfg.NewRunner == nil {156 if cfg.NewRunner == nil {
157 cfg.NewRunner = func(a *Agent) Runner {157 cfg.NewRunner = func(a *Agent) Runner {
158- return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}158+ return &execRunner{
159+ agent: a,
160+ resolveCredential: cfg.ResolveCredential,
161+ GracePeriod: cfg.Policy.CancelGracePeriod,
162+ }
159 }163 }
160 }164 }
161 cfg.Policy.normalize()165 cfg.Policy.normalize()
@@ -543,6 +547,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
543 }547 }
544 a.ctx, a.cancel = runCtx, cancel548 a.ctx, a.cancel = runCtx, cancel
545 a.persistFn = f.persistAgent549 a.persistFn = f.persistAgent
550+ a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
546 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }551 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
547 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }552 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
548 a.runner = f.cfg.NewRunner(a)553 a.runner = f.cfg.NewRunner(a)
@@ -1075,6 +1080,8 @@ type AgentSnapshot struct {
1075 ProcessState ProcessState1080 ProcessState ProcessState
1076 TurnState TurnState1081 TurnState TurnState
1077 CurrentTurnID string1082 CurrentTurnID string
1083+ LifetimeTurns int
1084+ CurrentRunTurns int
1078 Attempt int1085 Attempt int
1079 Activity string1086 Activity string
1080 Started time.Time1087 Started time.Time
@@ -1129,6 +1136,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
1129 status := a.status1136 status := a.status
1130 activity := a.activity1137 activity := a.activity
1131 lastAssistant := a.lastAssistant1138 lastAssistant := a.lastAssistant
1139+ outputTruncated := a.outputTruncated || a.streamingAssistantTruncated
1132 finished := a.finished1140 finished := a.finished
1133 a.mu.Unlock()1141 a.mu.Unlock()
1134 1142
@@ -1136,6 +1144,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
1136 processState := a.processState1144 processState := a.processState
1137 turnState := a.turnState1145 turnState := a.turnState
1138 currentTurnID := a.currentTurnID1146 currentTurnID := a.currentTurnID
1147+ lifetimeTurns := a.LifetimeTurns
1148+ currentRunTurns := a.CurrentRunTurns
1139 attempt := a.Attempt1149 attempt := a.Attempt
1140 lastActivity := a.lastActivity1150 lastActivity := a.lastActivity
1141 updatedAt := a.updatedAt1151 updatedAt := a.updatedAt
@@ -1143,12 +1153,11 @@ func (a *Agent) Snapshot() AgentSnapshot {
1143 resultRef := a.resultRef1153 resultRef := a.resultRef
1144 patchRef := a.patchRef1154 patchRef := a.patchRef
1145 changedFiles := append([]string(nil), a.changedFiles...)1155 changedFiles := append([]string(nil), a.changedFiles...)
1146- outputTruncated := a.outputTruncated
1147 a.lifecycleMu.Unlock()1156 a.lifecycleMu.Unlock()
1148 return AgentSnapshot{1157 return AgentSnapshot{
1149 ID: a.ID, Task: a.Task, Dir: a.Dir,1158 ID: a.ID, Task: a.Task, Dir: a.Dir,
1150 Status: status, ProcessState: processState, TurnState: turnState,1159 Status: status, ProcessState: processState, TurnState: turnState,
1151- CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity,1160+ CurrentTurnID: currentTurnID, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Attempt: attempt, Activity: activity,
1152 Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,1161 Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
1153 Err: errStr, Tail: tail, Lines: lines,1162 Err: errStr, Tail: tail, Lines: lines,
1154 LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,1163 LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
@@ -1208,6 +1217,8 @@ func (s agentSink) Transcript(chunk string) {
1208 }1217 }
1209 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }1218 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
1210 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }1219 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1220+func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }
1221+func (s agentSink) resetStreamingAssistant() { s.a.resetStreamingAssistant() }
1211 1222
1212 func truncate(s string, n int) string {1223 func truncate(s string, n int) string {
1213 runes := []rune(s)1224 runes := []rune(s)
M packages/agent/subagents/testdata/cmd/stubchild/main.go +20 -1
@@ -72,8 +72,20 @@ func main() {
72 72
73 // Initial task lives in the positional. Process it as the first73 // Initial task lives in the positional. Process it as the first
74 // user turn so the supervisor's "initial task" path is exercised.74 // user turn so the supervisor's "initial task" path is exercised.
75+ initialTurnOpen := false
75 if task := flag.Arg(0); task != "" {76 if task := flag.Arg(0); task != "" {
76- runTurn(emit, task, 0)77+ if os.Getenv("ZUT_STUB_BLOCK_INITIAL") == "1" {
78+ initialTurnOpen = true
79+ emit("turn_start", map[string]any{"step": 0})
80+ emit("user_message", map[string]any{
81+ "content": []any{
82+ map[string]any{"type": "text", "text": task},
83+ },
84+ })
85+ emit("message.delta", map[string]any{"delta": "partial answer"})
86+ } else {
87+ runTurn(emit, task, 0)
88+ }
77 }89 }
78 90
79 turn := 191 turn := 1
@@ -91,10 +103,17 @@ func main() {
91 if parseErr == nil {103 if parseErr == nil {
92 switch command.Type {104 switch command.Type {
93 case subagents.CommandAgentShutdown:105 case subagents.CommandAgentShutdown:
106+ if initialTurnOpen {
107+ emit("turn_end", map[string]any{"stop": "cancelled"})
108+ initialTurnOpen = false
109+ }
94 emit("agent_stopped", map[string]any{"reason": "shutdown"})110 emit("agent_stopped", map[string]any{"reason": "shutdown"})
95 _ = c.Close()111 _ = c.Close()
96 return112 return
97 case subagents.CommandTurnCancel:113 case subagents.CommandTurnCancel:
114+ if initialTurnOpen {
115+ initialTurnOpen = false
116+ }
98 emit("turn_end", map[string]any{"stop": "cancelled"})117 emit("turn_end", map[string]any{"stop": "cancelled"})
99 case subagents.CommandTurnStart:118 case subagents.CommandTurnStart:
100 var payload subagents.TurnStartPayload119 var payload subagents.TurnStartPayload
A packages/agent/subagents/turn_classification.go +93 -0
@@ -0,0 +1,93 @@
1+package subagents
2+
3+import "fmt"
4+
5+// turnEventClass identifies the boundary that owns a turn event. The worker
6+// daemon's turn.started event is the delegated message boundary; provider
7+// turn_start events are nested model-loop activity. New workers carry stable
8+// lifetime/current counters on the delegated boundary and nested_turn on the
9+// provider boundary. The fallbacks preserve protocol-v1 logs, which used
10+// turn.started without counters for delegated turns and turn_start for nested
11+// turns.
12+type turnEventClass uint8
13+
14+const (
15+ turnEventUnknown turnEventClass = iota
16+ turnEventDelegated
17+ turnEventNested
18+)
19+
20+func classifyTurnEvent(ev Event) turnEventClass {
21+ if nested, ok := ev.Data["nested_turn"].(bool); ok && nested {
22+ return turnEventNested
23+ }
24+ switch ev.Type {
25+ case "turn_start":
26+ return turnEventNested
27+ case EventTurnStarted:
28+ // v1 turn.started without counters is the delegated boundary. A
29+ // current worker marks nested canonical events explicitly above.
30+ return turnEventDelegated
31+ case "turn_end":
32+ if _, ok := eventCounter(ev.Data, "step"); ok {
33+ return turnEventDelegated
34+ }
35+ return turnEventNested
36+ }
37+ return turnEventUnknown
38+}
39+
40+func isDelegatedTurnStart(ev Event) bool {
41+ return ev.Type == EventTurnStarted && classifyTurnEvent(ev) == turnEventDelegated
42+}
43+
44+// IsDelegatedTurnStart is shared with the worker's replay bootstrap so live,
45+// replayed, and protocol-v1 event handling use the same boundary rules.
46+func IsDelegatedTurnStart(ev Event) bool {
47+ return isDelegatedTurnStart(ev)
48+}
49+
50+func isDelegatedTurnEnd(ev Event) bool {
51+ return ev.Type == "turn_end" && classifyTurnEvent(ev) == turnEventDelegated
52+}
53+
54+func eventTurnID(ev Event) string {
55+ if ev.TurnID != "" {
56+ return ev.TurnID
57+ }
58+ if step, ok := eventCounter(ev.Data, "step"); ok {
59+ return fmt.Sprintf("turn-%d", step)
60+ }
61+ return ""
62+}
63+
64+func userMessageText(ev Event) string {
65+ if ev.Type != "user_message" {
66+ return ""
67+ }
68+ content := ev.Data["content"]
69+ var text string
70+ appendBlock := func(fields map[string]any) {
71+ if typ, _ := fields["type"].(string); typ == "text" {
72+ if value, _ := fields["text"].(string); value != "" {
73+ if text != "" {
74+ text += "\n"
75+ }
76+ text += value
77+ }
78+ }
79+ }
80+ switch blocks := content.(type) {
81+ case []any:
82+ for _, block := range blocks {
83+ if fields, ok := block.(map[string]any); ok {
84+ appendBlock(fields)
85+ }
86+ }
87+ case []map[string]any:
88+ for _, fields := range blocks {
89+ appendBlock(fields)
90+ }
91+ }
92+ return text
93+}
A packages/agent/subagents/turn_classification_test.go +63 -0
@@ -0,0 +1,63 @@
1+package subagents
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestTurnClassificationUsesStableBoundaryFieldsAcrossProtocolVersions(t *testing.T) {
9+ cases := []struct {
10+ name string
11+ event Event
12+ class turnEventClass
13+ delegated bool
14+ }{
15+ {
16+ name: "current delegated",
17+ event: NewEvent(EventTurnStarted, map[string]any{
18+ "lifetime_turns": 4,
19+ "current_run_turns": 2,
20+ }),
21+ class: turnEventDelegated,
22+ delegated: true,
23+ },
24+ {
25+ name: "current nested",
26+ event: NewEvent(EventTurnStarted, map[string]any{
27+ "nested_turn": true,
28+ "step": 3,
29+ }),
30+ class: turnEventNested,
31+ },
32+ {
33+ name: "protocol v1 delegated fallback",
34+ event: Event{Type: EventTurnStarted, Version: 1, Time: time.Now()},
35+ class: turnEventDelegated,
36+ delegated: true,
37+ },
38+ {
39+ name: "protocol v1 nested turn_start",
40+ event: Event{Type: "turn_start", Version: 1, Time: time.Now()},
41+ class: turnEventNested,
42+ },
43+ }
44+ for _, tc := range cases {
45+ t.Run(tc.name, func(t *testing.T) {
46+ if got := classifyTurnEvent(tc.event); got != tc.class {
47+ t.Fatalf("classifyTurnEvent = %d, want %d", got, tc.class)
48+ }
49+ if got := isDelegatedTurnStart(tc.event); got != tc.delegated {
50+ t.Fatalf("isDelegatedTurnStart = %v, want %v", got, tc.delegated)
51+ }
52+ })
53+ }
54+}
55+
56+func TestTurnClassificationKeepsNestedAndDelegatedEndsSeparate(t *testing.T) {
57+ if isDelegatedTurnEnd(NewEvent("turn_end", map[string]any{"stop": "tool_use"})) {
58+ t.Fatal("provider turn_end was classified as delegated")
59+ }
60+ if !isDelegatedTurnEnd(NewEvent("turn_end", map[string]any{"step": 2})) {
61+ t.Fatal("worker turn_end was not classified as delegated")
62+ }
63+}
M packages/agent/tools/create_worktree.go +9 -7
@@ -19,11 +19,12 @@ import (
19 )19 )
20 20
21 const (21 const (
22- worktreesIgnoreEntry = "/.worktrees/"22+ worktreesIgnoreEntry = "/.worktrees/"
23- defaultWorktreeRoot = ".worktrees"23+ worktreesUnanchoredIgnoreEntry = ".worktrees/"
24- worktreeConfigKey = "zut.worktrees.path"24+ defaultWorktreeRoot = ".worktrees"
25- maxCreateWorktreeGitOutput = 64 * 102425+ worktreeConfigKey = "zut.worktrees.path"
26- maxCreateWorktreeIgnoreBytes = 1 << 2026+ maxCreateWorktreeGitOutput = 64 * 1024
27+ maxCreateWorktreeIgnoreBytes = 1 << 20
27 )28 )
28 29
29 var createWorktreeOperationMu sync.Mutex30 var createWorktreeOperationMu sync.Mutex
@@ -358,7 +359,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
358 if err != nil {359 if err != nil {
359 return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)360 return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
360 }361 }
361- ignoreAction = "already contains " + worktreesIgnoreEntry362+ ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
362 if ignore.changed {363 if ignore.changed {
363 ignoreAction = "add " + worktreesIgnoreEntry364 ignoreAction = "add " + worktreesIgnoreEntry
364 }365 }
@@ -818,7 +819,8 @@ func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
818 return worktreesIgnoreUpdate{}, err819 return worktreesIgnoreUpdate{}, err
819 }820 }
820 for _, line := range strings.Split(string(contents), "\n") {821 for _, line := range strings.Split(string(contents), "\n") {
821- if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {822+ switch strings.TrimSuffix(line, "\r") {
823+ case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
822 return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil824 return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
823 }825 }
824 }826 }
M packages/agent/tools/create_worktree_test.go +70 -0
@@ -431,6 +431,76 @@ func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *tes
431 }431 }
432 }432 }
433 433
434+func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {
435+ if _, err := exec.LookPath("git"); err != nil {
436+ t.Skip("git is required")
437+ }
438+ for _, test := range []struct {
439+ name string
440+ entry string
441+ }{
442+ {name: "root-anchored", entry: worktreesIgnoreEntry},
443+ {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444+ } {
445+ t.Run(test.name, func(t *testing.T) {
446+ repo := initWorktreeTestRepo(t)
447+ ignorePath := filepath.Join(repo, ".gitignore")
448+ before := []byte(test.entry + "\n")
449+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450+ t.Fatal(err)
451+ }
452+ tool := newCreateWorktreeTestTool(repo)
453+ args := mustJSON(t, map[string]string{
454+ "branch": "feature/" + test.name + "-ignore",
455+ "bootstrap_root": defaultWorktreeRoot,
456+ })
457+
458+ result, err := tool.Execute(context.Background(), args, nil)
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ text := result.Content[0].(provider.TextBlock).Text
463+ if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464+ t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465+ }
466+ ignore, err := os.ReadFile(ignorePath)
467+ if err != nil {
468+ t.Fatal(err)
469+ }
470+ if string(ignore) != string(before) {
471+ t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472+ }
473+ })
474+ }
475+}
476+
477+func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {
478+ for _, entry := range []string{
479+ "!.worktrees/\n",
480+ "nested/.worktrees/\n",
481+ "#.worktrees/\n",
482+ ".worktrees-other/\n",
483+ } {
484+ t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485+ ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486+ if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487+ t.Fatal(err)
488+ }
489+
490+ update, err := newWorktreesIgnoreUpdate(ignorePath)
491+ if err != nil {
492+ t.Fatal(err)
493+ }
494+ if !update.changed {
495+ t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))
496+ }
497+ if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498+ t.Fatalf("updated .gitignore = %q, want %q", got, want)
499+ }
500+ })
501+ }
502+}
503+
434 func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {504 func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
435 if _, err := exec.LookPath("git"); err != nil {505 if _, err := exec.LookPath("git"); err != nil {
436 t.Skip("git is required")506 t.Skip("git is required")
M packages/agent/tools/subagent_resume.go +8 -6
@@ -10,10 +10,12 @@ import (
10 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
11 )11 )
12 12
13-// SubagentResumeTool gives a completed or stopped sub-agent a follow-up turn13+// SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
14-// while preserving its existing session context. For an idle live worker, the14+// existing session context. Every explicit follow-up starts a fresh max_turns
15-// follow-up is delivered directly. For a terminal worker, the supervisor15+// budget. For an idle live worker, the follow-up is delivered directly; for an
16-// restarts the retained session with the follow-up as its initial turn.16+// active live worker, it is queued for the next available message turn; for a
17+// terminal worker, the supervisor restarts the retained session with the
18+// follow-up as its initial turn.
17 type SubagentResumeTool struct {19 type SubagentResumeTool struct {
18 Supervisor *subagents.Supervisor20 Supervisor *subagents.Supervisor
19 Enabled func() bool21 Enabled func() bool
@@ -48,7 +50,7 @@ const subagentResumeSchema = `{
48 func (t *SubagentResumeTool) Name() string { return "subagent_resume" }50 func (t *SubagentResumeTool) Name() string { return "subagent_resume" }
49 51
50 func (t *SubagentResumeTool) Description() string {52 func (t *SubagentResumeTool) Description() string {
51- return "Send a follow-up to an idle sub-agent, or restart a stopped one with its prior session context preserved."53+ return "Continue a sub-agent with a fresh turn budget and its retained session; deliver immediately when idle, queue while active, or restart a stopped worker."
52 }54 }
53 55
54 func (t *SubagentResumeTool) Schema() json.RawMessage {56 func (t *SubagentResumeTool) Schema() json.RawMessage {
@@ -68,7 +70,7 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
68 return protocolToolError(prefix + ": subagent supervisor not available in this mode")70 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
69 }71 }
70 if t.Enabled == nil || !t.Enabled() {72 if t.Enabled == nil || !t.Enabled() {
71- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.")73+ return protocolToolError(prefix + ": subagent management is unavailable in this mode")
72 }74 }
73 75
74 var args subagentResumeArgs76 var args subagentResumeArgs
M packages/agent/tools/subagent_spawn.go +8 -10
@@ -18,18 +18,16 @@ import (
18 // turn continues uninterrupted. The user can monitor / chat with the18 // turn continues uninterrupted. The user can monitor / chat with the
19 // spawned agent via /subagents.19 // spawned agent via /subagents.
20 //20 //
21-// Gated by the auto_subagents_enabled config flag at call time so a user21+// Available when the host's launch-time tool policy permits delegation. The
22-// can flip it off mid-session and the next call refuses cleanly22+// primary-agent prompt decides whether delegation is proactive or only on an
23-// without re-registering the tool.23+// explicit user request.
24 type SubagentSpawnTool struct {24 type SubagentSpawnTool struct {
25- // Supervisor is the supervisor used to spawn agents. Nil means25+ // Supervisor is the supervisor used to spawn agents. Nil means subagents
26- // "auto-subagents not available in this mode" and the tool always26+ // are unavailable in this mode and the tool always errors.
27- // errors.
28 Supervisor *subagents.Supervisor27 Supervisor *subagents.Supervisor
29 28
30- // Enabled reads the live config flag. Lets users toggle from29+ // Enabled reports whether the host currently exposes this tool. When nil,
31- // /settings without rebuilding the agent. When nil, the tool30+ // the tool is treated as disabled.
32- // is treated as disabled.
33 Enabled func() bool31 Enabled func() bool
34 32
35 // DefaultModel and DefaultProvider return the host agent's resolved33 // DefaultModel and DefaultProvider return the host agent's resolved
@@ -131,7 +129,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
131 return protocolToolError(prefix + ": subagent supervisor not available in this mode")129 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
132 }130 }
133 if t.Enabled == nil || !t.Enabled() {131 if t.Enabled == nil || !t.Enabled() {
134- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")132+ return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
135 }133 }
136 var a subagentSpawnArgs134 var a subagentSpawnArgs
137 if err := json.Unmarshal(raw, &a); err != nil {135 if err := json.Unmarshal(raw, &a); err != nil {
M packages/agent/tools/subagent_status.go +21 -17
@@ -34,15 +34,17 @@ type subagentStatusResponse struct {
34 }34 }
35 35
36 type subagentStatusEntry struct {36 type subagentStatusEntry struct {
37- ID string `json:"agent_id"`37+ ID string `json:"agent_id"`
38- State string `json:"state"`38+ State string `json:"state"`
39- ProcessState string `json:"process_state"`39+ ProcessState string `json:"process_state"`
40- TurnState string `json:"turn_state"`40+ TurnState string `json:"turn_state"`
41- StartedAt time.Time `json:"started_at"`41+ LifetimeTurns int `json:"lifetime_turns"`
42- UpdatedAt time.Time `json:"updated_at"`42+ CurrentRunTurns int `json:"current_run_turns"`
43- FinishedAt *time.Time `json:"finished_at,omitempty"`43+ StartedAt time.Time `json:"started_at"`
44- TaskSummary string `json:"task_summary,omitempty"`44+ UpdatedAt time.Time `json:"updated_at"`
45- Result *subagentStatusResult `json:"result,omitempty"`45+ FinishedAt *time.Time `json:"finished_at,omitempty"`
46+ TaskSummary string `json:"task_summary,omitempty"`
47+ Result *subagentStatusResult `json:"result,omitempty"`
46 }48 }
47 49
48 type subagentStatusResult struct {50 type subagentStatusResult struct {
@@ -84,7 +86,7 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
84 return protocolToolError(prefix + ": subagent supervisor not available in this mode")86 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
85 }87 }
86 if t.Enabled == nil || !t.Enabled() {88 if t.Enabled == nil || !t.Enabled() {
87- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before querying workers.")89+ return protocolToolError(prefix + ": subagent status is unavailable in this mode")
88 }90 }
89 91
90 var args subagentStatusArgs92 var args subagentStatusArgs
@@ -132,13 +134,15 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string)
132 134
133 func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {135 func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {
134 entry := subagentStatusEntry{136 entry := subagentStatusEntry{
135- ID: snapshot.ID,137+ ID: snapshot.ID,
136- State: publicSubagentState(snapshot),138+ State: publicSubagentState(snapshot),
137- ProcessState: string(snapshot.ProcessState),139+ ProcessState: string(snapshot.ProcessState),
138- TurnState: string(snapshot.TurnState),140+ TurnState: string(snapshot.TurnState),
139- StartedAt: snapshot.Started,141+ LifetimeTurns: snapshot.LifetimeTurns,
140- UpdatedAt: snapshot.UpdatedAt,142+ CurrentRunTurns: snapshot.CurrentRunTurns,
141- TaskSummary: summarizeSubagentTask(snapshot.Task),143+ StartedAt: snapshot.Started,
144+ UpdatedAt: snapshot.UpdatedAt,
145+ TaskSummary: summarizeSubagentTask(snapshot.Task),
142 }146 }
143 if !snapshot.Finished.IsZero() {147 if !snapshot.Finished.IsZero() {
144 finished := snapshot.Finished148 finished := snapshot.Finished
M packages/agent/tools/subagent_status_test.go +22 -0
@@ -39,6 +39,28 @@ func (r statusTestRunner) Run(ctx context.Context, sink subagents.Sink) error {
39 }39 }
40 }40 }
41 41
42+func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
43+ entry := publicSubagentStatus(subagents.AgentSnapshot{
44+ ID: "counter-agent",
45+ LifetimeTurns: 7,
46+ CurrentRunTurns: 2,
47+ })
48+ if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
49+ t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
50+ }
51+ encoded, err := json.Marshal(entry)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ var fields map[string]any
56+ if err := json.Unmarshal(encoded, &fields); err != nil {
57+ t.Fatal(err)
58+ }
59+ if fields["lifetime_turns"] != float64(7) || fields["current_run_turns"] != float64(2) {
60+ t.Fatalf("encoded status counters = %#v", fields)
61+ }
62+}
63+
42 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {64 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
43 var schema struct {65 var schema struct {
44 Type string `json:"type"`66 Type string `json:"type"`
M packages/agent/tools/subagent_stop.go +1 -1
@@ -67,7 +67,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
67 return protocolToolError(prefix + ": subagent supervisor not available in this mode")67 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
68 }68 }
69 if t.Enabled == nil || !t.Enabled() {69 if t.Enabled == nil || !t.Enabled() {
70- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.")70+ return protocolToolError(prefix + ": subagent management is unavailable in this mode")
71 }71 }
72 72
73 var args subagentStopArgs73 var args subagentStopArgs
M packages/core/session.go +13 -0
@@ -1187,6 +1187,19 @@ func (s *Session) AppendMessage(m provider.Message) error {
1187 return nil1187 return nil
1188 }1188 }
1189 1189
1190+// Sync flushes the session file's bytes through the filesystem. Interactive
1191+// hosts use this at acknowledgement boundaries where a crash must not make a
1192+// prompt look accepted before its user message is recoverable.
1193+func (s *Session) Sync() error {
1194+ if s == nil || s.writer == nil {
1195+ return nil
1196+ }
1197+ if err := s.buf.Flush(); err != nil {
1198+ return err
1199+ }
1200+ return s.writer.Sync()
1201+}
1202+
1190 // AppendCompaction writes a checkpoint that replaces all earlier1203 // AppendCompaction writes a checkpoint that replaces all earlier
1191 // transcript rows when the session is resumed. The old rows remain in1204 // transcript rows when the session is resumed. The old rows remain in
1192 // the JSONL file for audit/export, while loaders use the latest1205 // the JSONL file for audit/export, while loaders use the latest
M packages/tui/editor.go +15 -0
@@ -912,6 +912,21 @@ func stripANSI(s string) string {
912 }912 }
913 continue913 continue
914 }914 }
915+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
916+ i += 2
917+ for i < len(runes) {
918+ if runes[i] == 0x07 {
919+ i++
920+ break
921+ }
922+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '\\' {
923+ i += 2
924+ break
925+ }
926+ i++
927+ }
928+ continue
929+ }
915 out = append(out, runes[i])930 out = append(out, runes[i])
916 i++931 i++
917 }932 }
M packages/tui/render.go +131 -30
@@ -42,16 +42,24 @@ type Renderer struct {
42 logLines []string42 logLines []string
43 // Raw logical rows let DrawLog reject an unchanged frame before43 // Raw logical rows let DrawLog reject an unchanged frame before
44 // truncating and repainting every transcript row.44 // truncating and repainting every transcript row.
45- logRawChat []string45+ logRawChat []string
46- logRawBottom []string46+ logRawBottom []string
47- logRawCols int47+ logRawCols int
48- logRawRows int48+ logRawRows int
49- logRawBackground string49+ logRawContentCols int
50- logRawHasThemeBG bool50+ logRawBackground string
51- logViewportTop int51+ logRawHasThemeBG bool
52- logHardwareRow int52+ logViewportTop int
53- logInit bool53+ logHardwareRow int
54- logNeedsFullRepaint bool54+ logInit bool
55+
56+ // The right bar is a viewport overlay on top of the main-screen flow
57+ // renderer. Keeping its cache separate prevents persistent chrome from
58+ // replacing the transcript's logical buffer and native scrollback.
59+ rightBarActive bool
60+ rightBarLines []string
61+ rightBarMainCols int
62+ rightBarDimmed bool
55 63
56 // keepScrollback is true when we must NOT emit \x1b[3J64 // keepScrollback is true when we must NOT emit \x1b[3J
57 // (erase-in-display 3, "clear scrollback").65 // (erase-in-display 3, "clear scrollback").
@@ -130,6 +138,9 @@ func (r *Renderer) Resize(cols, rows int) {
130 r.logViewportTop = 0138 r.logViewportTop = 0
131 r.logHardwareRow = 0139 r.logHardwareRow = 0
132 r.logInit = false140 r.logInit = false
141+ r.rightBarActive = false
142+ r.rightBarLines = nil
143+ r.rightBarMainCols = 0
133 if r.out != nil {144 if r.out != nil {
134 if r.keepScrollback {145 if r.keepScrollback {
135 // A resize is a discrete user action, like Ctrl+L: the146 // A resize is a discrete user action, like Ctrl+L: the
@@ -166,6 +177,9 @@ func (r *Renderer) Clear() {
166 r.logViewportTop = 0177 r.logViewportTop = 0
167 r.logHardwareRow = 0178 r.logHardwareRow = 0
168 r.logInit = false179 r.logInit = false
180+ r.rightBarActive = false
181+ r.rightBarLines = nil
182+ r.rightBarMainCols = 0
169 if r.keepScrollback {183 if r.keepScrollback {
170 // On VS Code's xterm.js the transcript is taller than the184 // On VS Code's xterm.js the transcript is taller than the
171 // viewport, so an in-place clear (home + erase-to-end) only185 // viewport, so an in-place clear (home + erase-to-end) only
@@ -264,6 +278,7 @@ func truncateToWidth(s string, cols int) string {
264 var out strings.Builder278 var out strings.Builder
265 out.Grow(len(s))279 out.Grow(len(s))
266 seen := 0280 seen := 0
281+ osc8Active := false
267 runes := []rune(s)282 runes := []rune(s)
268 for i := 0; i < len(runes); {283 for i := 0; i < len(runes); {
269 r := runes[i]284 r := runes[i]
@@ -282,8 +297,41 @@ func truncateToWidth(s string, cols int) string {
282 }297 }
283 continue298 continue
284 }299 }
300+ // OSC string escape (BEL or ST terminated): zero-width and atomic.
301+ if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
302+ start := i
303+ i += 2
304+ for i < len(runes) {
305+ c := runes[i]
306+ i++
307+ if c == 0x07 {
308+ break
309+ }
310+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
311+ i++
312+ break
313+ }
314+ }
315+ osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' &&
316+ ((runes[start+5] == 0x07 && i == start+6) ||
317+ (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7))
318+ if seen < cols || osc8Closer {
319+ for _, c := range runes[start:i] {
320+ out.WriteRune(c)
321+ }
322+ if runes[start+2] == '8' {
323+ osc8Active = !osc8Closer
324+ }
325+ }
326+ continue
327+ }
285 rw := runewidthRune(r)328 rw := runewidthRune(r)
286 if seen+rw > cols {329 if seen+rw > cols {
330+ if osc8Active {
331+ // The remaining text is omitted, so close a hyperlink
332+ // whose opener was already emitted before the boundary.
333+ out.WriteString("\x1b]8;;\x07")
334+ }
287 // Flush any trailing ANSI escapes (resets, erase-to-EOL)335 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
288 // so background colors and cleanup sequences survive.336 // so background colors and cleanup sequences survive.
289 for i < len(runes) {337 for i < len(runes) {
@@ -299,6 +347,31 @@ func truncateToWidth(s string, cols int) string {
299 break347 break
300 }348 }
301 }349 }
350+ } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
351+ // Do not append a new OSC sequence after the visible
352+ // boundary: an OSC 8 opener would leak hyperlink state
353+ // into the next terminal row. A matching OSC 8 closer is
354+ // retained so a link opened before the boundary is closed.
355+ keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' &&
356+ (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\'))
357+ start := i
358+ i += 2
359+ for i < len(runes) {
360+ c := runes[i]
361+ i++
362+ if c == 0x07 {
363+ break
364+ }
365+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
366+ i++
367+ break
368+ }
369+ }
370+ if keep {
371+ for _, c := range runes[start:i] {
372+ out.WriteRune(c)
373+ }
374+ }
302 } else {375 } else {
303 break376 break
304 }377 }
@@ -432,26 +505,34 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
432 //505 //
433 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.506 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
434 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {507 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
508+ if r.rightBarActive {
509+ r.clearRightBarOverlay()
510+ }
511+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
512+}
513+
514+// drawLog is DrawLog's owning flow implementation. contentCols may reserve
515+// terminal columns for a separately composited viewport overlay.
516+func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
435 if r.cols == 0 || r.rows == 0 {517 if r.cols == 0 || r.rows == 0 {
436- return518+ return false
519+ }
520+ if contentCols <= 0 || contentCols > r.cols {
521+ contentCols = r.cols
437 }522 }
438 if len(bottom) == 0 {523 if len(bottom) == 0 {
439 bottom = []string{""}524 bottom = []string{""}
440 }525 }
441- if r.logInit && len(r.logLines) > 0 &&526+ if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
442- cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&527+ return false
443- r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
444- r.logRawHasThemeBG == (r.theme.Background != nil) &&
445- sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
446- return
447 }528 }
448 chatFrame := make([]string, len(chat))529 chatFrame := make([]string, len(chat))
449 for i, line := range chat {530 for i, line := range chat {
450- chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)531+ chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
451 }532 }
452 bottomFrame := make([]string, len(bottom))533 bottomFrame := make([]string, len(bottom))
453 for i, line := range bottom {534 for i, line := range bottom {
454- bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)535+ bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
455 }536 }
456 537
457 // Always reserve one real row below the editor/status band. This is538 // Always reserve one real row below the editor/status band. This is
@@ -463,7 +544,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
463 lines = append(lines, chatFrame...)544 lines = append(lines, chatFrame...)
464 lines = append(lines, bottomFrame...)545 lines = append(lines, bottomFrame...)
465 for range bottomMarginRows {546 for range bottomMarginRows {
466- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))547+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
467 }548 }
468 // In main-screen flow mode zut normally emits only its logical549 // In main-screen flow mode zut normally emits only its logical
469 // content rows and leaves the rest of the terminal viewport alone.550 // content rows and leaves the rest of the terminal viewport alone.
@@ -473,7 +554,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
473 // default transparent case.554 // default transparent case.
474 if r.theme.Background != nil {555 if r.theme.Background != nil {
475 for len(lines) < r.rows {556 for len(lines) < r.rows {
476- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))557+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
477 }558 }
478 }559 }
479 if len(lines) == 0 {560 if len(lines) == 0 {
@@ -495,8 +576,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
495 // that never blinks, because we keep "showing" it before the576 // that never blinks, because we keep "showing" it before the
496 // terminal can blink it off. Bailing out here lets the OS run577 // terminal can blink it off. Bailing out here lets the OS run
497 // its blink cycle.578 // its blink cycle.
498- if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {579+ if r.logInit && r.logRawContentCols == contentCols &&
499- return580+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
581+ return false
500 }582 }
501 583
502 var w strings.Builder584 var w strings.Builder
@@ -590,8 +672,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
590 }672 }
591 moveToLogicalRow(cursorTargetRow)673 moveToLogicalRow(cursorTargetRow)
592 w.WriteString("\r")674 w.WriteString("\r")
593- if cursorCol > 0 {675+ physicalCursorCol := cursorCol
594- w.WriteString("\x1b[" + itoa(cursorCol) + "C")676+ if physicalCursorCol < 0 {
677+ physicalCursorCol = 0
678+ }
679+ if physicalCursorCol >= contentCols {
680+ physicalCursorCol = contentCols - 1
681+ }
682+ if physicalCursorCol > 0 {
683+ w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
595 }684 }
596 w.WriteString(SeqShowCursor)685 w.WriteString(SeqShowCursor)
597 }686 }
@@ -605,12 +694,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
605 wasInitialized := r.logInit694 wasInitialized := r.logInit
606 full := !wasInitialized || len(r.logLines) == 0695 full := !wasInitialized || len(r.logLines) == 0
607 if full {696 if full {
608- // Returning from the fixed right-bar frame needs a full viewport697+ // A cache invalidation may require a full viewport repaint after flow
609- // repaint, but it must not discard terminal scrollback merely because698+ // has started, but only the initial paint may discard old scrollback.
610- // the renderer's flow cache is intentionally empty.699+ writeFull(true, !wasInitialized)
611- writeFull(true, !wasInitialized && !r.logNeedsFullRepaint)
612 r.logInit = true700 r.logInit = true
613- r.logNeedsFullRepaint = false
614 } else {701 } else {
615 firstChanged := -1702 firstChanged := -1
616 lastChanged := -1703 lastChanged := -1
@@ -827,10 +914,24 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
827 r.logRawBottom = append(r.logRawBottom[:0], bottom...)914 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
828 r.logRawCols = r.cols915 r.logRawCols = r.cols
829 r.logRawRows = r.rows916 r.logRawRows = r.rows
917+ r.logRawContentCols = contentCols
830 r.logRawBackground = r.backgroundStyle918 r.logRawBackground = r.backgroundStyle
831 r.logRawHasThemeBG = r.theme.Background != nil919 r.logRawHasThemeBG = r.theme.Background != nil
832 r.cursorRow = cursorBottomRow920 r.cursorRow = cursorBottomRow
833 r.cursorCol = cursorCol921 r.cursorCol = cursorCol
922+ return true
923+}
924+
925+func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
926+ return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
927+ r.logRawContentUnchanged(chat, bottom, contentCols)
928+}
929+
930+func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
931+ return r.logInit && len(r.logLines) > 0 &&
932+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols &&
933+ r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
934+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
834 }935 }
835 936
836 // sameLines reports whether two []string have the exact same937 // sameLines reports whether two []string have the exact same
M packages/tui/rightbar.go +92 -42
@@ -1,6 +1,7 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "io"
4 "sort"5 "sort"
5 "strings"6 "strings"
6 )7 )
@@ -372,12 +373,9 @@ func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int,
372 return main + separator + rightBar373 return main + separator + rightBar
373 }374 }
374 375
375-// DrawRightBar renders an already-laid-out visible chat slice and sticky376+// DrawRightBar renders the complete transcript through DrawLog, then
376-// bottom block in a fixed frame with a persistent right bar. The normal377+// composites the persistent rail over the visible viewport. The flow cache
377-// DrawLog path deliberately uses terminal scrollback; a persistent side rail378+// remains authoritative so appended chat still enters native scrollback.
378-// needs a fixed viewport so updates can repaint its columns without leaving
379-// stale rows behind. Interactive mode still owns chat scrolling and passes
380-// the selected visible slice here.
381 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {379 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
382 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)380 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
383 }381 }
@@ -394,50 +392,102 @@ func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow
394 }392 }
395 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)393 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
396 if !ok {394 if !ok {
395+ r.DrawLog(chat, bottom, cursorBottomRow, cursorCol)
397 return396 return
398 }397 }
398+
399 if len(bottom) == 0 {399 if len(bottom) == 0 {
400 bottom = []string{""}400 bottom = []string{""}
401 }401 }
402- if len(rightBar) < r.rows {
403- padded := make([]string, r.rows)
404- copy(padded, rightBar)
405- rightBar = padded
406- } else if len(rightBar) > r.rows {
407- rightBar = rightBar[:r.rows]
408- }
409 402
410- main := make([]string, 0, len(chat)+len(bottom)+1)403+ // Keep DrawLog's final blank margin unobstructed, matching the normal flow
411- main = append(main, chat...)404+ // renderer and the previous right-bar layout.
412- main = append(main, bottom...)405+ frameRows := max(0, r.rows-1)
413- main = append(main, "") // mirror DrawLog's renderer-owned bottom margin406+ barLines := make([]string, frameRows)
414- cursorRow := len(chat) + cursorBottomRow407+ copy(barLines, rightBar)
415- if len(main) > r.rows {408+
416- trim := len(main) - r.rows409+ barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth ||
417- main = main[trim:]410+ r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines)
418- cursorRow -= trim411+ contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth)
419- }412+ flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol
420- if len(main) < r.rows {413+ if flowUnchanged && !barChanged {
421- padding := make([]string, r.rows-len(main))414+ return
422- main = append(padding, main...)415+ }
423- cursorRow += len(padding)416+ if !contentUnchanged && r.rightBarActive {
417+ // Remove viewport chrome before DrawLog can naturally scroll a row into
418+ // history. Otherwise every appended transcript row captures a stale
419+ // copy of the rail in native terminal scrollback.
420+ r.clearRightBarOverlay()
424 }421 }
422+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth)
423+ if contentUnchanged && !barChanged {
424+ // Cursor-only updates do not disturb the composited rail.
425+ return
426+ }
427+ r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator)
428+ r.rightBarActive = true
429+ r.rightBarLines = append(r.rightBarLines[:0], barLines...)
430+ r.rightBarMainCols = mainWidth
431+ r.rightBarDimmed = dimSeparator
432+}
425 433
426- frame := make([]string, r.rows)434+// clearRightBarOverlay removes viewport chrome before DrawLog expands back to
427- for row := range frame {435+// the terminal width. Restoring the tracked flow cursor keeps DrawLog's
428- frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)436+// hardware-row model valid across the intermediate absolute writes.
437+func (r *Renderer) clearRightBarOverlay() {
438+ if !r.rightBarActive {
439+ return
429 }440 }
441+ remaining := r.cols - r.rightBarMainCols
442+ if remaining > 0 {
443+ blank := strings.Repeat(" ", remaining)
444+ var w strings.Builder
445+ w.WriteString(SeqSynchronizedOn)
446+ w.WriteString(SeqHideCursor)
447+ for row := 0; row < max(0, r.rows-1); row++ {
448+ w.WriteString(MoveTo(row+1, r.rightBarMainCols+1))
449+ w.WriteString("\x1b[0m")
450+ w.WriteString(blank)
451+ }
452+ r.appendTrackedLogCursor(&w, r.rightBarMainCols)
453+ w.WriteString(SeqSynchronizedOff)
454+ _, _ = io.WriteString(r.out, w.String())
455+ }
456+ r.rightBarActive = false
457+ r.rightBarLines = nil
458+ r.rightBarMainCols = 0
459+ r.rightBarDimmed = false
460+}
461+
462+func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) {
463+ var w strings.Builder
464+ w.WriteString(SeqSynchronizedOn)
465+ w.WriteString(SeqHideCursor)
466+ for row, line := range rightBar {
467+ w.WriteString(MoveTo(row+1, mainWidth+1))
468+ w.WriteString("\x1b[0m")
469+ w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator))
470+ }
471+ r.appendTrackedLogCursor(&w, mainWidth)
472+ w.WriteString(SeqSynchronizedOff)
473+ _, _ = io.WriteString(r.out, w.String())
474+}
430 475
431- // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a476+func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) {
432- // later fallback to DrawLog starts with a full repaint instead of trying477+ if !r.logInit || r.cursorRow < 0 {
433- // to diff against logical rows from before the rail was enabled. Mark the478+ return
434- // transition separately so that repaint does not clear scrollback.479+ }
435- r.logChat = nil480+ row := len(r.logChat) + r.cursorRow - r.logViewportTop
436- r.logBottom = nil481+ if row < 0 || row >= r.rows {
437- r.logLines = nil482+ return
438- r.logViewportTop = 0483+ }
439- r.logHardwareRow = 0484+ cursorCol := r.cursorCol
440- r.logInit = false485+ if cursorCol < 0 {
441- r.logNeedsFullRepaint = true486+ cursorCol = 0
442- r.Draw(frame, cursorRow, cursorCol)487+ }
488+ if cursorCol >= contentCols {
489+ cursorCol = contentCols - 1
490+ }
491+ w.WriteString(MoveTo(row+1, cursorCol+1))
492+ w.WriteString(SeqShowCursor)
443 }493 }
M packages/tui/rightbar_test.go +152 -6
@@ -209,6 +209,141 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
209 }209 }
210 }210 }
211 211
212+func TestRightBarTruncationPreservesOSCSequences(t *testing.T) {
213+ for _, tc := range []struct {
214+ opener string
215+ closer string
216+ }{
217+ {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"},
218+ {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"},
219+ } {
220+ got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1)
221+ if !strings.HasPrefix(got, tc.opener+"label") {
222+ t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got)
223+ }
224+ got = truncateToWidth("12345"+tc.opener+"tail", 5)
225+ if got != "12345" {
226+ t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got)
227+ }
228+ got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5)
229+ if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) {
230+ t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got)
231+ }
232+ }
233+}
234+
235+func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) {
236+ t.Setenv("TERM_PROGRAM", "")
237+
238+ var out bytes.Buffer
239+ r := NewRenderer(&out)
240+ r.SetTheme(Dark)
241+ r.Resize(80, 5)
242+ chat := []string{"one", "two", "three", "four", "five", "six"}
243+ r.DrawRightBar(
244+ chat,
245+ []string{"input"},
246+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
247+ 0,
248+ 0,
249+ )
250+ if !r.logInit {
251+ t.Fatal("right-bar draw abandoned the flow renderer")
252+ }
253+ if got := len(r.logChat); got != len(chat) {
254+ t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat))
255+ }
256+
257+ out.Reset()
258+ chat = append(chat, "seven")
259+ r.DrawRightBar(
260+ chat,
261+ []string{"input"},
262+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
263+ 0,
264+ 0,
265+ )
266+ got := out.String()
267+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
268+ t.Fatalf("right-bar append cleared retained scrollback: %q", got)
269+ }
270+ if !strings.Contains(stripANSI(got), "seven") {
271+ t.Fatalf("right-bar append omitted new transcript row: %q", got)
272+ }
273+ mainWidth, _, _ := RightBarColumns(80)
274+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
275+ if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt {
276+ t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got)
277+ }
278+ if r.logViewportTop == 0 {
279+ t.Fatal("test did not exercise transcript rows above the viewport")
280+ }
281+}
282+
283+func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) {
284+ t.Setenv("TERM_PROGRAM", "")
285+ var out bytes.Buffer
286+ r := NewRenderer(&out)
287+ r.SetTheme(Dark)
288+ r.Resize(80, 5)
289+ chat := []string{"chat"}
290+ bottom := []string{"input"}
291+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
292+ r.DrawRightBar(chat, bottom, rail, 0, 0)
293+ out.Reset()
294+
295+ r.DrawRightBar(chat, bottom, rail, 0, 0)
296+ if out.Len() != 0 {
297+ t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String())
298+ }
299+}
300+
301+func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) {
302+ t.Setenv("TERM_PROGRAM", "")
303+ var out bytes.Buffer
304+ r := NewRenderer(&out)
305+ r.SetTheme(Dark)
306+ r.Resize(80, 5)
307+ chat := []string{"chat"}
308+ bottom := []string{"input"}
309+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
310+ r.DrawRightBar(chat, bottom, rail, 0, 0)
311+ out.Reset()
312+
313+ r.DrawRightBar(chat, bottom, rail, 0, 1)
314+ if out.Len() == 0 {
315+ t.Fatal("cursor-only right-bar update emitted nothing")
316+ }
317+ if !strings.Contains(out.String(), "\r\x1b[1C") {
318+ t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String())
319+ }
320+ if strings.Contains(stripANSI(out.String()), "task") {
321+ t.Fatalf("cursor-only update repainted the entire rail: %q", out.String())
322+ }
323+}
324+
325+func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) {
326+ t.Setenv("TERM_PROGRAM", "")
327+ var out bytes.Buffer
328+ r := NewRenderer(&out)
329+ r.SetTheme(Dark)
330+ r.Resize(80, 5)
331+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
332+ r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100)
333+
334+ mainWidth, _, ok := RightBarColumns(80)
335+ if !ok {
336+ t.Fatal("right bar should fit")
337+ }
338+ got := out.String()
339+ if strings.Contains(got, MoveTo(2, 101)) {
340+ t.Fatalf("cursor escaped the main content area: %q", got)
341+ }
342+ if !strings.Contains(got, MoveTo(2, mainWidth)) {
343+ t.Fatalf("cursor was not clamped to the main content edge: %q", got)
344+ }
345+}
346+
212 func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {347 func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
213 t.Setenv("TERM_PROGRAM", "")348 t.Setenv("TERM_PROGRAM", "")
214 349
@@ -226,10 +361,21 @@ func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
226 361
227 out.Reset()362 out.Reset()
228 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)363 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
229- if !strings.Contains(stripANSI(out.String()), "input") {364+ if r.rightBarActive {
230- t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())365+ t.Fatal("right-bar fallback left the viewport overlay active")
366+ }
367+ if r.logRawContentCols != 80 {
368+ t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols)
231 }369 }
232- if strings.Contains(out.String(), SeqClearScrollback) {370+ mainWidth, _, ok := RightBarColumns(80)
371+ if !ok {
372+ t.Fatal("right bar should fit before falling back to DrawLog")
373+ }
374+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
375+ if !strings.Contains(out.String(), clearedRail) {
376+ t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String())
377+ }
378+ if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) {
233 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())379 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
234 }380 }
235 }381 }
@@ -261,14 +407,14 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
261 if !strings.Contains(stripANSI(out.String()), "Updated") {407 if !strings.Contains(stripANSI(out.String()), "Updated") {
262 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())408 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
263 }409 }
264- if r.logInit {410+ if !r.logInit {
265- t.Fatal("right-bar mode left flow renderer initialized")411+ t.Fatal("right-bar update abandoned the flow renderer")
266 }412 }
267 413
268 out.Reset()414 out.Reset()
269 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)415 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
270 if !r.logInit {416 if !r.logInit {
271- t.Fatal("flow renderer was not reinitialized after right-bar clearing")417+ t.Fatal("flow renderer was not retained after right-bar clearing")
272 }418 }
273 419
274 out.Reset()420 out.Reset()

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 477

↑↓ moveenter openesc close