Merge pull request #119 from bnema/feat/subagent-execution-trace
feat(subagents): trace factual execution operations
29 files · +1659 -927
M README.md +5 -5
| @@ -318,7 +318,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 318 | 318 | | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | |
| 319 | 319 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 320 | 320 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 321 | - | | `/subagents` | Spawn, monitor, and chat with background subagents. Each is a separate long-lived process with bounded lifecycle state, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | |
| 321 | + | | `/subagents` | Spawn, inspect, and chat with background subagents. Each is a separate long-lived process with structured results, factual execution observations, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | |
| 322 | 322 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 323 | 323 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 324 | 324 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. | | |
| @@ -400,7 +400,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 400 | 400 | /subagents remove <id> # delete durable state | |
| 401 | 401 | ``` | |
| 402 | 402 | ||
| 403 | - | **Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with process/turn state, queue position, age, workspace mode, and current activity. Keys: | |
| 403 | + | **Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with its current trace-observed operation (or last fact), age, workspace mode, and task. It does not infer `working`, `alive`, or `idle` from a process lifecycle. Keys: | |
| 404 | 404 | ||
| 405 | 405 | | Key | Action | | |
| 406 | 406 | |---|---| | |
| @@ -419,9 +419,9 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 419 | 419 | ||
| 420 | 420 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | |
| 421 | 421 | ||
| 422 | - | **Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | |
| 422 | + | **Persistence across zut restarts** — every spawn writes durable metadata and a session file under `$ZUT_HOME/subagents/agents/<id>/`; a completed or terminated worker writes structured `result.json`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | |
| 423 | 423 | ||
| 424 | - | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | |
| 424 | + | **Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | |
| 425 | 425 | ||
| 426 | 426 | **`/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. | |
| 427 | 427 | ||
| @@ -448,7 +448,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 448 | 448 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 449 | 449 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| 450 | 450 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |
| 451 | - | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then running subagents. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places live delegated-turn rows above or below the input; it defaults below the input and shows the profile or agent ID, current activity, and elapsed time since the last activity/heartbeat. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. Idle, detached, and terminal turns disappear from this area immediately. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields). | |
| 451 | + | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places trace-observed open-operation rows above or below the input; it defaults below the input and shows the profile or agent ID, operation, and elapsed operation duration. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. A process lifecycle or heartbeat alone never creates a row. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields). | |
| 452 | 452 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | |
| 453 | 453 | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md). | |
| 454 | 454 | - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running. | |
| @@ -318,7 +318,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 318 | | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | 318 | | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | ||
| 319 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 319 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| 320 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | 320 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | ||
| 321 | - | | `/subagents` | Spawn, monitor, and chat with background subagents. Each is a separate long-lived process with bounded lifecycle state, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | 321 | + | | `/subagents` | Spawn, inspect, and chat with background subagents. Each is a separate long-lived process with structured results, factual execution observations, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | |
| 322 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | 322 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | ||
| 323 | | `/compact` | Summarize the transcript into one message to free up context. | | 323 | | `/compact` | Summarize the transcript into one message to free up context. | | ||
| 324 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. | | 324 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. | | ||
| @@ -400,7 +400,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 400 | /subagents remove <id> # delete durable state | 400 | /subagents remove <id> # delete durable state | ||
| 401 | ``` | 401 | ``` | ||
| 402 | 402 | ||||
| 403 | - | **Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with process/turn state, queue position, age, workspace mode, and current activity. Keys: | 403 | + | **Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with its current trace-observed operation (or last fact), age, workspace mode, and task. It does not infer `working`, `alive`, or `idle` from a process lifecycle. Keys: |
| 404 | 404 | ||||
| 405 | | Key | Action | | 405 | | Key | Action | | ||
| 406 | |---|---| | 406 | |---|---| | ||
| @@ -419,9 +419,9 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 419 | 419 | ||||
| 420 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | 420 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | ||
| 421 | 421 | ||||
| 422 | - | **Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | 422 | + | **Persistence across zut restarts** — every spawn writes durable metadata and a session file under `$ZUT_HOME/subagents/agents/<id>/`; a completed or terminated worker writes structured `result.json`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. |
| 423 | 423 | ||||
| 424 | - | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | 424 | + | **Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. |
| 425 | 425 | ||||
| 426 | **`/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. | 426 | **`/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. | ||
| 427 | 427 | ||||
| @@ -448,7 +448,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 448 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 448 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 449 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 449 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| 450 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | 450 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | ||
| 451 | - | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then running subagents. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places live delegated-turn rows above or below the input; it defaults below the input and shows the profile or agent ID, current activity, and elapsed time since the last activity/heartbeat. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. Idle, detached, and terminal turns disappear from this area immediately. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields). | 451 | + | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places trace-observed open-operation rows above or below the input; it defaults below the input and shows the profile or agent ID, operation, and elapsed operation duration. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. A process lifecycle or heartbeat alone never creates a row. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields). |
| 452 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | 452 | - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | ||
| 453 | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md). | 453 | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md). | ||
| 454 | - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running. | 454 | - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running. | ||
M docs/subagents.md +16 -4
| @@ -135,9 +135,9 @@ Shared mode preserves the historical host working directory, so workers there mu | |||
| 135 | 135 | ||
| 136 | 136 | ## Lifecycle, results, and recovery | |
| 137 | 137 | ||
| 138 | - | Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, logical result references, and any required-work state, target turn, stable outcome error code, and parent-notification marker. | |
| 138 | + | Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation. | |
| 139 | 139 | ||
| 140 | - | During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results. | |
| 140 | + | During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the open operation, and its elapsed duration. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact. | |
| 141 | 141 | ||
| 142 | 142 | `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript. | |
| 143 | 143 | ||
| @@ -154,13 +154,25 @@ subagent://<id>/result | |||
| 154 | 154 | subagent://<id>/patch | |
| 155 | 155 | ``` | |
| 156 | 156 | ||
| 157 | - | `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. | |
| 157 | + | Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript. | |
| 158 | 158 | ||
| 159 | 159 | A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation. | |
| 160 | 160 | ||
| 161 | 161 | 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. | |
| 162 | 162 | ||
| 163 | - | When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. | |
| 163 | + | When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. 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 the shared trace projection 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, open operation and its start time when one is observed, a terminal fact or last observed event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. | |
| 164 | + | ||
| 165 | + | ## Local execution trace | |
| 166 | + | ||
| 167 | + | Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output that can contain credentials, secrets, personal data, or private source code. Enable it only when the local bundle can be protected and manually deleted afterwards; do not commit, upload, or otherwise expose trace data. Bundles request restrictive local permissions, but access control remains platform-dependent.\n\nExplicit runtime `TraceDir` and `TraceMode` configuration takes precedence over these environment variables. Environment mode values are case-insensitive (`normal` or `detailed`); an invalid value is ignored with a stderr warning. Detailed mode requires a non-empty trace directory, otherwise tracing remains disabled. | |
| 168 | + | ||
| 169 | + | Inspect a retained bundle with: | |
| 170 | + | ||
| 171 | + | ```sh | |
| 172 | + | zut debug trace <bundle> | |
| 173 | + | ``` | |
| 174 | + | ||
| 175 | + | Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress. | |
| 164 | 176 | ||
| 165 | 177 | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |
| 166 | 178 | ||
| @@ -135,9 +135,9 @@ Shared mode preserves the historical host working directory, so workers there mu | |||||
| 135 | 135 | ||||
| 136 | ## Lifecycle, results, and recovery | 136 | ## Lifecycle, results, and recovery | ||
| 137 | 137 | ||||
| 138 | - | Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, logical result references, and any required-work state, target turn, stable outcome error code, and parent-notification marker. | 138 | + | Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation. |
| 139 | 139 | ||||
| 140 | - | During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results. | 140 | + | During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the open operation, and its elapsed duration. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact. |
| 141 | 141 | ||||
| 142 | `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript. | 142 | `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript. | ||
| 143 | 143 | ||||
| @@ -154,13 +154,25 @@ subagent://<id>/result | |||||
| 154 | subagent://<id>/patch | 154 | subagent://<id>/patch | ||
| 155 | ``` | 155 | ``` | ||
| 156 | 156 | ||||
| 157 | - | `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. | 157 | + | Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript. |
| 158 | 158 | ||||
| 159 | A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation. | 159 | A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation. | ||
| 160 | 160 | ||||
| 161 | 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. | 161 | 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. | ||
| 162 | 162 | ||||
| 163 | - | When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. | 163 | + | When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. 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 the shared trace projection 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, open operation and its start time when one is observed, a terminal fact or last observed event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. |
| 164 | + | ||||
| 165 | + | ## Local execution trace | |||
| 166 | + | ||||
| 167 | + | Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output that can contain credentials, secrets, personal data, or private source code. Enable it only when the local bundle can be protected and manually deleted afterwards; do not commit, upload, or otherwise expose trace data. Bundles request restrictive local permissions, but access control remains platform-dependent.\n\nExplicit runtime `TraceDir` and `TraceMode` configuration takes precedence over these environment variables. Environment mode values are case-insensitive (`normal` or `detailed`); an invalid value is ignored with a stderr warning. Detailed mode requires a non-empty trace directory, otherwise tracing remains disabled. | |||
| 168 | + | ||||
| 169 | + | Inspect a retained bundle with: | |||
| 170 | + | ||||
| 171 | + | ```sh | |||
| 172 | + | zut debug trace <bundle> | |||
| 173 | + | ``` | |||
| 174 | + | ||||
| 175 | + | Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress. | |||
| 164 | 176 | ||||
| 165 | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | 177 | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | ||
| 166 | 178 | ||||
M packages/agent/cli.go +3 -0
| @@ -793,6 +793,9 @@ func Run(rawArgs []string, version string) error { | |||
| 793 | 793 | if handled, err := runZutfileCommand(rawArgs, version); handled { | |
| 794 | 794 | return err | |
| 795 | 795 | } | |
| 796 | + | if handled, err := runDebugCommand(rawArgs, os.Stdout); handled { | |
| 797 | + | return err | |
| 798 | + | } | |
| 796 | 799 | return runWithArgsRaw(rawArgs, version) | |
| 797 | 800 | } | |
| 798 | 801 | ||
| @@ -793,6 +793,9 @@ func Run(rawArgs []string, version string) error { | |||||
| 793 | if handled, err := runZutfileCommand(rawArgs, version); handled { | 793 | if handled, err := runZutfileCommand(rawArgs, version); handled { | ||
| 794 | return err | 794 | return err | ||
| 795 | } | 795 | } | ||
| 796 | + | if handled, err := runDebugCommand(rawArgs, os.Stdout); handled { | |||
| 797 | + | return err | |||
| 798 | + | } | |||
| 796 | return runWithArgsRaw(rawArgs, version) | 799 | return runWithArgsRaw(rawArgs, version) | ||
| 797 | } | 800 | } | ||
| 798 | 801 | ||||
M packages/agent/modes/interactive.go +3 -1
| @@ -2151,10 +2151,12 @@ func (i *Interactive) redraw() { | |||
| 2151 | 2151 | var allSubagentLines []string | |
| 2152 | 2152 | if !dashboardActive { | |
| 2153 | 2153 | now := i.clock() | |
| 2154 | + | activitySnapshots, traceViews := i.activeSubagentActivitySnapshots() | |
| 2154 | 2155 | allSubagentLines = renderSubagentActivityLines( | |
| 2155 | 2156 | i.cfg.Theme, | |
| 2156 | 2157 | i.spin.FrameAt(now), | |
| 2157 | - | i.activeSubagentActivitySnapshots(), | |
| 2158 | + | activitySnapshots, | |
| 2159 | + | traceViews, | |
| 2158 | 2160 | mainCols, | |
| 2159 | 2161 | now, | |
| 2160 | 2162 | ) | |
| @@ -2151,10 +2151,12 @@ func (i *Interactive) redraw() { | |||||
| 2151 | var allSubagentLines []string | 2151 | var allSubagentLines []string | ||
| 2152 | if !dashboardActive { | 2152 | if !dashboardActive { | ||
| 2153 | now := i.clock() | 2153 | now := i.clock() | ||
| 2154 | + | activitySnapshots, traceViews := i.activeSubagentActivitySnapshots() | |||
| 2154 | allSubagentLines = renderSubagentActivityLines( | 2155 | allSubagentLines = renderSubagentActivityLines( | ||
| 2155 | i.cfg.Theme, | 2156 | i.cfg.Theme, | ||
| 2156 | i.spin.FrameAt(now), | 2157 | i.spin.FrameAt(now), | ||
| 2157 | - | i.activeSubagentActivitySnapshots(), | 2158 | + | activitySnapshots, |
| 2159 | + | traceViews, | |||
| 2158 | mainCols, | 2160 | mainCols, | ||
| 2159 | now, | 2161 | now, | ||
| 2160 | ) | 2162 | ) | ||
M packages/agent/modes/subagent_indicator.go +28 -134
| @@ -10,17 +10,17 @@ import ( | |||
| 10 | 10 | "github.com/mattn/go-runewidth" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // renderSubagentActivityLines returns one compact, input-adjacent row for each | |
| 14 | - | // active delegated turn. Terminal and idle workers are intentionally omitted: | |
| 15 | - | // the /subagents dashboard remains the durable history, while this surface is | |
| 16 | - | // only a live "is it still moving?" signal. | |
| 17 | - | func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string { | |
| 13 | + | // renderSubagentActivityLines renders only trace-observed open operations. | |
| 14 | + | // A living process, generic lifecycle state, or heartbeat never starts a | |
| 15 | + | // spinner because none proves that an operation is progressing. | |
| 16 | + | func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, views map[string]subagents.AgentTraceView, width int, now time.Time) []string { | |
| 18 | 17 | lines := make([]string, 0, len(snapshots)) | |
| 19 | 18 | for _, snapshot := range snapshots { | |
| 20 | - | if !subagentTurnIsActive(snapshot) { | |
| 19 | + | view := views[snapshot.ID] | |
| 20 | + | if len(view.OpenOperations) == 0 { | |
| 21 | 21 | continue | |
| 22 | 22 | } | |
| 23 | - | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now) | |
| 23 | + | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, view.OpenOperations[0], width, now) | |
| 24 | 24 | if line != "" { | |
| 25 | 25 | lines = append(lines, line) | |
| 26 | 26 | } | |
| @@ -28,40 +28,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||
| 28 | 28 | return lines | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | - | func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool { | |
| 32 | - | switch snapshot.ProcessState { | |
| 33 | - | case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive: | |
| 34 | - | default: | |
| 35 | - | // A detached worker has no confirmed live connection. Keep it in the | |
| 36 | - | // dashboard, rather than presenting an endlessly animated row beside | |
| 37 | - | // the input as if it were still making progress. | |
| 38 | - | return false | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | switch snapshot.TurnState { | |
| 42 | - | case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled: | |
| 43 | - | return false | |
| 44 | - | } | |
| 45 | - | switch snapshot.Status { | |
| 46 | - | case subagents.StatusPending: | |
| 47 | - | return true | |
| 48 | - | case subagents.StatusRunning: | |
| 49 | - | default: | |
| 50 | - | return false | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | switch snapshot.TurnState { | |
| 54 | - | case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling: | |
| 55 | - | return true | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a | |
| 59 | - | // brief "starting" phase visible, but remove the row as soon as it reports | |
| 60 | - | // ordinary idle so an already-completed task never looks active. | |
| 61 | - | return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle" | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string { | |
| 31 | + | func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, width int, now time.Time) string { | |
| 65 | 32 | name := sanitizeSubagentIndicatorText(snapshot.Subagent) | |
| 66 | 33 | if name == "" { | |
| 67 | 34 | name = sanitizeSubagentIndicatorText(snapshot.ID) | |
| @@ -73,8 +40,8 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 73 | 40 | if spinnerGlyph == "" { | |
| 74 | 41 | spinnerGlyph = "." | |
| 75 | 42 | } | |
| 76 | - | activity := compactSubagentActivity(snapshot) | |
| 77 | - | age := formatSubagentActivityAge(snapshot, now) | |
| 43 | + | activity := operation.Label() | |
| 44 | + | age := formatSubagentActivityAge(operation.StartedAt, now) | |
| 78 | 45 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | |
| 79 | 46 | if plain == "" { | |
| 80 | 47 | return "" | |
| @@ -98,16 +65,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 98 | 65 | name = strings.TrimPrefix(parts[0], spinnerGlyph+" ") | |
| 99 | 66 | return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " + | |
| 100 | 67 | th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1]) | |
| 101 | - | case subagentActivityCompact: | |
| 102 | - | prefix := spinnerGlyph + " " | |
| 103 | - | suffix := " " + age | |
| 104 | - | if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) { | |
| 105 | - | name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix) | |
| 106 | - | return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " + | |
| 107 | - | th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age) | |
| 108 | - | } | |
| 68 | + | default: | |
| 69 | + | return " " + th.FGColor(th.Muted, plain) | |
| 109 | 70 | } | |
| 110 | - | return " " + th.FGColor(th.Muted, plain) | |
| 111 | 71 | } | |
| 112 | 72 | ||
| 113 | 73 | type subagentActivityLayout uint8 | |
| @@ -119,73 +79,31 @@ const ( | |||
| 119 | 79 | ) | |
| 120 | 80 | ||
| 121 | 81 | func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) { | |
| 122 | - | spinnerGlyph = strings.TrimSpace(spinnerGlyph) | |
| 123 | - | if spinnerGlyph == "" { | |
| 124 | - | spinnerGlyph = "." | |
| 125 | - | } | |
| 126 | 82 | if width <= 0 { | |
| 127 | 83 | return "", subagentActivityCompact | |
| 128 | 84 | } | |
| 129 | - | ||
| 130 | 85 | full := spinnerGlyph + " " + name + " · " + activity + " · " + age | |
| 131 | 86 | if runewidth.StringWidth(full) <= width { | |
| 132 | 87 | return full, subagentActivityFull | |
| 133 | 88 | } | |
| 134 | - | ||
| 135 | 89 | nameAge := spinnerGlyph + " " + name + " · " + age | |
| 136 | 90 | if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 { | |
| 137 | - | activity = truncateSubagentIndicatorText(activity, activityWidth) | |
| 138 | - | return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull | |
| 91 | + | return spinnerGlyph + " " + name + " · " + truncateSubagentIndicatorText(activity, activityWidth) + " · " + age, subagentActivityFull | |
| 139 | 92 | } | |
| 140 | 93 | if runewidth.StringWidth(nameAge) <= width { | |
| 141 | 94 | return nameAge, subagentActivityNameAge | |
| 142 | 95 | } | |
| 143 | - | ||
| 144 | - | prefix := spinnerGlyph + " " | |
| 145 | - | suffix := " " + age | |
| 146 | - | nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix) | |
| 147 | - | if nameWidth >= 1 { | |
| 148 | - | return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact | |
| 149 | - | } | |
| 150 | - | if runewidth.StringWidth(age) <= width { | |
| 151 | - | return age, subagentActivityCompact | |
| 152 | - | } | |
| 153 | - | return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact | |
| 96 | + | return truncateSubagentIndicatorText(nameAge, width), subagentActivityCompact | |
| 154 | 97 | } | |
| 155 | 98 | ||
| 156 | - | func compactSubagentActivity(snapshot subagents.AgentSnapshot) string { | |
| 157 | - | activity := sanitizeSubagentIndicatorText(snapshot.Activity) | |
| 158 | - | // Heartbeats report "idle" even while a delegated turn is still running. | |
| 159 | - | // Keep the row meaningful in that interval instead of contradicting its | |
| 160 | - | // spinner and turn state. | |
| 161 | - | if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) { | |
| 162 | - | return activity | |
| 163 | - | } | |
| 164 | - | switch snapshot.TurnState { | |
| 165 | - | case subagents.TurnQueued: | |
| 166 | - | return "queued" | |
| 167 | - | case subagents.TurnCanceling: | |
| 168 | - | return "cancelling" | |
| 169 | - | default: | |
| 170 | - | return "working" | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string { | |
| 175 | - | last := snapshot.LastActivity | |
| 176 | - | if last.IsZero() { | |
| 177 | - | last = snapshot.UpdatedAt | |
| 178 | - | } | |
| 179 | - | if last.IsZero() { | |
| 180 | - | last = snapshot.Started | |
| 181 | - | } | |
| 182 | - | if last.IsZero() { | |
| 99 | + | func formatSubagentActivityAge(started, now time.Time) string { | |
| 100 | + | if started.IsZero() { | |
| 183 | 101 | return "-" | |
| 184 | 102 | } | |
| 185 | 103 | if now.IsZero() { | |
| 186 | 104 | now = time.Now() | |
| 187 | 105 | } | |
| 188 | - | elapsed := now.Sub(last) | |
| 106 | + | elapsed := now.Sub(started) | |
| 189 | 107 | if elapsed < 0 { | |
| 190 | 108 | elapsed = 0 | |
| 191 | 109 | } | |
| @@ -196,11 +114,9 @@ func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) | |||
| 196 | 114 | case seconds < 60*60: | |
| 197 | 115 | return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60) | |
| 198 | 116 | case seconds < 24*60*60: | |
| 199 | - | minutes := (seconds % (60 * 60)) / 60 | |
| 200 | - | return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes) | |
| 117 | + | return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60) | |
| 201 | 118 | default: | |
| 202 | - | hours := (seconds % (24 * 60 * 60)) / (60 * 60) | |
| 203 | - | return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours) | |
| 119 | + | return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60)) | |
| 204 | 120 | } | |
| 205 | 121 | } | |
| 206 | 122 | ||
| @@ -217,16 +133,8 @@ func truncateSubagentIndicatorText(s string, width int) string { | |||
| 217 | 133 | return runewidth.Truncate(s, width, "...") | |
| 218 | 134 | } | |
| 219 | 135 | ||
| 220 | - | // sanitizeSubagentIndicatorText keeps worker-derived labels on one safe | |
| 221 | - | // terminal row before they are measured or styled. A worker's profile, id, or | |
| 222 | - | // tool activity is data, never terminal control output. | |
| 223 | - | func sanitizeSubagentIndicatorText(s string) string { | |
| 224 | - | return sanitizeSessionTreeText(s) | |
| 225 | - | } | |
| 136 | + | func sanitizeSubagentIndicatorText(s string) string { return sanitizeSessionTreeText(s) } | |
| 226 | 137 | ||
| 227 | - | // limitSubagentActivityLines reserves the input's terminal rows before adding | |
| 228 | - | // activity rows. When the frame has room for only part of the live set, retain | |
| 229 | - | // the earliest rows and make the omitted count explicit in one final row. | |
| 230 | 138 | func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string { | |
| 231 | 139 | if maxRows <= 0 || len(lines) == 0 { | |
| 232 | 140 | return nil | |
| @@ -242,20 +150,16 @@ func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int | |||
| 242 | 150 | } | |
| 243 | 151 | ||
| 244 | 152 | func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string { | |
| 245 | - | text := fmt.Sprintf(" … %d more active subagents", hidden) | |
| 153 | + | text := fmt.Sprintf(" … %d more open subagent operations", hidden) | |
| 246 | 154 | return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width)) | |
| 247 | 155 | } | |
| 248 | 156 | ||
| 249 | - | // activeSubagentActivitySnapshots builds the compact state needed by the | |
| 250 | - | // input indicator without copying transcript buffers. It mirrors the | |
| 251 | - | // supervisor's live-session filtering while deliberately omitting completed | |
| 252 | - | // workers, which belong in the dashboard and completion notification instead. | |
| 253 | - | func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot { | |
| 157 | + | func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapshot, map[string]subagents.AgentTraceView) { | |
| 254 | 158 | supervisor := i.cfg.Supervisor | |
| 255 | 159 | if supervisor == nil { | |
| 256 | - | return nil | |
| 160 | + | return nil, nil | |
| 257 | 161 | } | |
| 258 | - | ||
| 162 | + | views := supervisor.TraceViews() | |
| 259 | 163 | activeSession := supervisor.ActiveSession() | |
| 260 | 164 | agents := supervisor.List() | |
| 261 | 165 | out := make([]subagents.AgentSnapshot, 0, len(agents)) | |
| @@ -263,20 +167,10 @@ func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapsho | |||
| 263 | 167 | if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession { | |
| 264 | 168 | continue | |
| 265 | 169 | } | |
| 266 | - | snapshot := subagents.AgentSnapshot{ | |
| 267 | - | ID: agent.ID, | |
| 268 | - | Status: agent.Status(), | |
| 269 | - | ProcessState: agent.ProcessState(), | |
| 270 | - | TurnState: agent.TurnState(), | |
| 271 | - | Activity: agent.Activity(), | |
| 272 | - | Started: agent.Started, | |
| 273 | - | UpdatedAt: agent.UpdatedAt(), | |
| 274 | - | LastActivity: agent.LastActivity(), | |
| 275 | - | Subagent: agent.Subagent, | |
| 276 | - | } | |
| 277 | - | if subagentTurnIsActive(snapshot) { | |
| 278 | - | out = append(out, snapshot) | |
| 170 | + | if len(views[agent.ID].OpenOperations) == 0 { | |
| 171 | + | continue | |
| 279 | 172 | } | |
| 173 | + | out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent}) | |
| 280 | 174 | } | |
| 281 | - | return out | |
| 175 | + | return out, views | |
| 282 | 176 | } | |
| @@ -10,17 +10,17 @@ import ( | |||||
| 10 | "github.com/mattn/go-runewidth" | 10 | "github.com/mattn/go-runewidth" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | // renderSubagentActivityLines returns one compact, input-adjacent row for each | 13 | + | // renderSubagentActivityLines renders only trace-observed open operations. |
| 14 | - | // active delegated turn. Terminal and idle workers are intentionally omitted: | 14 | + | // A living process, generic lifecycle state, or heartbeat never starts a |
| 15 | - | // the /subagents dashboard remains the durable history, while this surface is | 15 | + | // spinner because none proves that an operation is progressing. |
| 16 | - | // only a live "is it still moving?" signal. | 16 | + | func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, views map[string]subagents.AgentTraceView, width int, now time.Time) []string { |
| 17 | - | func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string { | |||
| 18 | lines := make([]string, 0, len(snapshots)) | 17 | lines := make([]string, 0, len(snapshots)) | ||
| 19 | for _, snapshot := range snapshots { | 18 | for _, snapshot := range snapshots { | ||
| 20 | - | if !subagentTurnIsActive(snapshot) { | 19 | + | view := views[snapshot.ID] |
| 20 | + | if len(view.OpenOperations) == 0 { | |||
| 21 | continue | 21 | continue | ||
| 22 | } | 22 | } | ||
| 23 | - | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now) | 23 | + | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, view.OpenOperations[0], width, now) |
| 24 | if line != "" { | 24 | if line != "" { | ||
| 25 | lines = append(lines, line) | 25 | lines = append(lines, line) | ||
| 26 | } | 26 | } | ||
| @@ -28,40 +28,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||||
| 28 | return lines | 28 | return lines | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | - | func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool { | 31 | + | func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, width int, now time.Time) string { |
| 32 | - | switch snapshot.ProcessState { | |||
| 33 | - | case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive: | |||
| 34 | - | default: | |||
| 35 | - | // A detached worker has no confirmed live connection. Keep it in the | |||
| 36 | - | // dashboard, rather than presenting an endlessly animated row beside | |||
| 37 | - | // the input as if it were still making progress. | |||
| 38 | - | return false | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | switch snapshot.TurnState { | |||
| 42 | - | case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled: | |||
| 43 | - | return false | |||
| 44 | - | } | |||
| 45 | - | switch snapshot.Status { | |||
| 46 | - | case subagents.StatusPending: | |||
| 47 | - | return true | |||
| 48 | - | case subagents.StatusRunning: | |||
| 49 | - | default: | |||
| 50 | - | return false | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | switch snapshot.TurnState { | |||
| 54 | - | case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling: | |||
| 55 | - | return true | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a | |||
| 59 | - | // brief "starting" phase visible, but remove the row as soon as it reports | |||
| 60 | - | // ordinary idle so an already-completed task never looks active. | |||
| 61 | - | return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle" | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string { | |||
| 65 | name := sanitizeSubagentIndicatorText(snapshot.Subagent) | 32 | name := sanitizeSubagentIndicatorText(snapshot.Subagent) | ||
| 66 | if name == "" { | 33 | if name == "" { | ||
| 67 | name = sanitizeSubagentIndicatorText(snapshot.ID) | 34 | name = sanitizeSubagentIndicatorText(snapshot.ID) | ||
| @@ -73,8 +40,8 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||||
| 73 | if spinnerGlyph == "" { | 40 | if spinnerGlyph == "" { | ||
| 74 | spinnerGlyph = "." | 41 | spinnerGlyph = "." | ||
| 75 | } | 42 | } | ||
| 76 | - | activity := compactSubagentActivity(snapshot) | 43 | + | activity := operation.Label() |
| 77 | - | age := formatSubagentActivityAge(snapshot, now) | 44 | + | age := formatSubagentActivityAge(operation.StartedAt, now) |
| 78 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | 45 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | ||
| 79 | if plain == "" { | 46 | if plain == "" { | ||
| 80 | return "" | 47 | return "" | ||
| @@ -98,16 +65,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||||
| 98 | name = strings.TrimPrefix(parts[0], spinnerGlyph+" ") | 65 | name = strings.TrimPrefix(parts[0], spinnerGlyph+" ") | ||
| 99 | return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " + | 66 | return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " + | ||
| 100 | th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1]) | 67 | th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1]) | ||
| 101 | - | case subagentActivityCompact: | 68 | + | default: |
| 102 | - | prefix := spinnerGlyph + " " | 69 | + | return " " + th.FGColor(th.Muted, plain) |
| 103 | - | suffix := " " + age | |||
| 104 | - | if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) { | |||
| 105 | - | name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix) | |||
| 106 | - | return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " + | |||
| 107 | - | th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age) | |||
| 108 | - | } | |||
| 109 | } | 70 | } | ||
| 110 | - | return " " + th.FGColor(th.Muted, plain) | |||
| 111 | } | 71 | } | ||
| 112 | 72 | ||||
| 113 | type subagentActivityLayout uint8 | 73 | type subagentActivityLayout uint8 | ||
| @@ -119,73 +79,31 @@ const ( | |||||
| 119 | ) | 79 | ) | ||
| 120 | 80 | ||||
| 121 | func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) { | 81 | func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) { | ||
| 122 | - | spinnerGlyph = strings.TrimSpace(spinnerGlyph) | |||
| 123 | - | if spinnerGlyph == "" { | |||
| 124 | - | spinnerGlyph = "." | |||
| 125 | - | } | |||
| 126 | if width <= 0 { | 82 | if width <= 0 { | ||
| 127 | return "", subagentActivityCompact | 83 | return "", subagentActivityCompact | ||
| 128 | } | 84 | } | ||
| 129 | - | ||||
| 130 | full := spinnerGlyph + " " + name + " · " + activity + " · " + age | 85 | full := spinnerGlyph + " " + name + " · " + activity + " · " + age | ||
| 131 | if runewidth.StringWidth(full) <= width { | 86 | if runewidth.StringWidth(full) <= width { | ||
| 132 | return full, subagentActivityFull | 87 | return full, subagentActivityFull | ||
| 133 | } | 88 | } | ||
| 134 | - | ||||
| 135 | nameAge := spinnerGlyph + " " + name + " · " + age | 89 | nameAge := spinnerGlyph + " " + name + " · " + age | ||
| 136 | if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 { | 90 | if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 { | ||
| 137 | - | activity = truncateSubagentIndicatorText(activity, activityWidth) | 91 | + | return spinnerGlyph + " " + name + " · " + truncateSubagentIndicatorText(activity, activityWidth) + " · " + age, subagentActivityFull |
| 138 | - | return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull | |||
| 139 | } | 92 | } | ||
| 140 | if runewidth.StringWidth(nameAge) <= width { | 93 | if runewidth.StringWidth(nameAge) <= width { | ||
| 141 | return nameAge, subagentActivityNameAge | 94 | return nameAge, subagentActivityNameAge | ||
| 142 | } | 95 | } | ||
| 143 | - | 96 | + | return truncateSubagentIndicatorText(nameAge, width), subagentActivityCompact | |
| 144 | - | prefix := spinnerGlyph + " " | |||
| 145 | - | suffix := " " + age | |||
| 146 | - | nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix) | |||
| 147 | - | if nameWidth >= 1 { | |||
| 148 | - | return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact | |||
| 149 | - | } | |||
| 150 | - | if runewidth.StringWidth(age) <= width { | |||
| 151 | - | return age, subagentActivityCompact | |||
| 152 | - | } | |||
| 153 | - | return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact | |||
| 154 | } | 97 | } | ||
| 155 | 98 | ||||
| 156 | - | func compactSubagentActivity(snapshot subagents.AgentSnapshot) string { | 99 | + | func formatSubagentActivityAge(started, now time.Time) string { |
| 157 | - | activity := sanitizeSubagentIndicatorText(snapshot.Activity) | 100 | + | if started.IsZero() { |
| 158 | - | // Heartbeats report "idle" even while a delegated turn is still running. | |||
| 159 | - | // Keep the row meaningful in that interval instead of contradicting its | |||
| 160 | - | // spinner and turn state. | |||
| 161 | - | if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) { | |||
| 162 | - | return activity | |||
| 163 | - | } | |||
| 164 | - | switch snapshot.TurnState { | |||
| 165 | - | case subagents.TurnQueued: | |||
| 166 | - | return "queued" | |||
| 167 | - | case subagents.TurnCanceling: | |||
| 168 | - | return "cancelling" | |||
| 169 | - | default: | |||
| 170 | - | return "working" | |||
| 171 | - | } | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string { | |||
| 175 | - | last := snapshot.LastActivity | |||
| 176 | - | if last.IsZero() { | |||
| 177 | - | last = snapshot.UpdatedAt | |||
| 178 | - | } | |||
| 179 | - | if last.IsZero() { | |||
| 180 | - | last = snapshot.Started | |||
| 181 | - | } | |||
| 182 | - | if last.IsZero() { | |||
| 183 | return "-" | 101 | return "-" | ||
| 184 | } | 102 | } | ||
| 185 | if now.IsZero() { | 103 | if now.IsZero() { | ||
| 186 | now = time.Now() | 104 | now = time.Now() | ||
| 187 | } | 105 | } | ||
| 188 | - | elapsed := now.Sub(last) | 106 | + | elapsed := now.Sub(started) |
| 189 | if elapsed < 0 { | 107 | if elapsed < 0 { | ||
| 190 | elapsed = 0 | 108 | elapsed = 0 | ||
| 191 | } | 109 | } | ||
| @@ -196,11 +114,9 @@ func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) | |||||
| 196 | case seconds < 60*60: | 114 | case seconds < 60*60: | ||
| 197 | return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60) | 115 | return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60) | ||
| 198 | case seconds < 24*60*60: | 116 | case seconds < 24*60*60: | ||
| 199 | - | minutes := (seconds % (60 * 60)) / 60 | 117 | + | return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60) |
| 200 | - | return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes) | |||
| 201 | default: | 118 | default: | ||
| 202 | - | hours := (seconds % (24 * 60 * 60)) / (60 * 60) | 119 | + | return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60)) |
| 203 | - | return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours) | |||
| 204 | } | 120 | } | ||
| 205 | } | 121 | } | ||
| 206 | 122 | ||||
| @@ -217,16 +133,8 @@ func truncateSubagentIndicatorText(s string, width int) string { | |||||
| 217 | return runewidth.Truncate(s, width, "...") | 133 | return runewidth.Truncate(s, width, "...") | ||
| 218 | } | 134 | } | ||
| 219 | 135 | ||||
| 220 | - | // sanitizeSubagentIndicatorText keeps worker-derived labels on one safe | 136 | + | func sanitizeSubagentIndicatorText(s string) string { return sanitizeSessionTreeText(s) } |
| 221 | - | // terminal row before they are measured or styled. A worker's profile, id, or | |||
| 222 | - | // tool activity is data, never terminal control output. | |||
| 223 | - | func sanitizeSubagentIndicatorText(s string) string { | |||
| 224 | - | return sanitizeSessionTreeText(s) | |||
| 225 | - | } | |||
| 226 | 137 | ||||
| 227 | - | // limitSubagentActivityLines reserves the input's terminal rows before adding | |||
| 228 | - | // activity rows. When the frame has room for only part of the live set, retain | |||
| 229 | - | // the earliest rows and make the omitted count explicit in one final row. | |||
| 230 | func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string { | 138 | func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string { | ||
| 231 | if maxRows <= 0 || len(lines) == 0 { | 139 | if maxRows <= 0 || len(lines) == 0 { | ||
| 232 | return nil | 140 | return nil | ||
| @@ -242,20 +150,16 @@ func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int | |||||
| 242 | } | 150 | } | ||
| 243 | 151 | ||||
| 244 | func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string { | 152 | func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string { | ||
| 245 | - | text := fmt.Sprintf(" … %d more active subagents", hidden) | 153 | + | text := fmt.Sprintf(" … %d more open subagent operations", hidden) |
| 246 | return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width)) | 154 | return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width)) | ||
| 247 | } | 155 | } | ||
| 248 | 156 | ||||
| 249 | - | // activeSubagentActivitySnapshots builds the compact state needed by the | 157 | + | func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapshot, map[string]subagents.AgentTraceView) { |
| 250 | - | // input indicator without copying transcript buffers. It mirrors the | |||
| 251 | - | // supervisor's live-session filtering while deliberately omitting completed | |||
| 252 | - | // workers, which belong in the dashboard and completion notification instead. | |||
| 253 | - | func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot { | |||
| 254 | supervisor := i.cfg.Supervisor | 158 | supervisor := i.cfg.Supervisor | ||
| 255 | if supervisor == nil { | 159 | if supervisor == nil { | ||
| 256 | - | return nil | 160 | + | return nil, nil |
| 257 | } | 161 | } | ||
| 258 | - | 162 | + | views := supervisor.TraceViews() | |
| 259 | activeSession := supervisor.ActiveSession() | 163 | activeSession := supervisor.ActiveSession() | ||
| 260 | agents := supervisor.List() | 164 | agents := supervisor.List() | ||
| 261 | out := make([]subagents.AgentSnapshot, 0, len(agents)) | 165 | out := make([]subagents.AgentSnapshot, 0, len(agents)) | ||
| @@ -263,20 +167,10 @@ func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapsho | |||||
| 263 | if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession { | 167 | if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession { | ||
| 264 | continue | 168 | continue | ||
| 265 | } | 169 | } | ||
| 266 | - | snapshot := subagents.AgentSnapshot{ | 170 | + | if len(views[agent.ID].OpenOperations) == 0 { |
| 267 | - | ID: agent.ID, | 171 | + | continue |
| 268 | - | Status: agent.Status(), | |||
| 269 | - | ProcessState: agent.ProcessState(), | |||
| 270 | - | TurnState: agent.TurnState(), | |||
| 271 | - | Activity: agent.Activity(), | |||
| 272 | - | Started: agent.Started, | |||
| 273 | - | UpdatedAt: agent.UpdatedAt(), | |||
| 274 | - | LastActivity: agent.LastActivity(), | |||
| 275 | - | Subagent: agent.Subagent, | |||
| 276 | - | } | |||
| 277 | - | if subagentTurnIsActive(snapshot) { | |||
| 278 | - | out = append(out, snapshot) | |||
| 279 | } | 172 | } | ||
| 173 | + | out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent}) | |||
| 280 | } | 174 | } | ||
| 281 | - | return out | 175 | + | return out, views |
| 282 | } | 176 | } | ||
M packages/agent/modes/subagent_indicator_test.go +26 -328
| @@ -1,101 +1,27 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "strings" | |
| 6 | - | "sync" | |
| 7 | 5 | "testing" | |
| 8 | 6 | "time" | |
| 9 | - | "unicode" | |
| 10 | 7 | ||
| 11 | - | "github.com/bnema/zut/packages/agent/extproto" | |
| 12 | 8 | "github.com/bnema/zut/packages/agent/subagents" | |
| 13 | 9 | "github.com/bnema/zut/packages/tui" | |
| 14 | 10 | "github.com/mattn/go-runewidth" | |
| 15 | 11 | ) | |
| 16 | 12 | ||
| 17 | - | type sizedSubagentIndicatorTerminal struct { | |
| 18 | - | *alertTestTerminal | |
| 19 | - | cols int | |
| 20 | - | rows int | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows } | |
| 24 | - | ||
| 25 | - | func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) { | |
| 13 | + | func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) { | |
| 26 | 14 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 27 | - | lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{ | |
| 28 | - | { | |
| 29 | - | ID: "design-123", | |
| 30 | - | Subagent: "senior-engineer", | |
| 31 | - | Status: subagents.StatusPending, | |
| 32 | - | ProcessState: subagents.ProcessPending, | |
| 33 | - | TurnState: subagents.TurnQueued, | |
| 34 | - | Activity: "queued", | |
| 35 | - | LastActivity: now.Add(-4 * time.Second), | |
| 36 | - | }, | |
| 37 | - | { | |
| 38 | - | ID: "tests-456", | |
| 39 | - | Subagent: "", | |
| 40 | - | Status: subagents.StatusRunning, | |
| 41 | - | ProcessState: subagents.ProcessAlive, | |
| 42 | - | TurnState: subagents.TurnRunning, | |
| 43 | - | Activity: "tool: read", | |
| 44 | - | LastActivity: now.Add(-72 * time.Second), | |
| 45 | - | }, | |
| 46 | - | { | |
| 47 | - | ID: "heartbeat-789", | |
| 48 | - | Subagent: "implementer", | |
| 49 | - | Status: subagents.StatusRunning, | |
| 50 | - | ProcessState: subagents.ProcessAlive, | |
| 51 | - | TurnState: subagents.TurnRunning, | |
| 52 | - | Activity: "idle", | |
| 53 | - | LastActivity: now.Add(-5 * time.Second), | |
| 54 | - | }, | |
| 55 | - | { | |
| 56 | - | ID: "idle-789", | |
| 57 | - | Subagent: "reviewer", | |
| 58 | - | Status: subagents.StatusRunning, | |
| 59 | - | ProcessState: subagents.ProcessAlive, | |
| 60 | - | TurnState: subagents.TurnIdle, | |
| 61 | - | Activity: "idle", | |
| 62 | - | LastActivity: now.Add(-time.Second), | |
| 63 | - | }, | |
| 64 | - | { | |
| 65 | - | ID: "settled-012", | |
| 66 | - | Subagent: "implementer", | |
| 67 | - | Status: subagents.StatusPending, | |
| 68 | - | ProcessState: subagents.ProcessPending, | |
| 69 | - | TurnState: subagents.TurnSucceeded, | |
| 70 | - | Activity: "done", | |
| 71 | - | LastActivity: now.Add(-time.Second), | |
| 72 | - | }, | |
| 73 | - | { | |
| 74 | - | ID: "done-345", | |
| 75 | - | Subagent: "implementer", | |
| 76 | - | Status: subagents.StatusDone, | |
| 77 | - | ProcessState: subagents.ProcessExited, | |
| 78 | - | TurnState: subagents.TurnSucceeded, | |
| 79 | - | Activity: "done", | |
| 80 | - | LastActivity: now.Add(-time.Second), | |
| 81 | - | }, | |
| 82 | - | { | |
| 83 | - | ID: "detached-678", | |
| 84 | - | Subagent: "reviewer", | |
| 85 | - | Status: subagents.StatusRunning, | |
| 86 | - | ProcessState: subagents.ProcessDetached, | |
| 87 | - | TurnState: subagents.TurnRunning, | |
| 88 | - | Activity: "detached", | |
| 89 | - | LastActivity: now.Add(-time.Second), | |
| 90 | - | }, | |
| 91 | - | }, 80, now) | |
| 92 | - | ||
| 93 | - | got := plainActivityLines(lines) | |
| 94 | - | want := []string{ | |
| 95 | - | " / senior-engineer · queued · 4s", | |
| 96 | - | " / tests-456 · tool: read · 1m12s", | |
| 97 | - | " / implementer · working · 5s", | |
| 15 | + | snapshots := []subagents.AgentSnapshot{ | |
| 16 | + | {ID: "open", Subagent: "reviewer"}, | |
| 17 | + | {ID: "alive", Subagent: "implementer", Activity: "working"}, | |
| 18 | + | } | |
| 19 | + | views := map[string]subagents.AgentTraceView{ | |
| 20 | + | "open": {AgentID: "open", OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}}, | |
| 21 | + | "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}}, | |
| 98 | 22 | } | |
| 23 | + | got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now)) | |
| 24 | + | want := []string{" / reviewer · tool open · 1m12s"} | |
| 99 | 25 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 100 | 26 | t.Fatalf("activity lines = %#v, want %#v", got, want) | |
| 101 | 27 | } | |
| @@ -103,53 +29,26 @@ func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) { | |||
| 103 | 29 | ||
| 104 | 30 | func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) { | |
| 105 | 31 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 106 | - | const width = 27 | |
| 107 | - | lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ | |
| 108 | - | ID: "review-123", | |
| 109 | - | Subagent: "reviewer", | |
| 110 | - | Status: subagents.StatusRunning, | |
| 111 | - | ProcessState: subagents.ProcessAlive, | |
| 112 | - | TurnState: subagents.TurnRunning, | |
| 113 | - | Activity: "tool: read an exceptionally long filename", | |
| 114 | - | LastActivity: now.Add(-72 * time.Second), | |
| 115 | - | }}, width, now) | |
| 116 | - | ||
| 32 | + | lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | |
| 33 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}}, | |
| 34 | + | }, 27, now) | |
| 117 | 35 | if len(lines) != 1 { | |
| 118 | 36 | t.Fatalf("line count = %d, want 1", len(lines)) | |
| 119 | 37 | } | |
| 120 | 38 | line := plainActivityLines(lines)[0] | |
| 121 | - | if runewidth.StringWidth(line) > width { | |
| 122 | - | t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line) | |
| 123 | - | } | |
| 124 | - | for _, want := range []string{"reviewer", "1m12s"} { | |
| 125 | - | if !strings.Contains(line, want) { | |
| 126 | - | t.Fatalf("narrow line %q is missing %q", line, want) | |
| 127 | - | } | |
| 39 | + | if runewidth.StringWidth(line) > 27 || !strings.Contains(line, "reviewer") || !strings.Contains(line, "1m12s") { | |
| 40 | + | t.Fatalf("narrow line = %q", line) | |
| 128 | 41 | } | |
| 129 | 42 | } | |
| 130 | 43 | ||
| 131 | 44 | func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) { | |
| 132 | 45 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 133 | - | lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ | |
| 134 | - | ID: "review-123", | |
| 135 | - | Subagent: "reviewer\x1b]52;c;ignored\a", | |
| 136 | - | Status: subagents.StatusRunning, | |
| 137 | - | ProcessState: subagents.ProcessAlive, | |
| 138 | - | TurnState: subagents.TurnRunning, | |
| 139 | - | Activity: "tool:\tread\x1b[2J\rfile", | |
| 140 | - | LastActivity: now.Add(-time.Second), | |
| 141 | - | }}, 80, now) | |
| 142 | - | if len(lines) != 1 { | |
| 143 | - | t.Fatalf("line count = %d, want 1", len(lines)) | |
| 144 | - | } | |
| 46 | + | lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer\x1b]52;c;ignored\a"}}, map[string]subagents.AgentTraceView{ | |
| 47 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-time.Second)}}}, | |
| 48 | + | }, 80, now) | |
| 145 | 49 | plain := plainActivityLines(lines)[0] | |
| 146 | - | if strings.IndexFunc(plain, unicode.IsControl) >= 0 { | |
| 147 | - | t.Fatalf("indicator retained a control character: %q", plain) | |
| 148 | - | } | |
| 149 | - | for _, want := range []string{"reviewer", "tool: read file"} { | |
| 150 | - | if !strings.Contains(plain, want) { | |
| 151 | - | t.Fatalf("sanitized indicator %q is missing %q", plain, want) | |
| 152 | - | } | |
| 50 | + | if strings.ContainsRune(plain, '\x1b') || !strings.Contains(plain, "reviewer") { | |
| 51 | + | t.Fatalf("sanitized indicator = %q", plain) | |
| 153 | 52 | } | |
| 154 | 53 | } | |
| 155 | 54 | ||
| @@ -159,222 +58,21 @@ func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) { | |||
| 159 | 58 | } | |
| 160 | 59 | } | |
| 161 | 60 | ||
| 162 | - | func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) { | |
| 61 | + | func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) { | |
| 163 | 62 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 164 | - | if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want { | |
| 165 | - | t.Fatalf("updated-at age = %q, want %q", got, want) | |
| 166 | - | } | |
| 167 | - | if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want { | |
| 168 | - | t.Fatalf("started-at age = %q, want %q", got, want) | |
| 63 | + | if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want { | |
| 64 | + | t.Fatalf("age = %q, want %q", got, want) | |
| 169 | 65 | } | |
| 170 | 66 | } | |
| 171 | 67 | ||
| 172 | - | func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) { | |
| 173 | - | lines := []string{"one", "two", "three", "four"} | |
| 174 | - | got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80)) | |
| 175 | - | want := []string{"one", "two", " … 2 more active subagents"} | |
| 68 | + | func TestLimitSubagentActivityLinesSummarizesOmittedOperations(t *testing.T) { | |
| 69 | + | got := plainActivityLines(limitSubagentActivityLines(tui.Dark, []string{"one", "two", "three", "four"}, 3, 80)) | |
| 70 | + | want := []string{"one", "two", " … 2 more open subagent operations"} | |
| 176 | 71 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 177 | 72 | t.Fatalf("limited lines = %#v, want %#v", got, want) | |
| 178 | 73 | } | |
| 179 | 74 | } | |
| 180 | 75 | ||
| 181 | - | func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) { | |
| 182 | - | release := make(chan struct{}) | |
| 183 | - | supervisor := subagents.New(subagents.Config{ | |
| 184 | - | Root: t.TempDir(), | |
| 185 | - | RepoRoot: t.TempDir(), | |
| 186 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 187 | - | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |
| 188 | - | sink.Activity("thinking") | |
| 189 | - | select { | |
| 190 | - | case <-release: | |
| 191 | - | return nil | |
| 192 | - | case <-ctx.Done(): | |
| 193 | - | return ctx.Err() | |
| 194 | - | } | |
| 195 | - | }) | |
| 196 | - | }, | |
| 197 | - | }) | |
| 198 | - | defer func() { | |
| 199 | - | close(release) | |
| 200 | - | supervisor.StopAll() | |
| 201 | - | }() | |
| 202 | - | for n := 0; n < 7; n++ { | |
| 203 | - | if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{ | |
| 204 | - | Task: "inspect layout", | |
| 205 | - | Subagent: "reviewer", | |
| 206 | - | }); err != nil { | |
| 207 | - | t.Fatal(err) | |
| 208 | - | } | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | term := &sizedSubagentIndicatorTerminal{ | |
| 212 | - | alertTestTerminal: &alertTestTerminal{}, | |
| 213 | - | cols: 80, | |
| 214 | - | rows: 8, | |
| 215 | - | } | |
| 216 | - | interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor}) | |
| 217 | - | interactive.rend.Resize(80, 8) | |
| 218 | - | interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil) | |
| 219 | - | interactive.redraw() | |
| 220 | - | ||
| 221 | - | output := stripANSIBytes(term.String()) | |
| 222 | - | if !strings.Contains(output, "▌") { | |
| 223 | - | t.Fatalf("input was pushed out of the fixed frame: %q", output) | |
| 224 | - | } | |
| 225 | - | if !strings.Contains(output, "more active subagents") { | |
| 226 | - | t.Fatalf("omitted subagents were not summarized: %q", output) | |
| 227 | - | } | |
| 228 | - | if strings.Contains(term.String(), "\x1b[-") { | |
| 229 | - | t.Fatalf("renderer received a negative cursor row: %q", term.String()) | |
| 230 | - | } | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | func TestSubagentActivityLinesFollowInputPosition(t *testing.T) { | |
| 234 | - | started := make(chan struct{}) | |
| 235 | - | release := make(chan struct{}) | |
| 236 | - | var releaseOnce sync.Once | |
| 237 | - | releaseRunner := func() { releaseOnce.Do(func() { close(release) }) } | |
| 238 | - | supervisor := subagents.New(subagents.Config{ | |
| 239 | - | Root: t.TempDir(), | |
| 240 | - | RepoRoot: t.TempDir(), | |
| 241 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 242 | - | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |
| 243 | - | sink.Activity("thinking") | |
| 244 | - | close(started) | |
| 245 | - | select { | |
| 246 | - | case <-release: | |
| 247 | - | return nil | |
| 248 | - | case <-ctx.Done(): | |
| 249 | - | return ctx.Err() | |
| 250 | - | } | |
| 251 | - | }) | |
| 252 | - | }, | |
| 253 | - | }) | |
| 254 | - | defer func() { | |
| 255 | - | releaseRunner() | |
| 256 | - | supervisor.StopAll() | |
| 257 | - | }() | |
| 258 | - | agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{ | |
| 259 | - | Task: "inspect the input layout", | |
| 260 | - | Subagent: "senior-engineer", | |
| 261 | - | }) | |
| 262 | - | if err != nil { | |
| 263 | - | t.Fatal(err) | |
| 264 | - | } | |
| 265 | - | select { | |
| 266 | - | case <-started: | |
| 267 | - | case <-time.After(time.Second): | |
| 268 | - | t.Fatal("subagent runner did not start") | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | assertPosition := func(t *testing.T, position string, wantBelow bool) { | |
| 272 | - | t.Helper() | |
| 273 | - | term := &alertTestTerminal{} | |
| 274 | - | interactive := NewInteractive(InteractiveConfig{ | |
| 275 | - | Terminal: term, | |
| 276 | - | Theme: tui.Dark, | |
| 277 | - | Supervisor: supervisor, | |
| 278 | - | TUISubagentPosition: position, | |
| 279 | - | }) | |
| 280 | - | interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) } | |
| 281 | - | interactive.rend.Resize(80, 24) | |
| 282 | - | interactive.redraw() | |
| 283 | - | ||
| 284 | - | output := stripANSIBytes(term.String()) | |
| 285 | - | inputAt := strings.LastIndex(output, "▌") | |
| 286 | - | subagentAt := strings.LastIndex(output, "senior-engineer") | |
| 287 | - | if inputAt < 0 || subagentAt < 0 { | |
| 288 | - | t.Fatalf("rendered input/subagent missing from %q", output) | |
| 289 | - | } | |
| 290 | - | if gotBelow := subagentAt > inputAt; gotBelow != wantBelow { | |
| 291 | - | t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output) | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | t.Run("default below", func(t *testing.T) { | |
| 296 | - | assertPosition(t, "", true) | |
| 297 | - | }) | |
| 298 | - | t.Run("configured above", func(t *testing.T) { | |
| 299 | - | assertPosition(t, tui.SubagentPositionAboveInput, false) | |
| 300 | - | }) | |
| 301 | - | t.Run("dashboard editor suppresses inline animation", func(t *testing.T) { | |
| 302 | - | term := &alertTestTerminal{} | |
| 303 | - | interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor}) | |
| 304 | - | interactive.rend.Resize(80, 24) | |
| 305 | - | interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") | |
| 306 | - | interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 307 | - | if interactive.subagentsDialog.NeedsTickRefresh() { | |
| 308 | - | t.Fatal("subagent spawn editor should suppress dashboard ticks") | |
| 309 | - | } | |
| 310 | - | interactive.redraw() | |
| 311 | - | if interactive.subagentActivityActive { | |
| 312 | - | t.Fatal("hidden inline indicators should not request animation ticks") | |
| 313 | - | } | |
| 314 | - | }) | |
| 315 | - | ||
| 316 | - | releaseRunner() | |
| 317 | - | agent.Wait() | |
| 318 | - | term := &alertTestTerminal{} | |
| 319 | - | interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor}) | |
| 320 | - | interactive.rend.Resize(80, 24) | |
| 321 | - | interactive.redraw() | |
| 322 | - | if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") { | |
| 323 | - | t.Fatalf("finished subagent indicator remained visible: %q", output) | |
| 324 | - | } | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) { | |
| 328 | - | release := make(chan struct{}) | |
| 329 | - | supervisor := subagents.New(subagents.Config{ | |
| 330 | - | Root: t.TempDir(), | |
| 331 | - | RepoRoot: t.TempDir(), | |
| 332 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 333 | - | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |
| 334 | - | select { | |
| 335 | - | case <-release: | |
| 336 | - | return nil | |
| 337 | - | case <-ctx.Done(): | |
| 338 | - | return ctx.Err() | |
| 339 | - | } | |
| 340 | - | }) | |
| 341 | - | }, | |
| 342 | - | }) | |
| 343 | - | defer func() { | |
| 344 | - | close(release) | |
| 345 | - | supervisor.StopAll() | |
| 346 | - | }() | |
| 347 | - | supervisor.SetActiveSession("session-a") | |
| 348 | - | if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{ | |
| 349 | - | Task: "inspect scope", | |
| 350 | - | Subagent: "reviewer", | |
| 351 | - | }); err != nil { | |
| 352 | - | t.Fatal(err) | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor}) | |
| 356 | - | if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 { | |
| 357 | - | t.Fatalf("active rows in initial scope = %d, want 1", got) | |
| 358 | - | } | |
| 359 | - | interactive.SetSubagentSessionScope("session-b") | |
| 360 | - | if got := supervisor.ActiveSession(); got != "session-b" { | |
| 361 | - | t.Fatalf("active session = %q, want session-b", got) | |
| 362 | - | } | |
| 363 | - | select { | |
| 364 | - | case <-interactive.dirty: | |
| 365 | - | case <-time.After(time.Second): | |
| 366 | - | t.Fatal("session scope change did not invalidate the interactive view") | |
| 367 | - | } | |
| 368 | - | if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 { | |
| 369 | - | t.Fatalf("active rows after scope change = %d, want 0", got) | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | interactive.SetSubagentSessionScope("session-a") | |
| 373 | - | if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 { | |
| 374 | - | t.Fatalf("active rows after returning to scope = %d, want 1", got) | |
| 375 | - | } | |
| 376 | - | } | |
| 377 | - | ||
| 378 | 76 | func plainActivityLines(lines []string) []string { | |
| 379 | 77 | plain := make([]string, len(lines)) | |
| 380 | 78 | for idx, line := range lines { | |
| @@ -1,101 +1,27 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "strings" | 4 | "strings" | ||
| 6 | - | "sync" | |||
| 7 | "testing" | 5 | "testing" | ||
| 8 | "time" | 6 | "time" | ||
| 9 | - | "unicode" | |||
| 10 | 7 | ||||
| 11 | - | "github.com/bnema/zut/packages/agent/extproto" | |||
| 12 | "github.com/bnema/zut/packages/agent/subagents" | 8 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 13 | "github.com/bnema/zut/packages/tui" | 9 | "github.com/bnema/zut/packages/tui" | ||
| 14 | "github.com/mattn/go-runewidth" | 10 | "github.com/mattn/go-runewidth" | ||
| 15 | ) | 11 | ) | ||
| 16 | 12 | ||||
| 17 | - | type sizedSubagentIndicatorTerminal struct { | 13 | + | func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) { |
| 18 | - | *alertTestTerminal | |||
| 19 | - | cols int | |||
| 20 | - | rows int | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows } | |||
| 24 | - | ||||
| 25 | - | func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) { | |||
| 26 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 14 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | ||
| 27 | - | lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{ | 15 | + | snapshots := []subagents.AgentSnapshot{ |
| 28 | - | { | 16 | + | {ID: "open", Subagent: "reviewer"}, |
| 29 | - | ID: "design-123", | 17 | + | {ID: "alive", Subagent: "implementer", Activity: "working"}, |
| 30 | - | Subagent: "senior-engineer", | 18 | + | } |
| 31 | - | Status: subagents.StatusPending, | 19 | + | views := map[string]subagents.AgentTraceView{ |
| 32 | - | ProcessState: subagents.ProcessPending, | 20 | + | "open": {AgentID: "open", OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}}, |
| 33 | - | TurnState: subagents.TurnQueued, | 21 | + | "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}}, |
| 34 | - | Activity: "queued", | |||
| 35 | - | LastActivity: now.Add(-4 * time.Second), | |||
| 36 | - | }, | |||
| 37 | - | { | |||
| 38 | - | ID: "tests-456", | |||
| 39 | - | Subagent: "", | |||
| 40 | - | Status: subagents.StatusRunning, | |||
| 41 | - | ProcessState: subagents.ProcessAlive, | |||
| 42 | - | TurnState: subagents.TurnRunning, | |||
| 43 | - | Activity: "tool: read", | |||
| 44 | - | LastActivity: now.Add(-72 * time.Second), | |||
| 45 | - | }, | |||
| 46 | - | { | |||
| 47 | - | ID: "heartbeat-789", | |||
| 48 | - | Subagent: "implementer", | |||
| 49 | - | Status: subagents.StatusRunning, | |||
| 50 | - | ProcessState: subagents.ProcessAlive, | |||
| 51 | - | TurnState: subagents.TurnRunning, | |||
| 52 | - | Activity: "idle", | |||
| 53 | - | LastActivity: now.Add(-5 * time.Second), | |||
| 54 | - | }, | |||
| 55 | - | { | |||
| 56 | - | ID: "idle-789", | |||
| 57 | - | Subagent: "reviewer", | |||
| 58 | - | Status: subagents.StatusRunning, | |||
| 59 | - | ProcessState: subagents.ProcessAlive, | |||
| 60 | - | TurnState: subagents.TurnIdle, | |||
| 61 | - | Activity: "idle", | |||
| 62 | - | LastActivity: now.Add(-time.Second), | |||
| 63 | - | }, | |||
| 64 | - | { | |||
| 65 | - | ID: "settled-012", | |||
| 66 | - | Subagent: "implementer", | |||
| 67 | - | Status: subagents.StatusPending, | |||
| 68 | - | ProcessState: subagents.ProcessPending, | |||
| 69 | - | TurnState: subagents.TurnSucceeded, | |||
| 70 | - | Activity: "done", | |||
| 71 | - | LastActivity: now.Add(-time.Second), | |||
| 72 | - | }, | |||
| 73 | - | { | |||
| 74 | - | ID: "done-345", | |||
| 75 | - | Subagent: "implementer", | |||
| 76 | - | Status: subagents.StatusDone, | |||
| 77 | - | ProcessState: subagents.ProcessExited, | |||
| 78 | - | TurnState: subagents.TurnSucceeded, | |||
| 79 | - | Activity: "done", | |||
| 80 | - | LastActivity: now.Add(-time.Second), | |||
| 81 | - | }, | |||
| 82 | - | { | |||
| 83 | - | ID: "detached-678", | |||
| 84 | - | Subagent: "reviewer", | |||
| 85 | - | Status: subagents.StatusRunning, | |||
| 86 | - | ProcessState: subagents.ProcessDetached, | |||
| 87 | - | TurnState: subagents.TurnRunning, | |||
| 88 | - | Activity: "detached", | |||
| 89 | - | LastActivity: now.Add(-time.Second), | |||
| 90 | - | }, | |||
| 91 | - | }, 80, now) | |||
| 92 | - | ||||
| 93 | - | got := plainActivityLines(lines) | |||
| 94 | - | want := []string{ | |||
| 95 | - | " / senior-engineer · queued · 4s", | |||
| 96 | - | " / tests-456 · tool: read · 1m12s", | |||
| 97 | - | " / implementer · working · 5s", | |||
| 98 | } | 22 | } | ||
| 23 | + | got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now)) | |||
| 24 | + | want := []string{" / reviewer · tool open · 1m12s"} | |||
| 99 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | 25 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | ||
| 100 | t.Fatalf("activity lines = %#v, want %#v", got, want) | 26 | t.Fatalf("activity lines = %#v, want %#v", got, want) | ||
| 101 | } | 27 | } | ||
| @@ -103,53 +29,26 @@ func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) { | |||||
| 103 | 29 | ||||
| 104 | func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) { | 30 | func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) { | ||
| 105 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 31 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | ||
| 106 | - | const width = 27 | 32 | + | lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ |
| 107 | - | lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ | 33 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}}, |
| 108 | - | ID: "review-123", | 34 | + | }, 27, now) |
| 109 | - | Subagent: "reviewer", | |||
| 110 | - | Status: subagents.StatusRunning, | |||
| 111 | - | ProcessState: subagents.ProcessAlive, | |||
| 112 | - | TurnState: subagents.TurnRunning, | |||
| 113 | - | Activity: "tool: read an exceptionally long filename", | |||
| 114 | - | LastActivity: now.Add(-72 * time.Second), | |||
| 115 | - | }}, width, now) | |||
| 116 | - | ||||
| 117 | if len(lines) != 1 { | 35 | if len(lines) != 1 { | ||
| 118 | t.Fatalf("line count = %d, want 1", len(lines)) | 36 | t.Fatalf("line count = %d, want 1", len(lines)) | ||
| 119 | } | 37 | } | ||
| 120 | line := plainActivityLines(lines)[0] | 38 | line := plainActivityLines(lines)[0] | ||
| 121 | - | if runewidth.StringWidth(line) > width { | 39 | + | if runewidth.StringWidth(line) > 27 || !strings.Contains(line, "reviewer") || !strings.Contains(line, "1m12s") { |
| 122 | - | t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line) | 40 | + | t.Fatalf("narrow line = %q", line) |
| 123 | - | } | |||
| 124 | - | for _, want := range []string{"reviewer", "1m12s"} { | |||
| 125 | - | if !strings.Contains(line, want) { | |||
| 126 | - | t.Fatalf("narrow line %q is missing %q", line, want) | |||
| 127 | - | } | |||
| 128 | } | 41 | } | ||
| 129 | } | 42 | } | ||
| 130 | 43 | ||||
| 131 | func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) { | 44 | func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) { | ||
| 132 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 45 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | ||
| 133 | - | lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ | 46 | + | lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer\x1b]52;c;ignored\a"}}, map[string]subagents.AgentTraceView{ |
| 134 | - | ID: "review-123", | 47 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-time.Second)}}}, |
| 135 | - | Subagent: "reviewer\x1b]52;c;ignored\a", | 48 | + | }, 80, now) |
| 136 | - | Status: subagents.StatusRunning, | |||
| 137 | - | ProcessState: subagents.ProcessAlive, | |||
| 138 | - | TurnState: subagents.TurnRunning, | |||
| 139 | - | Activity: "tool:\tread\x1b[2J\rfile", | |||
| 140 | - | LastActivity: now.Add(-time.Second), | |||
| 141 | - | }}, 80, now) | |||
| 142 | - | if len(lines) != 1 { | |||
| 143 | - | t.Fatalf("line count = %d, want 1", len(lines)) | |||
| 144 | - | } | |||
| 145 | plain := plainActivityLines(lines)[0] | 49 | plain := plainActivityLines(lines)[0] | ||
| 146 | - | if strings.IndexFunc(plain, unicode.IsControl) >= 0 { | 50 | + | if strings.ContainsRune(plain, '\x1b') || !strings.Contains(plain, "reviewer") { |
| 147 | - | t.Fatalf("indicator retained a control character: %q", plain) | 51 | + | t.Fatalf("sanitized indicator = %q", plain) |
| 148 | - | } | |||
| 149 | - | for _, want := range []string{"reviewer", "tool: read file"} { | |||
| 150 | - | if !strings.Contains(plain, want) { | |||
| 151 | - | t.Fatalf("sanitized indicator %q is missing %q", plain, want) | |||
| 152 | - | } | |||
| 153 | } | 52 | } | ||
| 154 | } | 53 | } | ||
| 155 | 54 | ||||
| @@ -159,222 +58,21 @@ func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) { | |||||
| 159 | } | 58 | } | ||
| 160 | } | 59 | } | ||
| 161 | 60 | ||||
| 162 | - | func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) { | 61 | + | func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) { |
| 163 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 62 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | ||
| 164 | - | if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want { | 63 | + | if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want { |
| 165 | - | t.Fatalf("updated-at age = %q, want %q", got, want) | 64 | + | t.Fatalf("age = %q, want %q", got, want) |
| 166 | - | } | |||
| 167 | - | if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want { | |||
| 168 | - | t.Fatalf("started-at age = %q, want %q", got, want) | |||
| 169 | } | 65 | } | ||
| 170 | } | 66 | } | ||
| 171 | 67 | ||||
| 172 | - | func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) { | 68 | + | func TestLimitSubagentActivityLinesSummarizesOmittedOperations(t *testing.T) { |
| 173 | - | lines := []string{"one", "two", "three", "four"} | 69 | + | got := plainActivityLines(limitSubagentActivityLines(tui.Dark, []string{"one", "two", "three", "four"}, 3, 80)) |
| 174 | - | got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80)) | 70 | + | want := []string{"one", "two", " … 2 more open subagent operations"} |
| 175 | - | want := []string{"one", "two", " … 2 more active subagents"} | |||
| 176 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | 71 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | ||
| 177 | t.Fatalf("limited lines = %#v, want %#v", got, want) | 72 | t.Fatalf("limited lines = %#v, want %#v", got, want) | ||
| 178 | } | 73 | } | ||
| 179 | } | 74 | } | ||
| 180 | 75 | ||||
| 181 | - | func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) { | |||
| 182 | - | release := make(chan struct{}) | |||
| 183 | - | supervisor := subagents.New(subagents.Config{ | |||
| 184 | - | Root: t.TempDir(), | |||
| 185 | - | RepoRoot: t.TempDir(), | |||
| 186 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 187 | - | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |||
| 188 | - | sink.Activity("thinking") | |||
| 189 | - | select { | |||
| 190 | - | case <-release: | |||
| 191 | - | return nil | |||
| 192 | - | case <-ctx.Done(): | |||
| 193 | - | return ctx.Err() | |||
| 194 | - | } | |||
| 195 | - | }) | |||
| 196 | - | }, | |||
| 197 | - | }) | |||
| 198 | - | defer func() { | |||
| 199 | - | close(release) | |||
| 200 | - | supervisor.StopAll() | |||
| 201 | - | }() | |||
| 202 | - | for n := 0; n < 7; n++ { | |||
| 203 | - | if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{ | |||
| 204 | - | Task: "inspect layout", | |||
| 205 | - | Subagent: "reviewer", | |||
| 206 | - | }); err != nil { | |||
| 207 | - | t.Fatal(err) | |||
| 208 | - | } | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | term := &sizedSubagentIndicatorTerminal{ | |||
| 212 | - | alertTestTerminal: &alertTestTerminal{}, | |||
| 213 | - | cols: 80, | |||
| 214 | - | rows: 8, | |||
| 215 | - | } | |||
| 216 | - | interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor}) | |||
| 217 | - | interactive.rend.Resize(80, 8) | |||
| 218 | - | interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil) | |||
| 219 | - | interactive.redraw() | |||
| 220 | - | ||||
| 221 | - | output := stripANSIBytes(term.String()) | |||
| 222 | - | if !strings.Contains(output, "▌") { | |||
| 223 | - | t.Fatalf("input was pushed out of the fixed frame: %q", output) | |||
| 224 | - | } | |||
| 225 | - | if !strings.Contains(output, "more active subagents") { | |||
| 226 | - | t.Fatalf("omitted subagents were not summarized: %q", output) | |||
| 227 | - | } | |||
| 228 | - | if strings.Contains(term.String(), "\x1b[-") { | |||
| 229 | - | t.Fatalf("renderer received a negative cursor row: %q", term.String()) | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | func TestSubagentActivityLinesFollowInputPosition(t *testing.T) { | |||
| 234 | - | started := make(chan struct{}) | |||
| 235 | - | release := make(chan struct{}) | |||
| 236 | - | var releaseOnce sync.Once | |||
| 237 | - | releaseRunner := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 238 | - | supervisor := subagents.New(subagents.Config{ | |||
| 239 | - | Root: t.TempDir(), | |||
| 240 | - | RepoRoot: t.TempDir(), | |||
| 241 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 242 | - | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |||
| 243 | - | sink.Activity("thinking") | |||
| 244 | - | close(started) | |||
| 245 | - | select { | |||
| 246 | - | case <-release: | |||
| 247 | - | return nil | |||
| 248 | - | case <-ctx.Done(): | |||
| 249 | - | return ctx.Err() | |||
| 250 | - | } | |||
| 251 | - | }) | |||
| 252 | - | }, | |||
| 253 | - | }) | |||
| 254 | - | defer func() { | |||
| 255 | - | releaseRunner() | |||
| 256 | - | supervisor.StopAll() | |||
| 257 | - | }() | |||
| 258 | - | agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{ | |||
| 259 | - | Task: "inspect the input layout", | |||
| 260 | - | Subagent: "senior-engineer", | |||
| 261 | - | }) | |||
| 262 | - | if err != nil { | |||
| 263 | - | t.Fatal(err) | |||
| 264 | - | } | |||
| 265 | - | select { | |||
| 266 | - | case <-started: | |||
| 267 | - | case <-time.After(time.Second): | |||
| 268 | - | t.Fatal("subagent runner did not start") | |||
| 269 | - | } | |||
| 270 | - | ||||
| 271 | - | assertPosition := func(t *testing.T, position string, wantBelow bool) { | |||
| 272 | - | t.Helper() | |||
| 273 | - | term := &alertTestTerminal{} | |||
| 274 | - | interactive := NewInteractive(InteractiveConfig{ | |||
| 275 | - | Terminal: term, | |||
| 276 | - | Theme: tui.Dark, | |||
| 277 | - | Supervisor: supervisor, | |||
| 278 | - | TUISubagentPosition: position, | |||
| 279 | - | }) | |||
| 280 | - | interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) } | |||
| 281 | - | interactive.rend.Resize(80, 24) | |||
| 282 | - | interactive.redraw() | |||
| 283 | - | ||||
| 284 | - | output := stripANSIBytes(term.String()) | |||
| 285 | - | inputAt := strings.LastIndex(output, "▌") | |||
| 286 | - | subagentAt := strings.LastIndex(output, "senior-engineer") | |||
| 287 | - | if inputAt < 0 || subagentAt < 0 { | |||
| 288 | - | t.Fatalf("rendered input/subagent missing from %q", output) | |||
| 289 | - | } | |||
| 290 | - | if gotBelow := subagentAt > inputAt; gotBelow != wantBelow { | |||
| 291 | - | t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output) | |||
| 292 | - | } | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | t.Run("default below", func(t *testing.T) { | |||
| 296 | - | assertPosition(t, "", true) | |||
| 297 | - | }) | |||
| 298 | - | t.Run("configured above", func(t *testing.T) { | |||
| 299 | - | assertPosition(t, tui.SubagentPositionAboveInput, false) | |||
| 300 | - | }) | |||
| 301 | - | t.Run("dashboard editor suppresses inline animation", func(t *testing.T) { | |||
| 302 | - | term := &alertTestTerminal{} | |||
| 303 | - | interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor}) | |||
| 304 | - | interactive.rend.Resize(80, 24) | |||
| 305 | - | interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") | |||
| 306 | - | interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 307 | - | if interactive.subagentsDialog.NeedsTickRefresh() { | |||
| 308 | - | t.Fatal("subagent spawn editor should suppress dashboard ticks") | |||
| 309 | - | } | |||
| 310 | - | interactive.redraw() | |||
| 311 | - | if interactive.subagentActivityActive { | |||
| 312 | - | t.Fatal("hidden inline indicators should not request animation ticks") | |||
| 313 | - | } | |||
| 314 | - | }) | |||
| 315 | - | ||||
| 316 | - | releaseRunner() | |||
| 317 | - | agent.Wait() | |||
| 318 | - | term := &alertTestTerminal{} | |||
| 319 | - | interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor}) | |||
| 320 | - | interactive.rend.Resize(80, 24) | |||
| 321 | - | interactive.redraw() | |||
| 322 | - | if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") { | |||
| 323 | - | t.Fatalf("finished subagent indicator remained visible: %q", output) | |||
| 324 | - | } | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) { | |||
| 328 | - | release := make(chan struct{}) | |||
| 329 | - | supervisor := subagents.New(subagents.Config{ | |||
| 330 | - | Root: t.TempDir(), | |||
| 331 | - | RepoRoot: t.TempDir(), | |||
| 332 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 333 | - | return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error { | |||
| 334 | - | select { | |||
| 335 | - | case <-release: | |||
| 336 | - | return nil | |||
| 337 | - | case <-ctx.Done(): | |||
| 338 | - | return ctx.Err() | |||
| 339 | - | } | |||
| 340 | - | }) | |||
| 341 | - | }, | |||
| 342 | - | }) | |||
| 343 | - | defer func() { | |||
| 344 | - | close(release) | |||
| 345 | - | supervisor.StopAll() | |||
| 346 | - | }() | |||
| 347 | - | supervisor.SetActiveSession("session-a") | |||
| 348 | - | if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{ | |||
| 349 | - | Task: "inspect scope", | |||
| 350 | - | Subagent: "reviewer", | |||
| 351 | - | }); err != nil { | |||
| 352 | - | t.Fatal(err) | |||
| 353 | - | } | |||
| 354 | - | ||||
| 355 | - | interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor}) | |||
| 356 | - | if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 { | |||
| 357 | - | t.Fatalf("active rows in initial scope = %d, want 1", got) | |||
| 358 | - | } | |||
| 359 | - | interactive.SetSubagentSessionScope("session-b") | |||
| 360 | - | if got := supervisor.ActiveSession(); got != "session-b" { | |||
| 361 | - | t.Fatalf("active session = %q, want session-b", got) | |||
| 362 | - | } | |||
| 363 | - | select { | |||
| 364 | - | case <-interactive.dirty: | |||
| 365 | - | case <-time.After(time.Second): | |||
| 366 | - | t.Fatal("session scope change did not invalidate the interactive view") | |||
| 367 | - | } | |||
| 368 | - | if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 { | |||
| 369 | - | t.Fatalf("active rows after scope change = %d, want 0", got) | |||
| 370 | - | } | |||
| 371 | - | ||||
| 372 | - | interactive.SetSubagentSessionScope("session-a") | |||
| 373 | - | if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 { | |||
| 374 | - | t.Fatalf("active rows after returning to scope = %d, want 1", got) | |||
| 375 | - | } | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | func plainActivityLines(lines []string) []string { | 76 | func plainActivityLines(lines []string) []string { | ||
| 379 | plain := make([]string, len(lines)) | 77 | plain := make([]string, len(lines)) | ||
| 380 | for idx, line := range lines { | 78 | for idx, line := range lines { | ||
M packages/agent/modes/subagents_dialog.go +51 -38
| @@ -39,6 +39,7 @@ type subagentsDialog struct { | |||
| 39 | 39 | lineInput bool | |
| 40 | 40 | snapshot func() []subagents.AgentSnapshot | |
| 41 | 41 | allSnapshots func() []subagents.AgentSnapshot | |
| 42 | + | traceViews func() map[string]subagents.AgentTraceView | |
| 42 | 43 | showAll bool | |
| 43 | 44 | expandedID string | |
| 44 | 45 | stop func(id string) error | |
| @@ -139,6 +140,12 @@ func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) { | |||
| 139 | 140 | d.allSnapshots = fn | |
| 140 | 141 | } | |
| 141 | 142 | ||
| 143 | + | // SetTraceViews supplies the shared factual execution projection. Lifecycle | |
| 144 | + | // snapshots remain only for identity, transcript, and process-control wiring. | |
| 145 | + | func (d *subagentsDialog) SetTraceViews(fn func() map[string]subagents.AgentTraceView) { | |
| 146 | + | d.traceViews = fn | |
| 147 | + | } | |
| 148 | + | ||
| 142 | 149 | // SetMaxRows bounds the complete dashboard before the host composes its | |
| 143 | 150 | // bottom-sticky dialog band. Four rows are needed for the frame, column | |
| 144 | 151 | // heading, and selected agent; a non-positive value leaves it unbounded. | |
| @@ -337,7 +344,7 @@ func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowO | |||
| 337 | 344 | } | |
| 338 | 345 | ||
| 339 | 346 | row += popupRows // @-file-suggest popup, if active | |
| 340 | - | if agentIsBusy(a) { | |
| 347 | + | if a.Status == subagents.StatusRunning && d.send != nil && d.agentHasOpenOperation(a.ID) { | |
| 341 | 348 | row += 2 // blank + spinner row inserted above the editor | |
| 342 | 349 | } | |
| 343 | 350 | row++ // blank above editor (appendTranscriptEditor) | |
| @@ -1208,7 +1215,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||
| 1208 | 1215 | } | |
| 1209 | 1216 | ||
| 1210 | 1217 | // Column header for readability. | |
| 1211 | - | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY") | |
| 1218 | + | header := fmt.Sprintf(" %-30s %-26s %-8s %s", "OPERATION", "ID", "AGE", "TASK") | |
| 1212 | 1219 | out = append(out, th.FGColor(th.Muted, header)) | |
| 1213 | 1220 | out = append(out, d.renderVisibleRows(th, width)...) | |
| 1214 | 1221 | out = append(out, frameRule(th, width)) | |
| @@ -1236,7 +1243,7 @@ func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string { | |||
| 1236 | 1243 | nextRow := d.cursor | |
| 1237 | 1244 | for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ { | |
| 1238 | 1245 | r := d.rows[nextRow] | |
| 1239 | - | row := formatSupervisorRow(r, width-2) | |
| 1246 | + | row := formatSupervisorRow(r, d.traceView(r.ID), width-2) | |
| 1240 | 1247 | if nextRow == d.cursor { | |
| 1241 | 1248 | out = append(out, th.PadHighlight(" "+row, width)) | |
| 1242 | 1249 | } else { | |
| @@ -1297,7 +1304,7 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string { | |||
| 1297 | 1304 | frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width), | |
| 1298 | 1305 | " " + th.FGColor(th.Muted, "task: "+a.Task), | |
| 1299 | 1306 | " " + th.FGColor(th.Muted, "dir: "+a.Dir), | |
| 1300 | - | " " + th.FGColor(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | |
| 1307 | + | " " + th.FGColor(th.Muted, "observation: "+d.traceSummary(a.ID)), | |
| 1301 | 1308 | } | |
| 1302 | 1309 | if a.Model != "" { | |
| 1303 | 1310 | modelLine := "model: " + a.Model | |
| @@ -1413,12 +1420,9 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||
| 1413 | 1420 | } | |
| 1414 | 1421 | } | |
| 1415 | 1422 | ||
| 1416 | - | // Busy-spinner row, /btw-style. Shown whenever the agent's | |
| 1417 | - | // current activity isn't "idle" — so "thinking", "tool: foo", | |
| 1418 | - | // "starting", etc. all animate. The spinner is stopped (and the | |
| 1419 | - | // row hidden) once the agent reports idle, mirroring the main | |
| 1420 | - | // chat's busy line. | |
| 1421 | - | if agentIsBusy(a) { | |
| 1423 | + | // Busy-spinner row, /btw-style. It is shown only for a factual open | |
| 1424 | + | // trace operation, never for a lifecycle or heartbeat label. | |
| 1425 | + | if d.agentHasOpenOperation(a.ID) { | |
| 1422 | 1426 | if d.transcriptSpin == nil { | |
| 1423 | 1427 | d.transcriptSpin = newSpinner(th) | |
| 1424 | 1428 | d.transcriptSpin.Start() | |
| @@ -1428,7 +1432,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||
| 1428 | 1432 | out = append(out, "") | |
| 1429 | 1433 | prefix := fmt.Sprintf("%s %s, %s", | |
| 1430 | 1434 | th.FGColor(th.Assistant, d.transcriptSpin.Frame()), | |
| 1431 | - | th.FGColor(th.Assistant, a.Activity), | |
| 1435 | + | th.FGColor(th.Assistant, d.traceSummary(a.ID)), | |
| 1432 | 1436 | th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()), | |
| 1433 | 1437 | ) | |
| 1434 | 1438 | out = append(out, " "+prefix) | |
| @@ -1448,23 +1452,22 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||
| 1448 | 1452 | return out | |
| 1449 | 1453 | } | |
| 1450 | 1454 | ||
| 1451 | - | // agentIsBusy reports whether the agent is currently mid-turn and | |
| 1452 | - | // should render a spinner. Conservative: only known idle markers | |
| 1453 | - | // suppress the spinner so transient / unfamiliar activity strings | |
| 1454 | - | // still animate. Detached / done / failed / killed never animate — | |
| 1455 | - | // they aren't actively producing output. | |
| 1456 | - | func agentIsBusy(a *subagents.AgentSnapshot) bool { | |
| 1457 | - | if a == nil { | |
| 1458 | - | return false | |
| 1455 | + | func (d *subagentsDialog) agentHasOpenOperation(id string) bool { | |
| 1456 | + | return len(d.traceView(id).OpenOperations) != 0 | |
| 1457 | + | } | |
| 1458 | + | ||
| 1459 | + | func (d *subagentsDialog) traceSummary(id string) string { | |
| 1460 | + | view := d.traceView(id) | |
| 1461 | + | if view.Terminal != "" { | |
| 1462 | + | return view.Terminal | |
| 1459 | 1463 | } | |
| 1460 | - | if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending { | |
| 1461 | - | return false | |
| 1464 | + | if len(view.OpenOperations) != 0 { | |
| 1465 | + | return view.OpenOperations[0].Label() | |
| 1462 | 1466 | } | |
| 1463 | - | switch strings.TrimSpace(a.Activity) { | |
| 1464 | - | case "", "idle": | |
| 1465 | - | return false | |
| 1467 | + | if view.LastEvent.Type != "" { | |
| 1468 | + | return "last event " + view.LastEvent.Type | |
| 1466 | 1469 | } | |
| 1467 | - | return true | |
| 1470 | + | return "no observable operation" | |
| 1468 | 1471 | } | |
| 1469 | 1472 | ||
| 1470 | 1473 | // renderSupervisorTranscriptBlocks converts the agent's flat transcript | |
| @@ -1615,28 +1618,38 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri | |||
| 1615 | 1618 | return out | |
| 1616 | 1619 | } | |
| 1617 | 1620 | ||
| 1621 | + | // traceView retrieves the latest shared factual trace projection for id. | |
| 1622 | + | func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |
| 1623 | + | if d != nil && d.traceViews != nil { | |
| 1624 | + | return d.traceViews()[id] | |
| 1625 | + | } | |
| 1626 | + | return subagents.AgentTraceView{AgentID: id} | |
| 1627 | + | } | |
| 1628 | + | ||
| 1618 | 1629 | // formatSupervisorRow is the one-line summary shown per agent. | |
| 1619 | 1630 | // | |
| 1620 | - | // Layout (fixed-width columns, then free-form activity): | |
| 1631 | + | // Layout (fixed-width columns, then free-form task): | |
| 1621 | 1632 | // | |
| 1622 | - | // STATUS ID AGE ACTIVITY | |
| 1623 | - | // ● run fix-login-12345 3m editing main.go | |
| 1624 | - | // ✓ done write-tests-67890 1h done | |
| 1625 | - | func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string { | |
| 1626 | - | status := statusLabel(r.Status) | |
| 1627 | - | if r.ProcessState != "" && r.TurnState != "" { | |
| 1628 | - | status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18) | |
| 1633 | + | // OPERATION ID AGE TASK | |
| 1634 | + | // tool open 3m fix-login-12345 3m inspect main.go | |
| 1635 | + | // completed write-tests-67890 1h add coverage | |
| 1636 | + | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | |
| 1637 | + | fact := "no observable operation" | |
| 1638 | + | if view.Terminal != "" { | |
| 1639 | + | fact = view.Terminal | |
| 1640 | + | } else if len(view.OpenOperations) != 0 { | |
| 1641 | + | operation := view.OpenOperations[0] | |
| 1642 | + | fact = operation.Label() + " " + formatAge(operation.StartedAt) | |
| 1643 | + | } else if view.LastEvent.Type != "" { | |
| 1644 | + | fact = "last " + view.LastEvent.Type + " " + formatAge(view.LastEvent.Timestamp) | |
| 1629 | 1645 | } | |
| 1630 | 1646 | age := formatAge(r.Started) | |
| 1631 | - | left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | |
| 1647 | + | left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age) | |
| 1632 | 1648 | room := maxWidth - len([]rune(left)) | |
| 1633 | 1649 | if room < 10 { | |
| 1634 | 1650 | room = 10 | |
| 1635 | 1651 | } | |
| 1636 | - | act := strings.ReplaceAll(r.Activity, "\n", " ") | |
| 1637 | - | if act == "" { | |
| 1638 | - | act = r.Task | |
| 1639 | - | } | |
| 1652 | + | act := strings.ReplaceAll(r.Task, "\n", " ") | |
| 1640 | 1653 | if len([]rune(act)) > room { | |
| 1641 | 1654 | act = string([]rune(act)[:room-3]) + "..." | |
| 1642 | 1655 | } | |
| @@ -39,6 +39,7 @@ type subagentsDialog struct { | |||||
| 39 | lineInput bool | 39 | lineInput bool | ||
| 40 | snapshot func() []subagents.AgentSnapshot | 40 | snapshot func() []subagents.AgentSnapshot | ||
| 41 | allSnapshots func() []subagents.AgentSnapshot | 41 | allSnapshots func() []subagents.AgentSnapshot | ||
| 42 | + | traceViews func() map[string]subagents.AgentTraceView | |||
| 42 | showAll bool | 43 | showAll bool | ||
| 43 | expandedID string | 44 | expandedID string | ||
| 44 | stop func(id string) error | 45 | stop func(id string) error | ||
| @@ -139,6 +140,12 @@ func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) { | |||||
| 139 | d.allSnapshots = fn | 140 | d.allSnapshots = fn | ||
| 140 | } | 141 | } | ||
| 141 | 142 | ||||
| 143 | + | // SetTraceViews supplies the shared factual execution projection. Lifecycle | |||
| 144 | + | // snapshots remain only for identity, transcript, and process-control wiring. | |||
| 145 | + | func (d *subagentsDialog) SetTraceViews(fn func() map[string]subagents.AgentTraceView) { | |||
| 146 | + | d.traceViews = fn | |||
| 147 | + | } | |||
| 148 | + | ||||
| 142 | // SetMaxRows bounds the complete dashboard before the host composes its | 149 | // SetMaxRows bounds the complete dashboard before the host composes its | ||
| 143 | // bottom-sticky dialog band. Four rows are needed for the frame, column | 150 | // bottom-sticky dialog band. Four rows are needed for the frame, column | ||
| 144 | // heading, and selected agent; a non-positive value leaves it unbounded. | 151 | // heading, and selected agent; a non-positive value leaves it unbounded. | ||
| @@ -337,7 +344,7 @@ func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowO | |||||
| 337 | } | 344 | } | ||
| 338 | 345 | ||||
| 339 | row += popupRows // @-file-suggest popup, if active | 346 | row += popupRows // @-file-suggest popup, if active | ||
| 340 | - | if agentIsBusy(a) { | 347 | + | if a.Status == subagents.StatusRunning && d.send != nil && d.agentHasOpenOperation(a.ID) { |
| 341 | row += 2 // blank + spinner row inserted above the editor | 348 | row += 2 // blank + spinner row inserted above the editor | ||
| 342 | } | 349 | } | ||
| 343 | row++ // blank above editor (appendTranscriptEditor) | 350 | row++ // blank above editor (appendTranscriptEditor) | ||
| @@ -1208,7 +1215,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||||
| 1208 | } | 1215 | } | ||
| 1209 | 1216 | ||||
| 1210 | // Column header for readability. | 1217 | // Column header for readability. | ||
| 1211 | - | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY") | 1218 | + | header := fmt.Sprintf(" %-30s %-26s %-8s %s", "OPERATION", "ID", "AGE", "TASK") |
| 1212 | out = append(out, th.FGColor(th.Muted, header)) | 1219 | out = append(out, th.FGColor(th.Muted, header)) | ||
| 1213 | out = append(out, d.renderVisibleRows(th, width)...) | 1220 | out = append(out, d.renderVisibleRows(th, width)...) | ||
| 1214 | out = append(out, frameRule(th, width)) | 1221 | out = append(out, frameRule(th, width)) | ||
| @@ -1236,7 +1243,7 @@ func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string { | |||||
| 1236 | nextRow := d.cursor | 1243 | nextRow := d.cursor | ||
| 1237 | for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ { | 1244 | for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ { | ||
| 1238 | r := d.rows[nextRow] | 1245 | r := d.rows[nextRow] | ||
| 1239 | - | row := formatSupervisorRow(r, width-2) | 1246 | + | row := formatSupervisorRow(r, d.traceView(r.ID), width-2) |
| 1240 | if nextRow == d.cursor { | 1247 | if nextRow == d.cursor { | ||
| 1241 | out = append(out, th.PadHighlight(" "+row, width)) | 1248 | out = append(out, th.PadHighlight(" "+row, width)) | ||
| 1242 | } else { | 1249 | } else { | ||
| @@ -1297,7 +1304,7 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string { | |||||
| 1297 | frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width), | 1304 | frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width), | ||
| 1298 | " " + th.FGColor(th.Muted, "task: "+a.Task), | 1305 | " " + th.FGColor(th.Muted, "task: "+a.Task), | ||
| 1299 | " " + th.FGColor(th.Muted, "dir: "+a.Dir), | 1306 | " " + th.FGColor(th.Muted, "dir: "+a.Dir), | ||
| 1300 | - | " " + th.FGColor(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | 1307 | + | " " + th.FGColor(th.Muted, "observation: "+d.traceSummary(a.ID)), |
| 1301 | } | 1308 | } | ||
| 1302 | if a.Model != "" { | 1309 | if a.Model != "" { | ||
| 1303 | modelLine := "model: " + a.Model | 1310 | modelLine := "model: " + a.Model | ||
| @@ -1413,12 +1420,9 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||||
| 1413 | } | 1420 | } | ||
| 1414 | } | 1421 | } | ||
| 1415 | 1422 | ||||
| 1416 | - | // Busy-spinner row, /btw-style. Shown whenever the agent's | 1423 | + | // Busy-spinner row, /btw-style. It is shown only for a factual open |
| 1417 | - | // current activity isn't "idle" — so "thinking", "tool: foo", | 1424 | + | // trace operation, never for a lifecycle or heartbeat label. |
| 1418 | - | // "starting", etc. all animate. The spinner is stopped (and the | 1425 | + | if d.agentHasOpenOperation(a.ID) { |
| 1419 | - | // row hidden) once the agent reports idle, mirroring the main | |||
| 1420 | - | // chat's busy line. | |||
| 1421 | - | if agentIsBusy(a) { | |||
| 1422 | if d.transcriptSpin == nil { | 1426 | if d.transcriptSpin == nil { | ||
| 1423 | d.transcriptSpin = newSpinner(th) | 1427 | d.transcriptSpin = newSpinner(th) | ||
| 1424 | d.transcriptSpin.Start() | 1428 | d.transcriptSpin.Start() | ||
| @@ -1428,7 +1432,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||||
| 1428 | out = append(out, "") | 1432 | out = append(out, "") | ||
| 1429 | prefix := fmt.Sprintf("%s %s, %s", | 1433 | prefix := fmt.Sprintf("%s %s, %s", | ||
| 1430 | th.FGColor(th.Assistant, d.transcriptSpin.Frame()), | 1434 | th.FGColor(th.Assistant, d.transcriptSpin.Frame()), | ||
| 1431 | - | th.FGColor(th.Assistant, a.Activity), | 1435 | + | th.FGColor(th.Assistant, d.traceSummary(a.ID)), |
| 1432 | th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()), | 1436 | th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()), | ||
| 1433 | ) | 1437 | ) | ||
| 1434 | out = append(out, " "+prefix) | 1438 | out = append(out, " "+prefix) | ||
| @@ -1448,23 +1452,22 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid | |||||
| 1448 | return out | 1452 | return out | ||
| 1449 | } | 1453 | } | ||
| 1450 | 1454 | ||||
| 1451 | - | // agentIsBusy reports whether the agent is currently mid-turn and | 1455 | + | func (d *subagentsDialog) agentHasOpenOperation(id string) bool { |
| 1452 | - | // should render a spinner. Conservative: only known idle markers | 1456 | + | return len(d.traceView(id).OpenOperations) != 0 |
| 1453 | - | // suppress the spinner so transient / unfamiliar activity strings | 1457 | + | } |
| 1454 | - | // still animate. Detached / done / failed / killed never animate — | 1458 | + | |
| 1455 | - | // they aren't actively producing output. | 1459 | + | func (d *subagentsDialog) traceSummary(id string) string { |
| 1456 | - | func agentIsBusy(a *subagents.AgentSnapshot) bool { | 1460 | + | view := d.traceView(id) |
| 1457 | - | if a == nil { | 1461 | + | if view.Terminal != "" { |
| 1458 | - | return false | 1462 | + | return view.Terminal |
| 1459 | } | 1463 | } | ||
| 1460 | - | if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending { | 1464 | + | if len(view.OpenOperations) != 0 { |
| 1461 | - | return false | 1465 | + | return view.OpenOperations[0].Label() |
| 1462 | } | 1466 | } | ||
| 1463 | - | switch strings.TrimSpace(a.Activity) { | 1467 | + | if view.LastEvent.Type != "" { |
| 1464 | - | case "", "idle": | 1468 | + | return "last event " + view.LastEvent.Type |
| 1465 | - | return false | |||
| 1466 | } | 1469 | } | ||
| 1467 | - | return true | 1470 | + | return "no observable operation" |
| 1468 | } | 1471 | } | ||
| 1469 | 1472 | ||||
| 1470 | // renderSupervisorTranscriptBlocks converts the agent's flat transcript | 1473 | // renderSupervisorTranscriptBlocks converts the agent's flat transcript | ||
| @@ -1615,28 +1618,38 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri | |||||
| 1615 | return out | 1618 | return out | ||
| 1616 | } | 1619 | } | ||
| 1617 | 1620 | ||||
| 1621 | + | // traceView retrieves the latest shared factual trace projection for id. | |||
| 1622 | + | func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |||
| 1623 | + | if d != nil && d.traceViews != nil { | |||
| 1624 | + | return d.traceViews()[id] | |||
| 1625 | + | } | |||
| 1626 | + | return subagents.AgentTraceView{AgentID: id} | |||
| 1627 | + | } | |||
| 1628 | + | ||||
| 1618 | // formatSupervisorRow is the one-line summary shown per agent. | 1629 | // formatSupervisorRow is the one-line summary shown per agent. | ||
| 1619 | // | 1630 | // | ||
| 1620 | - | // Layout (fixed-width columns, then free-form activity): | 1631 | + | // Layout (fixed-width columns, then free-form task): |
| 1621 | // | 1632 | // | ||
| 1622 | - | // STATUS ID AGE ACTIVITY | 1633 | + | // OPERATION ID AGE TASK |
| 1623 | - | // ● run fix-login-12345 3m editing main.go | 1634 | + | // tool open 3m fix-login-12345 3m inspect main.go |
| 1624 | - | // ✓ done write-tests-67890 1h done | 1635 | + | // completed write-tests-67890 1h add coverage |
| 1625 | - | func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string { | 1636 | + | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { |
| 1626 | - | status := statusLabel(r.Status) | 1637 | + | fact := "no observable operation" |
| 1627 | - | if r.ProcessState != "" && r.TurnState != "" { | 1638 | + | if view.Terminal != "" { |
| 1628 | - | status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18) | 1639 | + | fact = view.Terminal |
| 1640 | + | } else if len(view.OpenOperations) != 0 { | |||
| 1641 | + | operation := view.OpenOperations[0] | |||
| 1642 | + | fact = operation.Label() + " " + formatAge(operation.StartedAt) | |||
| 1643 | + | } else if view.LastEvent.Type != "" { | |||
| 1644 | + | fact = "last " + view.LastEvent.Type + " " + formatAge(view.LastEvent.Timestamp) | |||
| 1629 | } | 1645 | } | ||
| 1630 | age := formatAge(r.Started) | 1646 | age := formatAge(r.Started) | ||
| 1631 | - | left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | 1647 | + | left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age) |
| 1632 | room := maxWidth - len([]rune(left)) | 1648 | room := maxWidth - len([]rune(left)) | ||
| 1633 | if room < 10 { | 1649 | if room < 10 { | ||
| 1634 | room = 10 | 1650 | room = 10 | ||
| 1635 | } | 1651 | } | ||
| 1636 | - | act := strings.ReplaceAll(r.Activity, "\n", " ") | 1652 | + | act := strings.ReplaceAll(r.Task, "\n", " ") |
| 1637 | - | if act == "" { | |||
| 1638 | - | act = r.Task | |||
| 1639 | - | } | |||
| 1640 | if len([]rune(act)) > room { | 1653 | if len([]rune(act)) > room { | ||
| 1641 | act = string([]rune(act)[:room-3]) + "..." | 1654 | act = string([]rune(act)[:room-3]) + "..." | ||
| 1642 | } | 1655 | } | ||
M packages/agent/modes/subagents_dialog_test.go +18 -11
| @@ -40,13 +40,19 @@ func TestSubagentsDialogEmptyState(t *testing.T) { | |||
| 40 | 40 | func TestSubagentsDialogRendersRows(t *testing.T) { | |
| 41 | 41 | now := time.Now() | |
| 42 | 42 | rows := []subagents.AgentSnapshot{ | |
| 43 | - | {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now}, | |
| 44 | - | {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now}, | |
| 43 | + | {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Started: now}, | |
| 44 | + | {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Started: now}, | |
| 45 | 45 | } | |
| 46 | 46 | d := newSubagentsDialog() | |
| 47 | + | d.SetTraceViews(func() map[string]subagents.AgentTraceView { | |
| 48 | + | return map[string]subagents.AgentTraceView{ | |
| 49 | + | "alpha-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now}}}, | |
| 50 | + | "beta-2": {Terminal: "completed"}, | |
| 51 | + | } | |
| 52 | + | }) | |
| 47 | 53 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 48 | 54 | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 49 | - | for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { | |
| 55 | + | for _, want := range []string{"alpha-1", "beta-2", "tool open", "completed", "OPERATION", "TASK"} { | |
| 50 | 56 | if !strings.Contains(out, want) { | |
| 51 | 57 | t.Fatalf("render missing %q:\n%s", want, out) | |
| 52 | 58 | } | |
| @@ -108,7 +114,7 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) { | |||
| 108 | 114 | // Esc should bring us back to the list view. | |
| 109 | 115 | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 110 | 116 | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 111 | - | if !strings.Contains(out, "STATUS") { | |
| 117 | + | if !strings.Contains(out, "OPERATION") { | |
| 112 | 118 | t.Fatalf("did not return to list view:\n%s", out) | |
| 113 | 119 | } | |
| 114 | 120 | } | |
| @@ -1299,6 +1305,8 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { | |||
| 1299 | 1305 | }} | |
| 1300 | 1306 | snap := func() []subagents.AgentSnapshot { return cur } | |
| 1301 | 1307 | d := newSubagentsDialog() | |
| 1308 | + | views := map[string]subagents.AgentTraceView{"agent-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: time.Now()}}}} | |
| 1309 | + | d.SetTraceViews(func() map[string]subagents.AgentTraceView { return views }) | |
| 1302 | 1310 | d.Open(snap, nil, nil, nil, | |
| 1303 | 1311 | func(string, string) error { return nil }, | |
| 1304 | 1312 | nil, "") | |
| @@ -1306,22 +1314,21 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { | |||
| 1306 | 1314 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1307 | 1315 | ||
| 1308 | 1316 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1309 | - | if !strings.Contains(out, "thinking") { | |
| 1310 | - | t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out) | |
| 1317 | + | if !strings.Contains(out, "tool open") { | |
| 1318 | + | t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out) | |
| 1311 | 1319 | } | |
| 1312 | 1320 | if d.transcriptSpin == nil { | |
| 1313 | 1321 | t.Error("spinner not initialised while agent is busy") | |
| 1314 | 1322 | } | |
| 1315 | 1323 | ||
| 1316 | - | // Flip the activity to idle: spinner should vanish. | |
| 1317 | - | cur[0].Activity = "idle" | |
| 1324 | + | // Close the operation: spinner should vanish. | |
| 1325 | + | views["agent-1"] = subagents.AgentTraceView{LastEvent: subagents.TraceEvent{Type: "tool.finished", Timestamp: time.Now()}} | |
| 1318 | 1326 | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1319 | 1327 | if d.transcriptSpin != nil { | |
| 1320 | 1328 | t.Error("spinner stayed live after agent reported idle") | |
| 1321 | 1329 | } | |
| 1322 | - | // The bare word "thinking" must not appear anymore. | |
| 1323 | - | if strings.Contains(out, "· thinking, ") { | |
| 1324 | - | t.Errorf("busy line still rendered when agent is idle:\n%s", out) | |
| 1330 | + | if strings.Contains(out, "tool open, ") { | |
| 1331 | + | t.Errorf("busy line still rendered after operation finished:\n%s", out) | |
| 1325 | 1332 | } | |
| 1326 | 1333 | } | |
| 1327 | 1334 | ||
| @@ -40,13 +40,19 @@ func TestSubagentsDialogEmptyState(t *testing.T) { | |||||
| 40 | func TestSubagentsDialogRendersRows(t *testing.T) { | 40 | func TestSubagentsDialogRendersRows(t *testing.T) { | ||
| 41 | now := time.Now() | 41 | now := time.Now() | ||
| 42 | rows := []subagents.AgentSnapshot{ | 42 | rows := []subagents.AgentSnapshot{ | ||
| 43 | - | {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now}, | 43 | + | {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Started: now}, |
| 44 | - | {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now}, | 44 | + | {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Started: now}, |
| 45 | } | 45 | } | ||
| 46 | d := newSubagentsDialog() | 46 | d := newSubagentsDialog() | ||
| 47 | + | d.SetTraceViews(func() map[string]subagents.AgentTraceView { | |||
| 48 | + | return map[string]subagents.AgentTraceView{ | |||
| 49 | + | "alpha-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now}}}, | |||
| 50 | + | "beta-2": {Terminal: "completed"}, | |||
| 51 | + | } | |||
| 52 | + | }) | |||
| 47 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | 53 | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | ||
| 48 | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | 54 | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | ||
| 49 | - | for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { | 55 | + | for _, want := range []string{"alpha-1", "beta-2", "tool open", "completed", "OPERATION", "TASK"} { |
| 50 | if !strings.Contains(out, want) { | 56 | if !strings.Contains(out, want) { | ||
| 51 | t.Fatalf("render missing %q:\n%s", want, out) | 57 | t.Fatalf("render missing %q:\n%s", want, out) | ||
| 52 | } | 58 | } | ||
| @@ -108,7 +114,7 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) { | |||||
| 108 | // Esc should bring us back to the list view. | 114 | // Esc should bring us back to the list view. | ||
| 109 | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | 115 | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | ||
| 110 | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | 116 | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | ||
| 111 | - | if !strings.Contains(out, "STATUS") { | 117 | + | if !strings.Contains(out, "OPERATION") { |
| 112 | t.Fatalf("did not return to list view:\n%s", out) | 118 | t.Fatalf("did not return to list view:\n%s", out) | ||
| 113 | } | 119 | } | ||
| 114 | } | 120 | } | ||
| @@ -1299,6 +1305,8 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { | |||||
| 1299 | }} | 1305 | }} | ||
| 1300 | snap := func() []subagents.AgentSnapshot { return cur } | 1306 | snap := func() []subagents.AgentSnapshot { return cur } | ||
| 1301 | d := newSubagentsDialog() | 1307 | d := newSubagentsDialog() | ||
| 1308 | + | views := map[string]subagents.AgentTraceView{"agent-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: time.Now()}}}} | |||
| 1309 | + | d.SetTraceViews(func() map[string]subagents.AgentTraceView { return views }) | |||
| 1302 | d.Open(snap, nil, nil, nil, | 1310 | d.Open(snap, nil, nil, nil, | ||
| 1303 | func(string, string) error { return nil }, | 1311 | func(string, string) error { return nil }, | ||
| 1304 | nil, "") | 1312 | nil, "") | ||
| @@ -1306,22 +1314,21 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { | |||||
| 1306 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 1314 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
| 1307 | 1315 | ||||
| 1308 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | 1316 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | ||
| 1309 | - | if !strings.Contains(out, "thinking") { | 1317 | + | if !strings.Contains(out, "tool open") { |
| 1310 | - | t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out) | 1318 | + | t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out) |
| 1311 | } | 1319 | } | ||
| 1312 | if d.transcriptSpin == nil { | 1320 | if d.transcriptSpin == nil { | ||
| 1313 | t.Error("spinner not initialised while agent is busy") | 1321 | t.Error("spinner not initialised while agent is busy") | ||
| 1314 | } | 1322 | } | ||
| 1315 | 1323 | ||||
| 1316 | - | // Flip the activity to idle: spinner should vanish. | 1324 | + | // Close the operation: spinner should vanish. |
| 1317 | - | cur[0].Activity = "idle" | 1325 | + | views["agent-1"] = subagents.AgentTraceView{LastEvent: subagents.TraceEvent{Type: "tool.finished", Timestamp: time.Now()}} |
| 1318 | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | 1326 | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | ||
| 1319 | if d.transcriptSpin != nil { | 1327 | if d.transcriptSpin != nil { | ||
| 1320 | t.Error("spinner stayed live after agent reported idle") | 1328 | t.Error("spinner stayed live after agent reported idle") | ||
| 1321 | } | 1329 | } | ||
| 1322 | - | // The bare word "thinking" must not appear anymore. | 1330 | + | if strings.Contains(out, "tool open, ") { |
| 1323 | - | if strings.Contains(out, "· thinking, ") { | 1331 | + | t.Errorf("busy line still rendered after operation finished:\n%s", out) |
| 1324 | - | t.Errorf("busy line still rendered when agent is idle:\n%s", out) | |||
| 1325 | } | 1332 | } | ||
| 1326 | } | 1333 | } | ||
| 1327 | 1334 | ||||
M packages/agent/modes/subagents_slash.go +1 -0
| @@ -78,6 +78,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 78 | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | |
| 79 | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |
| 80 | 80 | i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions) | |
| 81 | + | i.subagentsDialog.SetTraceViews(i.cfg.Supervisor.TraceViews) | |
| 81 | 82 | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | |
| 82 | 83 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |
| 83 | 84 | if i.cfg.LoggedInProviders != nil { | |
| @@ -78,6 +78,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | ||
| 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | ||
| 80 | i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions) | 80 | i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions) | ||
| 81 | + | i.subagentsDialog.SetTraceViews(i.cfg.Supervisor.TraceViews) | |||
| 81 | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | 82 | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | ||
| 82 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | 83 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | ||
| 83 | if i.cfg.LoggedInProviders != nil { | 84 | if i.cfg.LoggedInProviders != nil { | ||
M packages/agent/subagent_runtime.go +31 -5
| @@ -2,6 +2,9 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 5 | 8 | "path/filepath" | |
| 6 | 9 | "strings" | |
| 7 | 10 | "sync" | |
| @@ -67,10 +70,12 @@ type subagentRuntimeConfiguration struct { | |||
| 67 | 70 | // assemble a subagentRuntime. The active selection callbacks are optional; | |
| 68 | 71 | // when omitted, the initial Provider/Model/Reasoning values are used. | |
| 69 | 72 | type subagentRuntimeConfig struct { | |
| 70 | - | Context context.Context | |
| 71 | - | Args Args | |
| 72 | - | Root string | |
| 73 | - | RepoRoot string | |
| 73 | + | Context context.Context | |
| 74 | + | Args Args | |
| 75 | + | Root string | |
| 76 | + | RepoRoot string | |
| 77 | + | TraceDir string | |
| 78 | + | TraceMode subagents.TraceMode | |
| 74 | 79 | ||
| 75 | 80 | Provider string | |
| 76 | 81 | Model string | |
| @@ -98,6 +103,23 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||
| 98 | 103 | if cfg.Context == nil { | |
| 99 | 104 | cfg.Context = context.Background() | |
| 100 | 105 | } | |
| 106 | + | if cfg.TraceDir == "" { | |
| 107 | + | cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR")) | |
| 108 | + | } | |
| 109 | + | if cfg.TraceMode == "" { | |
| 110 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) { | |
| 111 | + | case "", string(subagents.TraceModeNormal): | |
| 112 | + | cfg.TraceMode = subagents.TraceModeNormal | |
| 113 | + | case string(subagents.TraceModeDetailed): | |
| 114 | + | cfg.TraceMode = subagents.TraceModeDetailed | |
| 115 | + | default: | |
| 116 | + | fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n") | |
| 117 | + | cfg.TraceMode = subagents.TraceModeNormal | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | if cfg.TraceDir == "" { | |
| 121 | + | cfg.TraceMode = subagents.TraceModeNormal | |
| 122 | + | } | |
| 101 | 123 | rt := &subagentRuntime{ | |
| 102 | 124 | args: cfg.Args, | |
| 103 | 125 | root: cfg.Root, | |
| @@ -159,6 +181,8 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||
| 159 | 181 | Context: cfg.Context, | |
| 160 | 182 | Root: root, | |
| 161 | 183 | RepoRoot: cfg.RepoRoot, | |
| 184 | + | TraceDir: cfg.TraceDir, | |
| 185 | + | TraceMode: cfg.TraceMode, | |
| 162 | 186 | Provider: rt.currentProvider(), | |
| 163 | 187 | FastMode: cfg.FastMode, | |
| 164 | 188 | WebSearchPolicy: cfg.WebSearchPolicy, | |
| @@ -449,7 +473,9 @@ func (rt *subagentRuntime) Close(ctx context.Context) error { | |||
| 449 | 473 | if rt.closed { | |
| 450 | 474 | return nil | |
| 451 | 475 | } | |
| 452 | - | if err := rt.supervisor.StopAllContext(ctx); err != nil { | |
| 476 | + | stopErr := rt.supervisor.StopAllContext(ctx) | |
| 477 | + | closeErr := rt.supervisor.CloseContext(ctx) | |
| 478 | + | if err := errors.Join(stopErr, closeErr); err != nil { | |
| 453 | 479 | return err | |
| 454 | 480 | } | |
| 455 | 481 | rt.closed = true | |
| @@ -2,6 +2,9 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 5 | "path/filepath" | 8 | "path/filepath" | ||
| 6 | "strings" | 9 | "strings" | ||
| 7 | "sync" | 10 | "sync" | ||
| @@ -67,10 +70,12 @@ type subagentRuntimeConfiguration struct { | |||||
| 67 | // assemble a subagentRuntime. The active selection callbacks are optional; | 70 | // assemble a subagentRuntime. The active selection callbacks are optional; | ||
| 68 | // when omitted, the initial Provider/Model/Reasoning values are used. | 71 | // when omitted, the initial Provider/Model/Reasoning values are used. | ||
| 69 | type subagentRuntimeConfig struct { | 72 | type subagentRuntimeConfig struct { | ||
| 70 | - | Context context.Context | 73 | + | Context context.Context |
| 71 | - | Args Args | 74 | + | Args Args |
| 72 | - | Root string | 75 | + | Root string |
| 73 | - | RepoRoot string | 76 | + | RepoRoot string |
| 77 | + | TraceDir string | |||
| 78 | + | TraceMode subagents.TraceMode | |||
| 74 | 79 | ||||
| 75 | Provider string | 80 | Provider string | ||
| 76 | Model string | 81 | Model string | ||
| @@ -98,6 +103,23 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||||
| 98 | if cfg.Context == nil { | 103 | if cfg.Context == nil { | ||
| 99 | cfg.Context = context.Background() | 104 | cfg.Context = context.Background() | ||
| 100 | } | 105 | } | ||
| 106 | + | if cfg.TraceDir == "" { | |||
| 107 | + | cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR")) | |||
| 108 | + | } | |||
| 109 | + | if cfg.TraceMode == "" { | |||
| 110 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) { | |||
| 111 | + | case "", string(subagents.TraceModeNormal): | |||
| 112 | + | cfg.TraceMode = subagents.TraceModeNormal | |||
| 113 | + | case string(subagents.TraceModeDetailed): | |||
| 114 | + | cfg.TraceMode = subagents.TraceModeDetailed | |||
| 115 | + | default: | |||
| 116 | + | fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n") | |||
| 117 | + | cfg.TraceMode = subagents.TraceModeNormal | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | if cfg.TraceDir == "" { | |||
| 121 | + | cfg.TraceMode = subagents.TraceModeNormal | |||
| 122 | + | } | |||
| 101 | rt := &subagentRuntime{ | 123 | rt := &subagentRuntime{ | ||
| 102 | args: cfg.Args, | 124 | args: cfg.Args, | ||
| 103 | root: cfg.Root, | 125 | root: cfg.Root, | ||
| @@ -159,6 +181,8 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||||
| 159 | Context: cfg.Context, | 181 | Context: cfg.Context, | ||
| 160 | Root: root, | 182 | Root: root, | ||
| 161 | RepoRoot: cfg.RepoRoot, | 183 | RepoRoot: cfg.RepoRoot, | ||
| 184 | + | TraceDir: cfg.TraceDir, | |||
| 185 | + | TraceMode: cfg.TraceMode, | |||
| 162 | Provider: rt.currentProvider(), | 186 | Provider: rt.currentProvider(), | ||
| 163 | FastMode: cfg.FastMode, | 187 | FastMode: cfg.FastMode, | ||
| 164 | WebSearchPolicy: cfg.WebSearchPolicy, | 188 | WebSearchPolicy: cfg.WebSearchPolicy, | ||
| @@ -449,7 +473,9 @@ func (rt *subagentRuntime) Close(ctx context.Context) error { | |||||
| 449 | if rt.closed { | 473 | if rt.closed { | ||
| 450 | return nil | 474 | return nil | ||
| 451 | } | 475 | } | ||
| 452 | - | if err := rt.supervisor.StopAllContext(ctx); err != nil { | 476 | + | stopErr := rt.supervisor.StopAllContext(ctx) |
| 477 | + | closeErr := rt.supervisor.CloseContext(ctx) | |||
| 478 | + | if err := errors.Join(stopErr, closeErr); err != nil { | |||
| 453 | return err | 479 | return err | ||
| 454 | } | 480 | } | ||
| 455 | rt.closed = true | 481 | rt.closed = true | ||
M packages/agent/subagent_worker.go +9 -96
| @@ -27,10 +27,8 @@ import ( | |||
| 27 | 27 | // initial positional task (if any) is the first turn; subsequent | |
| 28 | 28 | // turns arrive through the inbox unix socket at args.SubagentWorker. | |
| 29 | 29 | // | |
| 30 | - | // - Output: every emitted JSON line is also mirrored verbatim into | |
| 31 | - | // events.jsonl (see ZUT_SUBAGENT_EVENT_LOG) so a separate zut | |
| 32 | - | // process can /subagents open this agent and replay its full history | |
| 33 | - | // even after the parent that spawned us is long gone. | |
| 30 | + | // - Output: every emitted JSON line goes to the parent supervisor, which | |
| 31 | + | // owns the optional execution trace. Workers never persist trace data. | |
| 34 | 32 | // | |
| 35 | 33 | // The runner in packages/agent/subagents/runner.go is the only caller in | |
| 36 | 34 | // production; tests use the stubchild binary under | |
| @@ -185,20 +183,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 185 | 183 | } | |
| 186 | 184 | defer ln.Close() | |
| 187 | 185 | ||
| 188 | - | // Event log is owned by the supervisor's runner via stdout, but | |
| 189 | - | // the daemon also writes a redundant copy here when the runner's | |
| 190 | - | // pipe is closed (e.g. parent zut exited but the agent is still | |
| 191 | - | // running headless). The env var is set by the runner; if it's | |
| 192 | - | // empty we silently skip the second mirror. | |
| 193 | - | var logMirror *subagents.EventLog | |
| 194 | - | if path := os.Getenv("ZUT_SUBAGENT_EVENT_LOG"); path != "" { | |
| 195 | - | logMirror, _ = subagents.OpenEventLog(path) | |
| 196 | - | } | |
| 197 | - | if logMirror != nil { | |
| 198 | - | defer logMirror.Close() | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | em := newSubagentEmitter(os.Stdout, logMirror) | |
| 186 | + | em := newSubagentEmitter(os.Stdout) | |
| 202 | 187 | em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID")) | |
| 203 | 188 | em.emit("agent.ready", map[string]any{ | |
| 204 | 189 | "version": version, | |
| @@ -209,30 +194,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 209 | 194 | "max_turns": args.SubagentMaxTurns, | |
| 210 | 195 | }) | |
| 211 | 196 | ||
| 212 | - | // Heartbeats make a live-but-detached child distinguishable from a | |
| 213 | - | // failed turn after supervisor reconnect. The parent still treats | |
| 214 | - | // unknown event types as forward-compatible. | |
| 215 | - | heartbeatDone := make(chan struct{}) | |
| 216 | - | defer close(heartbeatDone) | |
| 217 | - | go func() { | |
| 218 | - | interval := 10 * time.Second | |
| 219 | - | if raw := os.Getenv("ZUT_SUBAGENT_HEARTBEAT_INTERVAL"); raw != "" { | |
| 220 | - | if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 { | |
| 221 | - | interval = parsed | |
| 222 | - | } | |
| 223 | - | } | |
| 224 | - | ticker := time.NewTicker(interval) | |
| 225 | - | defer ticker.Stop() | |
| 226 | - | for { | |
| 227 | - | select { | |
| 228 | - | case <-heartbeatDone: | |
| 229 | - | return | |
| 230 | - | case <-ticker.C: | |
| 231 | - | em.emit("agent.heartbeat", map[string]any{"activity": "alive"}) | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | }() | |
| 235 | - | ||
| 236 | 197 | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | |
| 237 | 198 | // an in-flight turn without tearing down the whole daemon. | |
| 238 | 199 | var ( | |
| @@ -240,7 +201,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 240 | 201 | cancelFn context.CancelFunc | |
| 241 | 202 | busyTurn bool | |
| 242 | 203 | budget = workerTurnBudget{ | |
| 243 | - | sequence: initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG")), | |
| 244 | 204 | lifetime: args.SubagentLifetimeTurns, | |
| 245 | 205 | current: args.SubagentRunTurns, | |
| 246 | 206 | } | |
| @@ -521,29 +481,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 521 | 481 | } | |
| 522 | 482 | } | |
| 523 | 483 | ||
| 524 | - | // subagentEmitter serialises events to stdout and (optionally) to a | |
| 525 | - | // durable log file. Concurrent goroutines call emit so we have to | |
| 526 | - | // hold a mutex around the encoder. | |
| 484 | + | // subagentEmitter serialises protocol events to stdout. The supervisor is | |
| 485 | + | // the single trace writer; workers never persist execution events themselves. | |
| 527 | 486 | type subagentEmitter struct { | |
| 528 | 487 | mu sync.Mutex | |
| 529 | 488 | w *os.File | |
| 530 | - | mirror *subagents.EventLog | |
| 531 | 489 | agentID string | |
| 532 | - | ||
| 533 | - | // orphan flips true the first time a stdout write fails (broken | |
| 534 | - | // pipe — the supervisor died). Until then the mirror stays | |
| 535 | - | // dormant: the supervisor is the canonical writer to events.jsonl | |
| 536 | - | // (it parses our stdout and Append()s each event itself). Writing | |
| 537 | - | // from both sides used to land every event in the log twice, | |
| 538 | - | // which showed up as a fully-duplicated transcript the next time | |
| 539 | - | // the agent was reloaded — the exact "why is everything doubled" | |
| 540 | - | // bug. Once orphaned the mirror takes over so the events still | |
| 541 | - | // land on disk for the next reload. | |
| 542 | - | orphan bool | |
| 543 | 490 | } | |
| 544 | 491 | ||
| 545 | - | func newSubagentEmitter(w *os.File, mirror *subagents.EventLog) *subagentEmitter { | |
| 546 | - | return &subagentEmitter{w: w, mirror: mirror} | |
| 492 | + | func newSubagentEmitter(w *os.File) *subagentEmitter { | |
| 493 | + | return &subagentEmitter{w: w} | |
| 547 | 494 | } | |
| 548 | 495 | ||
| 549 | 496 | func (e *subagentEmitter) setProtocolIdentity(agentID string) { | |
| @@ -569,17 +516,8 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||
| 569 | 516 | envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload) | |
| 570 | 517 | line, err := subagents.MarshalJSONL(envelope) | |
| 571 | 518 | ||
| 572 | - | if !e.orphan && err == nil { | |
| 573 | - | if _, werr := e.w.Write(line); werr != nil { | |
| 574 | - | // Supervisor's stdout pipe is gone (parent zut exited but we | |
| 575 | - | // kept running). Switch to mirror-only mode and preserve this | |
| 576 | - | // event in the durable log. | |
| 577 | - | e.orphan = true | |
| 578 | - | } | |
| 579 | - | } | |
| 580 | - | ||
| 581 | - | if e.orphan && e.mirror != nil { | |
| 582 | - | _ = e.mirror.Append(subagents.NewEvent(wireType, payload)) | |
| 519 | + | if err == nil { | |
| 520 | + | _, _ = e.w.Write(line) | |
| 583 | 521 | } | |
| 584 | 522 | } | |
| 585 | 523 | ||
| @@ -783,31 +721,6 @@ func errString(err error) string { | |||
| 783 | 721 | return err.Error() | |
| 784 | 722 | } | |
| 785 | 723 | ||
| 786 | - | func initialTurnNumber(path string) int { | |
| 787 | - | if strings.TrimSpace(path) == "" { | |
| 788 | - | return 0 | |
| 789 | - | } | |
| 790 | - | events, err := subagents.ReadEventLog(path) | |
| 791 | - | if err != nil { | |
| 792 | - | return 0 | |
| 793 | - | } | |
| 794 | - | maxStep := 0 | |
| 795 | - | for _, ev := range events { | |
| 796 | - | if !subagents.IsDelegatedTurnStart(ev) { | |
| 797 | - | continue | |
| 798 | - | } | |
| 799 | - | if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep { | |
| 800 | - | maxStep = int(step) | |
| 801 | - | } | |
| 802 | - | if strings.HasPrefix(ev.TurnID, "turn-") { | |
| 803 | - | if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep { | |
| 804 | - | maxStep = n | |
| 805 | - | } | |
| 806 | - | } | |
| 807 | - | } | |
| 808 | - | return maxStep | |
| 809 | - | } | |
| 810 | - | ||
| 811 | 724 | func truncateForLog(s string, n int) string { | |
| 812 | 725 | if len(s) <= n { | |
| 813 | 726 | return s | |
| @@ -27,10 +27,8 @@ import ( | |||||
| 27 | // initial positional task (if any) is the first turn; subsequent | 27 | // initial positional task (if any) is the first turn; subsequent | ||
| 28 | // turns arrive through the inbox unix socket at args.SubagentWorker. | 28 | // turns arrive through the inbox unix socket at args.SubagentWorker. | ||
| 29 | // | 29 | // | ||
| 30 | - | // - Output: every emitted JSON line is also mirrored verbatim into | 30 | + | // - Output: every emitted JSON line goes to the parent supervisor, which |
| 31 | - | // events.jsonl (see ZUT_SUBAGENT_EVENT_LOG) so a separate zut | 31 | + | // owns the optional execution trace. Workers never persist trace data. |
| 32 | - | // process can /subagents open this agent and replay its full history | |||
| 33 | - | // even after the parent that spawned us is long gone. | |||
| 34 | // | 32 | // | ||
| 35 | // The runner in packages/agent/subagents/runner.go is the only caller in | 33 | // The runner in packages/agent/subagents/runner.go is the only caller in | ||
| 36 | // production; tests use the stubchild binary under | 34 | // production; tests use the stubchild binary under | ||
| @@ -185,20 +183,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 185 | } | 183 | } | ||
| 186 | defer ln.Close() | 184 | defer ln.Close() | ||
| 187 | 185 | ||||
| 188 | - | // Event log is owned by the supervisor's runner via stdout, but | 186 | + | em := newSubagentEmitter(os.Stdout) |
| 189 | - | // the daemon also writes a redundant copy here when the runner's | |||
| 190 | - | // pipe is closed (e.g. parent zut exited but the agent is still | |||
| 191 | - | // running headless). The env var is set by the runner; if it's | |||
| 192 | - | // empty we silently skip the second mirror. | |||
| 193 | - | var logMirror *subagents.EventLog | |||
| 194 | - | if path := os.Getenv("ZUT_SUBAGENT_EVENT_LOG"); path != "" { | |||
| 195 | - | logMirror, _ = subagents.OpenEventLog(path) | |||
| 196 | - | } | |||
| 197 | - | if logMirror != nil { | |||
| 198 | - | defer logMirror.Close() | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | em := newSubagentEmitter(os.Stdout, logMirror) | |||
| 202 | em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID")) | 187 | em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID")) | ||
| 203 | em.emit("agent.ready", map[string]any{ | 188 | em.emit("agent.ready", map[string]any{ | ||
| 204 | "version": version, | 189 | "version": version, | ||
| @@ -209,30 +194,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 209 | "max_turns": args.SubagentMaxTurns, | 194 | "max_turns": args.SubagentMaxTurns, | ||
| 210 | }) | 195 | }) | ||
| 211 | 196 | ||||
| 212 | - | // Heartbeats make a live-but-detached child distinguishable from a | |||
| 213 | - | // failed turn after supervisor reconnect. The parent still treats | |||
| 214 | - | // unknown event types as forward-compatible. | |||
| 215 | - | heartbeatDone := make(chan struct{}) | |||
| 216 | - | defer close(heartbeatDone) | |||
| 217 | - | go func() { | |||
| 218 | - | interval := 10 * time.Second | |||
| 219 | - | if raw := os.Getenv("ZUT_SUBAGENT_HEARTBEAT_INTERVAL"); raw != "" { | |||
| 220 | - | if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 { | |||
| 221 | - | interval = parsed | |||
| 222 | - | } | |||
| 223 | - | } | |||
| 224 | - | ticker := time.NewTicker(interval) | |||
| 225 | - | defer ticker.Stop() | |||
| 226 | - | for { | |||
| 227 | - | select { | |||
| 228 | - | case <-heartbeatDone: | |||
| 229 | - | return | |||
| 230 | - | case <-ticker.C: | |||
| 231 | - | em.emit("agent.heartbeat", map[string]any{"activity": "alive"}) | |||
| 232 | - | } | |||
| 233 | - | } | |||
| 234 | - | }() | |||
| 235 | - | ||||
| 236 | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | 197 | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | ||
| 237 | // an in-flight turn without tearing down the whole daemon. | 198 | // an in-flight turn without tearing down the whole daemon. | ||
| 238 | var ( | 199 | var ( | ||
| @@ -240,7 +201,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 240 | cancelFn context.CancelFunc | 201 | cancelFn context.CancelFunc | ||
| 241 | busyTurn bool | 202 | busyTurn bool | ||
| 242 | budget = workerTurnBudget{ | 203 | budget = workerTurnBudget{ | ||
| 243 | - | sequence: initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG")), | |||
| 244 | lifetime: args.SubagentLifetimeTurns, | 204 | lifetime: args.SubagentLifetimeTurns, | ||
| 245 | current: args.SubagentRunTurns, | 205 | current: args.SubagentRunTurns, | ||
| 246 | } | 206 | } | ||
| @@ -521,29 +481,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 521 | } | 481 | } | ||
| 522 | } | 482 | } | ||
| 523 | 483 | ||||
| 524 | - | // subagentEmitter serialises events to stdout and (optionally) to a | 484 | + | // subagentEmitter serialises protocol events to stdout. The supervisor is |
| 525 | - | // durable log file. Concurrent goroutines call emit so we have to | 485 | + | // the single trace writer; workers never persist execution events themselves. |
| 526 | - | // hold a mutex around the encoder. | |||
| 527 | type subagentEmitter struct { | 486 | type subagentEmitter struct { | ||
| 528 | mu sync.Mutex | 487 | mu sync.Mutex | ||
| 529 | w *os.File | 488 | w *os.File | ||
| 530 | - | mirror *subagents.EventLog | |||
| 531 | agentID string | 489 | agentID string | ||
| 532 | - | ||||
| 533 | - | // orphan flips true the first time a stdout write fails (broken | |||
| 534 | - | // pipe — the supervisor died). Until then the mirror stays | |||
| 535 | - | // dormant: the supervisor is the canonical writer to events.jsonl | |||
| 536 | - | // (it parses our stdout and Append()s each event itself). Writing | |||
| 537 | - | // from both sides used to land every event in the log twice, | |||
| 538 | - | // which showed up as a fully-duplicated transcript the next time | |||
| 539 | - | // the agent was reloaded — the exact "why is everything doubled" | |||
| 540 | - | // bug. Once orphaned the mirror takes over so the events still | |||
| 541 | - | // land on disk for the next reload. | |||
| 542 | - | orphan bool | |||
| 543 | } | 490 | } | ||
| 544 | 491 | ||||
| 545 | - | func newSubagentEmitter(w *os.File, mirror *subagents.EventLog) *subagentEmitter { | 492 | + | func newSubagentEmitter(w *os.File) *subagentEmitter { |
| 546 | - | return &subagentEmitter{w: w, mirror: mirror} | 493 | + | return &subagentEmitter{w: w} |
| 547 | } | 494 | } | ||
| 548 | 495 | ||||
| 549 | func (e *subagentEmitter) setProtocolIdentity(agentID string) { | 496 | func (e *subagentEmitter) setProtocolIdentity(agentID string) { | ||
| @@ -569,17 +516,8 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||||
| 569 | envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload) | 516 | envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload) | ||
| 570 | line, err := subagents.MarshalJSONL(envelope) | 517 | line, err := subagents.MarshalJSONL(envelope) | ||
| 571 | 518 | ||||
| 572 | - | if !e.orphan && err == nil { | 519 | + | if err == nil { |
| 573 | - | if _, werr := e.w.Write(line); werr != nil { | 520 | + | _, _ = e.w.Write(line) |
| 574 | - | // Supervisor's stdout pipe is gone (parent zut exited but we | |||
| 575 | - | // kept running). Switch to mirror-only mode and preserve this | |||
| 576 | - | // event in the durable log. | |||
| 577 | - | e.orphan = true | |||
| 578 | - | } | |||
| 579 | - | } | |||
| 580 | - | ||||
| 581 | - | if e.orphan && e.mirror != nil { | |||
| 582 | - | _ = e.mirror.Append(subagents.NewEvent(wireType, payload)) | |||
| 583 | } | 521 | } | ||
| 584 | } | 522 | } | ||
| 585 | 523 | ||||
| @@ -783,31 +721,6 @@ func errString(err error) string { | |||||
| 783 | return err.Error() | 721 | return err.Error() | ||
| 784 | } | 722 | } | ||
| 785 | 723 | ||||
| 786 | - | func initialTurnNumber(path string) int { | |||
| 787 | - | if strings.TrimSpace(path) == "" { | |||
| 788 | - | return 0 | |||
| 789 | - | } | |||
| 790 | - | events, err := subagents.ReadEventLog(path) | |||
| 791 | - | if err != nil { | |||
| 792 | - | return 0 | |||
| 793 | - | } | |||
| 794 | - | maxStep := 0 | |||
| 795 | - | for _, ev := range events { | |||
| 796 | - | if !subagents.IsDelegatedTurnStart(ev) { | |||
| 797 | - | continue | |||
| 798 | - | } | |||
| 799 | - | if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep { | |||
| 800 | - | maxStep = int(step) | |||
| 801 | - | } | |||
| 802 | - | if strings.HasPrefix(ev.TurnID, "turn-") { | |||
| 803 | - | if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep { | |||
| 804 | - | maxStep = n | |||
| 805 | - | } | |||
| 806 | - | } | |||
| 807 | - | } | |||
| 808 | - | return maxStep | |||
| 809 | - | } | |||
| 810 | - | ||||
| 811 | func truncateForLog(s string, n int) string { | 724 | func truncateForLog(s string, n int) string { | ||
| 812 | if len(s) <= n { | 725 | if len(s) <= n { | ||
| 813 | return s | 726 | return s | ||
M packages/agent/subagent_worker_test.go +2 -95
| @@ -759,75 +759,6 @@ func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) { | |||
| 759 | 759 | } | |
| 760 | 760 | } | |
| 761 | 761 | ||
| 762 | - | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | |
| 763 | - | // "everything is doubled after reopening a subagent agent" bug. | |
| 764 | - | // | |
| 765 | - | // Symptom: events.jsonl held two copies of every event because the | |
| 766 | - | // child mirrored each event to disk AND the supervisor parsed the | |
| 767 | - | // child's stdout and appended each event to disk too. On next zut | |
| 768 | - | // launch the replay produced two transcript lines per real one. | |
| 769 | - | // | |
| 770 | - | // Fix invariant: while stdout writes succeed (i.e. the supervisor is | |
| 771 | - | // alive on the other end of the pipe), the child's mirror writes | |
| 772 | - | // NOTHING. Only when a stdout write fails (broken pipe → orphan) | |
| 773 | - | // does the mirror take over so events still get persisted. | |
| 774 | - | func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |
| 775 | - | // Real *os.File for the emitter's stdout-equivalent so the | |
| 776 | - | // emitter's write() path (which expects *os.File) actually runs. | |
| 777 | - | stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo") | |
| 778 | - | stdoutFile, err := os.Create(stdoutPath) | |
| 779 | - | if err != nil { | |
| 780 | - | t.Fatalf("create stdout file: %v", err) | |
| 781 | - | } | |
| 782 | - | defer stdoutFile.Close() | |
| 783 | - | ||
| 784 | - | // Mirror writes go to a separate events.jsonl that we can read | |
| 785 | - | // at the end to assert how many events the mirror emitted. | |
| 786 | - | mirrorPath := filepath.Join(t.TempDir(), "events.jsonl") | |
| 787 | - | mirror, err := subagents.OpenEventLog(mirrorPath) | |
| 788 | - | if err != nil { | |
| 789 | - | t.Fatalf("open mirror: %v", err) | |
| 790 | - | } | |
| 791 | - | defer mirror.Close() | |
| 792 | - | ||
| 793 | - | em := newSubagentEmitter(stdoutFile, mirror) | |
| 794 | - | ||
| 795 | - | // Healthy stdout: emit three events. Mirror must stay empty. | |
| 796 | - | em.emit("turn_start", map[string]any{"step": 1}) | |
| 797 | - | em.emit("assistant_message", map[string]any{"text": "hi"}) | |
| 798 | - | em.emit("turn_end", map[string]any{"step": 1}) | |
| 799 | - | ||
| 800 | - | got, err := subagents.ReadEventLog(mirrorPath) | |
| 801 | - | if err != nil { | |
| 802 | - | t.Fatalf("read mirror: %v", err) | |
| 803 | - | } | |
| 804 | - | if len(got) != 0 { | |
| 805 | - | t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v", | |
| 806 | - | len(got), got) | |
| 807 | - | } | |
| 808 | - | ||
| 809 | - | // Simulate supervisor death: close stdout so the next Write | |
| 810 | - | // returns EBADF / broken pipe. The emitter must flip into | |
| 811 | - | // orphan mode and start writing through the mirror. | |
| 812 | - | if err := stdoutFile.Close(); err != nil { | |
| 813 | - | t.Fatalf("close stdout: %v", err) | |
| 814 | - | } | |
| 815 | - | ||
| 816 | - | em.emit("assistant_message", map[string]any{"text": "after orphan"}) | |
| 817 | - | em.emit("turn_end", map[string]any{"step": 2}) | |
| 818 | - | ||
| 819 | - | got, err = subagents.ReadEventLog(mirrorPath) | |
| 820 | - | if err != nil { | |
| 821 | - | t.Fatalf("read mirror post-orphan: %v", err) | |
| 822 | - | } | |
| 823 | - | if len(got) < 2 { | |
| 824 | - | t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got)) | |
| 825 | - | } | |
| 826 | - | if got[len(got)-1].Type != "turn_end" { | |
| 827 | - | t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type) | |
| 828 | - | } | |
| 829 | - | } | |
| 830 | - | ||
| 831 | 762 | // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the | |
| 832 | 763 | // large-result wire duplication bug. | |
| 833 | 764 | func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | |
| @@ -838,7 +769,7 @@ func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | |||
| 838 | 769 | } | |
| 839 | 770 | defer file.Close() | |
| 840 | 771 | ||
| 841 | - | em := newSubagentEmitter(file, nil) | |
| 772 | + | em := newSubagentEmitter(file) | |
| 842 | 773 | em.setProtocolIdentity("agent-1") | |
| 843 | 774 | em.emit("turn.result", map[string]any{ | |
| 844 | 775 | "status": "succeeded", | |
| @@ -887,7 +818,7 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||
| 887 | 818 | } | |
| 888 | 819 | defer r.Close() | |
| 889 | 820 | ||
| 890 | - | em := newSubagentEmitter(w, nil) | |
| 821 | + | em := newSubagentEmitter(w) | |
| 891 | 822 | em.emit("turn_start", map[string]any{"step": 1}) | |
| 892 | 823 | _ = w.Close() | |
| 893 | 824 | ||
| @@ -916,30 +847,6 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||
| 916 | 847 | } | |
| 917 | 848 | } | |
| 918 | 849 | ||
| 919 | - | func TestInitialTurnNumberIgnoresNestedProviderTurns(t *testing.T) { | |
| 920 | - | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 921 | - | log, err := subagents.OpenEventLog(path) | |
| 922 | - | if err != nil { | |
| 923 | - | t.Fatal(err) | |
| 924 | - | } | |
| 925 | - | if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{ | |
| 926 | - | "step": float64(1), "lifetime_turns": 1, "current_run_turns": 1, | |
| 927 | - | })); err != nil { | |
| 928 | - | t.Fatal(err) | |
| 929 | - | } | |
| 930 | - | if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{ | |
| 931 | - | "step": float64(99), "nested_turn": true, | |
| 932 | - | })); err != nil { | |
| 933 | - | t.Fatal(err) | |
| 934 | - | } | |
| 935 | - | if err := log.Close(); err != nil { | |
| 936 | - | t.Fatal(err) | |
| 937 | - | } | |
| 938 | - | if got := initialTurnNumber(path); got != 1 { | |
| 939 | - | t.Fatalf("initial turn number = %d, want 1", got) | |
| 940 | - | } | |
| 941 | - | } | |
| 942 | - | ||
| 943 | 850 | func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) { | |
| 944 | 851 | var budget workerTurnBudget | |
| 945 | 852 | ||
| @@ -759,75 +759,6 @@ func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) { | |||||
| 759 | } | 759 | } | ||
| 760 | } | 760 | } | ||
| 761 | 761 | ||||
| 762 | - | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | |||
| 763 | - | // "everything is doubled after reopening a subagent agent" bug. | |||
| 764 | - | // | |||
| 765 | - | // Symptom: events.jsonl held two copies of every event because the | |||
| 766 | - | // child mirrored each event to disk AND the supervisor parsed the | |||
| 767 | - | // child's stdout and appended each event to disk too. On next zut | |||
| 768 | - | // launch the replay produced two transcript lines per real one. | |||
| 769 | - | // | |||
| 770 | - | // Fix invariant: while stdout writes succeed (i.e. the supervisor is | |||
| 771 | - | // alive on the other end of the pipe), the child's mirror writes | |||
| 772 | - | // NOTHING. Only when a stdout write fails (broken pipe → orphan) | |||
| 773 | - | // does the mirror take over so events still get persisted. | |||
| 774 | - | func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |||
| 775 | - | // Real *os.File for the emitter's stdout-equivalent so the | |||
| 776 | - | // emitter's write() path (which expects *os.File) actually runs. | |||
| 777 | - | stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo") | |||
| 778 | - | stdoutFile, err := os.Create(stdoutPath) | |||
| 779 | - | if err != nil { | |||
| 780 | - | t.Fatalf("create stdout file: %v", err) | |||
| 781 | - | } | |||
| 782 | - | defer stdoutFile.Close() | |||
| 783 | - | ||||
| 784 | - | // Mirror writes go to a separate events.jsonl that we can read | |||
| 785 | - | // at the end to assert how many events the mirror emitted. | |||
| 786 | - | mirrorPath := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 787 | - | mirror, err := subagents.OpenEventLog(mirrorPath) | |||
| 788 | - | if err != nil { | |||
| 789 | - | t.Fatalf("open mirror: %v", err) | |||
| 790 | - | } | |||
| 791 | - | defer mirror.Close() | |||
| 792 | - | ||||
| 793 | - | em := newSubagentEmitter(stdoutFile, mirror) | |||
| 794 | - | ||||
| 795 | - | // Healthy stdout: emit three events. Mirror must stay empty. | |||
| 796 | - | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 797 | - | em.emit("assistant_message", map[string]any{"text": "hi"}) | |||
| 798 | - | em.emit("turn_end", map[string]any{"step": 1}) | |||
| 799 | - | ||||
| 800 | - | got, err := subagents.ReadEventLog(mirrorPath) | |||
| 801 | - | if err != nil { | |||
| 802 | - | t.Fatalf("read mirror: %v", err) | |||
| 803 | - | } | |||
| 804 | - | if len(got) != 0 { | |||
| 805 | - | t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v", | |||
| 806 | - | len(got), got) | |||
| 807 | - | } | |||
| 808 | - | ||||
| 809 | - | // Simulate supervisor death: close stdout so the next Write | |||
| 810 | - | // returns EBADF / broken pipe. The emitter must flip into | |||
| 811 | - | // orphan mode and start writing through the mirror. | |||
| 812 | - | if err := stdoutFile.Close(); err != nil { | |||
| 813 | - | t.Fatalf("close stdout: %v", err) | |||
| 814 | - | } | |||
| 815 | - | ||||
| 816 | - | em.emit("assistant_message", map[string]any{"text": "after orphan"}) | |||
| 817 | - | em.emit("turn_end", map[string]any{"step": 2}) | |||
| 818 | - | ||||
| 819 | - | got, err = subagents.ReadEventLog(mirrorPath) | |||
| 820 | - | if err != nil { | |||
| 821 | - | t.Fatalf("read mirror post-orphan: %v", err) | |||
| 822 | - | } | |||
| 823 | - | if len(got) < 2 { | |||
| 824 | - | t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got)) | |||
| 825 | - | } | |||
| 826 | - | if got[len(got)-1].Type != "turn_end" { | |||
| 827 | - | t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type) | |||
| 828 | - | } | |||
| 829 | - | } | |||
| 830 | - | ||||
| 831 | // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the | 762 | // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the | ||
| 832 | // large-result wire duplication bug. | 763 | // large-result wire duplication bug. | ||
| 833 | func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | 764 | func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | ||
| @@ -838,7 +769,7 @@ func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | |||||
| 838 | } | 769 | } | ||
| 839 | defer file.Close() | 770 | defer file.Close() | ||
| 840 | 771 | ||||
| 841 | - | em := newSubagentEmitter(file, nil) | 772 | + | em := newSubagentEmitter(file) |
| 842 | em.setProtocolIdentity("agent-1") | 773 | em.setProtocolIdentity("agent-1") | ||
| 843 | em.emit("turn.result", map[string]any{ | 774 | em.emit("turn.result", map[string]any{ | ||
| 844 | "status": "succeeded", | 775 | "status": "succeeded", | ||
| @@ -887,7 +818,7 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||||
| 887 | } | 818 | } | ||
| 888 | defer r.Close() | 819 | defer r.Close() | ||
| 889 | 820 | ||||
| 890 | - | em := newSubagentEmitter(w, nil) | 821 | + | em := newSubagentEmitter(w) |
| 891 | em.emit("turn_start", map[string]any{"step": 1}) | 822 | em.emit("turn_start", map[string]any{"step": 1}) | ||
| 892 | _ = w.Close() | 823 | _ = w.Close() | ||
| 893 | 824 | ||||
| @@ -916,30 +847,6 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||||
| 916 | } | 847 | } | ||
| 917 | } | 848 | } | ||
| 918 | 849 | ||||
| 919 | - | func TestInitialTurnNumberIgnoresNestedProviderTurns(t *testing.T) { | |||
| 920 | - | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 921 | - | log, err := subagents.OpenEventLog(path) | |||
| 922 | - | if err != nil { | |||
| 923 | - | t.Fatal(err) | |||
| 924 | - | } | |||
| 925 | - | if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{ | |||
| 926 | - | "step": float64(1), "lifetime_turns": 1, "current_run_turns": 1, | |||
| 927 | - | })); err != nil { | |||
| 928 | - | t.Fatal(err) | |||
| 929 | - | } | |||
| 930 | - | if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{ | |||
| 931 | - | "step": float64(99), "nested_turn": true, | |||
| 932 | - | })); err != nil { | |||
| 933 | - | t.Fatal(err) | |||
| 934 | - | } | |||
| 935 | - | if err := log.Close(); err != nil { | |||
| 936 | - | t.Fatal(err) | |||
| 937 | - | } | |||
| 938 | - | if got := initialTurnNumber(path); got != 1 { | |||
| 939 | - | t.Fatalf("initial turn number = %d, want 1", got) | |||
| 940 | - | } | |||
| 941 | - | } | |||
| 942 | - | ||||
| 943 | func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) { | 850 | func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) { | ||
| 944 | var budget workerTurnBudget | 851 | var budget workerTurnBudget | ||
| 945 | 852 | ||||
M packages/agent/subagents/agent.go +11 -0
| @@ -182,6 +182,7 @@ type Agent struct { | |||
| 182 | 182 | workspaceCleanup func() error | |
| 183 | 183 | workspaceCapture func() (WorkspaceCapture, error) | |
| 184 | 184 | onTurnIdle func() | |
| 185 | + | trace *TraceWriter | |
| 185 | 186 | ||
| 186 | 187 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | |
| 187 | 188 | // emitted by the subagent daemon wrapper. Provider/tool-loop | |
| @@ -218,6 +219,16 @@ type Agent struct { | |||
| 218 | 219 | // doesn't speak the daemon protocol). | |
| 219 | 220 | func (a *Agent) Inbox() *Inbox { return a.inbox } | |
| 220 | 221 | ||
| 222 | + | func (a *Agent) recordTrace(event TraceEvent) { | |
| 223 | + | if a == nil || a.trace == nil { | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | if event.AgentID == "" { | |
| 227 | + | event.AgentID = a.ID | |
| 228 | + | } | |
| 229 | + | a.trace.Record(event) | |
| 230 | + | } | |
| 231 | + | ||
| 221 | 232 | // FastModeOverridesHost reports whether this child was created with an | |
| 222 | 233 | // explicit fast-mode override while the host setting was disabled. | |
| 223 | 234 | func (a *Agent) FastModeOverridesHost() bool { | |
| @@ -182,6 +182,7 @@ type Agent struct { | |||||
| 182 | workspaceCleanup func() error | 182 | workspaceCleanup func() error | ||
| 183 | workspaceCapture func() (WorkspaceCapture, error) | 183 | workspaceCapture func() (WorkspaceCapture, error) | ||
| 184 | onTurnIdle func() | 184 | onTurnIdle func() | ||
| 185 | + | trace *TraceWriter | |||
| 185 | 186 | ||||
| 186 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | 187 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | ||
| 187 | // emitted by the subagent daemon wrapper. Provider/tool-loop | 188 | // emitted by the subagent daemon wrapper. Provider/tool-loop | ||
| @@ -218,6 +219,16 @@ type Agent struct { | |||||
| 218 | // doesn't speak the daemon protocol). | 219 | // doesn't speak the daemon protocol). | ||
| 219 | func (a *Agent) Inbox() *Inbox { return a.inbox } | 220 | func (a *Agent) Inbox() *Inbox { return a.inbox } | ||
| 220 | 221 | ||||
| 222 | + | func (a *Agent) recordTrace(event TraceEvent) { | |||
| 223 | + | if a == nil || a.trace == nil { | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | if event.AgentID == "" { | |||
| 227 | + | event.AgentID = a.ID | |||
| 228 | + | } | |||
| 229 | + | a.trace.Record(event) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 221 | // FastModeOverridesHost reports whether this child was created with an | 232 | // FastModeOverridesHost reports whether this child was created with an | ||
| 222 | // explicit fast-mode override while the host setting was disabled. | 233 | // explicit fast-mode override while the host setting was disabled. | ||
| 223 | func (a *Agent) FastModeOverridesHost() bool { | 234 | func (a *Agent) FastModeOverridesHost() bool { | ||
M packages/agent/subagents/persist.go +1 -0
| @@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1325 | 1325 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | |
| 1326 | 1326 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |
| 1327 | 1327 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |
| 1328 | + | a.trace = f.trace | |
| 1328 | 1329 | a.runner = f.cfg.NewRunner(a) | |
| 1329 | 1330 | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | |
| 1330 | 1331 | if a.cancel != nil { | |
| @@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1325 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | 1325 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | ||
| 1326 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | 1326 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | ||
| 1327 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | 1327 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | ||
| 1328 | + | a.trace = f.trace | |||
| 1328 | a.runner = f.cfg.NewRunner(a) | 1329 | a.runner = f.cfg.NewRunner(a) | ||
| 1329 | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | 1330 | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | ||
| 1330 | if a.cancel != nil { | 1331 | if a.cancel != nil { | ||
A packages/agent/subagents/projection.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Operation is a currently open factual operation inferred from its start | |
| 10 | + | // boundary. Completed operations are deliberately not retained in the public | |
| 11 | + | // projection: their terminal trace fact remains available as LastEvent. | |
| 12 | + | type Operation struct { | |
| 13 | + | Type string | |
| 14 | + | AgentID string | |
| 15 | + | TurnID string | |
| 16 | + | CallID string | |
| 17 | + | StartedAt time.Time | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Open is always true because the projection exposes only unmatched starts. | |
| 21 | + | func (o Operation) Open() bool { return true } | |
| 22 | + | ||
| 23 | + | // Duration measures this open operation at now; callers supply now to make | |
| 24 | + | // rendering and tests deterministic. | |
| 25 | + | func (o Operation) Duration(now time.Time) time.Duration { | |
| 26 | + | if now.Before(o.StartedAt) { | |
| 27 | + | return 0 | |
| 28 | + | } | |
| 29 | + | return now.Sub(o.StartedAt) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Label returns the shared concise label for an open operation. | |
| 33 | + | func (o Operation) Label() string { return strings.TrimSuffix(o.Type, ".started") + " open" } | |
| 34 | + | ||
| 35 | + | // AgentTraceView is the only user-facing execution state. It intentionally | |
| 36 | + | // contains operations and terminal facts rather than lifecycle guesses such as | |
| 37 | + | // working, idle, or alive. | |
| 38 | + | type AgentTraceView struct { | |
| 39 | + | AgentID string | |
| 40 | + | LastEvent TraceEvent | |
| 41 | + | OpenOperations []Operation | |
| 42 | + | Terminal string | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ProjectTrace reduces a chronologically ordered trace into per-agent facts. | |
| 46 | + | // Unknown events are retained as LastEvent but never guessed into an operation. | |
| 47 | + | func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |
| 48 | + | views := make(map[string]AgentTraceView) | |
| 49 | + | open := make(map[string]map[string]Operation) | |
| 50 | + | for _, event := range events { | |
| 51 | + | if event.AgentID == "" { | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | view := views[event.AgentID] | |
| 55 | + | view.AgentID = event.AgentID | |
| 56 | + | view.LastEvent = event | |
| 57 | + | key, starts, ends, terminal := traceBoundary(event) | |
| 58 | + | if starts { | |
| 59 | + | if open[event.AgentID] == nil { | |
| 60 | + | open[event.AgentID] = make(map[string]Operation) | |
| 61 | + | } | |
| 62 | + | callID, _ := event.Data["call_id"].(string) | |
| 63 | + | open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, StartedAt: event.Timestamp} | |
| 64 | + | } | |
| 65 | + | if ends { | |
| 66 | + | delete(open[event.AgentID], key) | |
| 67 | + | } | |
| 68 | + | if terminal != "" { | |
| 69 | + | view.Terminal = terminal | |
| 70 | + | delete(open, event.AgentID) | |
| 71 | + | } | |
| 72 | + | views[event.AgentID] = view | |
| 73 | + | } | |
| 74 | + | for agentID, operations := range open { | |
| 75 | + | view := views[agentID] | |
| 76 | + | for _, operation := range operations { | |
| 77 | + | view.OpenOperations = append(view.OpenOperations, operation) | |
| 78 | + | } | |
| 79 | + | sort.Slice(view.OpenOperations, func(i, j int) bool { | |
| 80 | + | left, right := view.OpenOperations[i], view.OpenOperations[j] | |
| 81 | + | if !left.StartedAt.Equal(right.StartedAt) { | |
| 82 | + | return left.StartedAt.Before(right.StartedAt) | |
| 83 | + | } | |
| 84 | + | if left.Type != right.Type { | |
| 85 | + | return left.Type < right.Type | |
| 86 | + | } | |
| 87 | + | if left.TurnID != right.TurnID { | |
| 88 | + | return left.TurnID < right.TurnID | |
| 89 | + | } | |
| 90 | + | return left.CallID < right.CallID | |
| 91 | + | }) | |
| 92 | + | views[agentID] = view | |
| 93 | + | } | |
| 94 | + | return views | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) { | |
| 98 | + | callID, _ := event.Data["call_id"].(string) | |
| 99 | + | key = event.Type + ":" + event.TurnID + ":" + callID | |
| 100 | + | switch event.Type { | |
| 101 | + | case "turn.started": | |
| 102 | + | return "turn:" + event.TurnID, true, false, "" | |
| 103 | + | case "turn.finished", "turn.failed", "turn.cancelled": | |
| 104 | + | return "turn:" + event.TurnID, false, true, "" | |
| 105 | + | case "tool.started": | |
| 106 | + | return "tool:" + event.TurnID + ":" + callID, true, false, "" | |
| 107 | + | case "tool.finished", "tool.failed", "tool.cancelled": | |
| 108 | + | return "tool:" + event.TurnID + ":" + callID, false, true, "" | |
| 109 | + | case "agent.finished": | |
| 110 | + | return key, false, false, "completed" | |
| 111 | + | case "agent.failed": | |
| 112 | + | return key, false, false, "failed" | |
| 113 | + | case "agent.cancelled": | |
| 114 | + | return key, false, false, "cancelled" | |
| 115 | + | } | |
| 116 | + | return key, false, false, "" | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sort" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Operation is a currently open factual operation inferred from its start | |||
| 10 | + | // boundary. Completed operations are deliberately not retained in the public | |||
| 11 | + | // projection: their terminal trace fact remains available as LastEvent. | |||
| 12 | + | type Operation struct { | |||
| 13 | + | Type string | |||
| 14 | + | AgentID string | |||
| 15 | + | TurnID string | |||
| 16 | + | CallID string | |||
| 17 | + | StartedAt time.Time | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Open is always true because the projection exposes only unmatched starts. | |||
| 21 | + | func (o Operation) Open() bool { return true } | |||
| 22 | + | ||||
| 23 | + | // Duration measures this open operation at now; callers supply now to make | |||
| 24 | + | // rendering and tests deterministic. | |||
| 25 | + | func (o Operation) Duration(now time.Time) time.Duration { | |||
| 26 | + | if now.Before(o.StartedAt) { | |||
| 27 | + | return 0 | |||
| 28 | + | } | |||
| 29 | + | return now.Sub(o.StartedAt) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Label returns the shared concise label for an open operation. | |||
| 33 | + | func (o Operation) Label() string { return strings.TrimSuffix(o.Type, ".started") + " open" } | |||
| 34 | + | ||||
| 35 | + | // AgentTraceView is the only user-facing execution state. It intentionally | |||
| 36 | + | // contains operations and terminal facts rather than lifecycle guesses such as | |||
| 37 | + | // working, idle, or alive. | |||
| 38 | + | type AgentTraceView struct { | |||
| 39 | + | AgentID string | |||
| 40 | + | LastEvent TraceEvent | |||
| 41 | + | OpenOperations []Operation | |||
| 42 | + | Terminal string | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // ProjectTrace reduces a chronologically ordered trace into per-agent facts. | |||
| 46 | + | // Unknown events are retained as LastEvent but never guessed into an operation. | |||
| 47 | + | func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 48 | + | views := make(map[string]AgentTraceView) | |||
| 49 | + | open := make(map[string]map[string]Operation) | |||
| 50 | + | for _, event := range events { | |||
| 51 | + | if event.AgentID == "" { | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | view := views[event.AgentID] | |||
| 55 | + | view.AgentID = event.AgentID | |||
| 56 | + | view.LastEvent = event | |||
| 57 | + | key, starts, ends, terminal := traceBoundary(event) | |||
| 58 | + | if starts { | |||
| 59 | + | if open[event.AgentID] == nil { | |||
| 60 | + | open[event.AgentID] = make(map[string]Operation) | |||
| 61 | + | } | |||
| 62 | + | callID, _ := event.Data["call_id"].(string) | |||
| 63 | + | open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, StartedAt: event.Timestamp} | |||
| 64 | + | } | |||
| 65 | + | if ends { | |||
| 66 | + | delete(open[event.AgentID], key) | |||
| 67 | + | } | |||
| 68 | + | if terminal != "" { | |||
| 69 | + | view.Terminal = terminal | |||
| 70 | + | delete(open, event.AgentID) | |||
| 71 | + | } | |||
| 72 | + | views[event.AgentID] = view | |||
| 73 | + | } | |||
| 74 | + | for agentID, operations := range open { | |||
| 75 | + | view := views[agentID] | |||
| 76 | + | for _, operation := range operations { | |||
| 77 | + | view.OpenOperations = append(view.OpenOperations, operation) | |||
| 78 | + | } | |||
| 79 | + | sort.Slice(view.OpenOperations, func(i, j int) bool { | |||
| 80 | + | left, right := view.OpenOperations[i], view.OpenOperations[j] | |||
| 81 | + | if !left.StartedAt.Equal(right.StartedAt) { | |||
| 82 | + | return left.StartedAt.Before(right.StartedAt) | |||
| 83 | + | } | |||
| 84 | + | if left.Type != right.Type { | |||
| 85 | + | return left.Type < right.Type | |||
| 86 | + | } | |||
| 87 | + | if left.TurnID != right.TurnID { | |||
| 88 | + | return left.TurnID < right.TurnID | |||
| 89 | + | } | |||
| 90 | + | return left.CallID < right.CallID | |||
| 91 | + | }) | |||
| 92 | + | views[agentID] = view | |||
| 93 | + | } | |||
| 94 | + | return views | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) { | |||
| 98 | + | callID, _ := event.Data["call_id"].(string) | |||
| 99 | + | key = event.Type + ":" + event.TurnID + ":" + callID | |||
| 100 | + | switch event.Type { | |||
| 101 | + | case "turn.started": | |||
| 102 | + | return "turn:" + event.TurnID, true, false, "" | |||
| 103 | + | case "turn.finished", "turn.failed", "turn.cancelled": | |||
| 104 | + | return "turn:" + event.TurnID, false, true, "" | |||
| 105 | + | case "tool.started": | |||
| 106 | + | return "tool:" + event.TurnID + ":" + callID, true, false, "" | |||
| 107 | + | case "tool.finished", "tool.failed", "tool.cancelled": | |||
| 108 | + | return "tool:" + event.TurnID + ":" + callID, false, true, "" | |||
| 109 | + | case "agent.finished": | |||
| 110 | + | return key, false, false, "completed" | |||
| 111 | + | case "agent.failed": | |||
| 112 | + | return key, false, false, "failed" | |||
| 113 | + | case "agent.cancelled": | |||
| 114 | + | return key, false, false, "cancelled" | |||
| 115 | + | } | |||
| 116 | + | return key, false, false, "" | |||
| 117 | + | } | |||
A packages/agent/subagents/projection_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) { | |
| 9 | + | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |
| 10 | + | views := ProjectTrace([]TraceEvent{ | |
| 11 | + | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 12 | + | {Seq: 2, Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 13 | + | }) | |
| 14 | + | view := views["agent-1"] | |
| 15 | + | if view.Terminal != "" { | |
| 16 | + | t.Fatalf("terminal = %q, want empty", view.Terminal) | |
| 17 | + | } | |
| 18 | + | if len(view.OpenOperations) != 2 { | |
| 19 | + | t.Fatalf("open operations = %#v, want turn and tool", view.OpenOperations) | |
| 20 | + | } | |
| 21 | + | for _, operation := range view.OpenOperations { | |
| 22 | + | if !operation.Open() || operation.Duration(started.Add(time.Minute)) <= 0 { | |
| 23 | + | t.Fatalf("operation = %#v, want open operation with duration", operation) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) { | |
| 29 | + | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |
| 30 | + | views := ProjectTrace([]TraceEvent{ | |
| 31 | + | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 32 | + | {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 33 | + | {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 34 | + | {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}}, | |
| 35 | + | {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 36 | + | {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"}, | |
| 37 | + | }) | |
| 38 | + | view := views["agent-1"] | |
| 39 | + | if len(view.OpenOperations) != 0 { | |
| 40 | + | t.Fatalf("open operations = %#v, want none after terminal agent event", view.OpenOperations) | |
| 41 | + | } | |
| 42 | + | if view.Terminal != "completed" { | |
| 43 | + | t.Fatalf("terminal = %q, want completed", view.Terminal) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestProjectTraceClosesFailedToolByCallID(t *testing.T) { | |
| 48 | + | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |
| 49 | + | view := ProjectTrace([]TraceEvent{ | |
| 50 | + | {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 51 | + | {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}}, | |
| 52 | + | {Timestamp: started.Add(time.Second), Type: "tool.failed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 53 | + | })["agent-1"] | |
| 54 | + | if len(view.OpenOperations) != 1 || view.OpenOperations[0].CallID != "two" { | |
| 55 | + | t.Fatalf("open operations = %#v, want only call two", view.OpenOperations) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) { | |
| 60 | + | view := ProjectTrace([]TraceEvent{ | |
| 61 | + | {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |
| 62 | + | {Type: "agent.failed", AgentID: "agent-1"}, | |
| 63 | + | })["agent-1"] | |
| 64 | + | if len(view.OpenOperations) != 0 || view.Terminal != "failed" { | |
| 65 | + | t.Fatalf("view = %#v, want failed terminal with no open operations", view) | |
| 66 | + | } | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) { | |||
| 9 | + | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |||
| 10 | + | views := ProjectTrace([]TraceEvent{ | |||
| 11 | + | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | |||
| 12 | + | {Seq: 2, Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1"}, | |||
| 13 | + | }) | |||
| 14 | + | view := views["agent-1"] | |||
| 15 | + | if view.Terminal != "" { | |||
| 16 | + | t.Fatalf("terminal = %q, want empty", view.Terminal) | |||
| 17 | + | } | |||
| 18 | + | if len(view.OpenOperations) != 2 { | |||
| 19 | + | t.Fatalf("open operations = %#v, want turn and tool", view.OpenOperations) | |||
| 20 | + | } | |||
| 21 | + | for _, operation := range view.OpenOperations { | |||
| 22 | + | if !operation.Open() || operation.Duration(started.Add(time.Minute)) <= 0 { | |||
| 23 | + | t.Fatalf("operation = %#v, want open operation with duration", operation) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) { | |||
| 29 | + | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |||
| 30 | + | views := ProjectTrace([]TraceEvent{ | |||
| 31 | + | {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}, | |||
| 32 | + | {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"}, | |||
| 33 | + | {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |||
| 34 | + | {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}}, | |||
| 35 | + | {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |||
| 36 | + | {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"}, | |||
| 37 | + | }) | |||
| 38 | + | view := views["agent-1"] | |||
| 39 | + | if len(view.OpenOperations) != 0 { | |||
| 40 | + | t.Fatalf("open operations = %#v, want none after terminal agent event", view.OpenOperations) | |||
| 41 | + | } | |||
| 42 | + | if view.Terminal != "completed" { | |||
| 43 | + | t.Fatalf("terminal = %q, want completed", view.Terminal) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestProjectTraceClosesFailedToolByCallID(t *testing.T) { | |||
| 48 | + | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |||
| 49 | + | view := ProjectTrace([]TraceEvent{ | |||
| 50 | + | {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |||
| 51 | + | {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}}, | |||
| 52 | + | {Timestamp: started.Add(time.Second), Type: "tool.failed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |||
| 53 | + | })["agent-1"] | |||
| 54 | + | if len(view.OpenOperations) != 1 || view.OpenOperations[0].CallID != "two" { | |||
| 55 | + | t.Fatalf("open operations = %#v, want only call two", view.OpenOperations) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) { | |||
| 60 | + | view := ProjectTrace([]TraceEvent{ | |||
| 61 | + | {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}}, | |||
| 62 | + | {Type: "agent.failed", AgentID: "agent-1"}, | |||
| 63 | + | })["agent-1"] | |||
| 64 | + | if len(view.OpenOperations) != 0 || view.Terminal != "failed" { | |||
| 65 | + | t.Fatalf("view = %#v, want failed terminal with no open operations", view) | |||
| 66 | + | } | |||
| 67 | + | } | |||
M packages/agent/subagents/runner.go +45 -49
| @@ -12,7 +12,6 @@ import ( | |||
| 12 | 12 | "os/exec" | |
| 13 | 13 | "path/filepath" | |
| 14 | 14 | "strings" | |
| 15 | - | "sync" | |
| 16 | 15 | "time" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| @@ -299,15 +298,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 299 | 298 | } | |
| 300 | 299 | ||
| 301 | 300 | inboxPath := r.agent.InboxPath | |
| 302 | - | logPath := r.agent.EventLogPath | |
| 303 | - | if logPath == "" { | |
| 304 | - | return fmt.Errorf("subagent: agent missing event log path") | |
| 305 | - | } | |
| 306 | - | log, err := OpenEventLog(logPath) | |
| 307 | - | if err != nil { | |
| 308 | - | return err | |
| 309 | - | } | |
| 310 | - | defer log.Close() | |
| 311 | 301 | ||
| 312 | 302 | args := r.Command | |
| 313 | 303 | if len(args) == 0 { | |
| @@ -337,7 +327,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 337 | 327 | cmd.Dir = r.agent.Dir | |
| 338 | 328 | cmd.Env = append(workerEnvironment(r.agent.Provider), | |
| 339 | 329 | "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID, | |
| 340 | - | "ZUT_SUBAGENT_EVENT_LOG="+logPath, | |
| 341 | 330 | ) | |
| 342 | 331 | if r.agent.HeartbeatInterval > 0 { | |
| 343 | 332 | cmd.Env = append(cmd.Env, "ZUT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String()) | |
| @@ -388,28 +377,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 388 | 377 | defer close(runnerDone) | |
| 389 | 378 | go r.stopOnContextDone(ctx, cmd, runnerDone) | |
| 390 | 379 | ||
| 391 | - | var logErrMu sync.Mutex | |
| 392 | - | var logErr error | |
| 393 | - | var stopOnLogErr sync.Once | |
| 394 | - | appendLog := func(ev Event) { | |
| 395 | - | if err := log.Append(ev); err != nil { | |
| 396 | - | logErrMu.Lock() | |
| 397 | - | if logErr == nil { | |
| 398 | - | logErr = err | |
| 399 | - | } | |
| 400 | - | logErrMu.Unlock() | |
| 401 | - | // A durable event log is part of the runner's recovery contract. | |
| 402 | - | // Stop the worker rather than letting it continue in a state the | |
| 403 | - | // supervisor cannot reconstruct after restart. | |
| 404 | - | stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) }) | |
| 405 | - | } | |
| 406 | - | } | |
| 407 | - | firstLogErr := func() error { | |
| 408 | - | logErrMu.Lock() | |
| 409 | - | defer logErrMu.Unlock() | |
| 410 | - | return logErr | |
| 411 | - | } | |
| 412 | - | ||
| 413 | 380 | // stdout: parsed as JSONL. Every well-formed event is appended | |
| 414 | 381 | // to the durable log AND forwarded to the in-memory sink so the | |
| 415 | 382 | // dashboard updates without having to tail the file. Malformed | |
| @@ -431,7 +398,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 431 | 398 | trimmed := strings.TrimRight(string(line), "\r\n") | |
| 432 | 399 | if trimmed != "" && !truncated { | |
| 433 | 400 | if ev, ok := parseEventLine(trimmed); ok { | |
| 434 | - | appendLog(ev) | |
| 401 | + | recordWorkerTrace(r.agent, ev) | |
| 435 | 402 | if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil { | |
| 436 | 403 | sink.Transcript("error: metadata persistence failed: " + persistErr.Error()) | |
| 437 | 404 | } | |
| @@ -445,12 +412,12 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 445 | 412 | notifyPromptTurnEnd(r.agent, ev) | |
| 446 | 413 | } else { | |
| 447 | 414 | sink.Transcript(trimmed) | |
| 448 | - | appendLog(NewEvent("stdout", map[string]any{"text": trimmed})) | |
| 415 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed)}}) | |
| 449 | 416 | } | |
| 450 | 417 | } else if trimmed != "" { | |
| 451 | 418 | trimmed += "\n...[line truncated]" | |
| 452 | 419 | sink.Transcript(trimmed) | |
| 453 | - | appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true})) | |
| 420 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed), "truncated": true}}) | |
| 454 | 421 | } | |
| 455 | 422 | } | |
| 456 | 423 | if err != nil { | |
| @@ -481,7 +448,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 481 | 448 | txt += "\n...[line truncated]" | |
| 482 | 449 | } | |
| 483 | 450 | sink.Transcript("stderr: " + txt) | |
| 484 | - | appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated})) | |
| 451 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stderr", Data: map[string]any{"length": len(txt), "truncated": truncated}}) | |
| 485 | 452 | } | |
| 486 | 453 | if err != nil { | |
| 487 | 454 | return | |
| @@ -502,23 +469,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 502 | 469 | if errors.Is(ctxErr, context.DeadlineExceeded) { | |
| 503 | 470 | reason = "deadline" | |
| 504 | 471 | } | |
| 505 | - | appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason})) | |
| 506 | - | if logErr := firstLogErr(); logErr != nil { | |
| 507 | - | return fmt.Errorf("subagent event log: %w", logErr) | |
| 508 | - | } | |
| 472 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stopped", Data: map[string]any{"reason": reason}}) | |
| 509 | 473 | return ctxErr | |
| 510 | 474 | } | |
| 511 | 475 | if err != nil { | |
| 512 | - | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |
| 513 | - | if logErr := firstLogErr(); logErr != nil { | |
| 514 | - | return fmt.Errorf("subagent event log: %v; worker: %w", logErr, err) | |
| 515 | - | } | |
| 476 | + | r.agent.recordTrace(TraceEvent{Type: "worker.failed", Data: map[string]any{"reason": "exit", "code": exit, "error_kind": "exit"}}) | |
| 516 | 477 | return err | |
| 517 | 478 | } | |
| 518 | - | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |
| 519 | - | if logErr := firstLogErr(); logErr != nil { | |
| 520 | - | return fmt.Errorf("subagent event log: %w", logErr) | |
| 521 | - | } | |
| 479 | + | r.agent.recordTrace(TraceEvent{Type: "worker.finished", Data: map[string]any{"reason": "exit", "code": 0}}) | |
| 522 | 480 | sink.Activity("done") | |
| 523 | 481 | return nil | |
| 524 | 482 | } | |
| @@ -938,6 +896,44 @@ func isAssistantStreamBoundary(eventType string) bool { | |||
| 938 | 896 | } | |
| 939 | 897 | } | |
| 940 | 898 | ||
| 899 | + | // recordWorkerTrace reduces the worker's protocol event to a factual trace | |
| 900 | + | // observation. It deliberately records identifiers and bounded metadata, not | |
| 901 | + | // worker payloads, which can contain prompts, tool arguments, or credentials. | |
| 902 | + | func recordWorkerTrace(agent *Agent, ev Event) { | |
| 903 | + | if agent == nil { | |
| 904 | + | return | |
| 905 | + | } | |
| 906 | + | typeName := strings.ReplaceAll(ev.Type, "_", ".") | |
| 907 | + | traceType := "worker.event" | |
| 908 | + | switch ev.Type { | |
| 909 | + | case "turn_start", EventTurnStarted: | |
| 910 | + | traceType = "turn.started" | |
| 911 | + | case "turn_end", EventTurnResult: | |
| 912 | + | traceType = "turn.finished" | |
| 913 | + | case "turn_failed", EventTurnFailed: | |
| 914 | + | traceType = "turn.failed" | |
| 915 | + | case "tool_call", EventToolStarted: | |
| 916 | + | traceType = "tool.started" | |
| 917 | + | case "tool_result", EventToolFinished: | |
| 918 | + | traceType = "tool.finished" | |
| 919 | + | case "agent_ready", EventAgentReady: | |
| 920 | + | traceType = "agent.ready" | |
| 921 | + | case "agent_stopped": | |
| 922 | + | traceType = "agent.stopped" | |
| 923 | + | } | |
| 924 | + | data := map[string]any{"worker_event": typeName} | |
| 925 | + | if name, ok := ev.Data["name"].(string); ok && name != "" { | |
| 926 | + | data["name"] = name | |
| 927 | + | } | |
| 928 | + | if status, ok := ev.Data["status"].(string); ok && status != "" { | |
| 929 | + | data["status"] = status | |
| 930 | + | } | |
| 931 | + | if callID, ok := ev.Data["call_id"].(string); ok && callID != "" { | |
| 932 | + | data["call_id"] = callID | |
| 933 | + | } | |
| 934 | + | agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data}) | |
| 935 | + | } | |
| 936 | + | ||
| 941 | 937 | // applyEventToSink translates an Event into Sink updates. Only a | |
| 942 | 938 | // few event types are interpreted; the rest still land in the | |
| 943 | 939 | // durable log via the caller. | |
| @@ -12,7 +12,6 @@ import ( | |||||
| 12 | "os/exec" | 12 | "os/exec" | ||
| 13 | "path/filepath" | 13 | "path/filepath" | ||
| 14 | "strings" | 14 | "strings" | ||
| 15 | - | "sync" | |||
| 16 | "time" | 15 | "time" | ||
| 17 | ) | 16 | ) | ||
| 18 | 17 | ||||
| @@ -299,15 +298,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 299 | } | 298 | } | ||
| 300 | 299 | ||||
| 301 | inboxPath := r.agent.InboxPath | 300 | inboxPath := r.agent.InboxPath | ||
| 302 | - | logPath := r.agent.EventLogPath | |||
| 303 | - | if logPath == "" { | |||
| 304 | - | return fmt.Errorf("subagent: agent missing event log path") | |||
| 305 | - | } | |||
| 306 | - | log, err := OpenEventLog(logPath) | |||
| 307 | - | if err != nil { | |||
| 308 | - | return err | |||
| 309 | - | } | |||
| 310 | - | defer log.Close() | |||
| 311 | 301 | ||||
| 312 | args := r.Command | 302 | args := r.Command | ||
| 313 | if len(args) == 0 { | 303 | if len(args) == 0 { | ||
| @@ -337,7 +327,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 337 | cmd.Dir = r.agent.Dir | 327 | cmd.Dir = r.agent.Dir | ||
| 338 | cmd.Env = append(workerEnvironment(r.agent.Provider), | 328 | cmd.Env = append(workerEnvironment(r.agent.Provider), | ||
| 339 | "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID, | 329 | "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID, | ||
| 340 | - | "ZUT_SUBAGENT_EVENT_LOG="+logPath, | |||
| 341 | ) | 330 | ) | ||
| 342 | if r.agent.HeartbeatInterval > 0 { | 331 | if r.agent.HeartbeatInterval > 0 { | ||
| 343 | cmd.Env = append(cmd.Env, "ZUT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String()) | 332 | cmd.Env = append(cmd.Env, "ZUT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String()) | ||
| @@ -388,28 +377,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 388 | defer close(runnerDone) | 377 | defer close(runnerDone) | ||
| 389 | go r.stopOnContextDone(ctx, cmd, runnerDone) | 378 | go r.stopOnContextDone(ctx, cmd, runnerDone) | ||
| 390 | 379 | ||||
| 391 | - | var logErrMu sync.Mutex | |||
| 392 | - | var logErr error | |||
| 393 | - | var stopOnLogErr sync.Once | |||
| 394 | - | appendLog := func(ev Event) { | |||
| 395 | - | if err := log.Append(ev); err != nil { | |||
| 396 | - | logErrMu.Lock() | |||
| 397 | - | if logErr == nil { | |||
| 398 | - | logErr = err | |||
| 399 | - | } | |||
| 400 | - | logErrMu.Unlock() | |||
| 401 | - | // A durable event log is part of the runner's recovery contract. | |||
| 402 | - | // Stop the worker rather than letting it continue in a state the | |||
| 403 | - | // supervisor cannot reconstruct after restart. | |||
| 404 | - | stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) }) | |||
| 405 | - | } | |||
| 406 | - | } | |||
| 407 | - | firstLogErr := func() error { | |||
| 408 | - | logErrMu.Lock() | |||
| 409 | - | defer logErrMu.Unlock() | |||
| 410 | - | return logErr | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | // stdout: parsed as JSONL. Every well-formed event is appended | 380 | // stdout: parsed as JSONL. Every well-formed event is appended | ||
| 414 | // to the durable log AND forwarded to the in-memory sink so the | 381 | // to the durable log AND forwarded to the in-memory sink so the | ||
| 415 | // dashboard updates without having to tail the file. Malformed | 382 | // dashboard updates without having to tail the file. Malformed | ||
| @@ -431,7 +398,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 431 | trimmed := strings.TrimRight(string(line), "\r\n") | 398 | trimmed := strings.TrimRight(string(line), "\r\n") | ||
| 432 | if trimmed != "" && !truncated { | 399 | if trimmed != "" && !truncated { | ||
| 433 | if ev, ok := parseEventLine(trimmed); ok { | 400 | if ev, ok := parseEventLine(trimmed); ok { | ||
| 434 | - | appendLog(ev) | 401 | + | recordWorkerTrace(r.agent, ev) |
| 435 | if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil { | 402 | if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil { | ||
| 436 | sink.Transcript("error: metadata persistence failed: " + persistErr.Error()) | 403 | sink.Transcript("error: metadata persistence failed: " + persistErr.Error()) | ||
| 437 | } | 404 | } | ||
| @@ -445,12 +412,12 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 445 | notifyPromptTurnEnd(r.agent, ev) | 412 | notifyPromptTurnEnd(r.agent, ev) | ||
| 446 | } else { | 413 | } else { | ||
| 447 | sink.Transcript(trimmed) | 414 | sink.Transcript(trimmed) | ||
| 448 | - | appendLog(NewEvent("stdout", map[string]any{"text": trimmed})) | 415 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed)}}) |
| 449 | } | 416 | } | ||
| 450 | } else if trimmed != "" { | 417 | } else if trimmed != "" { | ||
| 451 | trimmed += "\n...[line truncated]" | 418 | trimmed += "\n...[line truncated]" | ||
| 452 | sink.Transcript(trimmed) | 419 | sink.Transcript(trimmed) | ||
| 453 | - | appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true})) | 420 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed), "truncated": true}}) |
| 454 | } | 421 | } | ||
| 455 | } | 422 | } | ||
| 456 | if err != nil { | 423 | if err != nil { | ||
| @@ -481,7 +448,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 481 | txt += "\n...[line truncated]" | 448 | txt += "\n...[line truncated]" | ||
| 482 | } | 449 | } | ||
| 483 | sink.Transcript("stderr: " + txt) | 450 | sink.Transcript("stderr: " + txt) | ||
| 484 | - | appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated})) | 451 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stderr", Data: map[string]any{"length": len(txt), "truncated": truncated}}) |
| 485 | } | 452 | } | ||
| 486 | if err != nil { | 453 | if err != nil { | ||
| 487 | return | 454 | return | ||
| @@ -502,23 +469,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 502 | if errors.Is(ctxErr, context.DeadlineExceeded) { | 469 | if errors.Is(ctxErr, context.DeadlineExceeded) { | ||
| 503 | reason = "deadline" | 470 | reason = "deadline" | ||
| 504 | } | 471 | } | ||
| 505 | - | appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason})) | 472 | + | r.agent.recordTrace(TraceEvent{Type: "worker.stopped", Data: map[string]any{"reason": reason}}) |
| 506 | - | if logErr := firstLogErr(); logErr != nil { | |||
| 507 | - | return fmt.Errorf("subagent event log: %w", logErr) | |||
| 508 | - | } | |||
| 509 | return ctxErr | 473 | return ctxErr | ||
| 510 | } | 474 | } | ||
| 511 | if err != nil { | 475 | if err != nil { | ||
| 512 | - | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | 476 | + | r.agent.recordTrace(TraceEvent{Type: "worker.failed", Data: map[string]any{"reason": "exit", "code": exit, "error_kind": "exit"}}) |
| 513 | - | if logErr := firstLogErr(); logErr != nil { | |||
| 514 | - | return fmt.Errorf("subagent event log: %v; worker: %w", logErr, err) | |||
| 515 | - | } | |||
| 516 | return err | 477 | return err | ||
| 517 | } | 478 | } | ||
| 518 | - | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | 479 | + | r.agent.recordTrace(TraceEvent{Type: "worker.finished", Data: map[string]any{"reason": "exit", "code": 0}}) |
| 519 | - | if logErr := firstLogErr(); logErr != nil { | |||
| 520 | - | return fmt.Errorf("subagent event log: %w", logErr) | |||
| 521 | - | } | |||
| 522 | sink.Activity("done") | 480 | sink.Activity("done") | ||
| 523 | return nil | 481 | return nil | ||
| 524 | } | 482 | } | ||
| @@ -938,6 +896,44 @@ func isAssistantStreamBoundary(eventType string) bool { | |||||
| 938 | } | 896 | } | ||
| 939 | } | 897 | } | ||
| 940 | 898 | ||||
| 899 | + | // recordWorkerTrace reduces the worker's protocol event to a factual trace | |||
| 900 | + | // observation. It deliberately records identifiers and bounded metadata, not | |||
| 901 | + | // worker payloads, which can contain prompts, tool arguments, or credentials. | |||
| 902 | + | func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 903 | + | if agent == nil { | |||
| 904 | + | return | |||
| 905 | + | } | |||
| 906 | + | typeName := strings.ReplaceAll(ev.Type, "_", ".") | |||
| 907 | + | traceType := "worker.event" | |||
| 908 | + | switch ev.Type { | |||
| 909 | + | case "turn_start", EventTurnStarted: | |||
| 910 | + | traceType = "turn.started" | |||
| 911 | + | case "turn_end", EventTurnResult: | |||
| 912 | + | traceType = "turn.finished" | |||
| 913 | + | case "turn_failed", EventTurnFailed: | |||
| 914 | + | traceType = "turn.failed" | |||
| 915 | + | case "tool_call", EventToolStarted: | |||
| 916 | + | traceType = "tool.started" | |||
| 917 | + | case "tool_result", EventToolFinished: | |||
| 918 | + | traceType = "tool.finished" | |||
| 919 | + | case "agent_ready", EventAgentReady: | |||
| 920 | + | traceType = "agent.ready" | |||
| 921 | + | case "agent_stopped": | |||
| 922 | + | traceType = "agent.stopped" | |||
| 923 | + | } | |||
| 924 | + | data := map[string]any{"worker_event": typeName} | |||
| 925 | + | if name, ok := ev.Data["name"].(string); ok && name != "" { | |||
| 926 | + | data["name"] = name | |||
| 927 | + | } | |||
| 928 | + | if status, ok := ev.Data["status"].(string); ok && status != "" { | |||
| 929 | + | data["status"] = status | |||
| 930 | + | } | |||
| 931 | + | if callID, ok := ev.Data["call_id"].(string); ok && callID != "" { | |||
| 932 | + | data["call_id"] = callID | |||
| 933 | + | } | |||
| 934 | + | agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data}) | |||
| 935 | + | } | |||
| 936 | + | ||||
| 941 | // applyEventToSink translates an Event into Sink updates. Only a | 937 | // applyEventToSink translates an Event into Sink updates. Only a | ||
| 942 | // few event types are interpreted; the rest still land in the | 938 | // few event types are interpreted; the rest still land in the | ||
| 943 | // durable log via the caller. | 939 | // durable log via the caller. | ||
M packages/agent/subagents/runner_e2e_test.go +6 -74
| @@ -106,34 +106,10 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te | |||
| 106 | 106 | t.Fatal("Run did not return after deadline expiry") | |
| 107 | 107 | } | |
| 108 | 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 | 109 | snapshot := a.Snapshot() | |
| 127 | 110 | if snapshot.LastAssistant != "partial answer" { | |
| 128 | 111 | t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer") | |
| 129 | 112 | } | |
| 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 | 113 | } | |
| 138 | 114 | ||
| 139 | 115 | func TestExecRunnerCancelsBlockedWorker(t *testing.T) { | |
| @@ -234,23 +210,18 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||
| 234 | 210 | t.Fatalf("Spawn: %v", err) | |
| 235 | 211 | } | |
| 236 | 212 | ||
| 237 | - | // Wait until the durable log has at least one assistant_message | |
| 238 | - | // from the initial task. That confirms stdout→log→follower. | |
| 213 | + | // Wait for the supervisor-owned in-memory transcript. Worker protocol | |
| 214 | + | // stdout is no longer duplicated into a per-agent durable event log. | |
| 239 | 215 | waitFor := func(want string) { | |
| 240 | 216 | t.Helper() | |
| 241 | - | deadline := time.Now().Add(5 * time.Second) | |
| 217 | + | deadline := time.Now().Add(runnerProcessTimeout) | |
| 242 | 218 | for time.Now().Before(deadline) { | |
| 243 | - | evs, _ := ReadEventLog(a.EventLogPath) | |
| 244 | - | for _, ev := range evs { | |
| 245 | - | if strings.Contains(eventText(ev), want) { | |
| 246 | - | return | |
| 247 | - | } | |
| 219 | + | if strings.Contains(strings.Join(a.Transcript(), "\n"), want) { | |
| 220 | + | return | |
| 248 | 221 | } | |
| 249 | 222 | time.Sleep(20 * time.Millisecond) | |
| 250 | 223 | } | |
| 251 | - | evs, _ := ReadEventLog(a.EventLogPath) | |
| 252 | - | t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s", | |
| 253 | - | want, len(evs), formatEvents(evs), dumpEventsVerbose(evs)) | |
| 224 | + | t.Fatalf("timed out waiting for %q in transcript: %q", want, a.Transcript()) | |
| 254 | 225 | } | |
| 255 | 226 | waitFor("echo: first task") | |
| 256 | 227 | ||
| @@ -291,45 +262,6 @@ func retrySend(f *Supervisor, id, msg string, timeout time.Duration) error { | |||
| 291 | 262 | return lastErr | |
| 292 | 263 | } | |
| 293 | 264 | ||
| 294 | - | func eventText(ev Event) string { | |
| 295 | - | if ev.Type != "assistant_message" && ev.Type != "user_message" { | |
| 296 | - | return "" | |
| 297 | - | } | |
| 298 | - | content, _ := ev.Data["content"].([]any) | |
| 299 | - | var sb strings.Builder | |
| 300 | - | for _, c := range content { | |
| 301 | - | m, _ := c.(map[string]any) | |
| 302 | - | if t, _ := m["type"].(string); t == "text" { | |
| 303 | - | if txt, _ := m["text"].(string); txt != "" { | |
| 304 | - | sb.WriteString(txt) | |
| 305 | - | sb.WriteByte('\n') | |
| 306 | - | } | |
| 307 | - | } | |
| 308 | - | } | |
| 309 | - | return sb.String() | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | func dumpEventsVerbose(evs []Event) string { | |
| 313 | - | var sb strings.Builder | |
| 314 | - | for _, ev := range evs { | |
| 315 | - | sb.WriteString(ev.Type) | |
| 316 | - | sb.WriteString("\t") | |
| 317 | - | for k, v := range ev.Data { | |
| 318 | - | sb.WriteString(k) | |
| 319 | - | sb.WriteString("=") | |
| 320 | - | switch vv := v.(type) { | |
| 321 | - | case string: | |
| 322 | - | sb.WriteString(vv) | |
| 323 | - | default: | |
| 324 | - | sb.WriteString("<...>") | |
| 325 | - | } | |
| 326 | - | sb.WriteString(" ") | |
| 327 | - | } | |
| 328 | - | sb.WriteString("\n") | |
| 329 | - | } | |
| 330 | - | return sb.String() | |
| 331 | - | } | |
| 332 | - | ||
| 333 | 265 | func formatEvents(evs []Event) string { | |
| 334 | 266 | var sb strings.Builder | |
| 335 | 267 | for _, ev := range evs { | |
| @@ -106,34 +106,10 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te | |||||
| 106 | t.Fatal("Run did not return after deadline expiry") | 106 | t.Fatal("Run did not return after deadline expiry") | ||
| 107 | } | 107 | } | ||
| 108 | 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() | 109 | snapshot := a.Snapshot() | ||
| 127 | if snapshot.LastAssistant != "partial answer" { | 110 | if snapshot.LastAssistant != "partial answer" { | ||
| 128 | t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer") | 111 | t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer") | ||
| 129 | } | 112 | } | ||
| 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 | } | 113 | } | ||
| 138 | 114 | ||||
| 139 | func TestExecRunnerCancelsBlockedWorker(t *testing.T) { | 115 | func TestExecRunnerCancelsBlockedWorker(t *testing.T) { | ||
| @@ -234,23 +210,18 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||||
| 234 | t.Fatalf("Spawn: %v", err) | 210 | t.Fatalf("Spawn: %v", err) | ||
| 235 | } | 211 | } | ||
| 236 | 212 | ||||
| 237 | - | // Wait until the durable log has at least one assistant_message | 213 | + | // Wait for the supervisor-owned in-memory transcript. Worker protocol |
| 238 | - | // from the initial task. That confirms stdout→log→follower. | 214 | + | // stdout is no longer duplicated into a per-agent durable event log. |
| 239 | waitFor := func(want string) { | 215 | waitFor := func(want string) { | ||
| 240 | t.Helper() | 216 | t.Helper() | ||
| 241 | - | deadline := time.Now().Add(5 * time.Second) | 217 | + | deadline := time.Now().Add(runnerProcessTimeout) |
| 242 | for time.Now().Before(deadline) { | 218 | for time.Now().Before(deadline) { | ||
| 243 | - | evs, _ := ReadEventLog(a.EventLogPath) | 219 | + | if strings.Contains(strings.Join(a.Transcript(), "\n"), want) { |
| 244 | - | for _, ev := range evs { | 220 | + | return |
| 245 | - | if strings.Contains(eventText(ev), want) { | |||
| 246 | - | return | |||
| 247 | - | } | |||
| 248 | } | 221 | } | ||
| 249 | time.Sleep(20 * time.Millisecond) | 222 | time.Sleep(20 * time.Millisecond) | ||
| 250 | } | 223 | } | ||
| 251 | - | evs, _ := ReadEventLog(a.EventLogPath) | 224 | + | t.Fatalf("timed out waiting for %q in transcript: %q", want, a.Transcript()) |
| 252 | - | t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s", | |||
| 253 | - | want, len(evs), formatEvents(evs), dumpEventsVerbose(evs)) | |||
| 254 | } | 225 | } | ||
| 255 | waitFor("echo: first task") | 226 | waitFor("echo: first task") | ||
| 256 | 227 | ||||
| @@ -291,45 +262,6 @@ func retrySend(f *Supervisor, id, msg string, timeout time.Duration) error { | |||||
| 291 | return lastErr | 262 | return lastErr | ||
| 292 | } | 263 | } | ||
| 293 | 264 | ||||
| 294 | - | func eventText(ev Event) string { | |||
| 295 | - | if ev.Type != "assistant_message" && ev.Type != "user_message" { | |||
| 296 | - | return "" | |||
| 297 | - | } | |||
| 298 | - | content, _ := ev.Data["content"].([]any) | |||
| 299 | - | var sb strings.Builder | |||
| 300 | - | for _, c := range content { | |||
| 301 | - | m, _ := c.(map[string]any) | |||
| 302 | - | if t, _ := m["type"].(string); t == "text" { | |||
| 303 | - | if txt, _ := m["text"].(string); txt != "" { | |||
| 304 | - | sb.WriteString(txt) | |||
| 305 | - | sb.WriteByte('\n') | |||
| 306 | - | } | |||
| 307 | - | } | |||
| 308 | - | } | |||
| 309 | - | return sb.String() | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | - | func dumpEventsVerbose(evs []Event) string { | |||
| 313 | - | var sb strings.Builder | |||
| 314 | - | for _, ev := range evs { | |||
| 315 | - | sb.WriteString(ev.Type) | |||
| 316 | - | sb.WriteString("\t") | |||
| 317 | - | for k, v := range ev.Data { | |||
| 318 | - | sb.WriteString(k) | |||
| 319 | - | sb.WriteString("=") | |||
| 320 | - | switch vv := v.(type) { | |||
| 321 | - | case string: | |||
| 322 | - | sb.WriteString(vv) | |||
| 323 | - | default: | |||
| 324 | - | sb.WriteString("<...>") | |||
| 325 | - | } | |||
| 326 | - | sb.WriteString(" ") | |||
| 327 | - | } | |||
| 328 | - | sb.WriteString("\n") | |||
| 329 | - | } | |||
| 330 | - | return sb.String() | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | func formatEvents(evs []Event) string { | 265 | func formatEvents(evs []Event) string { | ||
| 334 | var sb strings.Builder | 266 | var sb strings.Builder | ||
| 335 | for _, ev := range evs { | 267 | for _, ev := range evs { | ||
M packages/agent/subagents/supervisor.go +88 -0
| @@ -21,6 +21,8 @@ import ( | |||
| 21 | 21 | "sync" | |
| 22 | 22 | "sync/atomic" | |
| 23 | 23 | "time" | |
| 24 | + | ||
| 25 | + | "github.com/google/uuid" | |
| 24 | 26 | ) | |
| 25 | 27 | ||
| 26 | 28 | // Status is the high-level lifecycle state of an Agent. | |
| @@ -85,6 +87,12 @@ type Config struct { | |||
| 85 | 87 | // keeping command-backed credentials out of argv and the environment. | |
| 86 | 88 | ResolveCredential func(ctx context.Context, provider string) (Credential, error) | |
| 87 | 89 | ||
| 90 | + | // TraceDir enables local execution tracing. When non-empty, the supervisor | |
| 91 | + | // creates one private bundle beneath this directory and is its sole writer. | |
| 92 | + | // An empty directory leaves tracing disabled. | |
| 93 | + | TraceDir string | |
| 94 | + | TraceMode TraceMode | |
| 95 | + | ||
| 88 | 96 | // Now is a clock seam for tests; defaults to time.Now. | |
| 89 | 97 | Now func() time.Time | |
| 90 | 98 | } | |
| @@ -134,6 +142,7 @@ type Supervisor struct { | |||
| 134 | 142 | activeByParent map[string]int | |
| 135 | 143 | activeByBatch map[string]int | |
| 136 | 144 | batches map[string]*Batch | |
| 145 | + | trace *TraceWriter | |
| 137 | 146 | ||
| 138 | 147 | // activeSession is the host session id the dashboard is | |
| 139 | 148 | // currently scoped to. When non-empty, SnapshotAll filters out | |
| @@ -163,6 +172,17 @@ func New(cfg Config) *Supervisor { | |||
| 163 | 172 | } | |
| 164 | 173 | } | |
| 165 | 174 | cfg.Policy.normalize() | |
| 175 | + | trace := NewMemoryTraceWriter() | |
| 176 | + | if cfg.TraceDir != "" { | |
| 177 | + | // Tracing is observational only: inability to create its optional local | |
| 178 | + | // bundle must not prevent the supervisor from running workers. The | |
| 179 | + | // in-memory trace remains the sole status projection in that case. | |
| 180 | + | if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil { | |
| 181 | + | trace = bundle | |
| 182 | + | } else { | |
| 183 | + | trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}}) | |
| 184 | + | } | |
| 185 | + | } | |
| 166 | 186 | lifetimeParent := cfg.Context | |
| 167 | 187 | if lifetimeParent == nil { | |
| 168 | 188 | lifetimeParent = context.Background() | |
| @@ -176,6 +196,7 @@ func New(cfg Config) *Supervisor { | |||
| 176 | 196 | activeByParent: map[string]int{}, | |
| 177 | 197 | activeByBatch: map[string]int{}, | |
| 178 | 198 | batches: map[string]*Batch{}, | |
| 199 | + | trace: trace, | |
| 179 | 200 | } | |
| 180 | 201 | } | |
| 181 | 202 | ||
| @@ -551,6 +572,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 551 | 572 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | |
| 552 | 573 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |
| 553 | 574 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |
| 575 | + | a.trace = f.trace | |
| 554 | 576 | a.runner = f.cfg.NewRunner(a) | |
| 555 | 577 | if err := writeAgentMeta(stateDir, a); err != nil { | |
| 556 | 578 | if a.cancel != nil { | |
| @@ -569,6 +591,9 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 569 | 591 | f.queue = append(f.queue, a) | |
| 570 | 592 | f.mu.Unlock() | |
| 571 | 593 | leaseOwned = false | |
| 594 | + | f.recordTrace(TraceEvent{Type: "agent.spawned", AgentID: a.ID, Data: map[string]any{ | |
| 595 | + | "task_length": len(task), "workspace_mode": string(workspaceMode), | |
| 596 | + | }}) | |
| 572 | 597 | ||
| 573 | 598 | // Persisted metadata was written before queue admission, so a later | |
| 574 | 599 | // supervisor can reconstruct work that never reached a process. | |
| @@ -582,6 +607,57 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 582 | 607 | // expected to have already trimmed and expanded the text. | |
| 583 | 608 | // CancelTurn requests cancellation of the current turn while keeping the | |
| 584 | 609 | // worker process alive for a later follow-up. | |
| 610 | + | func traceErrorKind(err error) string { | |
| 611 | + | if err == nil { | |
| 612 | + | return "" | |
| 613 | + | } | |
| 614 | + | switch { | |
| 615 | + | case errors.Is(err, context.Canceled): | |
| 616 | + | return "canceled" | |
| 617 | + | case errors.Is(err, context.DeadlineExceeded): | |
| 618 | + | return "deadline_exceeded" | |
| 619 | + | default: | |
| 620 | + | return "error" | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | func (f *Supervisor) recordTrace(event TraceEvent) { | |
| 625 | + | if f == nil || f.trace == nil { | |
| 626 | + | return | |
| 627 | + | } | |
| 628 | + | f.trace.Record(event) | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | // TraceDir returns the opt-in bundle directory, or an empty string when tracing | |
| 632 | + | // is disabled. | |
| 633 | + | func (f *Supervisor) TraceDir() string { | |
| 634 | + | if f == nil || f.trace == nil { | |
| 635 | + | return "" | |
| 636 | + | } | |
| 637 | + | return f.trace.Dir() | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | // TraceViews projects the supervisor's factual trace into the diagnostic state | |
| 641 | + | // for each observed agent. It intentionally does not expose lifecycle labels. | |
| 642 | + | func (f *Supervisor) TraceViews() map[string]AgentTraceView { | |
| 643 | + | if f == nil || f.trace == nil { | |
| 644 | + | return map[string]AgentTraceView{} | |
| 645 | + | } | |
| 646 | + | return ProjectTrace(f.trace.Events()) | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | // Close flushes and closes the optional trace bundle. It does not stop agents; | |
| 650 | + | // their lifetime remains controlled by the supervisor context and Stop. | |
| 651 | + | func (f *Supervisor) Close() error { return f.CloseContext(context.Background()) } | |
| 652 | + | ||
| 653 | + | // CloseContext flushes and closes the optional trace bundle within ctx. | |
| 654 | + | func (f *Supervisor) CloseContext(ctx context.Context) error { | |
| 655 | + | if f == nil || f.trace == nil { | |
| 656 | + | return nil | |
| 657 | + | } | |
| 658 | + | return f.trace.CloseContext(ctx) | |
| 659 | + | } | |
| 660 | + | ||
| 585 | 661 | func (f *Supervisor) CancelTurn(id string) error { | |
| 586 | 662 | a := f.Get(id) | |
| 587 | 663 | if a == nil { | |
| @@ -591,6 +667,7 @@ func (f *Supervisor) CancelTurn(id string) error { | |||
| 591 | 667 | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |
| 592 | 668 | } | |
| 593 | 669 | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | |
| 670 | + | f.recordTrace(TraceEvent{Type: "turn.cancel.requested", AgentID: a.ID, TurnID: a.CurrentTurnID()}) | |
| 594 | 671 | return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"})) | |
| 595 | 672 | } | |
| 596 | 673 | ||
| @@ -602,6 +679,7 @@ func (f *Supervisor) SendUserTurn(id, text string) error { | |||
| 602 | 679 | if a.inbox == nil { | |
| 603 | 680 | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |
| 604 | 681 | } | |
| 682 | + | f.recordTrace(TraceEvent{Type: "turn.requested", AgentID: a.ID, Data: map[string]any{"prompt_length": len(text)}}) | |
| 605 | 683 | return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text})) | |
| 606 | 684 | } | |
| 607 | 685 | ||
| @@ -621,6 +699,7 @@ func (f *Supervisor) run(a *Agent) { | |||
| 621 | 699 | err = context.Canceled | |
| 622 | 700 | } | |
| 623 | 701 | } else { | |
| 702 | + | f.recordTrace(TraceEvent{Type: "agent.started", AgentID: a.ID}) | |
| 624 | 703 | a.setActivity("starting") | |
| 625 | 704 | if a.runner == nil { | |
| 626 | 705 | err = errors.New("subagents: agent has no runner") | |
| @@ -659,6 +738,14 @@ func (f *Supervisor) run(a *Agent) { | |||
| 659 | 738 | } | |
| 660 | 739 | finalStatus := a.status | |
| 661 | 740 | a.mu.Unlock() | |
| 741 | + | terminalType := "agent.finished" | |
| 742 | + | switch { | |
| 743 | + | case finalStatus == StatusKilled: | |
| 744 | + | terminalType = "agent.cancelled" | |
| 745 | + | case terminalErr != nil: | |
| 746 | + | terminalType = "agent.failed" | |
| 747 | + | } | |
| 748 | + | f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}}) | |
| 662 | 749 | ||
| 663 | 750 | switch finalStatus { | |
| 664 | 751 | case StatusKilled: | |
| @@ -1026,6 +1113,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu | |||
| 1026 | 1113 | // StopAll cancels every running agent. Used on shutdown. | |
| 1027 | 1114 | func (f *Supervisor) StopAll() { | |
| 1028 | 1115 | _ = f.stopAll(context.Background()) | |
| 1116 | + | _ = f.Close() | |
| 1029 | 1117 | } | |
| 1030 | 1118 | ||
| 1031 | 1119 | // StopAllContext is the context-aware shutdown variant. It returns the | |
| @@ -21,6 +21,8 @@ import ( | |||||
| 21 | "sync" | 21 | "sync" | ||
| 22 | "sync/atomic" | 22 | "sync/atomic" | ||
| 23 | "time" | 23 | "time" | ||
| 24 | + | ||||
| 25 | + | "github.com/google/uuid" | |||
| 24 | ) | 26 | ) | ||
| 25 | 27 | ||||
| 26 | // Status is the high-level lifecycle state of an Agent. | 28 | // Status is the high-level lifecycle state of an Agent. | ||
| @@ -85,6 +87,12 @@ type Config struct { | |||||
| 85 | // keeping command-backed credentials out of argv and the environment. | 87 | // keeping command-backed credentials out of argv and the environment. | ||
| 86 | ResolveCredential func(ctx context.Context, provider string) (Credential, error) | 88 | ResolveCredential func(ctx context.Context, provider string) (Credential, error) | ||
| 87 | 89 | ||||
| 90 | + | // TraceDir enables local execution tracing. When non-empty, the supervisor | |||
| 91 | + | // creates one private bundle beneath this directory and is its sole writer. | |||
| 92 | + | // An empty directory leaves tracing disabled. | |||
| 93 | + | TraceDir string | |||
| 94 | + | TraceMode TraceMode | |||
| 95 | + | ||||
| 88 | // Now is a clock seam for tests; defaults to time.Now. | 96 | // Now is a clock seam for tests; defaults to time.Now. | ||
| 89 | Now func() time.Time | 97 | Now func() time.Time | ||
| 90 | } | 98 | } | ||
| @@ -134,6 +142,7 @@ type Supervisor struct { | |||||
| 134 | activeByParent map[string]int | 142 | activeByParent map[string]int | ||
| 135 | activeByBatch map[string]int | 143 | activeByBatch map[string]int | ||
| 136 | batches map[string]*Batch | 144 | batches map[string]*Batch | ||
| 145 | + | trace *TraceWriter | |||
| 137 | 146 | ||||
| 138 | // activeSession is the host session id the dashboard is | 147 | // activeSession is the host session id the dashboard is | ||
| 139 | // currently scoped to. When non-empty, SnapshotAll filters out | 148 | // currently scoped to. When non-empty, SnapshotAll filters out | ||
| @@ -163,6 +172,17 @@ func New(cfg Config) *Supervisor { | |||||
| 163 | } | 172 | } | ||
| 164 | } | 173 | } | ||
| 165 | cfg.Policy.normalize() | 174 | cfg.Policy.normalize() | ||
| 175 | + | trace := NewMemoryTraceWriter() | |||
| 176 | + | if cfg.TraceDir != "" { | |||
| 177 | + | // Tracing is observational only: inability to create its optional local | |||
| 178 | + | // bundle must not prevent the supervisor from running workers. The | |||
| 179 | + | // in-memory trace remains the sole status projection in that case. | |||
| 180 | + | if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil { | |||
| 181 | + | trace = bundle | |||
| 182 | + | } else { | |||
| 183 | + | trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}}) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 166 | lifetimeParent := cfg.Context | 186 | lifetimeParent := cfg.Context | ||
| 167 | if lifetimeParent == nil { | 187 | if lifetimeParent == nil { | ||
| 168 | lifetimeParent = context.Background() | 188 | lifetimeParent = context.Background() | ||
| @@ -176,6 +196,7 @@ func New(cfg Config) *Supervisor { | |||||
| 176 | activeByParent: map[string]int{}, | 196 | activeByParent: map[string]int{}, | ||
| 177 | activeByBatch: map[string]int{}, | 197 | activeByBatch: map[string]int{}, | ||
| 178 | batches: map[string]*Batch{}, | 198 | batches: map[string]*Batch{}, | ||
| 199 | + | trace: trace, | |||
| 179 | } | 200 | } | ||
| 180 | } | 201 | } | ||
| 181 | 202 | ||||
| @@ -551,6 +572,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 551 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | 572 | a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) }) | ||
| 552 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | 573 | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | ||
| 553 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | 574 | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | ||
| 575 | + | a.trace = f.trace | |||
| 554 | a.runner = f.cfg.NewRunner(a) | 576 | a.runner = f.cfg.NewRunner(a) | ||
| 555 | if err := writeAgentMeta(stateDir, a); err != nil { | 577 | if err := writeAgentMeta(stateDir, a); err != nil { | ||
| 556 | if a.cancel != nil { | 578 | if a.cancel != nil { | ||
| @@ -569,6 +591,9 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 569 | f.queue = append(f.queue, a) | 591 | f.queue = append(f.queue, a) | ||
| 570 | f.mu.Unlock() | 592 | f.mu.Unlock() | ||
| 571 | leaseOwned = false | 593 | leaseOwned = false | ||
| 594 | + | f.recordTrace(TraceEvent{Type: "agent.spawned", AgentID: a.ID, Data: map[string]any{ | |||
| 595 | + | "task_length": len(task), "workspace_mode": string(workspaceMode), | |||
| 596 | + | }}) | |||
| 572 | 597 | ||||
| 573 | // Persisted metadata was written before queue admission, so a later | 598 | // Persisted metadata was written before queue admission, so a later | ||
| 574 | // supervisor can reconstruct work that never reached a process. | 599 | // supervisor can reconstruct work that never reached a process. | ||
| @@ -582,6 +607,57 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 582 | // expected to have already trimmed and expanded the text. | 607 | // expected to have already trimmed and expanded the text. | ||
| 583 | // CancelTurn requests cancellation of the current turn while keeping the | 608 | // CancelTurn requests cancellation of the current turn while keeping the | ||
| 584 | // worker process alive for a later follow-up. | 609 | // worker process alive for a later follow-up. | ||
| 610 | + | func traceErrorKind(err error) string { | |||
| 611 | + | if err == nil { | |||
| 612 | + | return "" | |||
| 613 | + | } | |||
| 614 | + | switch { | |||
| 615 | + | case errors.Is(err, context.Canceled): | |||
| 616 | + | return "canceled" | |||
| 617 | + | case errors.Is(err, context.DeadlineExceeded): | |||
| 618 | + | return "deadline_exceeded" | |||
| 619 | + | default: | |||
| 620 | + | return "error" | |||
| 621 | + | } | |||
| 622 | + | } | |||
| 623 | + | ||||
| 624 | + | func (f *Supervisor) recordTrace(event TraceEvent) { | |||
| 625 | + | if f == nil || f.trace == nil { | |||
| 626 | + | return | |||
| 627 | + | } | |||
| 628 | + | f.trace.Record(event) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | // TraceDir returns the opt-in bundle directory, or an empty string when tracing | |||
| 632 | + | // is disabled. | |||
| 633 | + | func (f *Supervisor) TraceDir() string { | |||
| 634 | + | if f == nil || f.trace == nil { | |||
| 635 | + | return "" | |||
| 636 | + | } | |||
| 637 | + | return f.trace.Dir() | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | // TraceViews projects the supervisor's factual trace into the diagnostic state | |||
| 641 | + | // for each observed agent. It intentionally does not expose lifecycle labels. | |||
| 642 | + | func (f *Supervisor) TraceViews() map[string]AgentTraceView { | |||
| 643 | + | if f == nil || f.trace == nil { | |||
| 644 | + | return map[string]AgentTraceView{} | |||
| 645 | + | } | |||
| 646 | + | return ProjectTrace(f.trace.Events()) | |||
| 647 | + | } | |||
| 648 | + | ||||
| 649 | + | // Close flushes and closes the optional trace bundle. It does not stop agents; | |||
| 650 | + | // their lifetime remains controlled by the supervisor context and Stop. | |||
| 651 | + | func (f *Supervisor) Close() error { return f.CloseContext(context.Background()) } | |||
| 652 | + | ||||
| 653 | + | // CloseContext flushes and closes the optional trace bundle within ctx. | |||
| 654 | + | func (f *Supervisor) CloseContext(ctx context.Context) error { | |||
| 655 | + | if f == nil || f.trace == nil { | |||
| 656 | + | return nil | |||
| 657 | + | } | |||
| 658 | + | return f.trace.CloseContext(ctx) | |||
| 659 | + | } | |||
| 660 | + | ||||
| 585 | func (f *Supervisor) CancelTurn(id string) error { | 661 | func (f *Supervisor) CancelTurn(id string) error { | ||
| 586 | a := f.Get(id) | 662 | a := f.Get(id) | ||
| 587 | if a == nil { | 663 | if a == nil { | ||
| @@ -591,6 +667,7 @@ func (f *Supervisor) CancelTurn(id string) error { | |||||
| 591 | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | 667 | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | ||
| 592 | } | 668 | } | ||
| 593 | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | 669 | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | ||
| 670 | + | f.recordTrace(TraceEvent{Type: "turn.cancel.requested", AgentID: a.ID, TurnID: a.CurrentTurnID()}) | |||
| 594 | return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"})) | 671 | return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"})) | ||
| 595 | } | 672 | } | ||
| 596 | 673 | ||||
| @@ -602,6 +679,7 @@ func (f *Supervisor) SendUserTurn(id, text string) error { | |||||
| 602 | if a.inbox == nil { | 679 | if a.inbox == nil { | ||
| 603 | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | 680 | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | ||
| 604 | } | 681 | } | ||
| 682 | + | f.recordTrace(TraceEvent{Type: "turn.requested", AgentID: a.ID, Data: map[string]any{"prompt_length": len(text)}}) | |||
| 605 | return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text})) | 683 | return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text})) | ||
| 606 | } | 684 | } | ||
| 607 | 685 | ||||
| @@ -621,6 +699,7 @@ func (f *Supervisor) run(a *Agent) { | |||||
| 621 | err = context.Canceled | 699 | err = context.Canceled | ||
| 622 | } | 700 | } | ||
| 623 | } else { | 701 | } else { | ||
| 702 | + | f.recordTrace(TraceEvent{Type: "agent.started", AgentID: a.ID}) | |||
| 624 | a.setActivity("starting") | 703 | a.setActivity("starting") | ||
| 625 | if a.runner == nil { | 704 | if a.runner == nil { | ||
| 626 | err = errors.New("subagents: agent has no runner") | 705 | err = errors.New("subagents: agent has no runner") | ||
| @@ -659,6 +738,14 @@ func (f *Supervisor) run(a *Agent) { | |||||
| 659 | } | 738 | } | ||
| 660 | finalStatus := a.status | 739 | finalStatus := a.status | ||
| 661 | a.mu.Unlock() | 740 | a.mu.Unlock() | ||
| 741 | + | terminalType := "agent.finished" | |||
| 742 | + | switch { | |||
| 743 | + | case finalStatus == StatusKilled: | |||
| 744 | + | terminalType = "agent.cancelled" | |||
| 745 | + | case terminalErr != nil: | |||
| 746 | + | terminalType = "agent.failed" | |||
| 747 | + | } | |||
| 748 | + | f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}}) | |||
| 662 | 749 | ||||
| 663 | switch finalStatus { | 750 | switch finalStatus { | ||
| 664 | case StatusKilled: | 751 | case StatusKilled: | ||
| @@ -1026,6 +1113,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu | |||||
| 1026 | // StopAll cancels every running agent. Used on shutdown. | 1113 | // StopAll cancels every running agent. Used on shutdown. | ||
| 1027 | func (f *Supervisor) StopAll() { | 1114 | func (f *Supervisor) StopAll() { | ||
| 1028 | _ = f.stopAll(context.Background()) | 1115 | _ = f.stopAll(context.Background()) | ||
| 1116 | + | _ = f.Close() | |||
| 1029 | } | 1117 | } | ||
| 1030 | 1118 | ||||
| 1031 | // StopAllContext is the context-aware shutdown variant. It returns the | 1119 | // StopAllContext is the context-aware shutdown variant. It returns the | ||
A packages/agent/subagents/trace.go +635 -0
| @@ -0,0 +1,635 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "reflect" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // TraceMode controls how much data a trace keeps. Normal is the safe default: | |
| 17 | + | // event metadata is retained, while payloads and values under sensitive keys | |
| 18 | + | // are removed. Detailed is intended for explicitly enabled local debugging. | |
| 19 | + | type TraceMode string | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | TraceModeNormal TraceMode = "normal" | |
| 23 | + | TraceModeDetailed TraceMode = "detailed" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // Normal and Detailed are short aliases useful to callers configuring a | |
| 27 | + | // TraceWriter. Invalid modes are treated as Normal. | |
| 28 | + | const ( | |
| 29 | + | Normal = TraceModeNormal | |
| 30 | + | Detailed = TraceModeDetailed | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | func (m TraceMode) valid() TraceMode { | |
| 34 | + | if m == TraceModeDetailed { | |
| 35 | + | return m | |
| 36 | + | } | |
| 37 | + | return TraceModeNormal | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // TraceEvent is one entry in trace.jsonl. Payload is never embedded in the | |
| 41 | + | // JSONL stream: detailed traces store it as a separate file and normal traces | |
| 42 | + | // discard it. Data is for small event metadata and is recursively redacted in | |
| 43 | + | // normal mode when a key identifies a secret or payload. | |
| 44 | + | type TraceEvent struct { | |
| 45 | + | Seq uint64 `json:"seq"` | |
| 46 | + | Timestamp time.Time `json:"timestamp"` | |
| 47 | + | Type string `json:"type"` | |
| 48 | + | AgentID string `json:"agent_id,omitempty"` | |
| 49 | + | TurnID string `json:"turn_id,omitempty"` | |
| 50 | + | Data map[string]any `json:"data,omitempty"` | |
| 51 | + | Payload any `json:"-"` | |
| 52 | + | PayloadFile string `json:"payload_file,omitempty"` | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // NewTraceEvent constructs an event with a UTC timestamp. A TraceWriter | |
| 56 | + | // assigns Seq when the event is recorded; callers should not assign it. | |
| 57 | + | func NewTraceEvent(typ string, data map[string]any) TraceEvent { | |
| 58 | + | return TraceEvent{ | |
| 59 | + | Timestamp: time.Now().UTC(), | |
| 60 | + | Type: typ, | |
| 61 | + | Data: data, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TraceManifest describes the files in a trace bundle. | |
| 66 | + | type TraceManifest struct { | |
| 67 | + | Version int `json:"version"` | |
| 68 | + | Mode TraceMode `json:"mode"` | |
| 69 | + | CreatedAt time.Time `json:"created_at"` | |
| 70 | + | TraceFile string `json:"trace_file"` | |
| 71 | + | Payloads string `json:"payloads"` | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // TraceWriter writes a trace bundle. Record is deliberately asynchronous: it | |
| 75 | + | // only snapshots the event and adds it to an in-memory queue. Disk failures | |
| 76 | + | // are retained for Close, but never returned from Record or allowed to affect | |
| 77 | + | // the agent that is being traced. | |
| 78 | + | type TraceWriter struct { | |
| 79 | + | mu sync.Mutex | |
| 80 | + | cond *sync.Cond | |
| 81 | + | pending []traceRecord | |
| 82 | + | closed bool | |
| 83 | + | finished chan struct{} | |
| 84 | + | closeOnce sync.Once | |
| 85 | + | closeDone chan struct{} | |
| 86 | + | ||
| 87 | + | dir string | |
| 88 | + | mode TraceMode | |
| 89 | + | traceFile *os.File | |
| 90 | + | payloadDir string | |
| 91 | + | memory bool | |
| 92 | + | sequence uint64 | |
| 93 | + | dropped uint64 | |
| 94 | + | events []TraceEvent | |
| 95 | + | ||
| 96 | + | errorMu sync.Mutex | |
| 97 | + | err error | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | type traceRecord struct { | |
| 101 | + | event TraceEvent | |
| 102 | + | payload []byte | |
| 103 | + | barrier chan struct{} | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | const ( | |
| 107 | + | traceQueueInitialCapacity = 64 | |
| 108 | + | traceQueueLimit = 1024 | |
| 109 | + | memoryTraceEventLimit = 4096 | |
| 110 | + | ) | |
| 111 | + | ||
| 112 | + | // NewMemoryTraceWriter creates the non-persistent projection source used when | |
| 113 | + | // bundle capture is disabled. It retains only normal-mode event metadata. | |
| 114 | + | func NewMemoryTraceWriter() *TraceWriter { | |
| 115 | + | w := &TraceWriter{ | |
| 116 | + | mode: TraceModeNormal, | |
| 117 | + | memory: true, | |
| 118 | + | pending: make([]traceRecord, 0, traceQueueInitialCapacity), | |
| 119 | + | finished: make(chan struct{}), | |
| 120 | + | closeDone: make(chan struct{}), | |
| 121 | + | events: make([]TraceEvent, 0, traceQueueInitialCapacity), | |
| 122 | + | } | |
| 123 | + | w.cond = sync.NewCond(&w.mu) | |
| 124 | + | go w.run() | |
| 125 | + | return w | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // NewTraceWriter creates a private trace bundle at dir. The directory and its | |
| 129 | + | // files are created with restrictive permissions. If mode is omitted or | |
| 130 | + | // invalid, TraceModeNormal is used. | |
| 131 | + | func NewTraceWriter(dir string, modes ...TraceMode) (*TraceWriter, error) { | |
| 132 | + | mode := TraceModeNormal | |
| 133 | + | if len(modes) != 0 { | |
| 134 | + | mode = modes[0].valid() | |
| 135 | + | } | |
| 136 | + | if dir == "" { | |
| 137 | + | return nil, errors.New("trace writer: empty directory") | |
| 138 | + | } | |
| 139 | + | absolute, err := filepath.Abs(dir) | |
| 140 | + | if err != nil { | |
| 141 | + | return nil, fmt.Errorf("trace writer path: %w", err) | |
| 142 | + | } | |
| 143 | + | if err := os.MkdirAll(absolute, 0o700); err != nil { | |
| 144 | + | return nil, fmt.Errorf("trace writer directory: %w", err) | |
| 145 | + | } | |
| 146 | + | // MkdirAll does not tighten an existing directory's permissions. | |
| 147 | + | if err := os.Chmod(absolute, 0o700); err != nil { | |
| 148 | + | return nil, fmt.Errorf("trace writer directory permissions: %w", err) | |
| 149 | + | } | |
| 150 | + | if _, err := os.Stat(filepath.Join(absolute, "trace.jsonl")); err == nil { | |
| 151 | + | return nil, fmt.Errorf("trace writer: bundle already exists") | |
| 152 | + | } else if !errors.Is(err, os.ErrNotExist) { | |
| 153 | + | return nil, fmt.Errorf("trace writer trace file: %w", err) | |
| 154 | + | } | |
| 155 | + | payloadDir := filepath.Join(absolute, "payloads") | |
| 156 | + | if err := os.MkdirAll(payloadDir, 0o700); err != nil { | |
| 157 | + | return nil, fmt.Errorf("trace writer payload directory: %w", err) | |
| 158 | + | } | |
| 159 | + | if err := os.Chmod(payloadDir, 0o700); err != nil { | |
| 160 | + | return nil, fmt.Errorf("trace writer payload permissions: %w", err) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | traceFile, err := os.OpenFile(filepath.Join(absolute, "trace.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) | |
| 164 | + | if err != nil { | |
| 165 | + | return nil, fmt.Errorf("trace writer trace file: %w", err) | |
| 166 | + | } | |
| 167 | + | if err := traceFile.Chmod(0o600); err != nil { | |
| 168 | + | _ = traceFile.Close() | |
| 169 | + | return nil, fmt.Errorf("trace writer trace permissions: %w", err) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | manifest := TraceManifest{ | |
| 173 | + | Version: 1, | |
| 174 | + | Mode: mode, | |
| 175 | + | CreatedAt: time.Now().UTC(), | |
| 176 | + | TraceFile: "trace.jsonl", | |
| 177 | + | Payloads: "payloads", | |
| 178 | + | } | |
| 179 | + | if err := writeTraceManifest(filepath.Join(absolute, "manifest.json"), manifest); err != nil { | |
| 180 | + | _ = traceFile.Close() | |
| 181 | + | return nil, err | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | w := &TraceWriter{ | |
| 185 | + | dir: absolute, | |
| 186 | + | mode: mode, | |
| 187 | + | traceFile: traceFile, | |
| 188 | + | payloadDir: payloadDir, | |
| 189 | + | pending: make([]traceRecord, 0, traceQueueInitialCapacity), | |
| 190 | + | finished: make(chan struct{}), | |
| 191 | + | closeDone: make(chan struct{}), | |
| 192 | + | events: make([]TraceEvent, 0, traceQueueInitialCapacity), | |
| 193 | + | } | |
| 194 | + | w.cond = sync.NewCond(&w.mu) | |
| 195 | + | go w.run() | |
| 196 | + | return w, nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func writeTraceManifest(path string, manifest TraceManifest) error { | |
| 200 | + | data, err := json.MarshalIndent(manifest, "", " ") | |
| 201 | + | if err != nil { | |
| 202 | + | return fmt.Errorf("trace manifest encode: %w", err) | |
| 203 | + | } | |
| 204 | + | data = append(data, '\n') | |
| 205 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) | |
| 206 | + | if err != nil { | |
| 207 | + | return fmt.Errorf("trace manifest open: %w", err) | |
| 208 | + | } | |
| 209 | + | defer file.Close() | |
| 210 | + | if err := file.Chmod(0o600); err != nil { | |
| 211 | + | return fmt.Errorf("trace manifest permissions: %w", err) | |
| 212 | + | } | |
| 213 | + | if _, err := file.Write(data); err != nil { | |
| 214 | + | return fmt.Errorf("trace manifest write: %w", err) | |
| 215 | + | } | |
| 216 | + | if err := file.Sync(); err != nil { | |
| 217 | + | return fmt.Errorf("trace manifest sync: %w", err) | |
| 218 | + | } | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Dir returns the trace bundle directory. | |
| 223 | + | func (w *TraceWriter) Dir() string { | |
| 224 | + | if w == nil { | |
| 225 | + | return "" | |
| 226 | + | } | |
| 227 | + | return w.dir | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // Mode returns the effective mode of the bundle. | |
| 231 | + | func (w *TraceWriter) Mode() TraceMode { | |
| 232 | + | if w == nil { | |
| 233 | + | return TraceModeNormal | |
| 234 | + | } | |
| 235 | + | return w.mode | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // Record snapshots and queues an event. It never returns an error. A nil | |
| 239 | + | // writer is a no-op, which makes tracing safe to leave disabled by default. | |
| 240 | + | func (w *TraceWriter) Record(event TraceEvent) { | |
| 241 | + | if w == nil { | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | record, ok := w.prepare(event) | |
| 245 | + | if !ok { | |
| 246 | + | return | |
| 247 | + | } | |
| 248 | + | w.mu.Lock() | |
| 249 | + | defer w.mu.Unlock() | |
| 250 | + | if w.closed { | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | w.sequence++ | |
| 254 | + | record.event.Seq = w.sequence | |
| 255 | + | if len(w.pending) >= traceQueueLimit { | |
| 256 | + | for index, pending := range w.pending { | |
| 257 | + | if pending.barrier == nil { | |
| 258 | + | copy(w.pending[index:], w.pending[index+1:]) | |
| 259 | + | w.pending[len(w.pending)-1] = traceRecord{} | |
| 260 | + | w.pending = w.pending[:len(w.pending)-1] | |
| 261 | + | w.dropped++ | |
| 262 | + | w.setError(errors.New("trace event queue overflow")) | |
| 263 | + | break | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | w.pending = append(w.pending, record) | |
| 268 | + | w.cond.Signal() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (w *TraceWriter) prepare(event TraceEvent) (traceRecord, bool) { | |
| 272 | + | if event.Timestamp.IsZero() { | |
| 273 | + | event.Timestamp = time.Now().UTC() | |
| 274 | + | } else { | |
| 275 | + | event.Timestamp = event.Timestamp.UTC() | |
| 276 | + | } | |
| 277 | + | if w.mode == TraceModeNormal { | |
| 278 | + | event.Data = redactTraceMap(event.Data) | |
| 279 | + | event.Payload = nil | |
| 280 | + | return traceRecord{event: event}, true | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | event.Data = cloneTraceMap(event.Data) | |
| 284 | + | record := traceRecord{event: event} | |
| 285 | + | if event.Payload != nil { | |
| 286 | + | payload, err := json.Marshal(event.Payload) | |
| 287 | + | if err != nil { | |
| 288 | + | // Keep the metadata event even if an optional detailed payload is | |
| 289 | + | // not JSON encodable. | |
| 290 | + | w.setError(fmt.Errorf("trace payload encode: %w", err)) | |
| 291 | + | } else { | |
| 292 | + | record.payload = append(payload, '\n') | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | record.event.Payload = nil | |
| 296 | + | return record, true | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func (w *TraceWriter) run() { | |
| 300 | + | defer close(w.finished) | |
| 301 | + | for { | |
| 302 | + | w.mu.Lock() | |
| 303 | + | for len(w.pending) == 0 && !w.closed { | |
| 304 | + | w.cond.Wait() | |
| 305 | + | } | |
| 306 | + | if len(w.pending) == 0 && w.closed { | |
| 307 | + | w.mu.Unlock() | |
| 308 | + | return | |
| 309 | + | } | |
| 310 | + | record := w.pending[0] | |
| 311 | + | w.pending[0] = traceRecord{} | |
| 312 | + | w.pending = w.pending[1:] | |
| 313 | + | w.mu.Unlock() | |
| 314 | + | w.write(record) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (w *TraceWriter) write(record traceRecord) { | |
| 319 | + | if record.barrier != nil { | |
| 320 | + | defer close(record.barrier) | |
| 321 | + | return | |
| 322 | + | } | |
| 323 | + | event := record.event | |
| 324 | + | if w.memory { | |
| 325 | + | w.mu.Lock() | |
| 326 | + | w.appendEvent(event) | |
| 327 | + | w.mu.Unlock() | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | if len(record.payload) != 0 { | |
| 331 | + | name := fmt.Sprintf("%020d.json", event.Seq) | |
| 332 | + | path := filepath.Join(w.payloadDir, name) | |
| 333 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 334 | + | if err != nil { | |
| 335 | + | w.setError(fmt.Errorf("trace payload file: %w", err)) | |
| 336 | + | } else { | |
| 337 | + | if _, err := file.Write(record.payload); err != nil { | |
| 338 | + | w.setError(fmt.Errorf("trace payload write: %w", err)) | |
| 339 | + | } else if err := file.Sync(); err != nil { | |
| 340 | + | w.setError(fmt.Errorf("trace payload sync: %w", err)) | |
| 341 | + | } | |
| 342 | + | _ = file.Chmod(0o600) | |
| 343 | + | if err := file.Close(); err != nil { | |
| 344 | + | w.setError(fmt.Errorf("trace payload close: %w", err)) | |
| 345 | + | } | |
| 346 | + | event.PayloadFile = filepath.ToSlash(filepath.Join("payloads", name)) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | line, err := json.Marshal(event) | |
| 351 | + | if err != nil { | |
| 352 | + | w.setError(fmt.Errorf("trace event encode: %w", err)) | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | line = append(line, '\n') | |
| 356 | + | if _, err := w.traceFile.Write(line); err != nil { | |
| 357 | + | w.setError(fmt.Errorf("trace event write: %w", err)) | |
| 358 | + | return | |
| 359 | + | } | |
| 360 | + | // Sync every event so a completed Record queue can be made durable by | |
| 361 | + | // Close, and so a crash loses at most the event currently being written. | |
| 362 | + | if err := w.traceFile.Sync(); err != nil { | |
| 363 | + | w.setError(fmt.Errorf("trace event sync: %w", err)) | |
| 364 | + | } | |
| 365 | + | w.mu.Lock() | |
| 366 | + | w.appendEvent(event) | |
| 367 | + | w.mu.Unlock() | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func (w *TraceWriter) appendEvent(event TraceEvent) { | |
| 371 | + | w.events = append(w.events, event) | |
| 372 | + | if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 { | |
| 373 | + | copy(w.events, w.events[overflow:]) | |
| 374 | + | w.events = w.events[:memoryTraceEventLimit] | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // Events returns a stable copy of events already written by this writer. It | |
| 379 | + | // never waits for queued filesystem work, keeping status and UI reads cheap. | |
| 380 | + | func (w *TraceWriter) Events() []TraceEvent { | |
| 381 | + | if w == nil { | |
| 382 | + | return nil | |
| 383 | + | } | |
| 384 | + | w.mu.Lock() | |
| 385 | + | defer w.mu.Unlock() | |
| 386 | + | return append([]TraceEvent(nil), w.events...) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func (w *TraceWriter) setError(err error) { | |
| 390 | + | if err == nil { | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | w.errorMu.Lock() | |
| 394 | + | if w.err == nil { | |
| 395 | + | w.err = err | |
| 396 | + | } | |
| 397 | + | w.errorMu.Unlock() | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Flush waits until all events recorded before the call have been written. | |
| 401 | + | func (w *TraceWriter) Flush() error { return w.FlushContext(context.Background()) } | |
| 402 | + | ||
| 403 | + | // FlushContext honors cancellation while waiting for the trace writer. | |
| 404 | + | func (w *TraceWriter) FlushContext(ctx context.Context) error { | |
| 405 | + | if w == nil { | |
| 406 | + | return nil | |
| 407 | + | } | |
| 408 | + | if ctx == nil { | |
| 409 | + | ctx = context.Background() | |
| 410 | + | } | |
| 411 | + | w.mu.Lock() | |
| 412 | + | wait := w.finished | |
| 413 | + | if !w.closed { | |
| 414 | + | barrier := make(chan struct{}) | |
| 415 | + | w.pending = append(w.pending, traceRecord{barrier: barrier}) | |
| 416 | + | w.cond.Signal() | |
| 417 | + | wait = barrier | |
| 418 | + | } | |
| 419 | + | w.mu.Unlock() | |
| 420 | + | select { | |
| 421 | + | case <-wait: | |
| 422 | + | return w.recordingError() | |
| 423 | + | case <-ctx.Done(): | |
| 424 | + | return fmt.Errorf("flush trace: %w", ctx.Err()) | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // Close drains the queue, syncs each event, and closes the bundle. | |
| 429 | + | func (w *TraceWriter) Close() error { return w.CloseContext(context.Background()) } | |
| 430 | + | ||
| 431 | + | // CloseContext starts closing once and honors cancellation while waiting. A | |
| 432 | + | // later caller can retry the wait without repeating file-close work. | |
| 433 | + | func (w *TraceWriter) CloseContext(ctx context.Context) error { | |
| 434 | + | if w == nil { | |
| 435 | + | return nil | |
| 436 | + | } | |
| 437 | + | if ctx == nil { | |
| 438 | + | ctx = context.Background() | |
| 439 | + | } | |
| 440 | + | w.closeOnce.Do(func() { | |
| 441 | + | go func() { | |
| 442 | + | w.mu.Lock() | |
| 443 | + | w.closed = true | |
| 444 | + | w.cond.Broadcast() | |
| 445 | + | w.mu.Unlock() | |
| 446 | + | <-w.finished | |
| 447 | + | if !w.memory { | |
| 448 | + | if err := w.traceFile.Close(); err != nil { | |
| 449 | + | w.setError(fmt.Errorf("trace file close: %w", err)) | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | close(w.closeDone) | |
| 453 | + | }() | |
| 454 | + | }) | |
| 455 | + | select { | |
| 456 | + | case <-w.closeDone: | |
| 457 | + | return w.recordingError() | |
| 458 | + | case <-ctx.Done(): | |
| 459 | + | return fmt.Errorf("close trace: %w", ctx.Err()) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func (w *TraceWriter) recordingError() error { | |
| 464 | + | w.errorMu.Lock() | |
| 465 | + | defer w.errorMu.Unlock() | |
| 466 | + | return w.err | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // TraceContext is the opt-in handle passed through agent code. Its zero value | |
| 470 | + | // is a no-op, so callers do not need conditionals when tracing is disabled. | |
| 471 | + | type TraceContext struct { | |
| 472 | + | writer *TraceWriter | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | // NewTraceContext returns an enabled context for writer. A nil writer creates | |
| 476 | + | // a no-op context. The writer's mode is authoritative. | |
| 477 | + | func NewTraceContext(writer *TraceWriter) TraceContext { | |
| 478 | + | return TraceContext{writer: writer} | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | // Context returns an enabled TraceContext for this writer. | |
| 482 | + | func (w *TraceWriter) Context() TraceContext { return NewTraceContext(w) } | |
| 483 | + | ||
| 484 | + | // Enabled reports whether this context will record events. | |
| 485 | + | func (t TraceContext) Enabled() bool { return t.writer != nil } | |
| 486 | + | ||
| 487 | + | // Mode returns the writer mode, or normal for a no-op context. | |
| 488 | + | func (t TraceContext) Mode() TraceMode { | |
| 489 | + | if t.writer == nil { | |
| 490 | + | return TraceModeNormal | |
| 491 | + | } | |
| 492 | + | return t.writer.Mode() | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // Record records an event when tracing is enabled. The no-op path is safe for | |
| 496 | + | // a zero TraceContext. | |
| 497 | + | func (t TraceContext) Record(event TraceEvent) { | |
| 498 | + | if t.writer != nil { | |
| 499 | + | t.writer.Record(event) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // WithTraceContext stores a trace handle in a standard context.Context. | |
| 504 | + | func WithTraceContext(ctx context.Context, trace TraceContext) context.Context { | |
| 505 | + | if ctx == nil { | |
| 506 | + | ctx = context.Background() | |
| 507 | + | } | |
| 508 | + | return context.WithValue(ctx, traceContextKey{}, trace) | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | type traceContextKey struct{} | |
| 512 | + | ||
| 513 | + | // TraceContextFrom retrieves a trace handle, returning a no-op context when | |
| 514 | + | // none was installed. | |
| 515 | + | func TraceContextFrom(ctx context.Context) TraceContext { | |
| 516 | + | if ctx == nil { | |
| 517 | + | return TraceContext{} | |
| 518 | + | } | |
| 519 | + | trace, _ := ctx.Value(traceContextKey{}).(TraceContext) | |
| 520 | + | return trace | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // RecordTrace records through the context's trace handle and is a convenient | |
| 524 | + | // best-effort call site for code that does not need to retain the handle. | |
| 525 | + | func RecordTrace(ctx context.Context, event TraceEvent) { | |
| 526 | + | TraceContextFrom(ctx).Record(event) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | var sensitiveTraceKeys = map[string]struct{}{ | |
| 530 | + | "api_key": {}, "apikey": {}, "authorization": {}, "auth": {}, | |
| 531 | + | "certificate": {}, "cookie": {}, "credential": {}, "credentials": {}, | |
| 532 | + | "password": {}, "private_key": {}, "secret": {}, "session": {}, | |
| 533 | + | "token": {}, "access_token": {}, "refresh_token": {}, "payload": {}, | |
| 534 | + | "prompt": {}, "input": {}, "output": {}, "content": {}, "message": {}, | |
| 535 | + | "text": {}, "body": {}, "data": {}, "result": {}, "results": {}, | |
| 536 | + | "request": {}, "response": {}, "arguments": {}, "args": {}, "query": {}, | |
| 537 | + | "value": {}, "values": {}, "raw": {}, "stdout": {}, "stderr": {}, | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func redactTraceMap(data map[string]any) map[string]any { | |
| 541 | + | if data == nil { | |
| 542 | + | return nil | |
| 543 | + | } | |
| 544 | + | out := make(map[string]any, len(data)) | |
| 545 | + | for key, value := range data { | |
| 546 | + | if sensitiveTraceKey(normalizeTraceKey(key)) { | |
| 547 | + | out[key] = "[REDACTED]" | |
| 548 | + | continue | |
| 549 | + | } | |
| 550 | + | out[key] = redactTraceValue(value) | |
| 551 | + | } | |
| 552 | + | return out | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func sensitiveTraceKey(key string) bool { | |
| 556 | + | for fragment := range sensitiveTraceKeys { | |
| 557 | + | if strings.Contains(key, fragment) { | |
| 558 | + | return true | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | return false | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func redactTraceValue(value any) any { | |
| 565 | + | if value == nil { | |
| 566 | + | return nil | |
| 567 | + | } | |
| 568 | + | switch value := value.(type) { | |
| 569 | + | case map[string]any: | |
| 570 | + | return redactTraceMap(value) | |
| 571 | + | case []any: | |
| 572 | + | out := make([]any, len(value)) | |
| 573 | + | for i := range value { | |
| 574 | + | out[i] = redactTraceValue(value[i]) | |
| 575 | + | } | |
| 576 | + | return out | |
| 577 | + | } | |
| 578 | + | v := reflect.ValueOf(value) | |
| 579 | + | if v.Kind() == reflect.Map && v.Type().Key().Kind() == reflect.String { | |
| 580 | + | out := make(map[string]any, v.Len()) | |
| 581 | + | iter := v.MapRange() | |
| 582 | + | for iter.Next() { | |
| 583 | + | key := iter.Key().String() | |
| 584 | + | if sensitiveTraceKey(normalizeTraceKey(key)) { | |
| 585 | + | out[key] = "[REDACTED]" | |
| 586 | + | } else { | |
| 587 | + | out[key] = redactTraceValue(iter.Value().Interface()) | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | return out | |
| 591 | + | } | |
| 592 | + | if v.Kind() == reflect.Struct { | |
| 593 | + | encoded, err := json.Marshal(value) | |
| 594 | + | if err == nil { | |
| 595 | + | var data map[string]any | |
| 596 | + | if json.Unmarshal(encoded, &data) == nil { | |
| 597 | + | return redactTraceMap(data) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | return value | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | func cloneTraceMap(data map[string]any) map[string]any { | |
| 605 | + | if data == nil { | |
| 606 | + | return nil | |
| 607 | + | } | |
| 608 | + | out := make(map[string]any, len(data)) | |
| 609 | + | for key, value := range data { | |
| 610 | + | out[key] = cloneTraceValue(value) | |
| 611 | + | } | |
| 612 | + | return out | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | func cloneTraceValue(value any) any { | |
| 616 | + | switch value := value.(type) { | |
| 617 | + | case map[string]any: | |
| 618 | + | return cloneTraceMap(value) | |
| 619 | + | case []any: | |
| 620 | + | out := make([]any, len(value)) | |
| 621 | + | for i := range value { | |
| 622 | + | out[i] = cloneTraceValue(value[i]) | |
| 623 | + | } | |
| 624 | + | return out | |
| 625 | + | default: | |
| 626 | + | return value | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | func normalizeTraceKey(key string) string { | |
| 631 | + | key = strings.ToLower(strings.TrimSpace(key)) | |
| 632 | + | key = strings.ReplaceAll(key, "-", "_") | |
| 633 | + | key = strings.ReplaceAll(key, " ", "_") | |
| 634 | + | return key | |
| 635 | + | } | |
| @@ -0,0 +1,635 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "reflect" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // TraceMode controls how much data a trace keeps. Normal is the safe default: | |||
| 17 | + | // event metadata is retained, while payloads and values under sensitive keys | |||
| 18 | + | // are removed. Detailed is intended for explicitly enabled local debugging. | |||
| 19 | + | type TraceMode string | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | TraceModeNormal TraceMode = "normal" | |||
| 23 | + | TraceModeDetailed TraceMode = "detailed" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // Normal and Detailed are short aliases useful to callers configuring a | |||
| 27 | + | // TraceWriter. Invalid modes are treated as Normal. | |||
| 28 | + | const ( | |||
| 29 | + | Normal = TraceModeNormal | |||
| 30 | + | Detailed = TraceModeDetailed | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | func (m TraceMode) valid() TraceMode { | |||
| 34 | + | if m == TraceModeDetailed { | |||
| 35 | + | return m | |||
| 36 | + | } | |||
| 37 | + | return TraceModeNormal | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // TraceEvent is one entry in trace.jsonl. Payload is never embedded in the | |||
| 41 | + | // JSONL stream: detailed traces store it as a separate file and normal traces | |||
| 42 | + | // discard it. Data is for small event metadata and is recursively redacted in | |||
| 43 | + | // normal mode when a key identifies a secret or payload. | |||
| 44 | + | type TraceEvent struct { | |||
| 45 | + | Seq uint64 `json:"seq"` | |||
| 46 | + | Timestamp time.Time `json:"timestamp"` | |||
| 47 | + | Type string `json:"type"` | |||
| 48 | + | AgentID string `json:"agent_id,omitempty"` | |||
| 49 | + | TurnID string `json:"turn_id,omitempty"` | |||
| 50 | + | Data map[string]any `json:"data,omitempty"` | |||
| 51 | + | Payload any `json:"-"` | |||
| 52 | + | PayloadFile string `json:"payload_file,omitempty"` | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // NewTraceEvent constructs an event with a UTC timestamp. A TraceWriter | |||
| 56 | + | // assigns Seq when the event is recorded; callers should not assign it. | |||
| 57 | + | func NewTraceEvent(typ string, data map[string]any) TraceEvent { | |||
| 58 | + | return TraceEvent{ | |||
| 59 | + | Timestamp: time.Now().UTC(), | |||
| 60 | + | Type: typ, | |||
| 61 | + | Data: data, | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // TraceManifest describes the files in a trace bundle. | |||
| 66 | + | type TraceManifest struct { | |||
| 67 | + | Version int `json:"version"` | |||
| 68 | + | Mode TraceMode `json:"mode"` | |||
| 69 | + | CreatedAt time.Time `json:"created_at"` | |||
| 70 | + | TraceFile string `json:"trace_file"` | |||
| 71 | + | Payloads string `json:"payloads"` | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // TraceWriter writes a trace bundle. Record is deliberately asynchronous: it | |||
| 75 | + | // only snapshots the event and adds it to an in-memory queue. Disk failures | |||
| 76 | + | // are retained for Close, but never returned from Record or allowed to affect | |||
| 77 | + | // the agent that is being traced. | |||
| 78 | + | type TraceWriter struct { | |||
| 79 | + | mu sync.Mutex | |||
| 80 | + | cond *sync.Cond | |||
| 81 | + | pending []traceRecord | |||
| 82 | + | closed bool | |||
| 83 | + | finished chan struct{} | |||
| 84 | + | closeOnce sync.Once | |||
| 85 | + | closeDone chan struct{} | |||
| 86 | + | ||||
| 87 | + | dir string | |||
| 88 | + | mode TraceMode | |||
| 89 | + | traceFile *os.File | |||
| 90 | + | payloadDir string | |||
| 91 | + | memory bool | |||
| 92 | + | sequence uint64 | |||
| 93 | + | dropped uint64 | |||
| 94 | + | events []TraceEvent | |||
| 95 | + | ||||
| 96 | + | errorMu sync.Mutex | |||
| 97 | + | err error | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | type traceRecord struct { | |||
| 101 | + | event TraceEvent | |||
| 102 | + | payload []byte | |||
| 103 | + | barrier chan struct{} | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | const ( | |||
| 107 | + | traceQueueInitialCapacity = 64 | |||
| 108 | + | traceQueueLimit = 1024 | |||
| 109 | + | memoryTraceEventLimit = 4096 | |||
| 110 | + | ) | |||
| 111 | + | ||||
| 112 | + | // NewMemoryTraceWriter creates the non-persistent projection source used when | |||
| 113 | + | // bundle capture is disabled. It retains only normal-mode event metadata. | |||
| 114 | + | func NewMemoryTraceWriter() *TraceWriter { | |||
| 115 | + | w := &TraceWriter{ | |||
| 116 | + | mode: TraceModeNormal, | |||
| 117 | + | memory: true, | |||
| 118 | + | pending: make([]traceRecord, 0, traceQueueInitialCapacity), | |||
| 119 | + | finished: make(chan struct{}), | |||
| 120 | + | closeDone: make(chan struct{}), | |||
| 121 | + | events: make([]TraceEvent, 0, traceQueueInitialCapacity), | |||
| 122 | + | } | |||
| 123 | + | w.cond = sync.NewCond(&w.mu) | |||
| 124 | + | go w.run() | |||
| 125 | + | return w | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // NewTraceWriter creates a private trace bundle at dir. The directory and its | |||
| 129 | + | // files are created with restrictive permissions. If mode is omitted or | |||
| 130 | + | // invalid, TraceModeNormal is used. | |||
| 131 | + | func NewTraceWriter(dir string, modes ...TraceMode) (*TraceWriter, error) { | |||
| 132 | + | mode := TraceModeNormal | |||
| 133 | + | if len(modes) != 0 { | |||
| 134 | + | mode = modes[0].valid() | |||
| 135 | + | } | |||
| 136 | + | if dir == "" { | |||
| 137 | + | return nil, errors.New("trace writer: empty directory") | |||
| 138 | + | } | |||
| 139 | + | absolute, err := filepath.Abs(dir) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return nil, fmt.Errorf("trace writer path: %w", err) | |||
| 142 | + | } | |||
| 143 | + | if err := os.MkdirAll(absolute, 0o700); err != nil { | |||
| 144 | + | return nil, fmt.Errorf("trace writer directory: %w", err) | |||
| 145 | + | } | |||
| 146 | + | // MkdirAll does not tighten an existing directory's permissions. | |||
| 147 | + | if err := os.Chmod(absolute, 0o700); err != nil { | |||
| 148 | + | return nil, fmt.Errorf("trace writer directory permissions: %w", err) | |||
| 149 | + | } | |||
| 150 | + | if _, err := os.Stat(filepath.Join(absolute, "trace.jsonl")); err == nil { | |||
| 151 | + | return nil, fmt.Errorf("trace writer: bundle already exists") | |||
| 152 | + | } else if !errors.Is(err, os.ErrNotExist) { | |||
| 153 | + | return nil, fmt.Errorf("trace writer trace file: %w", err) | |||
| 154 | + | } | |||
| 155 | + | payloadDir := filepath.Join(absolute, "payloads") | |||
| 156 | + | if err := os.MkdirAll(payloadDir, 0o700); err != nil { | |||
| 157 | + | return nil, fmt.Errorf("trace writer payload directory: %w", err) | |||
| 158 | + | } | |||
| 159 | + | if err := os.Chmod(payloadDir, 0o700); err != nil { | |||
| 160 | + | return nil, fmt.Errorf("trace writer payload permissions: %w", err) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | traceFile, err := os.OpenFile(filepath.Join(absolute, "trace.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return nil, fmt.Errorf("trace writer trace file: %w", err) | |||
| 166 | + | } | |||
| 167 | + | if err := traceFile.Chmod(0o600); err != nil { | |||
| 168 | + | _ = traceFile.Close() | |||
| 169 | + | return nil, fmt.Errorf("trace writer trace permissions: %w", err) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | manifest := TraceManifest{ | |||
| 173 | + | Version: 1, | |||
| 174 | + | Mode: mode, | |||
| 175 | + | CreatedAt: time.Now().UTC(), | |||
| 176 | + | TraceFile: "trace.jsonl", | |||
| 177 | + | Payloads: "payloads", | |||
| 178 | + | } | |||
| 179 | + | if err := writeTraceManifest(filepath.Join(absolute, "manifest.json"), manifest); err != nil { | |||
| 180 | + | _ = traceFile.Close() | |||
| 181 | + | return nil, err | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | w := &TraceWriter{ | |||
| 185 | + | dir: absolute, | |||
| 186 | + | mode: mode, | |||
| 187 | + | traceFile: traceFile, | |||
| 188 | + | payloadDir: payloadDir, | |||
| 189 | + | pending: make([]traceRecord, 0, traceQueueInitialCapacity), | |||
| 190 | + | finished: make(chan struct{}), | |||
| 191 | + | closeDone: make(chan struct{}), | |||
| 192 | + | events: make([]TraceEvent, 0, traceQueueInitialCapacity), | |||
| 193 | + | } | |||
| 194 | + | w.cond = sync.NewCond(&w.mu) | |||
| 195 | + | go w.run() | |||
| 196 | + | return w, nil | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func writeTraceManifest(path string, manifest TraceManifest) error { | |||
| 200 | + | data, err := json.MarshalIndent(manifest, "", " ") | |||
| 201 | + | if err != nil { | |||
| 202 | + | return fmt.Errorf("trace manifest encode: %w", err) | |||
| 203 | + | } | |||
| 204 | + | data = append(data, '\n') | |||
| 205 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return fmt.Errorf("trace manifest open: %w", err) | |||
| 208 | + | } | |||
| 209 | + | defer file.Close() | |||
| 210 | + | if err := file.Chmod(0o600); err != nil { | |||
| 211 | + | return fmt.Errorf("trace manifest permissions: %w", err) | |||
| 212 | + | } | |||
| 213 | + | if _, err := file.Write(data); err != nil { | |||
| 214 | + | return fmt.Errorf("trace manifest write: %w", err) | |||
| 215 | + | } | |||
| 216 | + | if err := file.Sync(); err != nil { | |||
| 217 | + | return fmt.Errorf("trace manifest sync: %w", err) | |||
| 218 | + | } | |||
| 219 | + | return nil | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Dir returns the trace bundle directory. | |||
| 223 | + | func (w *TraceWriter) Dir() string { | |||
| 224 | + | if w == nil { | |||
| 225 | + | return "" | |||
| 226 | + | } | |||
| 227 | + | return w.dir | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // Mode returns the effective mode of the bundle. | |||
| 231 | + | func (w *TraceWriter) Mode() TraceMode { | |||
| 232 | + | if w == nil { | |||
| 233 | + | return TraceModeNormal | |||
| 234 | + | } | |||
| 235 | + | return w.mode | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // Record snapshots and queues an event. It never returns an error. A nil | |||
| 239 | + | // writer is a no-op, which makes tracing safe to leave disabled by default. | |||
| 240 | + | func (w *TraceWriter) Record(event TraceEvent) { | |||
| 241 | + | if w == nil { | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | record, ok := w.prepare(event) | |||
| 245 | + | if !ok { | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 248 | + | w.mu.Lock() | |||
| 249 | + | defer w.mu.Unlock() | |||
| 250 | + | if w.closed { | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | w.sequence++ | |||
| 254 | + | record.event.Seq = w.sequence | |||
| 255 | + | if len(w.pending) >= traceQueueLimit { | |||
| 256 | + | for index, pending := range w.pending { | |||
| 257 | + | if pending.barrier == nil { | |||
| 258 | + | copy(w.pending[index:], w.pending[index+1:]) | |||
| 259 | + | w.pending[len(w.pending)-1] = traceRecord{} | |||
| 260 | + | w.pending = w.pending[:len(w.pending)-1] | |||
| 261 | + | w.dropped++ | |||
| 262 | + | w.setError(errors.New("trace event queue overflow")) | |||
| 263 | + | break | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | w.pending = append(w.pending, record) | |||
| 268 | + | w.cond.Signal() | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (w *TraceWriter) prepare(event TraceEvent) (traceRecord, bool) { | |||
| 272 | + | if event.Timestamp.IsZero() { | |||
| 273 | + | event.Timestamp = time.Now().UTC() | |||
| 274 | + | } else { | |||
| 275 | + | event.Timestamp = event.Timestamp.UTC() | |||
| 276 | + | } | |||
| 277 | + | if w.mode == TraceModeNormal { | |||
| 278 | + | event.Data = redactTraceMap(event.Data) | |||
| 279 | + | event.Payload = nil | |||
| 280 | + | return traceRecord{event: event}, true | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | event.Data = cloneTraceMap(event.Data) | |||
| 284 | + | record := traceRecord{event: event} | |||
| 285 | + | if event.Payload != nil { | |||
| 286 | + | payload, err := json.Marshal(event.Payload) | |||
| 287 | + | if err != nil { | |||
| 288 | + | // Keep the metadata event even if an optional detailed payload is | |||
| 289 | + | // not JSON encodable. | |||
| 290 | + | w.setError(fmt.Errorf("trace payload encode: %w", err)) | |||
| 291 | + | } else { | |||
| 292 | + | record.payload = append(payload, '\n') | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | record.event.Payload = nil | |||
| 296 | + | return record, true | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func (w *TraceWriter) run() { | |||
| 300 | + | defer close(w.finished) | |||
| 301 | + | for { | |||
| 302 | + | w.mu.Lock() | |||
| 303 | + | for len(w.pending) == 0 && !w.closed { | |||
| 304 | + | w.cond.Wait() | |||
| 305 | + | } | |||
| 306 | + | if len(w.pending) == 0 && w.closed { | |||
| 307 | + | w.mu.Unlock() | |||
| 308 | + | return | |||
| 309 | + | } | |||
| 310 | + | record := w.pending[0] | |||
| 311 | + | w.pending[0] = traceRecord{} | |||
| 312 | + | w.pending = w.pending[1:] | |||
| 313 | + | w.mu.Unlock() | |||
| 314 | + | w.write(record) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func (w *TraceWriter) write(record traceRecord) { | |||
| 319 | + | if record.barrier != nil { | |||
| 320 | + | defer close(record.barrier) | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 323 | + | event := record.event | |||
| 324 | + | if w.memory { | |||
| 325 | + | w.mu.Lock() | |||
| 326 | + | w.appendEvent(event) | |||
| 327 | + | w.mu.Unlock() | |||
| 328 | + | return | |||
| 329 | + | } | |||
| 330 | + | if len(record.payload) != 0 { | |||
| 331 | + | name := fmt.Sprintf("%020d.json", event.Seq) | |||
| 332 | + | path := filepath.Join(w.payloadDir, name) | |||
| 333 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |||
| 334 | + | if err != nil { | |||
| 335 | + | w.setError(fmt.Errorf("trace payload file: %w", err)) | |||
| 336 | + | } else { | |||
| 337 | + | if _, err := file.Write(record.payload); err != nil { | |||
| 338 | + | w.setError(fmt.Errorf("trace payload write: %w", err)) | |||
| 339 | + | } else if err := file.Sync(); err != nil { | |||
| 340 | + | w.setError(fmt.Errorf("trace payload sync: %w", err)) | |||
| 341 | + | } | |||
| 342 | + | _ = file.Chmod(0o600) | |||
| 343 | + | if err := file.Close(); err != nil { | |||
| 344 | + | w.setError(fmt.Errorf("trace payload close: %w", err)) | |||
| 345 | + | } | |||
| 346 | + | event.PayloadFile = filepath.ToSlash(filepath.Join("payloads", name)) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | line, err := json.Marshal(event) | |||
| 351 | + | if err != nil { | |||
| 352 | + | w.setError(fmt.Errorf("trace event encode: %w", err)) | |||
| 353 | + | return | |||
| 354 | + | } | |||
| 355 | + | line = append(line, '\n') | |||
| 356 | + | if _, err := w.traceFile.Write(line); err != nil { | |||
| 357 | + | w.setError(fmt.Errorf("trace event write: %w", err)) | |||
| 358 | + | return | |||
| 359 | + | } | |||
| 360 | + | // Sync every event so a completed Record queue can be made durable by | |||
| 361 | + | // Close, and so a crash loses at most the event currently being written. | |||
| 362 | + | if err := w.traceFile.Sync(); err != nil { | |||
| 363 | + | w.setError(fmt.Errorf("trace event sync: %w", err)) | |||
| 364 | + | } | |||
| 365 | + | w.mu.Lock() | |||
| 366 | + | w.appendEvent(event) | |||
| 367 | + | w.mu.Unlock() | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func (w *TraceWriter) appendEvent(event TraceEvent) { | |||
| 371 | + | w.events = append(w.events, event) | |||
| 372 | + | if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 { | |||
| 373 | + | copy(w.events, w.events[overflow:]) | |||
| 374 | + | w.events = w.events[:memoryTraceEventLimit] | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // Events returns a stable copy of events already written by this writer. It | |||
| 379 | + | // never waits for queued filesystem work, keeping status and UI reads cheap. | |||
| 380 | + | func (w *TraceWriter) Events() []TraceEvent { | |||
| 381 | + | if w == nil { | |||
| 382 | + | return nil | |||
| 383 | + | } | |||
| 384 | + | w.mu.Lock() | |||
| 385 | + | defer w.mu.Unlock() | |||
| 386 | + | return append([]TraceEvent(nil), w.events...) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func (w *TraceWriter) setError(err error) { | |||
| 390 | + | if err == nil { | |||
| 391 | + | return | |||
| 392 | + | } | |||
| 393 | + | w.errorMu.Lock() | |||
| 394 | + | if w.err == nil { | |||
| 395 | + | w.err = err | |||
| 396 | + | } | |||
| 397 | + | w.errorMu.Unlock() | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // Flush waits until all events recorded before the call have been written. | |||
| 401 | + | func (w *TraceWriter) Flush() error { return w.FlushContext(context.Background()) } | |||
| 402 | + | ||||
| 403 | + | // FlushContext honors cancellation while waiting for the trace writer. | |||
| 404 | + | func (w *TraceWriter) FlushContext(ctx context.Context) error { | |||
| 405 | + | if w == nil { | |||
| 406 | + | return nil | |||
| 407 | + | } | |||
| 408 | + | if ctx == nil { | |||
| 409 | + | ctx = context.Background() | |||
| 410 | + | } | |||
| 411 | + | w.mu.Lock() | |||
| 412 | + | wait := w.finished | |||
| 413 | + | if !w.closed { | |||
| 414 | + | barrier := make(chan struct{}) | |||
| 415 | + | w.pending = append(w.pending, traceRecord{barrier: barrier}) | |||
| 416 | + | w.cond.Signal() | |||
| 417 | + | wait = barrier | |||
| 418 | + | } | |||
| 419 | + | w.mu.Unlock() | |||
| 420 | + | select { | |||
| 421 | + | case <-wait: | |||
| 422 | + | return w.recordingError() | |||
| 423 | + | case <-ctx.Done(): | |||
| 424 | + | return fmt.Errorf("flush trace: %w", ctx.Err()) | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // Close drains the queue, syncs each event, and closes the bundle. | |||
| 429 | + | func (w *TraceWriter) Close() error { return w.CloseContext(context.Background()) } | |||
| 430 | + | ||||
| 431 | + | // CloseContext starts closing once and honors cancellation while waiting. A | |||
| 432 | + | // later caller can retry the wait without repeating file-close work. | |||
| 433 | + | func (w *TraceWriter) CloseContext(ctx context.Context) error { | |||
| 434 | + | if w == nil { | |||
| 435 | + | return nil | |||
| 436 | + | } | |||
| 437 | + | if ctx == nil { | |||
| 438 | + | ctx = context.Background() | |||
| 439 | + | } | |||
| 440 | + | w.closeOnce.Do(func() { | |||
| 441 | + | go func() { | |||
| 442 | + | w.mu.Lock() | |||
| 443 | + | w.closed = true | |||
| 444 | + | w.cond.Broadcast() | |||
| 445 | + | w.mu.Unlock() | |||
| 446 | + | <-w.finished | |||
| 447 | + | if !w.memory { | |||
| 448 | + | if err := w.traceFile.Close(); err != nil { | |||
| 449 | + | w.setError(fmt.Errorf("trace file close: %w", err)) | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | close(w.closeDone) | |||
| 453 | + | }() | |||
| 454 | + | }) | |||
| 455 | + | select { | |||
| 456 | + | case <-w.closeDone: | |||
| 457 | + | return w.recordingError() | |||
| 458 | + | case <-ctx.Done(): | |||
| 459 | + | return fmt.Errorf("close trace: %w", ctx.Err()) | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func (w *TraceWriter) recordingError() error { | |||
| 464 | + | w.errorMu.Lock() | |||
| 465 | + | defer w.errorMu.Unlock() | |||
| 466 | + | return w.err | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | // TraceContext is the opt-in handle passed through agent code. Its zero value | |||
| 470 | + | // is a no-op, so callers do not need conditionals when tracing is disabled. | |||
| 471 | + | type TraceContext struct { | |||
| 472 | + | writer *TraceWriter | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | // NewTraceContext returns an enabled context for writer. A nil writer creates | |||
| 476 | + | // a no-op context. The writer's mode is authoritative. | |||
| 477 | + | func NewTraceContext(writer *TraceWriter) TraceContext { | |||
| 478 | + | return TraceContext{writer: writer} | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | // Context returns an enabled TraceContext for this writer. | |||
| 482 | + | func (w *TraceWriter) Context() TraceContext { return NewTraceContext(w) } | |||
| 483 | + | ||||
| 484 | + | // Enabled reports whether this context will record events. | |||
| 485 | + | func (t TraceContext) Enabled() bool { return t.writer != nil } | |||
| 486 | + | ||||
| 487 | + | // Mode returns the writer mode, or normal for a no-op context. | |||
| 488 | + | func (t TraceContext) Mode() TraceMode { | |||
| 489 | + | if t.writer == nil { | |||
| 490 | + | return TraceModeNormal | |||
| 491 | + | } | |||
| 492 | + | return t.writer.Mode() | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | // Record records an event when tracing is enabled. The no-op path is safe for | |||
| 496 | + | // a zero TraceContext. | |||
| 497 | + | func (t TraceContext) Record(event TraceEvent) { | |||
| 498 | + | if t.writer != nil { | |||
| 499 | + | t.writer.Record(event) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // WithTraceContext stores a trace handle in a standard context.Context. | |||
| 504 | + | func WithTraceContext(ctx context.Context, trace TraceContext) context.Context { | |||
| 505 | + | if ctx == nil { | |||
| 506 | + | ctx = context.Background() | |||
| 507 | + | } | |||
| 508 | + | return context.WithValue(ctx, traceContextKey{}, trace) | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | type traceContextKey struct{} | |||
| 512 | + | ||||
| 513 | + | // TraceContextFrom retrieves a trace handle, returning a no-op context when | |||
| 514 | + | // none was installed. | |||
| 515 | + | func TraceContextFrom(ctx context.Context) TraceContext { | |||
| 516 | + | if ctx == nil { | |||
| 517 | + | return TraceContext{} | |||
| 518 | + | } | |||
| 519 | + | trace, _ := ctx.Value(traceContextKey{}).(TraceContext) | |||
| 520 | + | return trace | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | // RecordTrace records through the context's trace handle and is a convenient | |||
| 524 | + | // best-effort call site for code that does not need to retain the handle. | |||
| 525 | + | func RecordTrace(ctx context.Context, event TraceEvent) { | |||
| 526 | + | TraceContextFrom(ctx).Record(event) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | var sensitiveTraceKeys = map[string]struct{}{ | |||
| 530 | + | "api_key": {}, "apikey": {}, "authorization": {}, "auth": {}, | |||
| 531 | + | "certificate": {}, "cookie": {}, "credential": {}, "credentials": {}, | |||
| 532 | + | "password": {}, "private_key": {}, "secret": {}, "session": {}, | |||
| 533 | + | "token": {}, "access_token": {}, "refresh_token": {}, "payload": {}, | |||
| 534 | + | "prompt": {}, "input": {}, "output": {}, "content": {}, "message": {}, | |||
| 535 | + | "text": {}, "body": {}, "data": {}, "result": {}, "results": {}, | |||
| 536 | + | "request": {}, "response": {}, "arguments": {}, "args": {}, "query": {}, | |||
| 537 | + | "value": {}, "values": {}, "raw": {}, "stdout": {}, "stderr": {}, | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func redactTraceMap(data map[string]any) map[string]any { | |||
| 541 | + | if data == nil { | |||
| 542 | + | return nil | |||
| 543 | + | } | |||
| 544 | + | out := make(map[string]any, len(data)) | |||
| 545 | + | for key, value := range data { | |||
| 546 | + | if sensitiveTraceKey(normalizeTraceKey(key)) { | |||
| 547 | + | out[key] = "[REDACTED]" | |||
| 548 | + | continue | |||
| 549 | + | } | |||
| 550 | + | out[key] = redactTraceValue(value) | |||
| 551 | + | } | |||
| 552 | + | return out | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func sensitiveTraceKey(key string) bool { | |||
| 556 | + | for fragment := range sensitiveTraceKeys { | |||
| 557 | + | if strings.Contains(key, fragment) { | |||
| 558 | + | return true | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | return false | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | func redactTraceValue(value any) any { | |||
| 565 | + | if value == nil { | |||
| 566 | + | return nil | |||
| 567 | + | } | |||
| 568 | + | switch value := value.(type) { | |||
| 569 | + | case map[string]any: | |||
| 570 | + | return redactTraceMap(value) | |||
| 571 | + | case []any: | |||
| 572 | + | out := make([]any, len(value)) | |||
| 573 | + | for i := range value { | |||
| 574 | + | out[i] = redactTraceValue(value[i]) | |||
| 575 | + | } | |||
| 576 | + | return out | |||
| 577 | + | } | |||
| 578 | + | v := reflect.ValueOf(value) | |||
| 579 | + | if v.Kind() == reflect.Map && v.Type().Key().Kind() == reflect.String { | |||
| 580 | + | out := make(map[string]any, v.Len()) | |||
| 581 | + | iter := v.MapRange() | |||
| 582 | + | for iter.Next() { | |||
| 583 | + | key := iter.Key().String() | |||
| 584 | + | if sensitiveTraceKey(normalizeTraceKey(key)) { | |||
| 585 | + | out[key] = "[REDACTED]" | |||
| 586 | + | } else { | |||
| 587 | + | out[key] = redactTraceValue(iter.Value().Interface()) | |||
| 588 | + | } | |||
| 589 | + | } | |||
| 590 | + | return out | |||
| 591 | + | } | |||
| 592 | + | if v.Kind() == reflect.Struct { | |||
| 593 | + | encoded, err := json.Marshal(value) | |||
| 594 | + | if err == nil { | |||
| 595 | + | var data map[string]any | |||
| 596 | + | if json.Unmarshal(encoded, &data) == nil { | |||
| 597 | + | return redactTraceMap(data) | |||
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | } | |||
| 601 | + | return value | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | func cloneTraceMap(data map[string]any) map[string]any { | |||
| 605 | + | if data == nil { | |||
| 606 | + | return nil | |||
| 607 | + | } | |||
| 608 | + | out := make(map[string]any, len(data)) | |||
| 609 | + | for key, value := range data { | |||
| 610 | + | out[key] = cloneTraceValue(value) | |||
| 611 | + | } | |||
| 612 | + | return out | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | func cloneTraceValue(value any) any { | |||
| 616 | + | switch value := value.(type) { | |||
| 617 | + | case map[string]any: | |||
| 618 | + | return cloneTraceMap(value) | |||
| 619 | + | case []any: | |||
| 620 | + | out := make([]any, len(value)) | |||
| 621 | + | for i := range value { | |||
| 622 | + | out[i] = cloneTraceValue(value[i]) | |||
| 623 | + | } | |||
| 624 | + | return out | |||
| 625 | + | default: | |||
| 626 | + | return value | |||
| 627 | + | } | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | func normalizeTraceKey(key string) string { | |||
| 631 | + | key = strings.ToLower(strings.TrimSpace(key)) | |||
| 632 | + | key = strings.ReplaceAll(key, "-", "_") | |||
| 633 | + | key = strings.ReplaceAll(key, " ", "_") | |||
| 634 | + | return key | |||
| 635 | + | } | |||
A packages/agent/subagents/trace_read.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ReadTrace reads a trace bundle using a background context. | |
| 15 | + | func ReadTrace(bundle string) ([]TraceEvent, error) { | |
| 16 | + | return ReadTraceContext(context.Background(), bundle) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // ReadTraceContext reads the manifest-selected JSONL trace stream. If a stream | |
| 20 | + | // ends with malformed data, events decoded before it are returned with the | |
| 21 | + | // error so callers can still diagnose the valid prefix. | |
| 22 | + | func ReadTraceContext(ctx context.Context, bundle string) ([]TraceEvent, error) { | |
| 23 | + | if ctx == nil { | |
| 24 | + | ctx = context.Background() | |
| 25 | + | } | |
| 26 | + | manifestPath := filepath.Join(bundle, "manifest.json") | |
| 27 | + | data, err := os.ReadFile(manifestPath) | |
| 28 | + | if err != nil { | |
| 29 | + | return nil, fmt.Errorf("read trace manifest: %w", err) | |
| 30 | + | } | |
| 31 | + | var manifest TraceManifest | |
| 32 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |
| 33 | + | return nil, fmt.Errorf("decode trace manifest: %w", err) | |
| 34 | + | } | |
| 35 | + | if manifest.Version != 1 { | |
| 36 | + | return nil, fmt.Errorf("unsupported trace manifest version %d", manifest.Version) | |
| 37 | + | } | |
| 38 | + | tracePath, err := traceBundlePath(bundle, manifest.TraceFile) | |
| 39 | + | if err != nil { | |
| 40 | + | return nil, err | |
| 41 | + | } | |
| 42 | + | file, err := os.Open(tracePath) | |
| 43 | + | if err != nil { | |
| 44 | + | return nil, fmt.Errorf("open trace: %w", err) | |
| 45 | + | } | |
| 46 | + | defer file.Close() | |
| 47 | + | ||
| 48 | + | var events []TraceEvent | |
| 49 | + | scanner := bufio.NewScanner(file) | |
| 50 | + | buffer := make([]byte, 64*1024) | |
| 51 | + | scanner.Buffer(buffer, 4*1024*1024) | |
| 52 | + | for scanner.Scan() { | |
| 53 | + | if err := ctx.Err(); err != nil { | |
| 54 | + | return events, fmt.Errorf("read trace: %w", err) | |
| 55 | + | } | |
| 56 | + | var event TraceEvent | |
| 57 | + | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | |
| 58 | + | return events, fmt.Errorf("decode trace event: %w", err) | |
| 59 | + | } | |
| 60 | + | events = append(events, event) | |
| 61 | + | } | |
| 62 | + | if err := scanner.Err(); err != nil { | |
| 63 | + | return events, fmt.Errorf("read trace: %w", err) | |
| 64 | + | } | |
| 65 | + | if err := ctx.Err(); err != nil { | |
| 66 | + | return events, fmt.Errorf("read trace: %w", err) | |
| 67 | + | } | |
| 68 | + | return events, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func traceBundlePath(bundle, name string) (string, error) { | |
| 72 | + | if strings.TrimSpace(name) == "" || filepath.IsAbs(name) { | |
| 73 | + | return "", errors.New("invalid trace manifest path") | |
| 74 | + | } | |
| 75 | + | root, err := filepath.Abs(bundle) | |
| 76 | + | if err != nil { | |
| 77 | + | return "", fmt.Errorf("resolve trace bundle: %w", err) | |
| 78 | + | } | |
| 79 | + | path, err := filepath.Abs(filepath.Join(root, name)) | |
| 80 | + | if err != nil { | |
| 81 | + | return "", fmt.Errorf("resolve trace path: %w", err) | |
| 82 | + | } | |
| 83 | + | rel, err := filepath.Rel(root, path) | |
| 84 | + | if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 85 | + | return "", errors.New("trace manifest path escapes bundle") | |
| 86 | + | } | |
| 87 | + | return path, nil | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ReadTrace reads a trace bundle using a background context. | |||
| 15 | + | func ReadTrace(bundle string) ([]TraceEvent, error) { | |||
| 16 | + | return ReadTraceContext(context.Background(), bundle) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // ReadTraceContext reads the manifest-selected JSONL trace stream. If a stream | |||
| 20 | + | // ends with malformed data, events decoded before it are returned with the | |||
| 21 | + | // error so callers can still diagnose the valid prefix. | |||
| 22 | + | func ReadTraceContext(ctx context.Context, bundle string) ([]TraceEvent, error) { | |||
| 23 | + | if ctx == nil { | |||
| 24 | + | ctx = context.Background() | |||
| 25 | + | } | |||
| 26 | + | manifestPath := filepath.Join(bundle, "manifest.json") | |||
| 27 | + | data, err := os.ReadFile(manifestPath) | |||
| 28 | + | if err != nil { | |||
| 29 | + | return nil, fmt.Errorf("read trace manifest: %w", err) | |||
| 30 | + | } | |||
| 31 | + | var manifest TraceManifest | |||
| 32 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |||
| 33 | + | return nil, fmt.Errorf("decode trace manifest: %w", err) | |||
| 34 | + | } | |||
| 35 | + | if manifest.Version != 1 { | |||
| 36 | + | return nil, fmt.Errorf("unsupported trace manifest version %d", manifest.Version) | |||
| 37 | + | } | |||
| 38 | + | tracePath, err := traceBundlePath(bundle, manifest.TraceFile) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return nil, err | |||
| 41 | + | } | |||
| 42 | + | file, err := os.Open(tracePath) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return nil, fmt.Errorf("open trace: %w", err) | |||
| 45 | + | } | |||
| 46 | + | defer file.Close() | |||
| 47 | + | ||||
| 48 | + | var events []TraceEvent | |||
| 49 | + | scanner := bufio.NewScanner(file) | |||
| 50 | + | buffer := make([]byte, 64*1024) | |||
| 51 | + | scanner.Buffer(buffer, 4*1024*1024) | |||
| 52 | + | for scanner.Scan() { | |||
| 53 | + | if err := ctx.Err(); err != nil { | |||
| 54 | + | return events, fmt.Errorf("read trace: %w", err) | |||
| 55 | + | } | |||
| 56 | + | var event TraceEvent | |||
| 57 | + | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | |||
| 58 | + | return events, fmt.Errorf("decode trace event: %w", err) | |||
| 59 | + | } | |||
| 60 | + | events = append(events, event) | |||
| 61 | + | } | |||
| 62 | + | if err := scanner.Err(); err != nil { | |||
| 63 | + | return events, fmt.Errorf("read trace: %w", err) | |||
| 64 | + | } | |||
| 65 | + | if err := ctx.Err(); err != nil { | |||
| 66 | + | return events, fmt.Errorf("read trace: %w", err) | |||
| 67 | + | } | |||
| 68 | + | return events, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func traceBundlePath(bundle, name string) (string, error) { | |||
| 72 | + | if strings.TrimSpace(name) == "" || filepath.IsAbs(name) { | |||
| 73 | + | return "", errors.New("invalid trace manifest path") | |||
| 74 | + | } | |||
| 75 | + | root, err := filepath.Abs(bundle) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return "", fmt.Errorf("resolve trace bundle: %w", err) | |||
| 78 | + | } | |||
| 79 | + | path, err := filepath.Abs(filepath.Join(root, name)) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return "", fmt.Errorf("resolve trace path: %w", err) | |||
| 82 | + | } | |||
| 83 | + | rel, err := filepath.Rel(root, path) | |||
| 84 | + | if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |||
| 85 | + | return "", errors.New("trace manifest path escapes bundle") | |||
| 86 | + | } | |||
| 87 | + | return path, nil | |||
| 88 | + | } | |||
A packages/agent/subagents/trace_test.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sort" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestTraceContextZeroValueIsNoOp(t *testing.T) { | |
| 15 | + | var trace TraceContext | |
| 16 | + | if trace.Enabled() { | |
| 17 | + | t.Fatal("zero trace context is enabled") | |
| 18 | + | } | |
| 19 | + | trace.Record(NewTraceEvent("ignored", map[string]any{"secret": "not written"})) | |
| 20 | + | if got := TraceContextFrom(context.Background()); got.Enabled() { | |
| 21 | + | t.Fatal("context without trace is enabled") | |
| 22 | + | } | |
| 23 | + | ctx := WithTraceContext(context.Background(), trace) | |
| 24 | + | if TraceContextFrom(ctx).Enabled() { | |
| 25 | + | t.Fatal("stored no-op context is enabled") | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) { | |
| 30 | + | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeDetailed) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | writer.Record(TraceEvent{Type: "tool.result", Data: map[string]any{"name": "cat"}, Payload: map[string]any{"output": "sensitive result"}}) | |
| 35 | + | if err := writer.Close(); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | for _, name := range []string{"manifest.json", "trace.jsonl", "payloads"} { | |
| 40 | + | if _, err := os.Stat(filepath.Join(writer.Dir(), name)); err != nil { | |
| 41 | + | t.Fatalf("bundle member %s: %v", name, err) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | assertPrivate(t, writer.Dir(), 0o700) | |
| 45 | + | assertPrivate(t, filepath.Join(writer.Dir(), "manifest.json"), 0o600) | |
| 46 | + | assertPrivate(t, filepath.Join(writer.Dir(), "trace.jsonl"), 0o600) | |
| 47 | + | ||
| 48 | + | var manifest TraceManifest | |
| 49 | + | readJSONFile(t, filepath.Join(writer.Dir(), "manifest.json"), &manifest) | |
| 50 | + | if manifest.Mode != TraceModeDetailed || manifest.TraceFile != "trace.jsonl" || manifest.Payloads != "payloads" { | |
| 51 | + | t.Fatalf("manifest = %+v", manifest) | |
| 52 | + | } | |
| 53 | + | lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl")) | |
| 54 | + | if len(lines) != 1 { | |
| 55 | + | t.Fatalf("got %d trace lines, want 1", len(lines)) | |
| 56 | + | } | |
| 57 | + | if strings.Contains(lines[0], "sensitive result") { | |
| 58 | + | t.Fatalf("trace event leaked detailed payload: %s", lines[0]) | |
| 59 | + | } | |
| 60 | + | var event TraceEvent | |
| 61 | + | if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | if event.Seq == 0 || event.PayloadFile == "" { | |
| 65 | + | t.Fatalf("event = %+v", event) | |
| 66 | + | } | |
| 67 | + | payloadPath := filepath.Join(writer.Dir(), filepath.FromSlash(event.PayloadFile)) | |
| 68 | + | assertPrivate(t, payloadPath, 0o600) | |
| 69 | + | payload := string(mustRead(t, payloadPath)) | |
| 70 | + | if !strings.Contains(payload, "sensitive result") { | |
| 71 | + | t.Fatalf("payload does not contain detailed value: %s", payload) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) { | |
| 76 | + | writer := NewMemoryTraceWriter() | |
| 77 | + | for index := 0; index < memoryTraceEventLimit+2; index++ { | |
| 78 | + | writer.Record(TraceEvent{Type: "event", Data: map[string]any{"index": index}}) | |
| 79 | + | // Keep this retention test independent from the separate bounded-queue | |
| 80 | + | // contract: a slow Windows race run can otherwise intentionally drop | |
| 81 | + | // records before the in-memory limit is exercised. | |
| 82 | + | if (index+1)%traceQueueInitialCapacity == 0 { | |
| 83 | + | if err := writer.Flush(); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | if err := writer.Flush(); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | if got := writer.Dir(); got != "" { | |
| 92 | + | t.Fatalf("memory trace dir = %q, want empty", got) | |
| 93 | + | } | |
| 94 | + | events := writer.Events() | |
| 95 | + | if len(events) != memoryTraceEventLimit || events[len(events)-1].Seq != memoryTraceEventLimit+2 { | |
| 96 | + | t.Fatalf("memory events retained %d with final seq %d", len(events), events[len(events)-1].Seq) | |
| 97 | + | } | |
| 98 | + | if err := writer.Close(); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) { | |
| 104 | + | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal) | |
| 105 | + | if err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | secret := "super-secret-value" | |
| 109 | + | writer.Record(TraceEvent{ | |
| 110 | + | Type: "request", | |
| 111 | + | Data: map[string]any{ | |
| 112 | + | "name": "safe", "authorization": secret, "openai_api_key": secret, | |
| 113 | + | "nested": map[string]any{"token": secret, "count": 2, "headers": map[string]string{"x_api_key": secret}}, | |
| 114 | + | }, | |
| 115 | + | Payload: map[string]any{"content": secret}, | |
| 116 | + | }) | |
| 117 | + | if err := writer.Close(); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | data := string(mustRead(t, filepath.Join(writer.Dir(), "trace.jsonl"))) | |
| 121 | + | if strings.Contains(data, secret) || strings.Contains(data, "content") { | |
| 122 | + | t.Fatalf("normal trace leaked sensitive value: %s", data) | |
| 123 | + | } | |
| 124 | + | if !strings.Contains(data, "[REDACTED]") || !strings.Contains(data, "safe") { | |
| 125 | + | t.Fatalf("normal trace did not retain safe metadata/redaction: %s", data) | |
| 126 | + | } | |
| 127 | + | entries, err := os.ReadDir(filepath.Join(writer.Dir(), "payloads")) | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | if len(entries) != 0 { | |
| 132 | + | t.Fatalf("normal trace wrote %d payload files", len(entries)) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestTraceWriterConcurrentRecordHasOrderedGlobalSequences(t *testing.T) { | |
| 137 | + | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace")) | |
| 138 | + | if err != nil { | |
| 139 | + | t.Fatal(err) | |
| 140 | + | } | |
| 141 | + | const workers = 8 | |
| 142 | + | const perWorker = 25 | |
| 143 | + | var group sync.WaitGroup | |
| 144 | + | for worker := 0; worker < workers; worker++ { | |
| 145 | + | group.Add(1) | |
| 146 | + | go func(worker int) { | |
| 147 | + | defer group.Done() | |
| 148 | + | for i := 0; i < perWorker; i++ { | |
| 149 | + | writer.Record(NewTraceEvent("event", map[string]any{"worker": worker, "index": i})) | |
| 150 | + | } | |
| 151 | + | }(worker) | |
| 152 | + | } | |
| 153 | + | group.Wait() | |
| 154 | + | if err := writer.Close(); err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl")) | |
| 158 | + | if len(lines) != workers*perWorker { | |
| 159 | + | t.Fatalf("got %d lines, want %d", len(lines), workers*perWorker) | |
| 160 | + | } | |
| 161 | + | seqs := make([]uint64, 0, len(lines)) | |
| 162 | + | for _, line := range lines { | |
| 163 | + | var event TraceEvent | |
| 164 | + | if err := json.Unmarshal([]byte(line), &event); err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | seqs = append(seqs, event.Seq) | |
| 168 | + | } | |
| 169 | + | if !sort.SliceIsSorted(seqs, func(i, j int) bool { return seqs[i] < seqs[j] }) { | |
| 170 | + | t.Fatalf("trace sequences are not ordered: %v", seqs) | |
| 171 | + | } | |
| 172 | + | for i := 1; i < len(seqs); i++ { | |
| 173 | + | if seqs[i] != seqs[i-1]+1 { | |
| 174 | + | t.Fatalf("sequence gap at %d: %d then %d", i, seqs[i-1], seqs[i]) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func assertPrivate(t *testing.T, path string, want os.FileMode) { | |
| 180 | + | t.Helper() | |
| 181 | + | if os.PathSeparator == '\\' { | |
| 182 | + | // Windows does not expose POSIX permission bits through os.FileMode. | |
| 183 | + | // The writer still requests restrictive ACL-compatible modes; assert the | |
| 184 | + | // portable property that the bundle member exists instead. | |
| 185 | + | if _, err := os.Stat(path); err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | info, err := os.Stat(path) | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | if got := info.Mode().Perm(); got != want { | |
| 195 | + | t.Fatalf("%s mode = %o, want %o", path, got, want) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func readJSONFile(t *testing.T, path string, out any) { | |
| 200 | + | t.Helper() | |
| 201 | + | if err := json.Unmarshal(mustRead(t, path), out); err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func readLines(t *testing.T, path string) []string { | |
| 207 | + | t.Helper() | |
| 208 | + | data := strings.TrimSpace(string(mustRead(t, path))) | |
| 209 | + | if data == "" { | |
| 210 | + | return nil | |
| 211 | + | } | |
| 212 | + | return strings.Split(data, "\n") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func mustRead(t *testing.T, path string) []byte { | |
| 216 | + | t.Helper() | |
| 217 | + | data, err := os.ReadFile(path) | |
| 218 | + | if err != nil { | |
| 219 | + | t.Fatal(err) | |
| 220 | + | } | |
| 221 | + | return data | |
| 222 | + | } | |
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sort" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTraceContextZeroValueIsNoOp(t *testing.T) { | |||
| 15 | + | var trace TraceContext | |||
| 16 | + | if trace.Enabled() { | |||
| 17 | + | t.Fatal("zero trace context is enabled") | |||
| 18 | + | } | |||
| 19 | + | trace.Record(NewTraceEvent("ignored", map[string]any{"secret": "not written"})) | |||
| 20 | + | if got := TraceContextFrom(context.Background()); got.Enabled() { | |||
| 21 | + | t.Fatal("context without trace is enabled") | |||
| 22 | + | } | |||
| 23 | + | ctx := WithTraceContext(context.Background(), trace) | |||
| 24 | + | if TraceContextFrom(ctx).Enabled() { | |||
| 25 | + | t.Fatal("stored no-op context is enabled") | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) { | |||
| 30 | + | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeDetailed) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | writer.Record(TraceEvent{Type: "tool.result", Data: map[string]any{"name": "cat"}, Payload: map[string]any{"output": "sensitive result"}}) | |||
| 35 | + | if err := writer.Close(); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, name := range []string{"manifest.json", "trace.jsonl", "payloads"} { | |||
| 40 | + | if _, err := os.Stat(filepath.Join(writer.Dir(), name)); err != nil { | |||
| 41 | + | t.Fatalf("bundle member %s: %v", name, err) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | assertPrivate(t, writer.Dir(), 0o700) | |||
| 45 | + | assertPrivate(t, filepath.Join(writer.Dir(), "manifest.json"), 0o600) | |||
| 46 | + | assertPrivate(t, filepath.Join(writer.Dir(), "trace.jsonl"), 0o600) | |||
| 47 | + | ||||
| 48 | + | var manifest TraceManifest | |||
| 49 | + | readJSONFile(t, filepath.Join(writer.Dir(), "manifest.json"), &manifest) | |||
| 50 | + | if manifest.Mode != TraceModeDetailed || manifest.TraceFile != "trace.jsonl" || manifest.Payloads != "payloads" { | |||
| 51 | + | t.Fatalf("manifest = %+v", manifest) | |||
| 52 | + | } | |||
| 53 | + | lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl")) | |||
| 54 | + | if len(lines) != 1 { | |||
| 55 | + | t.Fatalf("got %d trace lines, want 1", len(lines)) | |||
| 56 | + | } | |||
| 57 | + | if strings.Contains(lines[0], "sensitive result") { | |||
| 58 | + | t.Fatalf("trace event leaked detailed payload: %s", lines[0]) | |||
| 59 | + | } | |||
| 60 | + | var event TraceEvent | |||
| 61 | + | if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | if event.Seq == 0 || event.PayloadFile == "" { | |||
| 65 | + | t.Fatalf("event = %+v", event) | |||
| 66 | + | } | |||
| 67 | + | payloadPath := filepath.Join(writer.Dir(), filepath.FromSlash(event.PayloadFile)) | |||
| 68 | + | assertPrivate(t, payloadPath, 0o600) | |||
| 69 | + | payload := string(mustRead(t, payloadPath)) | |||
| 70 | + | if !strings.Contains(payload, "sensitive result") { | |||
| 71 | + | t.Fatalf("payload does not contain detailed value: %s", payload) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) { | |||
| 76 | + | writer := NewMemoryTraceWriter() | |||
| 77 | + | for index := 0; index < memoryTraceEventLimit+2; index++ { | |||
| 78 | + | writer.Record(TraceEvent{Type: "event", Data: map[string]any{"index": index}}) | |||
| 79 | + | // Keep this retention test independent from the separate bounded-queue | |||
| 80 | + | // contract: a slow Windows race run can otherwise intentionally drop | |||
| 81 | + | // records before the in-memory limit is exercised. | |||
| 82 | + | if (index+1)%traceQueueInitialCapacity == 0 { | |||
| 83 | + | if err := writer.Flush(); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | if err := writer.Flush(); err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | if got := writer.Dir(); got != "" { | |||
| 92 | + | t.Fatalf("memory trace dir = %q, want empty", got) | |||
| 93 | + | } | |||
| 94 | + | events := writer.Events() | |||
| 95 | + | if len(events) != memoryTraceEventLimit || events[len(events)-1].Seq != memoryTraceEventLimit+2 { | |||
| 96 | + | t.Fatalf("memory events retained %d with final seq %d", len(events), events[len(events)-1].Seq) | |||
| 97 | + | } | |||
| 98 | + | if err := writer.Close(); err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) { | |||
| 104 | + | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal) | |||
| 105 | + | if err != nil { | |||
| 106 | + | t.Fatal(err) | |||
| 107 | + | } | |||
| 108 | + | secret := "super-secret-value" | |||
| 109 | + | writer.Record(TraceEvent{ | |||
| 110 | + | Type: "request", | |||
| 111 | + | Data: map[string]any{ | |||
| 112 | + | "name": "safe", "authorization": secret, "openai_api_key": secret, | |||
| 113 | + | "nested": map[string]any{"token": secret, "count": 2, "headers": map[string]string{"x_api_key": secret}}, | |||
| 114 | + | }, | |||
| 115 | + | Payload: map[string]any{"content": secret}, | |||
| 116 | + | }) | |||
| 117 | + | if err := writer.Close(); err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | data := string(mustRead(t, filepath.Join(writer.Dir(), "trace.jsonl"))) | |||
| 121 | + | if strings.Contains(data, secret) || strings.Contains(data, "content") { | |||
| 122 | + | t.Fatalf("normal trace leaked sensitive value: %s", data) | |||
| 123 | + | } | |||
| 124 | + | if !strings.Contains(data, "[REDACTED]") || !strings.Contains(data, "safe") { | |||
| 125 | + | t.Fatalf("normal trace did not retain safe metadata/redaction: %s", data) | |||
| 126 | + | } | |||
| 127 | + | entries, err := os.ReadDir(filepath.Join(writer.Dir(), "payloads")) | |||
| 128 | + | if err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | if len(entries) != 0 { | |||
| 132 | + | t.Fatalf("normal trace wrote %d payload files", len(entries)) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestTraceWriterConcurrentRecordHasOrderedGlobalSequences(t *testing.T) { | |||
| 137 | + | writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace")) | |||
| 138 | + | if err != nil { | |||
| 139 | + | t.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | const workers = 8 | |||
| 142 | + | const perWorker = 25 | |||
| 143 | + | var group sync.WaitGroup | |||
| 144 | + | for worker := 0; worker < workers; worker++ { | |||
| 145 | + | group.Add(1) | |||
| 146 | + | go func(worker int) { | |||
| 147 | + | defer group.Done() | |||
| 148 | + | for i := 0; i < perWorker; i++ { | |||
| 149 | + | writer.Record(NewTraceEvent("event", map[string]any{"worker": worker, "index": i})) | |||
| 150 | + | } | |||
| 151 | + | }(worker) | |||
| 152 | + | } | |||
| 153 | + | group.Wait() | |||
| 154 | + | if err := writer.Close(); err != nil { | |||
| 155 | + | t.Fatal(err) | |||
| 156 | + | } | |||
| 157 | + | lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl")) | |||
| 158 | + | if len(lines) != workers*perWorker { | |||
| 159 | + | t.Fatalf("got %d lines, want %d", len(lines), workers*perWorker) | |||
| 160 | + | } | |||
| 161 | + | seqs := make([]uint64, 0, len(lines)) | |||
| 162 | + | for _, line := range lines { | |||
| 163 | + | var event TraceEvent | |||
| 164 | + | if err := json.Unmarshal([]byte(line), &event); err != nil { | |||
| 165 | + | t.Fatal(err) | |||
| 166 | + | } | |||
| 167 | + | seqs = append(seqs, event.Seq) | |||
| 168 | + | } | |||
| 169 | + | if !sort.SliceIsSorted(seqs, func(i, j int) bool { return seqs[i] < seqs[j] }) { | |||
| 170 | + | t.Fatalf("trace sequences are not ordered: %v", seqs) | |||
| 171 | + | } | |||
| 172 | + | for i := 1; i < len(seqs); i++ { | |||
| 173 | + | if seqs[i] != seqs[i-1]+1 { | |||
| 174 | + | t.Fatalf("sequence gap at %d: %d then %d", i, seqs[i-1], seqs[i]) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func assertPrivate(t *testing.T, path string, want os.FileMode) { | |||
| 180 | + | t.Helper() | |||
| 181 | + | if os.PathSeparator == '\\' { | |||
| 182 | + | // Windows does not expose POSIX permission bits through os.FileMode. | |||
| 183 | + | // The writer still requests restrictive ACL-compatible modes; assert the | |||
| 184 | + | // portable property that the bundle member exists instead. | |||
| 185 | + | if _, err := os.Stat(path); err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | info, err := os.Stat(path) | |||
| 191 | + | if err != nil { | |||
| 192 | + | t.Fatal(err) | |||
| 193 | + | } | |||
| 194 | + | if got := info.Mode().Perm(); got != want { | |||
| 195 | + | t.Fatalf("%s mode = %o, want %o", path, got, want) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func readJSONFile(t *testing.T, path string, out any) { | |||
| 200 | + | t.Helper() | |||
| 201 | + | if err := json.Unmarshal(mustRead(t, path), out); err != nil { | |||
| 202 | + | t.Fatal(err) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func readLines(t *testing.T, path string) []string { | |||
| 207 | + | t.Helper() | |||
| 208 | + | data := strings.TrimSpace(string(mustRead(t, path))) | |||
| 209 | + | if data == "" { | |||
| 210 | + | return nil | |||
| 211 | + | } | |||
| 212 | + | return strings.Split(data, "\n") | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func mustRead(t *testing.T, path string) []byte { | |||
| 216 | + | t.Helper() | |||
| 217 | + | data, err := os.ReadFile(path) | |||
| 218 | + | if err != nil { | |||
| 219 | + | t.Fatal(err) | |||
| 220 | + | } | |||
| 221 | + | return data | |||
| 222 | + | } | |||
M packages/agent/tools/subagent_lifecycle_test.go +3 -4
| @@ -66,15 +66,14 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) { | |||
| 66 | 66 | var got struct { | |
| 67 | 67 | Action string `json:"action"` | |
| 68 | 68 | Agent struct { | |
| 69 | - | ID string `json:"agent_id"` | |
| 70 | - | State string `json:"state"` | |
| 69 | + | ID string `json:"agent_id"` | |
| 71 | 70 | } `json:"agent"` | |
| 72 | 71 | } | |
| 73 | 72 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| 74 | 73 | t.Fatalf("stop response JSON: %v", err) | |
| 75 | 74 | } | |
| 76 | - | if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" { | |
| 77 | - | t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID) | |
| 75 | + | if got.Action != "stop_requested" || got.Agent.ID != agent.ID { | |
| 76 | + | t.Fatalf("stop response = %+v, want stop_requested agent %s", got, agent.ID) | |
| 78 | 77 | } | |
| 79 | 78 | } | |
| 80 | 79 | ||
| @@ -66,15 +66,14 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) { | |||||
| 66 | var got struct { | 66 | var got struct { | ||
| 67 | Action string `json:"action"` | 67 | Action string `json:"action"` | ||
| 68 | Agent struct { | 68 | Agent struct { | ||
| 69 | - | ID string `json:"agent_id"` | 69 | + | ID string `json:"agent_id"` |
| 70 | - | State string `json:"state"` | |||
| 71 | } `json:"agent"` | 70 | } `json:"agent"` | ||
| 72 | } | 71 | } | ||
| 73 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | 72 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | ||
| 74 | t.Fatalf("stop response JSON: %v", err) | 73 | t.Fatalf("stop response JSON: %v", err) | ||
| 75 | } | 74 | } | ||
| 76 | - | if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" { | 75 | + | if got.Action != "stop_requested" || got.Agent.ID != agent.ID { |
| 77 | - | t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID) | 76 | + | t.Fatalf("stop response = %+v, want stop_requested agent %s", got, agent.ID) |
| 78 | } | 77 | } | ||
| 79 | } | 78 | } | ||
| 80 | 79 | ||||
M packages/agent/tools/subagent_resume.go +1 -1
| @@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 125 | 125 | if !ok { | |
| 126 | 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | |
| 127 | 127 | } | |
| 128 | - | return renderSubagentAction("resumed", snapshot) | |
| 128 | + | return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) | |
| 129 | 129 | } | |
| @@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 125 | if !ok { | 125 | if !ok { | ||
| 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | ||
| 127 | } | 127 | } | ||
| 128 | - | return renderSubagentAction("resumed", snapshot) | 128 | + | return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) |
| 129 | } | 129 | } | ||
M packages/agent/tools/subagent_status.go +28 -39
| @@ -35,19 +35,27 @@ type subagentStatusResponse struct { | |||
| 35 | 35 | ||
| 36 | 36 | type subagentStatusEntry struct { | |
| 37 | 37 | ID string `json:"agent_id"` | |
| 38 | - | State string `json:"state"` | |
| 39 | - | ProcessState string `json:"process_state"` | |
| 40 | - | TurnState string `json:"turn_state"` | |
| 38 | + | Operation *statusOperation `json:"operation,omitempty"` | |
| 39 | + | Terminal string `json:"terminal,omitempty"` | |
| 40 | + | LastEvent *statusLastEvent `json:"last_event,omitempty"` | |
| 41 | + | StartedAt time.Time `json:"started_at"` | |
| 41 | 42 | LifetimeTurns int `json:"lifetime_turns"` | |
| 42 | 43 | CurrentRunTurns int `json:"current_run_turns"` | |
| 43 | - | StartedAt time.Time `json:"started_at"` | |
| 44 | - | UpdatedAt time.Time `json:"updated_at"` | |
| 45 | - | FinishedAt *time.Time `json:"finished_at,omitempty"` | |
| 46 | 44 | TaskSummary string `json:"task_summary,omitempty"` | |
| 47 | 45 | Requirement *statusRequirement `json:"requirement,omitempty"` | |
| 48 | 46 | Result *subagentStatusResult `json:"result,omitempty"` | |
| 49 | 47 | } | |
| 50 | 48 | ||
| 49 | + | type statusOperation struct { | |
| 50 | + | Type string `json:"type"` | |
| 51 | + | StartedAt time.Time `json:"started_at"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | type statusLastEvent struct { | |
| 55 | + | Type string `json:"type"` | |
| 56 | + | At time.Time `json:"at"` | |
| 57 | + | } | |
| 58 | + | ||
| 51 | 59 | type statusRequirement struct { | |
| 52 | 60 | State string `json:"state"` | |
| 53 | 61 | TargetTurn int `json:"target_turn"` | |
| @@ -102,15 +110,15 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 102 | 110 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 103 | 111 | } | |
| 104 | 112 | ||
| 105 | - | // SnapshotAll is intentionally used for both list and single-worker | |
| 106 | - | // queries. Besides being non-blocking, it applies the supervisor's active | |
| 107 | - | // session visibility rules so an id lookup cannot bypass list scoping. | |
| 113 | + | // SnapshotAll provides identity, session scope, and durable result facts. | |
| 114 | + | // The execution indication itself comes solely from the trace projection. | |
| 108 | 115 | snapshots := t.Supervisor.SnapshotAll() | |
| 116 | + | views := t.Supervisor.TraceViews() | |
| 109 | 117 | id := strings.TrimSpace(args.AgentID) | |
| 110 | 118 | if id == "" { | |
| 111 | 119 | entries := make([]subagentStatusEntry, 0, len(snapshots)) | |
| 112 | 120 | for _, snapshot := range snapshots { | |
| 113 | - | entries = append(entries, publicSubagentStatus(snapshot)) | |
| 121 | + | entries = append(entries, publicSubagentStatus(snapshot, views[snapshot.ID])) | |
| 114 | 122 | } | |
| 115 | 123 | return renderSubagentStatus(subagentStatusResponse{Agents: entries}) | |
| 116 | 124 | } | |
| @@ -119,7 +127,7 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 119 | 127 | if !ok { | |
| 120 | 128 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 121 | 129 | } | |
| 122 | - | entry := publicSubagentStatus(snapshot) | |
| 130 | + | entry := publicSubagentStatus(snapshot, views[snapshot.ID]) | |
| 123 | 131 | return renderSubagentStatus(subagentStatusResponse{Agent: &entry}) | |
| 124 | 132 | } | |
| 125 | 133 | ||
| @@ -140,21 +148,21 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) | |||
| 140 | 148 | return match, hits == 1 | |
| 141 | 149 | } | |
| 142 | 150 | ||
| 143 | - | func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry { | |
| 151 | + | func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry { | |
| 144 | 152 | entry := subagentStatusEntry{ | |
| 145 | 153 | ID: snapshot.ID, | |
| 146 | - | State: publicSubagentState(snapshot), | |
| 147 | - | ProcessState: string(snapshot.ProcessState), | |
| 148 | - | TurnState: string(snapshot.TurnState), | |
| 154 | + | Terminal: view.Terminal, | |
| 155 | + | StartedAt: snapshot.Started, | |
| 149 | 156 | LifetimeTurns: snapshot.LifetimeTurns, | |
| 150 | 157 | CurrentRunTurns: snapshot.CurrentRunTurns, | |
| 151 | - | StartedAt: snapshot.Started, | |
| 152 | - | UpdatedAt: snapshot.UpdatedAt, | |
| 153 | 158 | TaskSummary: summarizeSubagentTask(snapshot.Task), | |
| 154 | 159 | } | |
| 155 | - | if !snapshot.Finished.IsZero() { | |
| 156 | - | finished := snapshot.Finished | |
| 157 | - | entry.FinishedAt = &finished | |
| 160 | + | if len(view.OpenOperations) != 0 { | |
| 161 | + | operation := view.OpenOperations[0] | |
| 162 | + | entry.Operation = &statusOperation{Type: operation.Type, StartedAt: operation.StartedAt} | |
| 163 | + | } | |
| 164 | + | if view.LastEvent.Type != "" { | |
| 165 | + | entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp} | |
| 158 | 166 | } | |
| 159 | 167 | if snapshot.Requirement.Required { | |
| 160 | 168 | entry.Requirement = &statusRequirement{ | |
| @@ -174,25 +182,6 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry | |||
| 174 | 182 | return entry | |
| 175 | 183 | } | |
| 176 | 184 | ||
| 177 | - | func publicSubagentState(snapshot subagents.AgentSnapshot) string { | |
| 178 | - | switch snapshot.Status { | |
| 179 | - | case subagents.StatusPending: | |
| 180 | - | return "starting" | |
| 181 | - | case subagents.StatusRunning: | |
| 182 | - | return "running" | |
| 183 | - | case subagents.StatusDone: | |
| 184 | - | return "completed" | |
| 185 | - | case subagents.StatusFailed: | |
| 186 | - | return "failed" | |
| 187 | - | case subagents.StatusKilled: | |
| 188 | - | return "cancelled" | |
| 189 | - | case subagents.StatusDetached: | |
| 190 | - | return "detached" | |
| 191 | - | default: | |
| 192 | - | return "unknown" | |
| 193 | - | } | |
| 194 | - | } | |
| 195 | - | ||
| 196 | 185 | func publicResultState(status subagents.TurnStatus) string { | |
| 197 | 186 | switch status { | |
| 198 | 187 | case subagents.ResultSucceeded: | |
| @@ -35,19 +35,27 @@ type subagentStatusResponse struct { | |||||
| 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 | + | Operation *statusOperation `json:"operation,omitempty"` |
| 39 | - | ProcessState string `json:"process_state"` | 39 | + | Terminal string `json:"terminal,omitempty"` |
| 40 | - | TurnState string `json:"turn_state"` | 40 | + | LastEvent *statusLastEvent `json:"last_event,omitempty"` |
| 41 | + | StartedAt time.Time `json:"started_at"` | |||
| 41 | LifetimeTurns int `json:"lifetime_turns"` | 42 | LifetimeTurns int `json:"lifetime_turns"` | ||
| 42 | CurrentRunTurns int `json:"current_run_turns"` | 43 | CurrentRunTurns int `json:"current_run_turns"` | ||
| 43 | - | StartedAt time.Time `json:"started_at"` | |||
| 44 | - | UpdatedAt time.Time `json:"updated_at"` | |||
| 45 | - | FinishedAt *time.Time `json:"finished_at,omitempty"` | |||
| 46 | TaskSummary string `json:"task_summary,omitempty"` | 44 | TaskSummary string `json:"task_summary,omitempty"` | ||
| 47 | Requirement *statusRequirement `json:"requirement,omitempty"` | 45 | Requirement *statusRequirement `json:"requirement,omitempty"` | ||
| 48 | Result *subagentStatusResult `json:"result,omitempty"` | 46 | Result *subagentStatusResult `json:"result,omitempty"` | ||
| 49 | } | 47 | } | ||
| 50 | 48 | ||||
| 49 | + | type statusOperation struct { | |||
| 50 | + | Type string `json:"type"` | |||
| 51 | + | StartedAt time.Time `json:"started_at"` | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | type statusLastEvent struct { | |||
| 55 | + | Type string `json:"type"` | |||
| 56 | + | At time.Time `json:"at"` | |||
| 57 | + | } | |||
| 58 | + | ||||
| 51 | type statusRequirement struct { | 59 | type statusRequirement struct { | ||
| 52 | State string `json:"state"` | 60 | State string `json:"state"` | ||
| 53 | TargetTurn int `json:"target_turn"` | 61 | TargetTurn int `json:"target_turn"` | ||
| @@ -102,15 +110,15 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 102 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | 110 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | ||
| 103 | } | 111 | } | ||
| 104 | 112 | ||||
| 105 | - | // SnapshotAll is intentionally used for both list and single-worker | 113 | + | // SnapshotAll provides identity, session scope, and durable result facts. |
| 106 | - | // queries. Besides being non-blocking, it applies the supervisor's active | 114 | + | // The execution indication itself comes solely from the trace projection. |
| 107 | - | // session visibility rules so an id lookup cannot bypass list scoping. | |||
| 108 | snapshots := t.Supervisor.SnapshotAll() | 115 | snapshots := t.Supervisor.SnapshotAll() | ||
| 116 | + | views := t.Supervisor.TraceViews() | |||
| 109 | id := strings.TrimSpace(args.AgentID) | 117 | id := strings.TrimSpace(args.AgentID) | ||
| 110 | if id == "" { | 118 | if id == "" { | ||
| 111 | entries := make([]subagentStatusEntry, 0, len(snapshots)) | 119 | entries := make([]subagentStatusEntry, 0, len(snapshots)) | ||
| 112 | for _, snapshot := range snapshots { | 120 | for _, snapshot := range snapshots { | ||
| 113 | - | entries = append(entries, publicSubagentStatus(snapshot)) | 121 | + | entries = append(entries, publicSubagentStatus(snapshot, views[snapshot.ID])) |
| 114 | } | 122 | } | ||
| 115 | return renderSubagentStatus(subagentStatusResponse{Agents: entries}) | 123 | return renderSubagentStatus(subagentStatusResponse{Agents: entries}) | ||
| 116 | } | 124 | } | ||
| @@ -119,7 +127,7 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 119 | if !ok { | 127 | if !ok { | ||
| 120 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | 128 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | ||
| 121 | } | 129 | } | ||
| 122 | - | entry := publicSubagentStatus(snapshot) | 130 | + | entry := publicSubagentStatus(snapshot, views[snapshot.ID]) |
| 123 | return renderSubagentStatus(subagentStatusResponse{Agent: &entry}) | 131 | return renderSubagentStatus(subagentStatusResponse{Agent: &entry}) | ||
| 124 | } | 132 | } | ||
| 125 | 133 | ||||
| @@ -140,21 +148,21 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string) | |||||
| 140 | return match, hits == 1 | 148 | return match, hits == 1 | ||
| 141 | } | 149 | } | ||
| 142 | 150 | ||||
| 143 | - | func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry { | 151 | + | func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry { |
| 144 | entry := subagentStatusEntry{ | 152 | entry := subagentStatusEntry{ | ||
| 145 | ID: snapshot.ID, | 153 | ID: snapshot.ID, | ||
| 146 | - | State: publicSubagentState(snapshot), | 154 | + | Terminal: view.Terminal, |
| 147 | - | ProcessState: string(snapshot.ProcessState), | 155 | + | StartedAt: snapshot.Started, |
| 148 | - | TurnState: string(snapshot.TurnState), | |||
| 149 | LifetimeTurns: snapshot.LifetimeTurns, | 156 | LifetimeTurns: snapshot.LifetimeTurns, | ||
| 150 | CurrentRunTurns: snapshot.CurrentRunTurns, | 157 | CurrentRunTurns: snapshot.CurrentRunTurns, | ||
| 151 | - | StartedAt: snapshot.Started, | |||
| 152 | - | UpdatedAt: snapshot.UpdatedAt, | |||
| 153 | TaskSummary: summarizeSubagentTask(snapshot.Task), | 158 | TaskSummary: summarizeSubagentTask(snapshot.Task), | ||
| 154 | } | 159 | } | ||
| 155 | - | if !snapshot.Finished.IsZero() { | 160 | + | if len(view.OpenOperations) != 0 { |
| 156 | - | finished := snapshot.Finished | 161 | + | operation := view.OpenOperations[0] |
| 157 | - | entry.FinishedAt = &finished | 162 | + | entry.Operation = &statusOperation{Type: operation.Type, StartedAt: operation.StartedAt} |
| 163 | + | } | |||
| 164 | + | if view.LastEvent.Type != "" { | |||
| 165 | + | entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp} | |||
| 158 | } | 166 | } | ||
| 159 | if snapshot.Requirement.Required { | 167 | if snapshot.Requirement.Required { | ||
| 160 | entry.Requirement = &statusRequirement{ | 168 | entry.Requirement = &statusRequirement{ | ||
| @@ -174,25 +182,6 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry | |||||
| 174 | return entry | 182 | return entry | ||
| 175 | } | 183 | } | ||
| 176 | 184 | ||||
| 177 | - | func publicSubagentState(snapshot subagents.AgentSnapshot) string { | |||
| 178 | - | switch snapshot.Status { | |||
| 179 | - | case subagents.StatusPending: | |||
| 180 | - | return "starting" | |||
| 181 | - | case subagents.StatusRunning: | |||
| 182 | - | return "running" | |||
| 183 | - | case subagents.StatusDone: | |||
| 184 | - | return "completed" | |||
| 185 | - | case subagents.StatusFailed: | |||
| 186 | - | return "failed" | |||
| 187 | - | case subagents.StatusKilled: | |||
| 188 | - | return "cancelled" | |||
| 189 | - | case subagents.StatusDetached: | |||
| 190 | - | return "detached" | |||
| 191 | - | default: | |||
| 192 | - | return "unknown" | |||
| 193 | - | } | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | func publicResultState(status subagents.TurnStatus) string { | 185 | func publicResultState(status subagents.TurnStatus) string { | ||
| 197 | switch status { | 186 | switch status { | ||
| 198 | case subagents.ResultSucceeded: | 187 | case subagents.ResultSucceeded: | ||
M packages/agent/tools/subagent_status_test.go +56 -40
| @@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) { | |||
| 44 | 44 | ID: "counter-agent", | |
| 45 | 45 | LifetimeTurns: 7, | |
| 46 | 46 | CurrentRunTurns: 2, | |
| 47 | - | }) | |
| 47 | + | }, subagents.AgentTraceView{}) | |
| 48 | 48 | if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 { | |
| 49 | 49 | t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns) | |
| 50 | 50 | } | |
| @@ -119,13 +119,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) { | |||
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | var got struct { | |
| 122 | - | Agents []struct { | |
| 123 | - | ID string `json:"agent_id"` | |
| 124 | - | State string `json:"state"` | |
| 125 | - | StartedAt string `json:"started_at"` | |
| 126 | - | UpdatedAt string `json:"updated_at"` | |
| 127 | - | TaskSummary string `json:"task_summary"` | |
| 128 | - | } `json:"agents"` | |
| 122 | + | Agents []json.RawMessage `json:"agents"` | |
| 129 | 123 | } | |
| 130 | 124 | text := textResult(res.Content) | |
| 131 | 125 | if err := json.Unmarshal([]byte(text), &got); err != nil { | |
| @@ -134,17 +128,36 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) { | |||
| 134 | 128 | if len(got.Agents) != 1 { | |
| 135 | 129 | t.Fatalf("listed agents = %d, want 1", len(got.Agents)) | |
| 136 | 130 | } | |
| 137 | - | if got.Agents[0].ID != agent.ID { | |
| 138 | - | t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID) | |
| 131 | + | var entry map[string]json.RawMessage | |
| 132 | + | if err := json.Unmarshal(got.Agents[0], &entry); err != nil { | |
| 133 | + | t.Fatal(err) | |
| 139 | 134 | } | |
| 140 | - | if got.Agents[0].State != "running" { | |
| 141 | - | t.Fatalf("state = %q, want running", got.Agents[0].State) | |
| 135 | + | for _, forbiddenField := range []string{"state", "process_state", "turn_state", "updated_at", "finished_at"} { | |
| 136 | + | if _, present := entry[forbiddenField]; present { | |
| 137 | + | t.Fatalf("status response exposes removed lifecycle field %q", forbiddenField) | |
| 138 | + | } | |
| 142 | 139 | } | |
| 143 | - | if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" { | |
| 144 | - | t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt) | |
| 140 | + | var agentID, startedAt, taskSummary string | |
| 141 | + | if err := json.Unmarshal(entry["agent_id"], &agentID); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if err := json.Unmarshal(entry["started_at"], &startedAt); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | if err := json.Unmarshal(entry["task_summary"], &taskSummary); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 145 | 149 | } | |
| 146 | - | if got.Agents[0].TaskSummary != "review architecture" { | |
| 147 | - | t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary) | |
| 150 | + | if agentID != agent.ID { | |
| 151 | + | t.Fatalf("agent id = %q, want %q", agentID, agent.ID) | |
| 152 | + | } | |
| 153 | + | if startedAt == "" { | |
| 154 | + | t.Fatal("started_at is empty") | |
| 155 | + | } | |
| 156 | + | if taskSummary != "review architecture" { | |
| 157 | + | t.Fatalf("task summary = %q, want first task line", taskSummary) | |
| 158 | + | } | |
| 159 | + | if _, present := entry["last_event"]; !present { | |
| 160 | + | t.Fatal("status response omits trace-backed last_event") | |
| 148 | 161 | } | |
| 149 | 162 | for _, forbidden := range []string{ | |
| 150 | 163 | "PRIVATE_PROMPT_MARKER", | |
| @@ -163,7 +176,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) { | |||
| 163 | 176 | agent.Wait() | |
| 164 | 177 | } | |
| 165 | 178 | ||
| 166 | - | func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) { | |
| 179 | + | func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) { | |
| 167 | 180 | root := t.TempDir() | |
| 168 | 181 | started := make(chan struct{}, 2) | |
| 169 | 182 | release := make(chan struct{}) | |
| @@ -203,14 +216,18 @@ func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) { | |||
| 203 | 216 | } | |
| 204 | 217 | var got struct { | |
| 205 | 218 | Agent struct { | |
| 206 | - | State string `json:"state"` | |
| 219 | + | ID string `json:"agent_id"` | |
| 220 | + | Operation json.RawMessage `json:"operation"` | |
| 207 | 221 | } `json:"agent"` | |
| 208 | 222 | } | |
| 209 | 223 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| 210 | 224 | t.Fatal(err) | |
| 211 | 225 | } | |
| 212 | - | if got.Agent.State != "starting" { | |
| 213 | - | t.Fatalf("queued state = %q, want starting", got.Agent.State) | |
| 226 | + | if got.Agent.ID != queued.ID { | |
| 227 | + | t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID) | |
| 228 | + | } | |
| 229 | + | if len(got.Agent.Operation) != 0 { | |
| 230 | + | t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation) | |
| 214 | 231 | } | |
| 215 | 232 | ||
| 216 | 233 | close(release) | |
| @@ -249,10 +266,8 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi | |||
| 249 | 266 | ||
| 250 | 267 | var got struct { | |
| 251 | 268 | Agent struct { | |
| 252 | - | ID string `json:"agent_id"` | |
| 253 | - | State string `json:"state"` | |
| 254 | - | Finished string `json:"finished_at"` | |
| 255 | - | Result *struct { | |
| 269 | + | ID string `json:"agent_id"` | |
| 270 | + | Result *struct { | |
| 256 | 271 | State string `json:"state"` | |
| 257 | 272 | Available bool `json:"available"` | |
| 258 | 273 | } `json:"result"` | |
| @@ -265,12 +280,6 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi | |||
| 265 | 280 | if got.Agent.ID != agent.ID { | |
| 266 | 281 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | |
| 267 | 282 | } | |
| 268 | - | if got.Agent.State != "completed" { | |
| 269 | - | t.Fatalf("state = %q, want completed", got.Agent.State) | |
| 270 | - | } | |
| 271 | - | if got.Agent.Finished == "" { | |
| 272 | - | t.Fatal("finished_at is empty for completed worker") | |
| 273 | - | } | |
| 274 | 283 | if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" { | |
| 275 | 284 | t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result) | |
| 276 | 285 | } | |
| @@ -319,15 +328,15 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) { | |||
| 319 | 328 | } | |
| 320 | 329 | } | |
| 321 | 330 | ||
| 322 | - | func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) { | |
| 331 | + | func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) { | |
| 323 | 332 | for _, tc := range []struct { | |
| 324 | - | name string | |
| 325 | - | runnerErr error | |
| 326 | - | stop bool | |
| 327 | - | wantState string | |
| 333 | + | name string | |
| 334 | + | runnerErr error | |
| 335 | + | stop bool | |
| 336 | + | wantTerminal string | |
| 328 | 337 | }{ | |
| 329 | - | {name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"}, | |
| 330 | - | {name: "cancelled", stop: true, wantState: "cancelled"}, | |
| 338 | + | {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"}, | |
| 339 | + | {name: "cancelled", stop: true, wantTerminal: "cancelled"}, | |
| 331 | 340 | } { | |
| 332 | 341 | t.Run(tc.name, func(t *testing.T) { | |
| 333 | 342 | root := t.TempDir() | |
| @@ -370,14 +379,21 @@ func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) { | |||
| 370 | 379 | } | |
| 371 | 380 | var got struct { | |
| 372 | 381 | Agent struct { | |
| 373 | - | State string `json:"state"` | |
| 382 | + | ID string `json:"agent_id"` | |
| 383 | + | Terminal string `json:"terminal"` | |
| 374 | 384 | } `json:"agent"` | |
| 375 | 385 | } | |
| 376 | 386 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| 377 | 387 | t.Fatal(err) | |
| 378 | 388 | } | |
| 379 | - | if got.Agent.State != tc.wantState { | |
| 380 | - | t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState) | |
| 389 | + | if got.Agent.ID != agent.ID { | |
| 390 | + | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | |
| 391 | + | } | |
| 392 | + | if got.Agent.Terminal != tc.wantTerminal { | |
| 393 | + | t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal) | |
| 394 | + | } | |
| 395 | + | if tc.stop && got.Agent.Terminal == "completed" { | |
| 396 | + | t.Fatalf("cancelled worker reported completed terminal") | |
| 381 | 397 | } | |
| 382 | 398 | }) | |
| 383 | 399 | } | |
| @@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) { | |||||
| 44 | ID: "counter-agent", | 44 | ID: "counter-agent", | ||
| 45 | LifetimeTurns: 7, | 45 | LifetimeTurns: 7, | ||
| 46 | CurrentRunTurns: 2, | 46 | CurrentRunTurns: 2, | ||
| 47 | - | }) | 47 | + | }, subagents.AgentTraceView{}) |
| 48 | if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 { | 48 | if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 { | ||
| 49 | t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns) | 49 | t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns) | ||
| 50 | } | 50 | } | ||
| @@ -119,13 +119,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) { | |||||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | var got struct { | 121 | var got struct { | ||
| 122 | - | Agents []struct { | 122 | + | Agents []json.RawMessage `json:"agents"` |
| 123 | - | ID string `json:"agent_id"` | |||
| 124 | - | State string `json:"state"` | |||
| 125 | - | StartedAt string `json:"started_at"` | |||
| 126 | - | UpdatedAt string `json:"updated_at"` | |||
| 127 | - | TaskSummary string `json:"task_summary"` | |||
| 128 | - | } `json:"agents"` | |||
| 129 | } | 123 | } | ||
| 130 | text := textResult(res.Content) | 124 | text := textResult(res.Content) | ||
| 131 | if err := json.Unmarshal([]byte(text), &got); err != nil { | 125 | if err := json.Unmarshal([]byte(text), &got); err != nil { | ||
| @@ -134,17 +128,36 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) { | |||||
| 134 | if len(got.Agents) != 1 { | 128 | if len(got.Agents) != 1 { | ||
| 135 | t.Fatalf("listed agents = %d, want 1", len(got.Agents)) | 129 | t.Fatalf("listed agents = %d, want 1", len(got.Agents)) | ||
| 136 | } | 130 | } | ||
| 137 | - | if got.Agents[0].ID != agent.ID { | 131 | + | var entry map[string]json.RawMessage |
| 138 | - | t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID) | 132 | + | if err := json.Unmarshal(got.Agents[0], &entry); err != nil { |
| 133 | + | t.Fatal(err) | |||
| 139 | } | 134 | } | ||
| 140 | - | if got.Agents[0].State != "running" { | 135 | + | for _, forbiddenField := range []string{"state", "process_state", "turn_state", "updated_at", "finished_at"} { |
| 141 | - | t.Fatalf("state = %q, want running", got.Agents[0].State) | 136 | + | if _, present := entry[forbiddenField]; present { |
| 137 | + | t.Fatalf("status response exposes removed lifecycle field %q", forbiddenField) | |||
| 138 | + | } | |||
| 142 | } | 139 | } | ||
| 143 | - | if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" { | 140 | + | var agentID, startedAt, taskSummary string |
| 144 | - | t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt) | 141 | + | if err := json.Unmarshal(entry["agent_id"], &agentID); err != nil { |
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | if err := json.Unmarshal(entry["started_at"], &startedAt); err != nil { | |||
| 145 | + | t.Fatal(err) | |||
| 146 | + | } | |||
| 147 | + | if err := json.Unmarshal(entry["task_summary"], &taskSummary); err != nil { | |||
| 148 | + | t.Fatal(err) | |||
| 145 | } | 149 | } | ||
| 146 | - | if got.Agents[0].TaskSummary != "review architecture" { | 150 | + | if agentID != agent.ID { |
| 147 | - | t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary) | 151 | + | t.Fatalf("agent id = %q, want %q", agentID, agent.ID) |
| 152 | + | } | |||
| 153 | + | if startedAt == "" { | |||
| 154 | + | t.Fatal("started_at is empty") | |||
| 155 | + | } | |||
| 156 | + | if taskSummary != "review architecture" { | |||
| 157 | + | t.Fatalf("task summary = %q, want first task line", taskSummary) | |||
| 158 | + | } | |||
| 159 | + | if _, present := entry["last_event"]; !present { | |||
| 160 | + | t.Fatal("status response omits trace-backed last_event") | |||
| 148 | } | 161 | } | ||
| 149 | for _, forbidden := range []string{ | 162 | for _, forbidden := range []string{ | ||
| 150 | "PRIVATE_PROMPT_MARKER", | 163 | "PRIVATE_PROMPT_MARKER", | ||
| @@ -163,7 +176,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) { | |||||
| 163 | agent.Wait() | 176 | agent.Wait() | ||
| 164 | } | 177 | } | ||
| 165 | 178 | ||||
| 166 | - | func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) { | 179 | + | func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) { |
| 167 | root := t.TempDir() | 180 | root := t.TempDir() | ||
| 168 | started := make(chan struct{}, 2) | 181 | started := make(chan struct{}, 2) | ||
| 169 | release := make(chan struct{}) | 182 | release := make(chan struct{}) | ||
| @@ -203,14 +216,18 @@ func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) { | |||||
| 203 | } | 216 | } | ||
| 204 | var got struct { | 217 | var got struct { | ||
| 205 | Agent struct { | 218 | Agent struct { | ||
| 206 | - | State string `json:"state"` | 219 | + | ID string `json:"agent_id"` |
| 220 | + | Operation json.RawMessage `json:"operation"` | |||
| 207 | } `json:"agent"` | 221 | } `json:"agent"` | ||
| 208 | } | 222 | } | ||
| 209 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | 223 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | ||
| 210 | t.Fatal(err) | 224 | t.Fatal(err) | ||
| 211 | } | 225 | } | ||
| 212 | - | if got.Agent.State != "starting" { | 226 | + | if got.Agent.ID != queued.ID { |
| 213 | - | t.Fatalf("queued state = %q, want starting", got.Agent.State) | 227 | + | t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID) |
| 228 | + | } | |||
| 229 | + | if len(got.Agent.Operation) != 0 { | |||
| 230 | + | t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation) | |||
| 214 | } | 231 | } | ||
| 215 | 232 | ||||
| 216 | close(release) | 233 | close(release) | ||
| @@ -249,10 +266,8 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi | |||||
| 249 | 266 | ||||
| 250 | var got struct { | 267 | var got struct { | ||
| 251 | Agent struct { | 268 | Agent struct { | ||
| 252 | - | ID string `json:"agent_id"` | 269 | + | ID string `json:"agent_id"` |
| 253 | - | State string `json:"state"` | 270 | + | Result *struct { |
| 254 | - | Finished string `json:"finished_at"` | |||
| 255 | - | Result *struct { | |||
| 256 | State string `json:"state"` | 271 | State string `json:"state"` | ||
| 257 | Available bool `json:"available"` | 272 | Available bool `json:"available"` | ||
| 258 | } `json:"result"` | 273 | } `json:"result"` | ||
| @@ -265,12 +280,6 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi | |||||
| 265 | if got.Agent.ID != agent.ID { | 280 | if got.Agent.ID != agent.ID { | ||
| 266 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | 281 | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) | ||
| 267 | } | 282 | } | ||
| 268 | - | if got.Agent.State != "completed" { | |||
| 269 | - | t.Fatalf("state = %q, want completed", got.Agent.State) | |||
| 270 | - | } | |||
| 271 | - | if got.Agent.Finished == "" { | |||
| 272 | - | t.Fatal("finished_at is empty for completed worker") | |||
| 273 | - | } | |||
| 274 | if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" { | 283 | if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" { | ||
| 275 | t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result) | 284 | t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result) | ||
| 276 | } | 285 | } | ||
| @@ -319,15 +328,15 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) { | |||||
| 319 | } | 328 | } | ||
| 320 | } | 329 | } | ||
| 321 | 330 | ||||
| 322 | - | func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) { | 331 | + | func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) { |
| 323 | for _, tc := range []struct { | 332 | for _, tc := range []struct { | ||
| 324 | - | name string | 333 | + | name string |
| 325 | - | runnerErr error | 334 | + | runnerErr error |
| 326 | - | stop bool | 335 | + | stop bool |
| 327 | - | wantState string | 336 | + | wantTerminal string |
| 328 | }{ | 337 | }{ | ||
| 329 | - | {name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"}, | 338 | + | {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"}, |
| 330 | - | {name: "cancelled", stop: true, wantState: "cancelled"}, | 339 | + | {name: "cancelled", stop: true, wantTerminal: "cancelled"}, |
| 331 | } { | 340 | } { | ||
| 332 | t.Run(tc.name, func(t *testing.T) { | 341 | t.Run(tc.name, func(t *testing.T) { | ||
| 333 | root := t.TempDir() | 342 | root := t.TempDir() | ||
| @@ -370,14 +379,21 @@ func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) { | |||||
| 370 | } | 379 | } | ||
| 371 | var got struct { | 380 | var got struct { | ||
| 372 | Agent struct { | 381 | Agent struct { | ||
| 373 | - | State string `json:"state"` | 382 | + | ID string `json:"agent_id"` |
| 383 | + | Terminal string `json:"terminal"` | |||
| 374 | } `json:"agent"` | 384 | } `json:"agent"` | ||
| 375 | } | 385 | } | ||
| 376 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | 386 | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | ||
| 377 | t.Fatal(err) | 387 | t.Fatal(err) | ||
| 378 | } | 388 | } | ||
| 379 | - | if got.Agent.State != tc.wantState { | 389 | + | if got.Agent.ID != agent.ID { |
| 380 | - | t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState) | 390 | + | t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID) |
| 391 | + | } | |||
| 392 | + | if got.Agent.Terminal != tc.wantTerminal { | |||
| 393 | + | t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal) | |||
| 394 | + | } | |||
| 395 | + | if tc.stop && got.Agent.Terminal == "completed" { | |||
| 396 | + | t.Fatalf("cancelled worker reported completed terminal") | |||
| 381 | } | 397 | } | ||
| 382 | }) | 398 | }) | ||
| 383 | } | 399 | } | ||
M packages/agent/tools/subagent_stop.go +3 -3
| @@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||
| 98 | 98 | if !ok { | |
| 99 | 99 | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | |
| 100 | 100 | } | |
| 101 | - | return renderSubagentAction("stop_requested", snapshot) | |
| 101 | + | return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |
| @@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |||
| 107 | 107 | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | - | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) { | |
| 110 | + | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) { | |
| 111 | 111 | response := subagentActionResponse{ | |
| 112 | 112 | Action: action, | |
| 113 | - | Agent: publicSubagentStatus(snapshot), | |
| 113 | + | Agent: publicSubagentStatus(snapshot, view), | |
| 114 | 114 | } | |
| 115 | 115 | data, err := json.Marshal(response) | |
| 116 | 116 | if err != nil { | |
| @@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||||
| 98 | if !ok { | 98 | if !ok { | ||
| 99 | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | 99 | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | ||
| 100 | } | 100 | } | ||
| 101 | - | return renderSubagentAction("stop_requested", snapshot) | 101 | + | return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID]) |
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | 104 | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | ||
| @@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |||||
| 107 | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | 107 | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | - | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) { | 110 | + | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) { |
| 111 | response := subagentActionResponse{ | 111 | response := subagentActionResponse{ | ||
| 112 | Action: action, | 112 | Action: action, | ||
| 113 | - | Agent: publicSubagentStatus(snapshot), | 113 | + | Agent: publicSubagentStatus(snapshot, view), |
| 114 | } | 114 | } | ||
| 115 | data, err := json.Marshal(response) | 115 | data, err := json.Marshal(response) | ||
| 116 | if err != nil { | 116 | if err != nil { | ||
A packages/agent/tracecmd.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func runDebugCommand(rawArgs []string, stdout io.Writer) (bool, error) { | |
| 14 | + | if len(rawArgs) == 0 || rawArgs[0] != "debug" { | |
| 15 | + | return false, nil | |
| 16 | + | } | |
| 17 | + | if len(rawArgs) != 3 || rawArgs[1] != "trace" { | |
| 18 | + | return true, fmt.Errorf("usage: zut debug trace <bundle>") | |
| 19 | + | } | |
| 20 | + | events, readErr := subagents.ReadTrace(rawArgs[2]) | |
| 21 | + | if err := renderTraceInspection(stdout, subagents.ProjectTrace(events), time.Now()); err != nil { | |
| 22 | + | return true, err | |
| 23 | + | } | |
| 24 | + | return true, readErr | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceView, now time.Time) error { | |
| 28 | + | ids := make([]string, 0, len(views)) | |
| 29 | + | for id := range views { | |
| 30 | + | ids = append(ids, id) | |
| 31 | + | } | |
| 32 | + | sort.Strings(ids) | |
| 33 | + | for _, id := range ids { | |
| 34 | + | view := views[id] | |
| 35 | + | if view.Terminal != "" { | |
| 36 | + | if _, err := fmt.Fprintf(w, "%s %s\n", id, view.Terminal); err != nil { | |
| 37 | + | return err | |
| 38 | + | } | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | if len(view.OpenOperations) != 0 { | |
| 42 | + | operation := view.OpenOperations[0] | |
| 43 | + | if _, err := fmt.Fprintf(w, "%s %s open %s\n", id, strings.Replace(operation.Type, ".started", "", 1), operation.Duration(now).Round(time.Second)); err != nil { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | age := now.Sub(view.LastEvent.Timestamp).Round(time.Second) | |
| 49 | + | when := age.String() + " ago" | |
| 50 | + | if age < 0 { | |
| 51 | + | when = "in " + (-age).String() | |
| 52 | + | } | |
| 53 | + | if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return nil | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func runDebugCommand(rawArgs []string, stdout io.Writer) (bool, error) { | |||
| 14 | + | if len(rawArgs) == 0 || rawArgs[0] != "debug" { | |||
| 15 | + | return false, nil | |||
| 16 | + | } | |||
| 17 | + | if len(rawArgs) != 3 || rawArgs[1] != "trace" { | |||
| 18 | + | return true, fmt.Errorf("usage: zut debug trace <bundle>") | |||
| 19 | + | } | |||
| 20 | + | events, readErr := subagents.ReadTrace(rawArgs[2]) | |||
| 21 | + | if err := renderTraceInspection(stdout, subagents.ProjectTrace(events), time.Now()); err != nil { | |||
| 22 | + | return true, err | |||
| 23 | + | } | |||
| 24 | + | return true, readErr | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceView, now time.Time) error { | |||
| 28 | + | ids := make([]string, 0, len(views)) | |||
| 29 | + | for id := range views { | |||
| 30 | + | ids = append(ids, id) | |||
| 31 | + | } | |||
| 32 | + | sort.Strings(ids) | |||
| 33 | + | for _, id := range ids { | |||
| 34 | + | view := views[id] | |||
| 35 | + | if view.Terminal != "" { | |||
| 36 | + | if _, err := fmt.Fprintf(w, "%s %s\n", id, view.Terminal); err != nil { | |||
| 37 | + | return err | |||
| 38 | + | } | |||
| 39 | + | continue | |||
| 40 | + | } | |||
| 41 | + | if len(view.OpenOperations) != 0 { | |||
| 42 | + | operation := view.OpenOperations[0] | |||
| 43 | + | if _, err := fmt.Fprintf(w, "%s %s open %s\n", id, strings.Replace(operation.Type, ".started", "", 1), operation.Duration(now).Round(time.Second)); err != nil { | |||
| 44 | + | return err | |||
| 45 | + | } | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | age := now.Sub(view.LastEvent.Timestamp).Round(time.Second) | |||
| 49 | + | when := age.String() + " ago" | |||
| 50 | + | if age < 0 { | |||
| 51 | + | when = "in " + (-age).String() | |||
| 52 | + | } | |||
| 53 | + | if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil { | |||
| 54 | + | return err | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return nil | |||
| 58 | + | } | |||
A packages/agent/tracecmd_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) { | |
| 12 | + | now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC) | |
| 13 | + | views := subagents.ProjectTrace([]subagents.TraceEvent{{ | |
| 14 | + | Type: "tool.started", AgentID: "agent-a", TurnID: "turn-1", Timestamp: now.Add(-30 * time.Second), | |
| 15 | + | }}) | |
| 16 | + | var out bytes.Buffer | |
| 17 | + | if err := renderTraceInspection(&out, views, now); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if got, want := out.String(), "agent-a tool open 30s\n"; got != want { | |
| 21 | + | t.Fatalf("inspection = %q, want %q", got, want) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) { | |
| 26 | + | now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC) | |
| 27 | + | views := map[string]subagents.AgentTraceView{ | |
| 28 | + | "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}}, | |
| 29 | + | } | |
| 30 | + | var out bytes.Buffer | |
| 31 | + | if err := renderTraceInspection(&out, views, now); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want { | |
| 35 | + | t.Fatalf("inspection = %q, want %q", got, want) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) { | |||
| 12 | + | now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC) | |||
| 13 | + | views := subagents.ProjectTrace([]subagents.TraceEvent{{ | |||
| 14 | + | Type: "tool.started", AgentID: "agent-a", TurnID: "turn-1", Timestamp: now.Add(-30 * time.Second), | |||
| 15 | + | }}) | |||
| 16 | + | var out bytes.Buffer | |||
| 17 | + | if err := renderTraceInspection(&out, views, now); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | if got, want := out.String(), "agent-a tool open 30s\n"; got != want { | |||
| 21 | + | t.Fatalf("inspection = %q, want %q", got, want) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) { | |||
| 26 | + | now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC) | |||
| 27 | + | views := map[string]subagents.AgentTraceView{ | |||
| 28 | + | "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}}, | |||
| 29 | + | } | |||
| 30 | + | var out bytes.Buffer | |||
| 31 | + | if err := renderTraceInspection(&out, views, now); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want { | |||
| 35 | + | t.Fatalf("inspection = %q, want %q", got, want) | |||
| 36 | + | } | |||
| 37 | + | } | |||