merge: integrate origin/main
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 profiles | 395 | ### 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 profile | 55 | ## 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>/patch | 125 | 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 policy | 138 | ## 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 string | 140 | SubagentWorker string | ||
| 141 | 141 | ||||
| 142 | - | // SubagentMaxTurns limits prompt-level turns in worker mode. | 142 | + | // SubagentMaxTurns limits message turns in one worker run. |
| 143 | SubagentMaxTurns int | 143 | 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 tool | 151 | // 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 = n | 378 | 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-subagents | 1499 | + | // Register the canonical subagent tools whenever launch-time policy permits |
| 1500 | - | // is currently enabled. Registering them unconditionally leaves the model | 1500 | + | // them. The auto-subagents setting controls strict orchestration guidance, |
| 1501 | - | // trying to call them (and getting a polite error) even when the user has | 1501 | + | // 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 reg | 1504 | 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()] = statusTool | 1531 | 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()] = stopTool | 1539 | 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()] = resumeTool | 1547 | 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.Request | 263 | 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.ThemeOption | 180 | ExtensionThemes func() []tui.ThemeOption | ||
| 181 | 181 | ||||
| 182 | - | // AutoSubagentsSystemAddendum is the system-prompt block that gets | 182 | + | // 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 import | 184 | + | // the cli so this package doesn't have to import agent (cycle). |
| 185 | - | // agent (cycle). | |||
| 186 | AutoSubagentsSystemAddendum string | 185 | 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 string | 193 | 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.Agent | 982 | 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.titleRealPromptSeen | 1005 | i.titleGenerationStarted = i.titleRealPromptSeen | ||
| 1005 | } | 1006 | } | ||
| 1006 | - | if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { | 1007 | + | i.applyAutoSubagentsTool() |
| 1007 | - | i.applyAutoSubagentsTool(true) | |||
| 1008 | - | } | |||
| 1009 | return i | 1008 | 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 | - | return | 1347 | + | // 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) > 0 | 2222 | 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 = 1 | 2226 | chatRows = 1 | ||
| 2234 | } | 2227 | } | ||
| @@ -4018,11 +4011,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 4018 | i.agent = ag | 4011 | i.agent = ag | ||
| 4019 | i.cfg.CWD = cwd | 4012 | i.cfg.CWD = cwd | ||
| 4020 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | 4013 | 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 = nil | 4016 | 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 | return | 5233 | return | ||
| 5245 | } | 5234 | } | ||
| 5246 | } | 5235 | } | ||
| 5247 | - | // Add/remove the auto-subagent tools on the live agent so the | 5236 | + | // 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 and | 5238 | + | // 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 model | 5241 | + | // 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 apply | 6896 | // dynamically-registered tools need to be reattached. The apply | ||
| 6910 | // helpers are no-ops when their feature is inactive, so the | 6897 | // 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 live | 6944 | // 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-subagents | 8840 | // 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 addenda | 8862 | 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 to | 8865 | // removeLastAutoSubagentsAddendum removes one occurrence of a block known to | ||
| 8878 | // have been appended by auto-subagents. Resolve appends owned blocks at the | 8866 | // 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):], true | 8873 | return system[:idx] + system[idx+len(addendum):], true | ||
| 8886 | } | 8874 | } | ||
| 8887 | 8875 | ||||
| 8888 | - | // applyAutoSubagentsSystemPrompt appends (active=true) or strips | 8876 | + | // 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 so | 8878 | + | // 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 system | 8880 | // 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 or | 8881 | // 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 | return | 8886 | 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 := false | 8890 | changed := false | ||
| @@ -8923,16 +8894,33 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||||
| 8923 | changed = changed || removed | 8894 | changed = changed || removed | ||
| 8924 | } | 8895 | } | ||
| 8925 | i.managedAutoSubagentsAddenda = nil | 8896 | 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 them | 8920 | + | // 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 additions | 8922 | + | // 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] = t | 9007 | 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()] = statusTool | 9027 | 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()] = stopTool | 9035 | 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()] = resumeTool | 9043 | 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.statusOK | 299 | + | t.Fatal("timed out waiting for wait watcher to report completion") |
| 298 | - | iv.mu.Unlock() | 300 | + | } |
| 299 | - | if status == "completed "+a.ID { | 301 | + | |
| 300 | - | return | 302 | + | 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 injecting | 256 | + | // strict orchestrator mode. The canonical subagent tools remain available for |
| 257 | - | // the auto-subagent tools into the tool registry; interactive mode mirrors | 257 | + | // 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 when | 266 | // AutoSubagentsSystemAddendum is appended to the system prompt when | ||
| 268 | // auto-subagents is enabled. It makes the interactive primary agent an | 267 | // 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 strict | 283 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | ||
| 281 | // orchestrator contract still applies when launch-time policy withholds the | 284 | // 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 under | 36 | // production; tests use the stubchild binary under | ||
| 37 | // packages/agent/subagents/testdata/cmd/stubchild instead of the real model | 37 | // 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 err | 129 | return err | ||
| 80 | } | 130 | } | ||
| 131 | + | var sessionPersistence sessionPersistenceState | |||
| 81 | if sess != nil { | 132 | if sess != nil { | ||
| 82 | var providerName, model string | 133 | 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, model | 138 | 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 a | 201 | // 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 interrupt | 225 | // 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.Mutex | 228 | + | mu sync.Mutex |
| 158 | - | cancelFn context.CancelFunc | 229 | + | cancelFn context.CancelFunc |
| 159 | - | busyTurn bool | 230 | + | 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.WaitGroup | 236 | turnWG sync.WaitGroup | ||
| 162 | closing bool | 237 | closing bool | ||
| 238 | + | turnPending bool | |||
| 239 | + | seenCommands = make(map[string]struct{}) | |||
| 163 | shutdown = make(chan struct{}) | 240 | shutdown = make(chan struct{}) | ||
| 164 | shutdownOnce sync.Once | 241 | 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 so | 247 | // launchTurn admits the goroutine to turnWG before it starts so | ||
| 171 | // shutdown can join it. A shutdown can win the scheduling race | 248 | // shutdown can join it. A shutdown can win the scheduling race | ||
| 172 | // after that admission but before this goroutine acquires mu; in | 249 | // after that admission but before this goroutine acquires mu; in | ||
| 173 | // that case do not start a new Prompt after waitForActiveTurn has | 250 | // 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 | return | 255 | return | ||
| 178 | } | 256 | } | ||
| 179 | - | if args.SubagentMaxTurns > 0 && turnNo >= args.SubagentMaxTurns { | 257 | + | busyTurn = true |
| 180 | - | // Consume a unique rejected attempt so repeated prompts after | 258 | + | 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 := turnNo | 261 | + | 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 | - | return | 270 | + | // 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. The | 273 | + | "lifetime_turns": lifetime, |
| 195 | - | // supervisor protocol assumes one outstanding turn per | 274 | + | "current_run_turns": currentRun, |
| 196 | - | // agent; if a user really wants to interrupt and start | 275 | + | }) |
| 197 | - | // another, they should send "cancel" first. | |||
| 198 | - | mu.Unlock() | |||
| 199 | - | em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"}) | |||
| 200 | return | 276 | return | ||
| 201 | } | 277 | } | ||
| 202 | - | busyTurn = true | 278 | + | 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 = cancel | 280 | 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 = persistErr | 329 | + | 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 manager | 372 | // 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 | - | return | 381 | + | 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 then | 384 | + | // Reserve the worker before launching the goroutine. A second command |
| 286 | - | // close the gate and wait for every turn that was admitted, even | 385 | + | // 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 users | 415 | // "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 spawn | 421 | // 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.TurnStartPayload | 462 | 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 | continue | 465 | 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 as | 479 | // 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 := 0 | 736 | 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 after | 14 | // Agent is one supervised task. Public fields are immutable after | ||
| @@ -17,6 +19,18 @@ type turnEndNotice struct { | |||||
| 17 | errMsg string | 19 | 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 string | 35 | ID string | ||
| 22 | Task string | 36 | Task string | ||
| @@ -36,6 +50,14 @@ type Agent struct { | |||||
| 36 | WorkspaceBase string | 50 | WorkspaceBase string | ||
| 37 | WorkspaceCapture CaptureMode | 51 | WorkspaceCapture CaptureMode | ||
| 38 | MaxTurns int | 52 | 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 resumed | 61 | // 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.Duration | 63 | 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 bool | 103 | Resuming bool | ||
| 82 | 104 | ||||
| 83 | - | // resumePromptText is an optional manager follow-up passed to a resumed | 105 | + | // resumePromptText is the manager follow-up currently handed to the |
| 84 | - | // child as its initial prompt. It remains in durable metadata until the | 106 | + | // child. It remains in durable metadata until the child reports |
| 85 | - | // child reports turn.started, so a host exit while the worker is queued | 107 | + | // 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 string | 109 | + | // resumePromptQueue until the next agent idle turn. |
| 88 | - | resumePromptAt time.Time | 110 | + | 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 for | 115 | // InboxPath is the unix socket the child agent listens on for | ||
| 91 | // follow-up prompts and control messages. The supervisor opens | 116 | // 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 *Inbox | 142 | inbox *Inbox | ||
| 118 | 143 | ||||
| 119 | - | mu sync.Mutex | 144 | + | mu sync.Mutex |
| 120 | - | status Status | 145 | + | status Status |
| 121 | - | activity string | 146 | + | activity string |
| 122 | - | transcript []string | 147 | + | transcript []string |
| 123 | - | lastAssistant string | 148 | + | lastAssistant string |
| 124 | - | finished time.Time | 149 | + | streamingAssistantText string |
| 125 | - | lastErr error | 150 | + | streamingAssistantTruncated bool |
| 126 | - | transcriptLoaded bool | 151 | + | streamingAssistantStart int |
| 127 | - | legacyEventState bool | 152 | + | streamingAssistantLines int |
| 128 | - | transcriptMu sync.Mutex | 153 | + | finished time.Time |
| 154 | + | lastErr error | |||
| 155 | + | transcriptLoaded bool | |||
| 156 | + | legacyEventState bool | |||
| 157 | + | transcriptMu sync.Mutex | |||
| 129 | 158 | ||||
| 130 | lifecycleMu sync.Mutex | 159 | lifecycleMu sync.Mutex | ||
| 131 | processState ProcessState | 160 | processState ProcessState | ||
| @@ -142,10 +171,12 @@ type Agent struct { | |||||
| 142 | outputTruncated bool | 171 | outputTruncated bool | ||
| 143 | maxOutputBytes int | 172 | maxOutputBytes int | ||
| 144 | maxOutputLines int | 173 | maxOutputLines int | ||
| 145 | - | persistFn func(*Agent) | 174 | + | persistFn func(*Agent) error |
| 175 | + | persistMu sync.Mutex | |||
| 146 | stateDir string | 176 | stateDir string | ||
| 147 | workspaceCleanup func() error | 177 | 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 event | 181 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | ||
| 151 | // emitted by the subagent daemon wrapper. Provider/tool-loop | 182 | // emitted by the subagent daemon wrapper. Provider/tool-loop | ||
| @@ -230,6 +261,66 @@ func (a *Agent) AttemptValue() int { | |||||
| 230 | return a.Attempt | 261 | 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.resumePromptAt | 491 | 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.resumePromptAt | 497 | + | previous := resumePromptSnapshot{ |
| 498 | + | Prompt: a.resumePromptText, | |||
| 499 | + | AcceptedAt: a.resumePromptAt, | |||
| 500 | + | CommandID: a.resumePromptID, | |||
| 501 | + | } | |||
| 407 | a.resumePromptText = prompt | 502 | a.resumePromptText = prompt | ||
| 408 | a.resumePromptAt = acceptedAt | 503 | a.resumePromptAt = acceptedAt | ||
| 409 | - | return previousPrompt, previousAcceptedAt | 504 | + | 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 true | 616 | 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 = fn | 649 | 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 := chunk | 783 | message := chunk | ||
| 457 | chunk = strings.TrimRight(chunk, "\n") | 784 | chunk = strings.TrimRight(chunk, "\n") | ||
| 458 | if chunk == "" { | 785 | if chunk == "" { | ||
| 459 | - | return | 786 | + | 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 + line | 790 | line = linePrefix + line | ||
| 464 | a.transcript = append(a.transcript, line) | 791 | a.transcript = append(a.transcript, line) | ||
| 465 | a.outputBytes += len(line) + 1 | 792 | 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 session | 798 | + | return len(lines) |
| 472 | - | // remain available through the logical history reference. | 799 | + | } |
| 473 | - | lineCap := a.maxOutputLines | 800 | + | |
| 474 | - | if lineCap <= 0 { | 801 | + | func (a *Agent) outputLimits() (byteCap, lineCap int) { |
| 475 | - | lineCap = 2_000 | 802 | + | byteCap = a.maxOutputBytes |
| 476 | - | } | |||
| 477 | - | byteCap := a.maxOutputBytes | |||
| 478 | if byteCap <= 0 { | 803 | if byteCap <= 0 { | ||
| 479 | byteCap = 500_000 | 804 | 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) + 1 | 820 | 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 = true | 829 | a.outputTruncated = true | ||
| 494 | } | 830 | } | ||
| 495 | if a.outputLines == 0 { | 831 | if a.outputLines == 0 { | ||
| 496 | a.outputBytes = 0 | 832 | 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 the | 836 | // 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 subagents | 1 | 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 is | 116 | + | 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 ctxErr | 127 | return ctxErr | ||
| 127 | } | 128 | } | ||
| 128 | - | return err | 129 | + | 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 it | 137 | + | // 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 = nil | 142 | b.conn = nil | ||
| 136 | } | 143 | } | ||
| 137 | b.mu.Unlock() | 144 | b.mu.Unlock() | ||
| 138 | - | return ctx.Err() | |||
| 139 | } | 145 | } | ||
| 140 | return nil | 146 | 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 path | 203 | // dialUnixContext retries a Unix-socket connection briefly so the happy path | ||
| 183 | // "child just started, supervisor sends right away" works without forcing the | 204 | // "child just started, supervisor sends right away" works without forcing the | ||
| 184 | // caller to poll. Errors collapse onto ErrNotReady whenever the failure means | 205 | // 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.Context | 157 | 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 fields | 29 | // 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 yet | 81 | // 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 a | 82 | // 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 particular | 89 | // SessionID, when non-empty, scopes the agent to a particular | ||
| 84 | // host zut session: the dashboard only shows agents whose | 90 | // 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 a | 102 | // 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.FastMode | 111 | 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.finished | 116 | finished := a.finished | ||
| 105 | resumePrompt := a.resumePromptText | 117 | resumePrompt := a.resumePromptText | ||
| 106 | resumePromptAt := a.resumePromptAt | 118 | 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.processState | 127 | processState := a.processState | ||
| 114 | turnState := a.turnState | 128 | turnState := a.turnState | ||
| 115 | currentTurnID := a.currentTurnID | 129 | currentTurnID := a.currentTurnID | ||
| 130 | + | lifetimeTurns := a.LifetimeTurns | |||
| 131 | + | currentRunTurns := a.CurrentRunTurns | |||
| 116 | attempt := a.Attempt | 132 | attempt := a.Attempt | ||
| 117 | processPID := a.ProcessPID | 133 | processPID := a.ProcessPID | ||
| 118 | updatedAt := a.updatedAt | 134 | 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 nil | 242 | 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 = lastActivity | 536 | 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) win | 643 | // Status precedence: explicit lifecycle events (agent_stopped) win | ||
| 584 | // over inferred ones (assistant_message → idle). If the log contains | 644 | // 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 false | 648 | 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 true | 663 | 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 := false | 670 | 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.TurnID | 675 | + | 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 = StatusDone | 696 | 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 = true | 700 | 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 []string | 756 | 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 retaining | 810 | // ResumeWithPrompt continues an agent with a manager follow-up while retaining | ||
| 737 | // its existing session. An idle live worker receives the turn through its | 811 | // 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 initial | 812 | + | // 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 call | 870 | // Reserve the worker before writing to its inbox. A second manager call | ||
| 763 | // must not enqueue another turn in the small interval before the worker | 871 | // 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 exit | 872 | // 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.inbox | 876 | 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, nil | 912 | 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 fresh | 990 | // 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.FastMode | 1054 | 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 = resumePrompt | 1064 | + | currentRunTurns = 0 |
| 858 | - | pendingResumePromptAt = now | 1065 | + | 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, cancel | 1231 | a.ctx, a.cancel = runCtx, cancel | ||
| 991 | a.persistFn = f.persistAgent | 1232 | 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: every | 45 | // TestSpawnWritesMetaJSON asserts the durability contract: every | ||
| 18 | // successful Spawn leaves a meta.json on disk with the agent's | 46 | // successful Spawn leaves a meta.json on disk with the agent's | ||
| 19 | // identity bits. Without this, Reload on the next zut launch can't | 47 | // 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 | return | 443 | 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 = nil | 471 | 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 := ResultSucceeded | 488 | 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 = ResultFailed | 522 | 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 string | 272 | + | name string |
| 273 | - | status Status | 273 | + | status Status |
| 274 | - | runErr error | 274 | + | runErr error |
| 275 | - | wantState TurnStatus | 275 | + | 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 points | 54 | // would litter the user's repo — every agent's Dir points | ||
| 53 | // at it directly. | 55 | // at it directly. | ||
| 54 | SessionPath string | 56 | 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 bool | 80 | FastModeSet bool | ||
| 74 | Subagent string | 81 | Subagent string | ||
| 75 | MaxTurns int | 82 | MaxTurns int | ||
| 83 | + | LifetimeTurns int | |||
| 84 | + | RunTurns int | |||
| 85 | + | CountersSet bool | |||
| 76 | Tools []string | 86 | Tools []string | ||
| 77 | WebSearchPolicy WebSearchPolicy | 87 | 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 a | 174 | // Always propagate the final capability decision, including deny, so a | ||
| 156 | // child with no ordinary --tools list cannot re-enable web search from its | 175 | // 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.Dir | 332 | 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.Mutex | 386 | var logErrMu sync.Mutex | ||
| 357 | var logErr error | 387 | var logErr error | ||
| 358 | var stopOnLogErr sync.Once | 388 | 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 any | 434 | // Fan prompt-level task completions up to any | ||
| 403 | // subscriber on the supervised Agent. The child | 435 | // 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 nil | 518 | 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 transferred | 564 | // Provider credentials are resolved by the supervisor and transferred | ||
| 487 | // over the child's stdin. Do not inherit API-key/token variables into the | 565 | // 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 | }, true | 672 | }, 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 | - | return | 677 | + | 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.Time | 741 | 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 := false | 746 | 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 = true | 764 | persist = true | ||
| 612 | case EventAgentHeartbeat, "agent_heartbeat": | 765 | case EventAgentHeartbeat, "agent_heartbeat": | ||
| 613 | a.setProcessState(ProcessAlive) | 766 | a.setProcessState(ProcessAlive) | ||
| 614 | persist = true | 767 | persist = true | ||
| 615 | - | case EventTurnStarted, "turn_start": | 768 | + | case EventTurnStarted: |
| 616 | - | turnID := ev.TurnID | 769 | + | 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 = true | 791 | 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 = true | 825 | 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 = true | 834 | 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 = true | 837 | 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 for | 853 | // 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 not | 854 | // 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 | break | 857 | 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 = true | 864 | 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 subagent | 878 | // 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 | return | 884 | 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 | return | 891 | return | ||
| 692 | } | 892 | } | ||
| @@ -700,7 +900,16 @@ func notifyPromptTurnEnd(a *Agent, ev Event) { | |||||
| 700 | return | 900 | 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 a | 915 | // 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 | return | 936 | 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 []string | 963 | 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 the | 15 | + | type notifyingAgentSink struct { |
| 16 | - | // new daemon-mode runner. It compiles the stubchild binary in | 16 | + | agentSink |
| 17 | - | // testdata/cmd/stubchild, points an execRunner at it, and drives | 17 | + | 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 can | 22 | + | 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 the | 24 | + | default: |
| 25 | - | // durable log. | 25 | + | } |
| 26 | - | // - applyEventToSink turns events into Activity / Transcript | 26 | + | } |
| 27 | - | // updates the dashboard reads. | 27 | + | |
| 28 | - | // - The supervisor inbox dials the child's socket and a follow-up | 28 | + | 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 the | 30 | + | 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 := 0 | 547 | 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 := 0 | 565 | 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 | - | return | 386 | + | 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 | - | return | 390 | + | // 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, cancel | 548 | a.ctx, a.cancel = runCtx, cancel | ||
| 545 | a.persistFn = f.persistAgent | 549 | 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 ProcessState | 1080 | ProcessState ProcessState | ||
| 1076 | TurnState TurnState | 1081 | TurnState TurnState | ||
| 1077 | CurrentTurnID string | 1082 | CurrentTurnID string | ||
| 1083 | + | LifetimeTurns int | |||
| 1084 | + | CurrentRunTurns int | |||
| 1078 | Attempt int | 1085 | Attempt int | ||
| 1079 | Activity string | 1086 | Activity string | ||
| 1080 | Started time.Time | 1087 | Started time.Time | ||
| @@ -1129,6 +1136,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 1129 | status := a.status | 1136 | status := a.status | ||
| 1130 | activity := a.activity | 1137 | activity := a.activity | ||
| 1131 | lastAssistant := a.lastAssistant | 1138 | lastAssistant := a.lastAssistant | ||
| 1139 | + | outputTruncated := a.outputTruncated || a.streamingAssistantTruncated | |||
| 1132 | finished := a.finished | 1140 | 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.processState | 1144 | processState := a.processState | ||
| 1137 | turnState := a.turnState | 1145 | turnState := a.turnState | ||
| 1138 | currentTurnID := a.currentTurnID | 1146 | currentTurnID := a.currentTurnID | ||
| 1147 | + | lifetimeTurns := a.LifetimeTurns | |||
| 1148 | + | currentRunTurns := a.CurrentRunTurns | |||
| 1139 | attempt := a.Attempt | 1149 | attempt := a.Attempt | ||
| 1140 | lastActivity := a.lastActivity | 1150 | lastActivity := a.lastActivity | ||
| 1141 | updatedAt := a.updatedAt | 1151 | updatedAt := a.updatedAt | ||
| @@ -1143,12 +1153,11 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 1143 | resultRef := a.resultRef | 1153 | resultRef := a.resultRef | ||
| 1144 | patchRef := a.patchRef | 1154 | 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 first | 73 | // 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 := 1 | 91 | 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 | return | 112 | 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.TurnStartPayload | 119 | 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 * 1024 | 25 | + | worktreeConfigKey = "zut.worktrees.path" |
| 26 | - | maxCreateWorktreeIgnoreBytes = 1 << 20 | 26 | + | maxCreateWorktreeGitOutput = 64 * 1024 |
| 27 | + | maxCreateWorktreeIgnoreBytes = 1 << 20 | |||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | var createWorktreeOperationMu sync.Mutex | 30 | 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 " + worktreesIgnoreEntry | 362 | + | ignoreAction = "already ignores " + defaultWorktreeRoot + "/" |
| 362 | if ignore.changed { | 363 | if ignore.changed { | ||
| 363 | ignoreAction = "add " + worktreesIgnoreEntry | 364 | ignoreAction = "add " + worktreesIgnoreEntry | ||
| 364 | } | 365 | } | ||
| @@ -818,7 +819,8 @@ func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) { | |||||
| 818 | return worktreesIgnoreUpdate{}, err | 819 | 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}, nil | 824 | 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 turn | 13 | + | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its |
| 14 | - | // while preserving its existing session context. For an idle live worker, the | 14 | + | // existing session context. Every explicit follow-up starts a fresh max_turns |
| 15 | - | // follow-up is delivered directly. For a terminal worker, the supervisor | 15 | + | // 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.Supervisor | 20 | Supervisor *subagents.Supervisor | ||
| 19 | Enabled func() bool | 21 | 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 subagentResumeArgs | 76 | 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 the | 18 | // 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 user | 21 | + | // Available when the host's launch-time tool policy permits delegation. The |
| 22 | - | // can flip it off mid-session and the next call refuses cleanly | 22 | + | // 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 means | 25 | + | // Supervisor is the supervisor used to spawn agents. Nil means subagents |
| 26 | - | // "auto-subagents not available in this mode" and the tool always | 26 | + | // are unavailable in this mode and the tool always errors. |
| 27 | - | // errors. | |||
| 28 | Supervisor *subagents.Supervisor | 27 | Supervisor *subagents.Supervisor | ||
| 29 | 28 | ||||
| 30 | - | // Enabled reads the live config flag. Lets users toggle from | 29 | + | // Enabled reports whether the host currently exposes this tool. When nil, |
| 31 | - | // /settings without rebuilding the agent. When nil, the tool | 30 | + | // the tool is treated as disabled. |
| 32 | - | // is treated as disabled. | |||
| 33 | Enabled func() bool | 31 | Enabled func() bool | ||
| 34 | 32 | ||||
| 35 | // DefaultModel and DefaultProvider return the host agent's resolved | 33 | // 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 subagentSpawnArgs | 134 | 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 subagentStatusArgs | 92 | 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.Finished | 148 | 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 subagentStopArgs | 73 | var args subagentStopArgs | ||
M packages/core/session.go +13 -0
| @@ -1187,6 +1187,19 @@ func (s *Session) AppendMessage(m provider.Message) error { | |||||
| 1187 | return nil | 1187 | 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 earlier | 1203 | // AppendCompaction writes a checkpoint that replaces all earlier | ||
| 1191 | // transcript rows when the session is resumed. The old rows remain in | 1204 | // transcript rows when the session is resumed. The old rows remain in | ||
| 1192 | // the JSONL file for audit/export, while loaders use the latest | 1205 | // 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 | continue | 913 | 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 []string | 42 | logLines []string | ||
| 43 | // Raw logical rows let DrawLog reject an unchanged frame before | 43 | // 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 []string | 45 | + | logRawChat []string |
| 46 | - | logRawBottom []string | 46 | + | logRawBottom []string |
| 47 | - | logRawCols int | 47 | + | logRawCols int |
| 48 | - | logRawRows int | 48 | + | logRawRows int |
| 49 | - | logRawBackground string | 49 | + | logRawContentCols int |
| 50 | - | logRawHasThemeBG bool | 50 | + | logRawBackground string |
| 51 | - | logViewportTop int | 51 | + | logRawHasThemeBG bool |
| 52 | - | logHardwareRow int | 52 | + | logViewportTop int |
| 53 | - | logInit bool | 53 | + | logHardwareRow int |
| 54 | - | logNeedsFullRepaint bool | 54 | + | 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[3J | 64 | // 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 = 0 | 138 | r.logViewportTop = 0 | ||
| 131 | r.logHardwareRow = 0 | 139 | r.logHardwareRow = 0 | ||
| 132 | r.logInit = false | 140 | 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: the | 146 | // A resize is a discrete user action, like Ctrl+L: the | ||
| @@ -166,6 +177,9 @@ func (r *Renderer) Clear() { | |||||
| 166 | r.logViewportTop = 0 | 177 | r.logViewportTop = 0 | ||
| 167 | r.logHardwareRow = 0 | 178 | r.logHardwareRow = 0 | ||
| 168 | r.logInit = false | 179 | 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 the | 184 | // On VS Code's xterm.js the transcript is taller than the | ||
| 171 | // viewport, so an in-place clear (home + erase-to-end) only | 185 | // 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.Builder | 278 | var out strings.Builder | ||
| 265 | out.Grow(len(s)) | 279 | out.Grow(len(s)) | ||
| 266 | seen := 0 | 280 | 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 | continue | 298 | 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 | break | 347 | 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 | break | 376 | 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 | - | return | 518 | + | 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 is | 538 | // 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 logical | 549 | // 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 the | 576 | // that never blinks, because we keep "showing" it before the | ||
| 496 | // terminal can blink it off. Bailing out here lets the OS run | 577 | // 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 | - | return | 580 | + | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { |
| 581 | + | return false | |||
| 500 | } | 582 | } | ||
| 501 | 583 | ||||
| 502 | var w strings.Builder | 584 | 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.logInit | 694 | wasInitialized := r.logInit | ||
| 606 | full := !wasInitialized || len(r.logLines) == 0 | 695 | full := !wasInitialized || len(r.logLines) == 0 | ||
| 607 | if full { | 696 | if full { | ||
| 608 | - | // Returning from the fixed right-bar frame needs a full viewport | 697 | + | // A cache invalidation may require a full viewport repaint after flow |
| 609 | - | // repaint, but it must not discard terminal scrollback merely because | 698 | + | // 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 = true | 700 | r.logInit = true | ||
| 613 | - | r.logNeedsFullRepaint = false | |||
| 614 | } else { | 701 | } else { | ||
| 615 | firstChanged := -1 | 702 | firstChanged := -1 | ||
| 616 | lastChanged := -1 | 703 | 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.cols | 915 | r.logRawCols = r.cols | ||
| 829 | r.logRawRows = r.rows | 916 | r.logRawRows = r.rows | ||
| 917 | + | r.logRawContentCols = contentCols | |||
| 830 | r.logRawBackground = r.backgroundStyle | 918 | r.logRawBackground = r.backgroundStyle | ||
| 831 | r.logRawHasThemeBG = r.theme.Background != nil | 919 | r.logRawHasThemeBG = r.theme.Background != nil | ||
| 832 | r.cursorRow = cursorBottomRow | 920 | r.cursorRow = cursorBottomRow | ||
| 833 | r.cursorCol = cursorCol | 921 | 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 same | 937 | // sameLines reports whether two []string have the exact same | ||
M packages/tui/rightbar.go +92 -42
| @@ -1,6 +1,7 @@ | |||||
| 1 | package tui | 1 | 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 + rightBar | 373 | return main + separator + rightBar | ||
| 373 | } | 374 | } | ||
| 374 | 375 | ||||
| 375 | - | // DrawRightBar renders an already-laid-out visible chat slice and sticky | 376 | + | // DrawRightBar renders the complete transcript through DrawLog, then |
| 376 | - | // bottom block in a fixed frame with a persistent right bar. The normal | 377 | + | // composites the persistent rail over the visible viewport. The flow cache |
| 377 | - | // DrawLog path deliberately uses terminal scrollback; a persistent side rail | 378 | + | // 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 | return | 396 | 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 margin | 406 | + | barLines := make([]string, frameRows) |
| 414 | - | cursorRow := len(chat) + cursorBottomRow | 407 | + | copy(barLines, rightBar) |
| 415 | - | if len(main) > r.rows { | 408 | + | |
| 416 | - | trim := len(main) - r.rows | 409 | + | barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth || |
| 417 | - | main = main[trim:] | 410 | + | r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines) |
| 418 | - | cursorRow -= trim | 411 | + | 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 a | 476 | + | func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) { |
| 432 | - | // later fallback to DrawLog starts with a full repaint instead of trying | 477 | + | if !r.logInit || r.cursorRow < 0 { |
| 433 | - | // to diff against logical rows from before the rail was enabled. Mark the | 478 | + | return |
| 434 | - | // transition separately so that repaint does not clear scrollback. | 479 | + | } |
| 435 | - | r.logChat = nil | 480 | + | row := len(r.logChat) + r.cursorRow - r.logViewportTop |
| 436 | - | r.logBottom = nil | 481 | + | if row < 0 || row >= r.rows { |
| 437 | - | r.logLines = nil | 482 | + | return |
| 438 | - | r.logViewportTop = 0 | 483 | + | } |
| 439 | - | r.logHardwareRow = 0 | 484 | + | cursorCol := r.cursorCol |
| 440 | - | r.logInit = false | 485 | + | if cursorCol < 0 { |
| 441 | - | r.logNeedsFullRepaint = true | 486 | + | 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() | ||