feat: add durable subagents supervisor
- commit
- 3463e9c340fca0160979ba84e3f941178c1aad51
- author
- brice · · 2026-08-04 14:13 UTC
- committer
- brice · · 2026-08-05 04:22 UTC
- parent
- 5f266b1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
49 files · +7252 -585
M README.md +22 -22
| @@ -290,7 +290,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 290 | 290 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` 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. | | |
| 291 | 291 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 292 | 292 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 293 | - | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | |
| 293 | + | | `/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. `/swarm` remains a compatibility alias. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | |
| 294 | 294 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 295 | 295 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 296 | 296 | | `/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/zot/main.go`. | | |
| @@ -346,28 +346,28 @@ Each question runs an isolated agent turn against `system + main transcript + si | |||
| 346 | 346 | ||
| 347 | 347 | Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file. | |
| 348 | 348 | ||
| 349 | - | ### `/swarm` | |
| 349 | + | ### `/subagents` | |
| 350 | 350 | ||
| 351 | - | Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, its own persistent session file, and its own chat in the dashboard — but they all run in **the same working directory as the host**, so they see and edit the same files you do. Spawn one for a side task (“draft the migration”, “investigate this stack trace”, “write the test harness for module X”), keep going in the main thread, check in on it whenever you want. | |
| 351 | + | Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, persistent session file, versioned JSONL control protocol, structured turn result, and lifecycle state. Shared checkout mode preserves the historical behavior; worktree mode creates a temporary detached Git worktree, captures a patch and changed-file list, and never merges automatically. | |
| 352 | 352 | ||
| 353 | - | > **Agents edit the same files you do.** They use the same `read` / `write` / `edit` / `bash` tools as the main agent against the host's working directory. There's no per-agent worktree or branch. If you need parallel edits on isolated checkouts, set that up yourself with `git worktree` outside zot. | |
| 353 | + | > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox. | |
| 354 | 354 | ||
| 355 | 355 | ``` | |
| 356 | - | /swarm # open the dashboard | |
| 357 | - | /swarm new <task> # spawn an agent | |
| 358 | - | /swarm new --agent reviewer <task> # use a named markdown profile | |
| 359 | - | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | |
| 360 | - | /swarm new --reasoning high <task> # set the child reasoning level | |
| 361 | - | /swarm logs <id> # jump straight into one agent's transcript | |
| 362 | - | /swarm send <id> <text> # send a follow-up without opening the dashboard | |
| 363 | - | /swarm resume # pick a stopped agent to bring back | |
| 364 | - | /swarm resume <id> # bring a specific agent back | |
| 365 | - | /swarm kill <id> # stop a running agent (its state stays) | |
| 366 | - | /swarm remove <id> # delete the agent's session and state | |
| 367 | - | /swarm list # alias for opening the dashboard | |
| 356 | + | /subagents # open the dashboard | |
| 357 | + | /subagents new <task> # spawn an agent (shared mode) | |
| 358 | + | /subagents new --agent reviewer <task> # use a named markdown profile | |
| 359 | + | /subagents new --reasoning high <task> # set child reasoning | |
| 360 | + | /subagents logs <id> # open one agent's transcript | |
| 361 | + | /subagents send <id> <text> # send a follow-up turn | |
| 362 | + | /subagents result <id> # show the structured result reference | |
| 363 | + | /subagents resume-session <id> # continue without replaying the task | |
| 364 | + | /subagents restart-task <id> # intentionally run the stored task again | |
| 365 | + | /subagents kill <id> # cancel gracefully, then force-stop | |
| 366 | + | /subagents remove <id> # delete durable state | |
| 367 | + | # /swarm remains an alias for /subagents | |
| 368 | 368 | ``` | |
| 369 | 369 | ||
| 370 | - | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys: | |
| 370 | + | **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: | |
| 371 | 371 | ||
| 372 | 372 | | Key | Action | | |
| 373 | 373 | |---|---| | |
| @@ -386,17 +386,17 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 386 | 386 | ||
| 387 | 387 | **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. | |
| 388 | 388 | ||
| 389 | - | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. | |
| 389 | + | **Persistence across zot restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up as **detached**; 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. Existing `$ZOT_HOME/swarm` state remains readable. | |
| 390 | 390 | ||
| 391 | - | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. | |
| 391 | + | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZOT_HOME/swarm/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. | |
| 392 | 392 | ||
| 393 | 393 | **`/session export` does NOT bundle subagents.** A `.zotsession` 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. | |
| 394 | 394 | ||
| 395 | - | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). If named markdown profiles are available, zot adds a compact `[subagents_list]` to the main prompt; the model can choose a profile with the tool's `agent` field and optionally override its `model`, `provider`, or `reasoning`. Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and complete final response. If an agent produced no assistant response, zot includes a truncated transcript tail for diagnostics instead. The main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`. | |
| 395 | + | **Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets the canonical `subagent_spawn` tool (`swarm_spawn` remains an alias) and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`. | |
| 396 | 396 | ||
| 397 | 397 | ### Named subagent profiles | |
| 398 | 398 | ||
| 399 | - | zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-swarm enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `swarm_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples. | |
| 399 | + | zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-swarm enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `subagent_spawn` (`swarm_spawn` remains an alias). Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples. | |
| 400 | 400 | ||
| 401 | 401 | ### `/settings` | |
| 402 | 402 | ||
| @@ -405,7 +405,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 405 | 405 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 406 | 406 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | |
| 407 | 407 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | |
| 408 | - | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. | |
| 408 | + | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool (`swarm_spawn` remains an alias). Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; lifecycle and result references are persisted. Completion updates still arrive through the auto-swarm watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 409 | 409 | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | |
| 410 | 410 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | |
| 411 | 411 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | |
M docs/subagents.md +27 -6
| @@ -39,7 +39,7 @@ Supported metadata: | |||
| 39 | 39 | ||
| 40 | 40 | | Field | Meaning | | |
| 41 | 41 | |---|---| | |
| 42 | - | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | |
| 42 | + | | `name` | Name passed to `subagent_spawn`'s `agent` field (`swarm_spawn` is a compatibility alias). Falls back to the filename. | | |
| 43 | 43 | | `description` | Short description shown to the main agent. | | |
| 44 | 44 | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | |
| 45 | 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |
| @@ -50,11 +50,11 @@ Supported metadata: | |||
| 50 | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | |
| 51 | 51 | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | |
| 52 | 52 | ||
| 53 | - | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | |
| 53 | + | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | |
| 54 | 54 | ||
| 55 | 55 | ## Selecting a profile | |
| 56 | 56 | ||
| 57 | - | When **auto-swarm** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile: | |
| 57 | + | When **auto-swarm** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile for `subagent_spawn`: | |
| 58 | 58 | ||
| 59 | 59 | ```json | |
| 60 | 60 | { | |
| @@ -80,8 +80,29 @@ A per-spawn reasoning override is also accepted: | |||
| 80 | 80 | The interactive command also supports the same selection explicitly: | |
| 81 | 81 | ||
| 82 | 82 | ```text | |
| 83 | - | /swarm new --agent reviewer Review the authentication package | |
| 84 | - | /swarm new --agent implementer --reasoning high Implement the parser change | |
| 83 | + | /subagents new --agent reviewer Review the authentication package | |
| 84 | + | /subagents new --agent implementer --reasoning high Implement the parser change | |
| 85 | 85 | ``` | |
| 86 | 86 | ||
| 87 | - | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| 87 | + | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| 88 | + | ||
| 89 | + | ## Lifecycle, results, and recovery | |
| 90 | + | ||
| 91 | + | 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, and logical result references. | |
| 92 | + | ||
| 93 | + | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Older flat JSON events and text commands (`user ...`, `cancel`, `shutdown`) remain readable during the compatibility window. | |
| 94 | + | ||
| 95 | + | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | |
| 96 | + | ||
| 97 | + | ```text | |
| 98 | + | subagent://<id> | |
| 99 | + | subagent://<id>/history | |
| 100 | + | subagent://<id>/result | |
| 101 | + | subagent://<id>/patch | |
| 102 | + | ``` | |
| 103 | + | ||
| 104 | + | 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. | |
| 105 | + | ||
| 106 | + | ## Resource policy | |
| 107 | + | ||
| 108 | + | The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. | |
M packages/agent/args.go +29 -9
| @@ -22,11 +22,12 @@ const ( | |||
| 22 | 22 | ModeRPC Mode = "rpc" | |
| 23 | 23 | // ModeSwarmAgent is the long-lived, headless daemon mode used by | |
| 24 | 24 | // swarm-spawned agents. The binary opens a unix-socket inbox at | |
| 25 | - | // the path provided by --swarm-agent, reads supervisor messages | |
| 26 | - | // off it ("user ...", "cancel", "shutdown"), runs each user turn | |
| 27 | - | // against a persistent session, and streams JSONL events on | |
| 28 | - | // stdout. See packages/agent/swarm/inbox.go for the wire protocol. | |
| 29 | - | ModeSwarmAgent Mode = "swarm-agent" | |
| 25 | + | // the path provided by --subagent-worker, reads supervisor messages | |
| 26 | + | // off it (versioned JSONL, with a legacy text reader), runs each user | |
| 27 | + | // turn against a persistent session, and streams JSONL events on | |
| 28 | + | // stdout. --swarm-agent remains an input compatibility alias. | |
| 29 | + | ModeSubagentWorker Mode = "subagent-worker" | |
| 30 | + | ModeSwarmAgent Mode = ModeSubagentWorker | |
| 30 | 31 | ) | |
| 31 | 32 | ||
| 32 | 33 | // Args holds parsed command-line options. | |
| @@ -124,10 +125,13 @@ type Args struct { | |||
| 124 | 125 | StartupPre string | |
| 125 | 126 | ||
| 126 | 127 | // SwarmAgent is the inbox-socket path when this process is a | |
| 127 | - | // swarm-spawned agent. Empty in every other mode. Set by | |
| 128 | - | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | |
| 128 | + | // subagent worker. Empty in every other mode. Set by either | |
| 129 | + | // --subagent-worker or the --swarm-agent compatibility alias. | |
| 129 | 130 | SwarmAgent string | |
| 130 | 131 | ||
| 132 | + | // SubagentMaxTurns limits prompt-level turns in worker mode. | |
| 133 | + | SubagentMaxTurns int | |
| 134 | + | ||
| 131 | 135 | // Subagent selects a named markdown profile for a swarm child. | |
| 132 | 136 | // It is intentionally an internal child flag; the parent swarm tool | |
| 133 | 137 | // passes only the profile name and the child discovers the definition | |
| @@ -158,6 +162,12 @@ func ParseArgs(in []string) (Args, error) { | |||
| 158 | 162 | for i := 0; i < len(in); i++ { | |
| 159 | 163 | arg := in[i] | |
| 160 | 164 | switch arg { | |
| 165 | + | case "--": | |
| 166 | + | // Everything after the terminator is prompt text, including | |
| 167 | + | // values that begin with a dash. This is used by subagent | |
| 168 | + | // workers and is also a conventional CLI escape hatch. | |
| 169 | + | positional = append(positional, in[i+1:]...) | |
| 170 | + | i = len(in) | |
| 161 | 171 | case "-h", "--help": | |
| 162 | 172 | a.Help = true | |
| 163 | 173 | case "-v", "--version": | |
| @@ -289,13 +299,13 @@ func ParseArgs(in []string) (Args, error) { | |||
| 289 | 299 | return a, err | |
| 290 | 300 | } | |
| 291 | 301 | a.CWD = v | |
| 292 | - | case "--swarm-agent": | |
| 302 | + | case "--subagent-worker", "--swarm-agent": | |
| 293 | 303 | v, err := want(&i, arg) | |
| 294 | 304 | if err != nil { | |
| 295 | 305 | return a, err | |
| 296 | 306 | } | |
| 297 | 307 | a.SwarmAgent = v | |
| 298 | - | a.Mode = ModeSwarmAgent | |
| 308 | + | a.Mode = ModeSubagentWorker | |
| 299 | 309 | case "--subagent": | |
| 300 | 310 | v, err := want(&i, arg) | |
| 301 | 311 | if err != nil { | |
| @@ -323,6 +333,16 @@ func ParseArgs(in []string) (Args, error) { | |||
| 323 | 333 | return a, fmt.Errorf("--max-steps must be a positive integer") | |
| 324 | 334 | } | |
| 325 | 335 | a.MaxSteps = n | |
| 336 | + | case "--max-turns": | |
| 337 | + | v, err := want(&i, arg) | |
| 338 | + | if err != nil { | |
| 339 | + | return a, err | |
| 340 | + | } | |
| 341 | + | var n int | |
| 342 | + | if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 { | |
| 343 | + | return a, fmt.Errorf("--max-turns must be a positive integer") | |
| 344 | + | } | |
| 345 | + | a.SubagentMaxTurns = n | |
| 326 | 346 | default: | |
| 327 | 347 | if strings.HasPrefix(arg, "-") && arg != "-" { | |
| 328 | 348 | return a, fmt.Errorf("unknown flag %q", arg) | |
M packages/agent/args_test.go +10 -0
| @@ -22,6 +22,16 @@ func TestParseArgsNoLSP(t *testing.T) { | |||
| 22 | 22 | } | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | + | func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) { | |
| 26 | + | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"}) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if args.Prompt != "--inspect auth flow" { | |
| 31 | + | t.Fatalf("prompt = %q, want leading-dash task preserved", args.Prompt) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 25 | 35 | func TestParseArgsTemperatureAllowsZero(t *testing.T) { | |
| 26 | 36 | args, err := ParseArgs([]string{"--temperature", "0"}) | |
| 27 | 37 | if err != nil { | |
M packages/agent/build.go +16 -1
| @@ -737,7 +737,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 737 | 737 | append_ = append(append_, skillAddendum) | |
| 738 | 738 | } | |
| 739 | 739 | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive | |
| 740 | - | if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 740 | + | if selectedProfile == nil && interactiveMode && autoSwarmToolAllowed(args) && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 741 | 741 | homeDir, _ := os.UserHomeDir() | |
| 742 | 742 | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
| 743 | 743 | if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { | |
| @@ -1188,6 +1188,21 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo | |||
| 1188 | 1188 | return false | |
| 1189 | 1189 | } | |
| 1190 | 1190 | ||
| 1191 | + | func autoSwarmToolAllowed(args Args) bool { | |
| 1192 | + | if args.NoTools || args.PermissionSet != nil { | |
| 1193 | + | return false | |
| 1194 | + | } | |
| 1195 | + | if len(args.Tools) == 0 { | |
| 1196 | + | return true | |
| 1197 | + | } | |
| 1198 | + | for _, name := range args.Tools { | |
| 1199 | + | if name == "subagent_spawn" || name == "swarm_spawn" { | |
| 1200 | + | return true | |
| 1201 | + | } | |
| 1202 | + | } | |
| 1203 | + | return false | |
| 1204 | + | } | |
| 1205 | + | ||
| 1191 | 1206 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1192 | 1207 | order := []string{"read", "write", "edit", "bash", "lsp"} | |
| 1193 | 1208 | var out []ToolSummary | |
M packages/agent/cli.go +101 -14
| @@ -1183,7 +1183,37 @@ func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *exte | |||
| 1183 | 1183 | ||
| 1184 | 1184 | // ---- interactive mode: opens the TUI even without credentials ---- | |
| 1185 | 1185 | ||
| 1186 | + | func subagentPolicyFromConfig(cfg SubagentsConfig) swarm.SubagentPolicy { | |
| 1187 | + | parseDuration := func(value string) time.Duration { | |
| 1188 | + | if strings.TrimSpace(value) == "" { | |
| 1189 | + | return 0 | |
| 1190 | + | } | |
| 1191 | + | parsed, err := time.ParseDuration(strings.TrimSpace(value)) | |
| 1192 | + | if err != nil || parsed <= 0 { | |
| 1193 | + | return 0 | |
| 1194 | + | } | |
| 1195 | + | return parsed | |
| 1196 | + | } | |
| 1197 | + | return swarm.SubagentPolicy{ | |
| 1198 | + | MaxConcurrent: cfg.MaxConcurrent, | |
| 1199 | + | MaxConcurrentPerParent: cfg.MaxConcurrentPerParent, | |
| 1200 | + | MaxTotalSpawned: cfg.MaxTotalSpawned, | |
| 1201 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 1202 | + | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |
| 1203 | + | MaxTurns: cfg.MaxTurns, | |
| 1204 | + | MaxOutputBytes: cfg.MaxOutputBytes, | |
| 1205 | + | MaxOutputLines: cfg.MaxOutputLines, | |
| 1206 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 1207 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 1208 | + | HeartbeatInterval: parseDuration(cfg.HeartbeatInterval), | |
| 1209 | + | IdleTimeout: parseDuration(cfg.IdleTimeout), | |
| 1210 | + | ReconnectTimeout: parseDuration(cfg.ReconnectTimeout), | |
| 1211 | + | CancelGracePeriod: parseDuration(cfg.CancelGracePeriod), | |
| 1212 | + | } | |
| 1213 | + | } | |
| 1214 | + | ||
| 1186 | 1215 | func runInteractive(ctx context.Context, args Args, version string) error { | |
| 1216 | + | initialCfg, _ := LoadConfig() | |
| 1187 | 1217 | // Resolve WITHOUT requiring credentials. | |
| 1188 | 1218 | r, err := Resolve(args, false) | |
| 1189 | 1219 | if err != nil { | |
| @@ -1256,18 +1286,40 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1256 | 1286 | // in this outer scope rather than scoping it tighter. | |
| 1257 | 1287 | var swarmMgr *swarm.Swarm | |
| 1258 | 1288 | swarmMgr = swarm.New(swarm.Config{ | |
| 1259 | - | Root: filepath.Join(ZotHome(), "swarm"), | |
| 1260 | - | RepoRoot: r.CWD, | |
| 1261 | - | FastMode: r.FastMode, | |
| 1289 | + | Context: ctx, | |
| 1290 | + | Root: filepath.Join(ZotHome(), "swarm"), | |
| 1291 | + | RepoRoot: r.CWD, | |
| 1292 | + | Provider: r.Provider, | |
| 1293 | + | FastMode: r.FastMode, | |
| 1294 | + | BaseURL: r.BaseURL, | |
| 1295 | + | InsecureTLS: r.InsecureTLS, | |
| 1296 | + | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | |
| 1297 | + | // Keep an explicit launch key in this callback only. Swarm persists | |
| 1298 | + | // connection settings, but never stores credentials in Agent metadata | |
| 1299 | + | // or puts them in the worker's argv/environment. | |
| 1262 | 1300 | ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) { | |
| 1263 | - | if providerID == "ollama" { | |
| 1301 | + | childProvider := canonicalProvider(providerID) | |
| 1302 | + | persistMu.Lock() | |
| 1303 | + | hostProvider := canonicalProvider(activeProvider) | |
| 1304 | + | persistMu.Unlock() | |
| 1305 | + | if childProvider == "" { | |
| 1306 | + | childProvider = hostProvider | |
| 1307 | + | } | |
| 1308 | + | explicit := "" | |
| 1309 | + | // A launch-time API key is scoped to the provider selected at | |
| 1310 | + | // startup. Never send it to an explicitly different child | |
| 1311 | + | // provider; resolve that provider's own credential instead. | |
| 1312 | + | if args.APIKey != "" && childProvider == hostProvider { | |
| 1313 | + | explicit = args.APIKey | |
| 1314 | + | } | |
| 1315 | + | if childProvider == "ollama" && explicit == "" { | |
| 1264 | 1316 | return swarm.Credential{Value: "ollama", Method: "apikey"}, nil | |
| 1265 | 1317 | } | |
| 1266 | - | credential, method, accountID, err := ResolveCredentialFullContext(ctx, providerID, "") | |
| 1318 | + | credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit) | |
| 1267 | 1319 | if err != nil { | |
| 1268 | 1320 | // Providers backed only by a local endpoint do not need a key. | |
| 1269 | 1321 | // Leave stdin untouched and let the child resolve that endpoint. | |
| 1270 | - | if !CredentialAvailable(providerID) { | |
| 1322 | + | if !CredentialAvailable(childProvider) { | |
| 1271 | 1323 | return swarm.Credential{}, nil | |
| 1272 | 1324 | } | |
| 1273 | 1325 | return swarm.Credential{}, err | |
| @@ -1278,6 +1330,13 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1278 | 1330 | // Pull any previously-spawned agents off disk so the dashboard | |
| 1279 | 1331 | // shows them as detached and the user can resume / remove them. | |
| 1280 | 1332 | _, _ = swarmMgr.Reload() | |
| 1333 | + | if args.PermissionSet != nil { | |
| 1334 | + | // Packaged zotfile agents have an explicit capability ceiling. A | |
| 1335 | + | // child process would otherwise rebuild a fresh unrestricted sandbox, | |
| 1336 | + | // so do not expose either slash or tool-based delegation from this | |
| 1337 | + | // restricted host until worker capability propagation exists. | |
| 1338 | + | swarmMgr = nil | |
| 1339 | + | } | |
| 1281 | 1340 | ||
| 1282 | 1341 | // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool | |
| 1283 | 1342 | // fires after every successful spawn. It hands the agent off to | |
| @@ -1300,15 +1359,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1300 | 1359 | // toggle live-mutates the running agent's registry separately so | |
| 1301 | 1360 | // flipping the flag mid-session takes effect on the next turn. | |
| 1302 | 1361 | injectSwarmSpawn := func(reg core.Registry) core.Registry { | |
| 1303 | - | if reg == nil { | |
| 1362 | + | if reg == nil || swarmMgr == nil || !autoSwarmToolAllowed(args) { | |
| 1304 | 1363 | return reg | |
| 1305 | 1364 | } | |
| 1306 | 1365 | if !AutoSwarmEnabled() { | |
| 1307 | 1366 | return reg | |
| 1308 | 1367 | } | |
| 1309 | - | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 1310 | - | Swarm: swarmMgr, | |
| 1311 | - | Enabled: AutoSwarmEnabled, | |
| 1368 | + | canonical := &tools.SwarmSpawnTool{ | |
| 1369 | + | ToolName: "subagent_spawn", | |
| 1370 | + | Swarm: swarmMgr, | |
| 1371 | + | Enabled: AutoSwarmEnabled, | |
| 1312 | 1372 | DefaultModel: func() string { | |
| 1313 | 1373 | persistMu.Lock() | |
| 1314 | 1374 | defer persistMu.Unlock() | |
| @@ -1323,6 +1383,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1323 | 1383 | ResolveSubagent: resolveSubagent, | |
| 1324 | 1384 | OnSpawned: onSpawnedSwarm, | |
| 1325 | 1385 | } | |
| 1386 | + | reg[canonical.Name()] = canonical | |
| 1387 | + | legacy := *canonical | |
| 1388 | + | legacy.ToolName = "swarm_spawn" | |
| 1389 | + | reg[legacy.Name()] = &legacy | |
| 1326 | 1390 | return reg | |
| 1327 | 1391 | } | |
| 1328 | 1392 | injectSwarmSpawn(r.ToolRegistry) | |
| @@ -1407,6 +1471,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1407 | 1471 | return nil, "", "", err | |
| 1408 | 1472 | } | |
| 1409 | 1473 | resolved.UseSandbox(sharedSandbox) | |
| 1474 | + | if swarmMgr != nil { | |
| 1475 | + | swarmMgr.SetProvider(resolved.Provider) | |
| 1476 | + | swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1477 | + | } | |
| 1410 | 1478 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1411 | 1479 | injectSwarmSpawn(resolved.ToolRegistry) | |
| 1412 | 1480 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| @@ -1426,6 +1494,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1426 | 1494 | return nil, "", "", err | |
| 1427 | 1495 | } | |
| 1428 | 1496 | resolved.UseSandbox(sharedSandbox) | |
| 1497 | + | if swarmMgr != nil { | |
| 1498 | + | swarmMgr.SetProvider(resolved.Provider) | |
| 1499 | + | swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1500 | + | } | |
| 1429 | 1501 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1430 | 1502 | injectSwarmSpawn(resolved.ToolRegistry) | |
| 1431 | 1503 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| @@ -1453,6 +1525,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1453 | 1525 | return nil, "", "", err | |
| 1454 | 1526 | } | |
| 1455 | 1527 | resolved.UseSandbox(sharedSandbox) | |
| 1528 | + | if swarmMgr != nil { | |
| 1529 | + | swarmMgr.SetProvider(resolved.Provider) | |
| 1530 | + | swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1531 | + | } | |
| 1456 | 1532 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1457 | 1533 | injectSwarmSpawn(resolved.ToolRegistry) | |
| 1458 | 1534 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| @@ -1943,7 +2019,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1943 | 2019 | }() | |
| 1944 | 2020 | } | |
| 1945 | 2021 | ||
| 1946 | - | initialCfg, _ := LoadConfig() | |
| 1947 | 2022 | fastMode := r.FastMode | |
| 1948 | 2023 | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | |
| 1949 | 2024 | for idx, s := range initialCfg.QuickModelShortcuts { | |
| @@ -1964,12 +2039,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1964 | 2039 | // shows agents this session spawned (and any pre-upgrade unscoped | |
| 1965 | 2040 | // agents — see SnapshotAll docs). Updated again whenever the | |
| 1966 | 2041 | // user swaps sessions via loadSession below. | |
| 1967 | - | if sess != nil { | |
| 2042 | + | if sess != nil && swarmMgr != nil { | |
| 1968 | 2043 | swarmMgr.SetActiveSession(sess.ID) | |
| 1969 | 2044 | } | |
| 1970 | 2045 | // Best-effort shutdown on interactive exit: stop all running | |
| 1971 | 2046 | // agents so they don't outlive their parent zot. | |
| 1972 | - | defer swarmMgr.StopAll() | |
| 2047 | + | if swarmMgr != nil { | |
| 2048 | + | defer swarmMgr.StopAll() | |
| 2049 | + | } | |
| 1973 | 2050 | ||
| 1974 | 2051 | var startupSkills []*skills.Skill | |
| 1975 | 2052 | if r.SkillTool != nil { | |
| @@ -1978,6 +2055,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1978 | 2055 | homeDir, _ := os.UserHomeDir() | |
| 1979 | 2056 | discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir) | |
| 1980 | 2057 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | |
| 2058 | + | autoSwarmToolAllowedForSession := autoSwarmToolAllowed(args) | |
| 1981 | 2059 | ||
| 1982 | 2060 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 1983 | 2061 | Terminal: term, | |
| @@ -1986,6 +2064,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1986 | 2064 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 1987 | 2065 | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | |
| 1988 | 2066 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 2067 | + | AutoSwarmToolAllowed: &autoSwarmToolAllowedForSession, | |
| 1989 | 2068 | FastMode: &fastMode, | |
| 1990 | 2069 | LSPEnabled: initialCfg.LSPEnabled, | |
| 1991 | 2070 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | |
| @@ -2238,9 +2317,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2238 | 2317 | } | |
| 2239 | 2318 | closeAgentLSP(finalAg) | |
| 2240 | 2319 | closeResolvedLSP(r) | |
| 2320 | + | // Stop supervised workers before exiting. os.Exit skips deferred | |
| 2321 | + | // cleanup, so relying only on the normal runInteractive defer would | |
| 2322 | + | // leave child daemons (and their descendants) alive after a TERM or | |
| 2323 | + | // HUP delivered to the host. | |
| 2324 | + | if swarmMgr != nil { | |
| 2325 | + | swarmMgr.StopAll() | |
| 2326 | + | } | |
| 2241 | 2327 | // Exit cleanly. Re-raising the signal would skip os.Exit's | |
| 2242 | 2328 | // at-exit hooks; explicit exit is fine because we've already | |
| 2243 | - | // flushed the only at-risk state (the session file). | |
| 2329 | + | // flushed the only at-risk state (the session file) and stopped | |
| 2330 | + | // supervised workers. | |
| 2244 | 2331 | os.Exit(0) | |
| 2245 | 2332 | }() | |
| 2246 | 2333 | ||
M packages/agent/config.go +22 -0
| @@ -22,6 +22,25 @@ type QuickModelShortcut struct { | |||
| 22 | 22 | Model string `json:"model"` | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | + | // SubagentsConfig contains the persisted supervisor policy. Duration values | |
| 26 | + | // use Go strings such as "20m" so config files remain readable. | |
| 27 | + | type SubagentsConfig struct { | |
| 28 | + | MaxConcurrent int `json:"max_concurrent,omitempty"` | |
| 29 | + | MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"` | |
| 30 | + | MaxTotalSpawned int `json:"max_total_spawned,omitempty"` | |
| 31 | + | QueueTimeout string `json:"queue_timeout,omitempty"` | |
| 32 | + | DefaultTimeout string `json:"default_timeout,omitempty"` | |
| 33 | + | MaxTurns int `json:"max_turns,omitempty"` | |
| 34 | + | MaxOutputBytes int `json:"max_output_bytes,omitempty"` | |
| 35 | + | MaxOutputLines int `json:"max_output_lines,omitempty"` | |
| 36 | + | AllowedTools []string `json:"allowed_tools,omitempty"` | |
| 37 | + | AllowedRoots []string `json:"allowed_roots,omitempty"` | |
| 38 | + | HeartbeatInterval string `json:"heartbeat_interval,omitempty"` | |
| 39 | + | IdleTimeout string `json:"idle_timeout,omitempty"` | |
| 40 | + | ReconnectTimeout string `json:"reconnect_timeout,omitempty"` | |
| 41 | + | CancelGracePeriod string `json:"cancel_grace_period,omitempty"` | |
| 42 | + | } | |
| 43 | + | ||
| 25 | 44 | // Config is the persisted user configuration. | |
| 26 | 45 | type Config struct { | |
| 27 | 46 | Provider string `json:"provider"` | |
| @@ -68,6 +87,9 @@ type Config struct { | |||
| 68 | 87 | // both so no extra provider request is made. Toggle from /settings. | |
| 69 | 88 | TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"` | |
| 70 | 89 | ||
| 90 | + | // Subagents controls manager-owned child lifecycle and resource policy. | |
| 91 | + | Subagents SubagentsConfig `json:"subagents,omitempty"` | |
| 92 | + | ||
| 71 | 93 | // AutoSwarmEnabled lets the main agent spawn background sub-agents | |
| 72 | 94 | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | |
| 73 | 95 | // default; nil/missing means disabled. Toggle from /settings. | |
M packages/agent/modes/interactive.go +16 -5
| @@ -73,6 +73,10 @@ type InteractiveConfig struct { | |||
| 73 | 73 | // re-reading config.json on every open. | |
| 74 | 74 | AutoSwarmEnabled *bool | |
| 75 | 75 | ||
| 76 | + | // AutoSwarmToolAllowed is nil for normal sessions and false when the | |
| 77 | + | // launch-time --no-tools/--tools policy excludes delegation. | |
| 78 | + | AutoSwarmToolAllowed *bool | |
| 79 | + | ||
| 76 | 80 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | |
| 77 | 81 | // nil/missing means disabled. Unsupported providers reject attempts | |
| 78 | 82 | // to enable it and reject requests when it remains enabled. | |
| @@ -1692,6 +1696,7 @@ func (i *Interactive) redraw() { | |||
| 1692 | 1696 | Provider: i.cfg.Provider, | |
| 1693 | 1697 | Model: i.cfg.Model, | |
| 1694 | 1698 | Reasoning: i.cfg.Reasoning, | |
| 1699 | + | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | |
| 1695 | 1700 | Busy: i.busy, | |
| 1696 | 1701 | BusyPrefix: statusBusyPrefix, | |
| 1697 | 1702 | CWD: i.cfg.CWD, | |
| @@ -3979,7 +3984,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3979 | 3984 | { | |
| 3980 | 3985 | key: "auto_swarm_enabled", | |
| 3981 | 3986 | label: "auto-swarm", | |
| 3982 | - | desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool", | |
| 3987 | + | desc: "let the agent spawn background sub-agents in parallel via the subagent_spawn tool", | |
| 3983 | 3988 | value: autoSwarm, | |
| 3984 | 3989 | disabled: autoSwarmDisabled, | |
| 3985 | 3990 | hint: autoSwarmHint, | |
| @@ -5210,7 +5215,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5210 | 5215 | break | |
| 5211 | 5216 | } | |
| 5212 | 5217 | i.openSessionOpsDialog() | |
| 5213 | - | case "/swarm": | |
| 5218 | + | case "/subagents", "/swarm": | |
| 5214 | 5219 | i.runSwarm(ctx, parts[1:]) | |
| 5215 | 5220 | default: | |
| 5216 | 5221 | // Last-resort fallback: try the extension manager. Built-in | |
| @@ -7419,13 +7424,15 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||
| 7419 | 7424 | current := i.agent.Tools | |
| 7420 | 7425 | next := core.Registry{} | |
| 7421 | 7426 | for name, t := range current { | |
| 7422 | - | if name == "swarm_spawn" { | |
| 7427 | + | if name == "swarm_spawn" || name == "subagent_spawn" { | |
| 7423 | 7428 | continue | |
| 7424 | 7429 | } | |
| 7425 | 7430 | next[name] = t | |
| 7426 | 7431 | } | |
| 7427 | - | if active && i.cfg.Swarm != nil { | |
| 7428 | - | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 7432 | + | toolAllowed := i.cfg.AutoSwarmToolAllowed == nil || *i.cfg.AutoSwarmToolAllowed | |
| 7433 | + | if active && toolAllowed && i.cfg.Swarm != nil { | |
| 7434 | + | canonical := &tools.SwarmSpawnTool{ | |
| 7435 | + | ToolName: "subagent_spawn", | |
| 7429 | 7436 | Swarm: i.cfg.Swarm, | |
| 7430 | 7437 | Enabled: func() bool { return true }, | |
| 7431 | 7438 | DefaultModel: func() string { return i.cfg.Model }, | |
| @@ -7434,6 +7441,10 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||
| 7434 | 7441 | ResolveSubagent: i.cfg.ResolveSubagent, | |
| 7435 | 7442 | OnSpawned: i.trackSwarmAgent, | |
| 7436 | 7443 | } | |
| 7444 | + | next[canonical.Name()] = canonical | |
| 7445 | + | legacy := *canonical | |
| 7446 | + | legacy.ToolName = "swarm_spawn" | |
| 7447 | + | next[legacy.Name()] = &legacy | |
| 7437 | 7448 | } | |
| 7438 | 7449 | i.agent.SetTools(next) | |
| 7439 | 7450 | } | |
M packages/agent/modes/slash_suggest.go +2 -1
| @@ -62,7 +62,8 @@ var slashCatalog = []slashCommand{ | |||
| 62 | 62 | {Name: "/jail", Desc: "confine tools to the current directory"}, | |
| 63 | 63 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | |
| 64 | 64 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | |
| 65 | - | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, | |
| 65 | + | {Name: "/subagents", Desc: "supervise background subagents with lifecycle, results, and optional worktrees"}, | |
| 66 | + | {Name: "/swarm", Desc: "compatibility alias for /subagents"}, | |
| 66 | 67 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | |
| 67 | 68 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | |
| 68 | 69 | {Name: "/settings", Desc: "open settings"}, | |
M packages/agent/modes/swarm_dialog.go +6 -3
| @@ -1006,7 +1006,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||
| 1006 | 1006 | return d.renderTranscript(th, width) | |
| 1007 | 1007 | } | |
| 1008 | 1008 | ||
| 1009 | - | out := []string{frameHeader(th, "swarm (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)} | |
| 1009 | + | out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)} | |
| 1010 | 1010 | if d.prompting { | |
| 1011 | 1011 | return d.renderPromptEditor(th, width, out) | |
| 1012 | 1012 | } | |
| @@ -1067,7 +1067,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||
| 1067 | 1067 | } | |
| 1068 | 1068 | ||
| 1069 | 1069 | // Column header for readability. | |
| 1070 | - | header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY") | |
| 1070 | + | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROCESS/TURN STATUS", "ID", "AGE", "ACTIVITY") | |
| 1071 | 1071 | out = append(out, th.FG256(th.Muted, header)) | |
| 1072 | 1072 | ||
| 1073 | 1073 | for i, r := range d.rows { | |
| @@ -1384,8 +1384,11 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) | |||
| 1384 | 1384 | // ✓ done write-tests-67890 1h done | |
| 1385 | 1385 | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | |
| 1386 | 1386 | status := statusLabel(r.Status) | |
| 1387 | + | if r.ProcessState != "" && r.TurnState != "" { | |
| 1388 | + | status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18) | |
| 1389 | + | } | |
| 1387 | 1390 | age := formatAge(r.Started) | |
| 1388 | - | left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | |
| 1391 | + | left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | |
| 1389 | 1392 | room := maxWidth - len([]rune(left)) | |
| 1390 | 1393 | if room < 10 { | |
| 1391 | 1394 | room = 10 | |
M packages/agent/modes/swarm_slash.go +84 -17
| @@ -62,7 +62,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 62 | 62 | return i.cfg.Swarm.SendUserTurn(id, text) | |
| 63 | 63 | } | |
| 64 | 64 | resumeAdapter := func(id string) error { | |
| 65 | - | _, err := i.cfg.Swarm.Resume(ctx, id) | |
| 65 | + | _, err := i.cfg.Swarm.ResumeSession(ctx, id) | |
| 66 | 66 | return err | |
| 67 | 67 | } | |
| 68 | 68 | ||
| @@ -99,6 +99,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 99 | 99 | // Anything that isn't a recognised flag terminates parsing and | |
| 100 | 100 | // the rest becomes the task; only leading flags are consumed. | |
| 101 | 101 | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) | |
| 102 | + | var profileTools []string | |
| 102 | 103 | var fastModeOverride *bool | |
| 103 | 104 | if task == "" { | |
| 104 | 105 | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") | |
| @@ -119,17 +120,25 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 119 | 120 | return | |
| 120 | 121 | } | |
| 121 | 122 | subagent = profile.Name | |
| 123 | + | profileTools = append([]string(nil), profile.Tools...) | |
| 122 | 124 | fastModeOverride = profile.FastMode | |
| 123 | - | if model == "" && provider == "" { | |
| 124 | - | profileProvider, profileModel := profile.ModelSelection() | |
| 125 | - | if profileProvider != "" && profileModel != "" { | |
| 126 | - | provider, model = profileProvider, profileModel | |
| 127 | - | } | |
| 125 | + | profileProvider, profileModel := profile.ModelSelection() | |
| 126 | + | if model == "" { | |
| 127 | + | model = profileModel | |
| 128 | + | } | |
| 129 | + | if provider == "" { | |
| 130 | + | provider = profileProvider | |
| 128 | 131 | } | |
| 129 | 132 | if reasoning == "" { | |
| 130 | 133 | reasoning = profile.Thinking | |
| 131 | 134 | } | |
| 132 | 135 | } | |
| 136 | + | if model == "" { | |
| 137 | + | model = i.cfg.Model | |
| 138 | + | } | |
| 139 | + | if provider == "" { | |
| 140 | + | provider = i.cfg.Provider | |
| 141 | + | } | |
| 133 | 142 | if reasoning == "" { | |
| 134 | 143 | reasoning = i.cfg.Reasoning | |
| 135 | 144 | } | |
| @@ -144,6 +153,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 144 | 153 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 145 | 154 | Task: task, Model: model, Provider: provider, Reasoning: reasoning, | |
| 146 | 155 | FastMode: fastModeOverride, Subagent: subagent, | |
| 156 | + | Tools: profileTools, | |
| 147 | 157 | }) | |
| 148 | 158 | if err != nil { | |
| 149 | 159 | i.swarmStatus("", "spawn: "+err.Error()) | |
| @@ -158,7 +168,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 158 | 168 | i.swarmStatus(status, "") | |
| 159 | 169 | case "kill", "stop": | |
| 160 | 170 | if rest == "" { | |
| 161 | - | i.swarmStatus("", "/swarm kill <id>: missing id") | |
| 171 | + | i.swarmStatus("", "/subagents kill <id>: missing id") | |
| 162 | 172 | return | |
| 163 | 173 | } | |
| 164 | 174 | if err := i.cfg.Swarm.Stop(rest); err != nil { | |
| @@ -166,6 +176,16 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 166 | 176 | return | |
| 167 | 177 | } | |
| 168 | 178 | i.swarmStatus("stopped "+rest, "") | |
| 179 | + | case "cancel": | |
| 180 | + | if rest == "" { | |
| 181 | + | i.swarmStatus("", "/subagents cancel <id>: missing id") | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | if err := i.cfg.Swarm.CancelTurn(rest); err != nil { | |
| 185 | + | i.swarmStatus("", "cancel: "+err.Error()) | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | i.swarmStatus("canceling turn "+rest, "") | |
| 169 | 189 | case "remove", "rm": | |
| 170 | 190 | if rest == "" { | |
| 171 | 191 | i.swarmStatus("", "/swarm remove <id>: missing id") | |
| @@ -194,13 +214,38 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 194 | 214 | if !ok { | |
| 195 | 215 | i.swarmStatus("", "/swarm logs: no agent matching "+rest) | |
| 196 | 216 | } | |
| 197 | - | case "resume", "reattach", "reopen": | |
| 217 | + | case "result", "inspect": | |
| 218 | + | if rest == "" { | |
| 219 | + | i.swarmStatus("", "/subagents result <id>: missing id") | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | result, err := i.cfg.Swarm.ReadResult(rest) | |
| 223 | + | if err != nil { | |
| 224 | + | i.swarmStatus("", "result: "+err.Error()) | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Swarm.ResultReference(rest)) | |
| 228 | + | if result.Summary != "" { | |
| 229 | + | status += ": " + truncateStatus(result.Summary, 160) | |
| 230 | + | } | |
| 231 | + | i.swarmStatus(status, "") | |
| 232 | + | case "wait": | |
| 233 | + | if rest == "" { | |
| 234 | + | i.swarmStatus("", "/subagents wait <id>: missing id") | |
| 235 | + | return | |
| 236 | + | } | |
| 237 | + | a := i.cfg.Swarm.Get(rest) | |
| 238 | + | if a == nil { | |
| 239 | + | i.swarmStatus("", "wait: no such agent "+rest) | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | go func() { | |
| 243 | + | a.Wait() | |
| 244 | + | i.swarmStatus("completed "+a.ID, "") | |
| 245 | + | }() | |
| 246 | + | i.swarmStatus("waiting for "+a.ID, "") | |
| 247 | + | case "resume-session", "resume", "reattach", "reopen": | |
| 198 | 248 | if rest == "" { | |
| 199 | - | // No id given: open the dashboard with the cursor | |
| 200 | - | // pre-positioned on the first resumable agent, and | |
| 201 | - | // tell the user how many there are so they know what | |
| 202 | - | // to expect. Pressing R confirms; ↑/↓ to pick a | |
| 203 | - | // different row first. | |
| 204 | 249 | count := i.swarmDialog.OpenForResume( | |
| 205 | 250 | i.cfg.Swarm.SnapshotAll, | |
| 206 | 251 | i.cfg.Swarm.Stop, | |
| @@ -212,7 +257,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 212 | 257 | ) | |
| 213 | 258 | switch count { | |
| 214 | 259 | case 0: | |
| 215 | - | i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)") | |
| 260 | + | i.swarmStatus("", "/subagents resume-session: no resumable agents") | |
| 216 | 261 | case 1: | |
| 217 | 262 | i.swarmStatus("1 resumable agent, press R to resume", "") | |
| 218 | 263 | default: | |
| @@ -220,12 +265,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 220 | 265 | } | |
| 221 | 266 | return | |
| 222 | 267 | } | |
| 223 | - | a, err := i.cfg.Swarm.Resume(ctx, rest) | |
| 268 | + | a, err := i.cfg.Swarm.ResumeSession(ctx, rest) | |
| 224 | 269 | if err != nil { | |
| 225 | - | i.swarmStatus("", "resume: "+err.Error()) | |
| 270 | + | i.swarmStatus("", "resume-session: "+err.Error()) | |
| 226 | 271 | return | |
| 227 | 272 | } | |
| 228 | 273 | i.swarmStatus("resumed "+a.ID, "") | |
| 274 | + | case "restart-task", "restart": | |
| 275 | + | if rest == "" { | |
| 276 | + | i.swarmStatus("", "/subagents restart-task <id>: missing id") | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | a, err := i.cfg.Swarm.RestartTask(ctx, rest) | |
| 280 | + | if err != nil { | |
| 281 | + | i.swarmStatus("", "restart-task: "+err.Error()) | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | i.swarmStatus("restarted task "+a.ID, "") | |
| 229 | 285 | case "send", "prompt", "msg": | |
| 230 | 286 | // /swarm send <id> <text...> is the non-interactive | |
| 231 | 287 | // counterpart of pressing 'p' in the dashboard. We split the | |
| @@ -255,7 +311,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 255 | 311 | // feature. Point the user at /swarm logs in the meantime. | |
| 256 | 312 | i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.") | |
| 257 | 313 | default: | |
| 258 | - | i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)") | |
| 314 | + | i.swarmStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / result / resume-session / restart-task)") | |
| 259 | 315 | } | |
| 260 | 316 | } | |
| 261 | 317 | ||
| @@ -375,6 +431,17 @@ func firstWord(s string) string { | |||
| 375 | 431 | return s | |
| 376 | 432 | } | |
| 377 | 433 | ||
| 434 | + | func truncateStatus(value string, max int) string { | |
| 435 | + | value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ") | |
| 436 | + | if len([]rune(value)) <= max { | |
| 437 | + | return value | |
| 438 | + | } | |
| 439 | + | if max <= 3 { | |
| 440 | + | return string([]rune(value)[:max]) | |
| 441 | + | } | |
| 442 | + | return string([]rune(value)[:max-3]) + "..." | |
| 443 | + | } | |
| 444 | + | ||
| 378 | 445 | func (i *Interactive) swarmStatus(ok, errMsg string) { | |
| 379 | 446 | i.mu.Lock() | |
| 380 | 447 | i.statusOK = ok | |
M packages/agent/settings_store.go +5 -5
| @@ -213,7 +213,7 @@ func (configSettingsStore) SetTheme(name string) error { | |||
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 215 | // AutoSwarmEnabled reads the current auto-swarm flag from config. | |
| 216 | - | // Used by the swarm_spawn tool at call time to gate execution. | |
| 216 | + | // Used by the subagent_spawn/swarm_spawn tools at call time to gate execution. | |
| 217 | 217 | func AutoSwarmEnabled() bool { | |
| 218 | 218 | cfg, err := LoadConfig() | |
| 219 | 219 | if err != nil { | |
| @@ -226,10 +226,10 @@ func AutoSwarmEnabled() bool { | |||
| 226 | 226 | // auto-swarm is enabled, so the model knows it may delegate to | |
| 227 | 227 | // background sub-agents without the user having to mention the tool | |
| 228 | 228 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | |
| 229 | - | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory. | |
| 229 | + | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a subagent_spawn tool (with swarm_spawn as a compatibility alias) that starts background sub-agents in separate long-lived processes. | |
| 230 | 230 | ||
| 231 | - | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |
| 231 | + | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding and use the returned result_ref/event path for completion (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |
| 232 | 232 | ||
| 233 | - | Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. | |
| 233 | + | Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1. | |
| 234 | 234 | ||
| 235 | - | When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-swarm update] message recapping each agent's status, task, and transcript tail. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing the agents by id.` | |
| 235 | + | When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-swarm update] message recapping each agent's status, task, and result reference. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.` | |
M packages/agent/swarm/agent.go +292 -19
| @@ -3,6 +3,7 @@ package swarm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "path/filepath" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| @@ -11,22 +12,50 @@ import ( | |||
| 11 | 12 | // Agent is one supervised task. Public fields are immutable after | |
| 12 | 13 | // Spawn; mutable state (status, activity, transcript) lives behind | |
| 13 | 14 | // the embedded mutex. | |
| 15 | + | type turnEndNotice struct { | |
| 16 | + | step int | |
| 17 | + | errMsg string | |
| 18 | + | } | |
| 19 | + | ||
| 14 | 20 | type Agent struct { | |
| 15 | 21 | ID string | |
| 16 | 22 | Task string | |
| 17 | - | Dir string // always the host's RepoRoot; agents share its cwd. | |
| 23 | + | Dir string // shared checkout or isolated worktree used by the child. | |
| 18 | 24 | Started time.Time | |
| 19 | 25 | ||
| 26 | + | // ParentID and RootSessionID identify the supervisor/session that owns | |
| 27 | + | // this worker. Empty ParentID means the main supervisor created it; | |
| 28 | + | // child-originated spawning is rejected in v1. | |
| 29 | + | ParentID string | |
| 30 | + | BatchID string | |
| 31 | + | RootSessionID string | |
| 32 | + | OriginalTask string | |
| 33 | + | RepositoryRoot string | |
| 34 | + | WorkspaceMode WorkspaceMode | |
| 35 | + | WorkspacePath string | |
| 36 | + | WorkspaceBase string | |
| 37 | + | WorkspaceCapture CaptureMode | |
| 38 | + | MaxTurns int | |
| 39 | + | // Timeout is the effective per-agent lifetime, persisted so a resumed | |
| 40 | + | // worker keeps the timeout selected at its spawn boundary. | |
| 41 | + | Timeout time.Duration | |
| 42 | + | HeartbeatInterval time.Duration | |
| 43 | + | Tools []string | |
| 44 | + | ||
| 20 | 45 | // Model and Provider, when non-empty, override the child | |
| 21 | - | // subprocess's model resolution. Empty means "inherit whatever | |
| 22 | - | // the child resolves on its own from config / env / flags" — | |
| 23 | - | // the historical behaviour. Persisted in meta.json so Resume | |
| 24 | - | // keeps using the same model across zot restarts. | |
| 25 | - | Model string | |
| 26 | - | Provider string | |
| 27 | - | Reasoning string | |
| 28 | - | FastMode bool | |
| 29 | - | Subagent string | |
| 46 | + | // subprocess's model resolution. BaseURL and InsecureTLS carry | |
| 47 | + | // the effective provider connection settings from the supervisor. | |
| 48 | + | // Empty values inherit whatever the child resolves on its own from | |
| 49 | + | // config / env / flags. All four settings are persisted in meta.json | |
| 50 | + | // so Resume keeps using the same provider configuration across zot | |
| 51 | + | // restarts. | |
| 52 | + | Model string | |
| 53 | + | Provider string | |
| 54 | + | BaseURL string | |
| 55 | + | InsecureTLS bool | |
| 56 | + | Reasoning string | |
| 57 | + | FastMode bool | |
| 58 | + | Subagent string | |
| 30 | 59 | ||
| 31 | 60 | // SessionID, when non-empty, scopes the agent to a particular | |
| 32 | 61 | // host zot session: the dashboard only surfaces agents whose | |
| @@ -52,6 +81,16 @@ type Agent struct { | |||
| 52 | 81 | // an Inbox at this path; the child opens a Listener. | |
| 53 | 82 | InboxPath string | |
| 54 | 83 | ||
| 84 | + | // Attempt increments whenever the supervisor starts a process for this | |
| 85 | + | // agent. It is persisted so recovery can distinguish a resumed worker | |
| 86 | + | // from a first launch. | |
| 87 | + | Attempt int | |
| 88 | + | ||
| 89 | + | // ProcessPID identifies the current worker when known. It is persisted | |
| 90 | + | // so a reloaded supervisor can force-kill a detached worker after a | |
| 91 | + | // graceful shutdown deadline. | |
| 92 | + | ProcessPID int | |
| 93 | + | ||
| 55 | 94 | // EventLogPath is the durable JSONL event log for this agent. | |
| 56 | 95 | // The runner appends every well-formed event from the child | |
| 57 | 96 | // (plus lifecycle events of its own) here. /swarm open in any | |
| @@ -75,6 +114,27 @@ type Agent struct { | |||
| 75 | 114 | finished time.Time | |
| 76 | 115 | lastErr error | |
| 77 | 116 | ||
| 117 | + | lifecycleMu sync.Mutex | |
| 118 | + | processState ProcessState | |
| 119 | + | turnState TurnState | |
| 120 | + | protocolVersion int | |
| 121 | + | currentTurnID string | |
| 122 | + | updatedAt time.Time | |
| 123 | + | lastActivity time.Time | |
| 124 | + | result *TurnResult | |
| 125 | + | resultRef string | |
| 126 | + | patchRef string | |
| 127 | + | changedFiles []string | |
| 128 | + | outputBytes int | |
| 129 | + | outputLines int | |
| 130 | + | outputTruncated bool | |
| 131 | + | maxOutputBytes int | |
| 132 | + | maxOutputLines int | |
| 133 | + | persistFn func(*Agent) | |
| 134 | + | stateDir string | |
| 135 | + | workspaceCleanup func() error | |
| 136 | + | workspaceCapture func() (WorkspaceCapture, error) | |
| 137 | + | ||
| 78 | 138 | // OnTurnEnd, if set, fires once per prompt-level turn_end event | |
| 79 | 139 | // emitted by the swarm daemon wrapper. Provider/tool-loop | |
| 80 | 140 | // turn_end events (for example stop=tool_use) are ignored by the | |
| @@ -84,16 +144,25 @@ type Agent struct { | |||
| 84 | 144 | // daemon itself to exit — sub-agents keep running on the inbox | |
| 85 | 145 | // even after the initial task completes, so Wait() never unblocks | |
| 86 | 146 | // for them. | |
| 87 | - | OnTurnEnd func(step int, errMsg string) | |
| 147 | + | OnTurnEnd func(step int, errMsg string) | |
| 148 | + | pendingTurnEnds []turnEndNotice | |
| 88 | 149 | ||
| 89 | 150 | ctx context.Context | |
| 90 | 151 | cancel context.CancelFunc | |
| 91 | 152 | runner Runner | |
| 153 | + | lease *agentLease | |
| 154 | + | ||
| 155 | + | // launchState is an admission gate for the scheduler's goroutine: | |
| 156 | + | // 0=not admitted, 1=runner admitted, 2=stopped before admission. It | |
| 157 | + | // prevents an immediate Stop from starting a runner after cancellation. | |
| 158 | + | launchState int32 | |
| 92 | 159 | ||
| 93 | 160 | // done closes when the run goroutine finalises the agent's | |
| 94 | 161 | // status (done / failed / killed). Wait blocks on this so | |
| 95 | 162 | // callers don't have to poll. | |
| 96 | - | done chan struct{} | |
| 163 | + | done chan struct{} | |
| 164 | + | turnResults chan *TurnResult | |
| 165 | + | doneOnce sync.Once | |
| 97 | 166 | } | |
| 98 | 167 | ||
| 99 | 168 | // Inbox exposes the supervisor-side socket handle. Returns nil for | |
| @@ -115,6 +184,124 @@ func (a *Agent) Activity() string { | |||
| 115 | 184 | return a.activity | |
| 116 | 185 | } | |
| 117 | 186 | ||
| 187 | + | // ProcessState returns the child process lifecycle state. | |
| 188 | + | func (a *Agent) ProcessState() ProcessState { | |
| 189 | + | a.lifecycleMu.Lock() | |
| 190 | + | defer a.lifecycleMu.Unlock() | |
| 191 | + | return a.processState | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // TurnState returns the current delegated-turn state. | |
| 195 | + | func (a *Agent) TurnState() TurnState { | |
| 196 | + | a.lifecycleMu.Lock() | |
| 197 | + | defer a.lifecycleMu.Unlock() | |
| 198 | + | return a.turnState | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // CurrentTurnID returns the stable id of the current or most recent turn. | |
| 202 | + | func (a *Agent) CurrentTurnID() string { | |
| 203 | + | a.lifecycleMu.Lock() | |
| 204 | + | defer a.lifecycleMu.Unlock() | |
| 205 | + | return a.currentTurnID | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // ProcessPIDValue returns the current worker pid, when known. | |
| 209 | + | func (a *Agent) ProcessPIDValue() int { | |
| 210 | + | a.lifecycleMu.Lock() | |
| 211 | + | defer a.lifecycleMu.Unlock() | |
| 212 | + | return a.ProcessPID | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // LastActivity returns the last heartbeat or meaningful child event time. | |
| 216 | + | func (a *Agent) LastActivity() time.Time { | |
| 217 | + | a.lifecycleMu.Lock() | |
| 218 | + | defer a.lifecycleMu.Unlock() | |
| 219 | + | return a.lastActivity | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Result returns a copy of the most recent structured turn result. | |
| 223 | + | func (a *Agent) Result() *TurnResult { | |
| 224 | + | a.lifecycleMu.Lock() | |
| 225 | + | defer a.lifecycleMu.Unlock() | |
| 226 | + | return cloneTurnResult(a.result) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (a *Agent) setProtocolVersion(version int) { | |
| 230 | + | a.lifecycleMu.Lock() | |
| 231 | + | if version > a.protocolVersion { | |
| 232 | + | a.protocolVersion = version | |
| 233 | + | } | |
| 234 | + | a.updatedAt = time.Now() | |
| 235 | + | a.lifecycleMu.Unlock() | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (a *Agent) protocolReady() bool { | |
| 239 | + | a.lifecycleMu.Lock() | |
| 240 | + | defer a.lifecycleMu.Unlock() | |
| 241 | + | return a.protocolVersion >= ProtocolVersion | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (a *Agent) setProcessState(state ProcessState) { | |
| 245 | + | a.lifecycleMu.Lock() | |
| 246 | + | a.processState = state | |
| 247 | + | a.updatedAt = time.Now() | |
| 248 | + | a.lifecycleMu.Unlock() | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (a *Agent) incrementAttempt() int { | |
| 252 | + | a.lifecycleMu.Lock() | |
| 253 | + | a.Attempt++ | |
| 254 | + | a.updatedAt = time.Now() | |
| 255 | + | attempt := a.Attempt | |
| 256 | + | a.lifecycleMu.Unlock() | |
| 257 | + | return attempt | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (a *Agent) setProcessPID(pid int) { | |
| 261 | + | a.lifecycleMu.Lock() | |
| 262 | + | a.ProcessPID = pid | |
| 263 | + | a.updatedAt = time.Now() | |
| 264 | + | a.lifecycleMu.Unlock() | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (a *Agent) releaseLease() error { | |
| 268 | + | a.lifecycleMu.Lock() | |
| 269 | + | lease := a.lease | |
| 270 | + | a.lease = nil | |
| 271 | + | a.lifecycleMu.Unlock() | |
| 272 | + | if lease == nil { | |
| 273 | + | return nil | |
| 274 | + | } | |
| 275 | + | return lease.Close() | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (a *Agent) setTurnState(state TurnState, turnID string) { | |
| 279 | + | a.lifecycleMu.Lock() | |
| 280 | + | a.turnState = state | |
| 281 | + | if turnID != "" { | |
| 282 | + | a.currentTurnID = turnID | |
| 283 | + | } | |
| 284 | + | a.updatedAt = time.Now() | |
| 285 | + | a.lifecycleMu.Unlock() | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (a *Agent) markActivity(now time.Time) { | |
| 289 | + | if now.IsZero() { | |
| 290 | + | now = time.Now() | |
| 291 | + | } | |
| 292 | + | a.lifecycleMu.Lock() | |
| 293 | + | a.lastActivity = now | |
| 294 | + | a.updatedAt = now | |
| 295 | + | a.lifecycleMu.Unlock() | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (a *Agent) setResult(result *TurnResult) { | |
| 299 | + | a.lifecycleMu.Lock() | |
| 300 | + | a.result = cloneTurnResult(result) | |
| 301 | + | a.updatedAt = time.Now() | |
| 302 | + | a.lifecycleMu.Unlock() | |
| 303 | + | } | |
| 304 | + | ||
| 118 | 305 | // Transcript returns a copy of the running transcript. | |
| 119 | 306 | func (a *Agent) Transcript() []string { | |
| 120 | 307 | a.mu.Lock() | |
| @@ -135,6 +322,35 @@ func (a *Agent) Err() error { | |||
| 135 | 322 | // and by /swarm wait <id>. | |
| 136 | 323 | func (a *Agent) Wait() { <-a.done } | |
| 137 | 324 | ||
| 325 | + | func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) { | |
| 326 | + | if ctx == nil { | |
| 327 | + | ctx = context.Background() | |
| 328 | + | } | |
| 329 | + | select { | |
| 330 | + | case result := <-a.turnResults: | |
| 331 | + | if result == nil { | |
| 332 | + | return nil, fmt.Errorf("subagents: nil turn result for %s", a.ID) | |
| 333 | + | } | |
| 334 | + | return cloneTurnResult(result), nil | |
| 335 | + | case <-a.done: | |
| 336 | + | if result := a.Result(); result != nil { | |
| 337 | + | return result, nil | |
| 338 | + | } | |
| 339 | + | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |
| 340 | + | case <-ctx.Done(): | |
| 341 | + | return nil, ctx.Err() | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (a *Agent) closeDone() { a.doneOnce.Do(func() { close(a.done) }) } | |
| 346 | + | ||
| 347 | + | func (a *Agent) stateDirectory(defaultRoot string) string { | |
| 348 | + | if a.stateDir != "" { | |
| 349 | + | return a.stateDir | |
| 350 | + | } | |
| 351 | + | return filepath.Join(defaultRoot, "agents", a.ID) | |
| 352 | + | } | |
| 353 | + | ||
| 138 | 354 | // SetOnTurnEnd installs (or clears, with nil) the callback fired | |
| 139 | 355 | // from the runner when the child daemon emits a prompt-level | |
| 140 | 356 | // turn_end event with a step field. Safe to call from any goroutine: | |
| @@ -142,19 +358,30 @@ func (a *Agent) Wait() { <-a.done } | |||
| 142 | 358 | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |
| 143 | 359 | a.mu.Lock() | |
| 144 | 360 | a.OnTurnEnd = fn | |
| 361 | + | pending := append([]turnEndNotice(nil), a.pendingTurnEnds...) | |
| 362 | + | a.pendingTurnEnds = nil | |
| 145 | 363 | a.mu.Unlock() | |
| 364 | + | if fn != nil { | |
| 365 | + | for _, notice := range pending { | |
| 366 | + | go fn(notice.step, notice.errMsg) | |
| 367 | + | } | |
| 368 | + | } | |
| 146 | 369 | } | |
| 147 | 370 | ||
| 148 | 371 | func (a *Agent) setStatus(s Status) { | |
| 149 | 372 | a.mu.Lock() | |
| 150 | 373 | a.status = s | |
| 151 | 374 | a.mu.Unlock() | |
| 375 | + | if s == StatusRunning { | |
| 376 | + | a.setProcessState(ProcessAlive) | |
| 377 | + | } | |
| 152 | 378 | } | |
| 153 | 379 | ||
| 154 | 380 | func (a *Agent) setActivity(msg string) { | |
| 155 | 381 | a.mu.Lock() | |
| 156 | 382 | a.activity = strings.TrimSpace(msg) | |
| 157 | 383 | a.mu.Unlock() | |
| 384 | + | a.markActivity(time.Now()) | |
| 158 | 385 | } | |
| 159 | 386 | ||
| 160 | 387 | func (a *Agent) appendTranscript(chunk string) { | |
| @@ -180,22 +407,68 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) | |||
| 180 | 407 | a.transcript = append(a.transcript, linePrefix+line) | |
| 181 | 408 | } | |
| 182 | 409 | if assistant { | |
| 183 | - | a.lastAssistant = message | |
| 410 | + | a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines) | |
| 411 | + | } | |
| 412 | + | // Bound the inline dashboard copy. The durable event log and session | |
| 413 | + | // remain available through the logical history reference. | |
| 414 | + | lineCap := a.maxOutputLines | |
| 415 | + | if lineCap <= 0 { | |
| 416 | + | lineCap = 2_000 | |
| 417 | + | } | |
| 418 | + | byteCap := a.maxOutputBytes | |
| 419 | + | if byteCap <= 0 { | |
| 420 | + | byteCap = 500_000 | |
| 184 | 421 | } | |
| 185 | - | // Bound the transcript so dashboards don't hold gigabytes for | |
| 186 | - | // long-running agents. 2000 lines is plenty for inspection; | |
| 187 | - | // the durable record lives in the agent's session file. | |
| 188 | - | const cap = 2000 | |
| 189 | - | if len(a.transcript) > cap { | |
| 190 | - | a.transcript = a.transcript[len(a.transcript)-cap:] | |
| 422 | + | for len(a.transcript) > lineCap { | |
| 423 | + | a.transcript = a.transcript[1:] | |
| 424 | + | a.outputTruncated = true | |
| 425 | + | } | |
| 426 | + | for len(a.transcript) > 0 { | |
| 427 | + | bytes := 0 | |
| 428 | + | for _, line := range a.transcript { | |
| 429 | + | bytes += len(line) + 1 | |
| 430 | + | } | |
| 431 | + | if bytes <= byteCap { | |
| 432 | + | a.outputBytes = bytes | |
| 433 | + | a.outputLines = len(a.transcript) | |
| 434 | + | break | |
| 435 | + | } | |
| 436 | + | a.transcript = a.transcript[1:] | |
| 437 | + | a.outputTruncated = true | |
| 438 | + | } | |
| 439 | + | if len(a.transcript) == 0 { | |
| 440 | + | a.outputBytes = 0 | |
| 441 | + | a.outputLines = 0 | |
| 191 | 442 | } | |
| 192 | 443 | a.mu.Unlock() | |
| 444 | + | a.markActivity(time.Now()) | |
| 193 | 445 | } | |
| 194 | 446 | ||
| 195 | 447 | // newAgentID returns a short, mostly-collision-free identifier of the | |
| 196 | 448 | // form "<slug>-<nano>". The slug is derived from the task text so | |
| 197 | 449 | // dashboards stay readable; the nano suffix guarantees uniqueness | |
| 198 | 450 | // even when two agents are spawned in the same millisecond. | |
| 451 | + | func boundInlineText(value string, maxBytes, maxLines int) string { | |
| 452 | + | const marker = "...[output truncated]" | |
| 453 | + | if maxLines > 0 { | |
| 454 | + | lines := strings.Split(value, "\n") | |
| 455 | + | if len(lines) > maxLines { | |
| 456 | + | if maxLines == 1 { | |
| 457 | + | value = marker | |
| 458 | + | } else { | |
| 459 | + | value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | if maxBytes > 0 && len([]byte(value)) > maxBytes { | |
| 464 | + | if maxBytes <= len(marker) { | |
| 465 | + | return truncateUTF8(value, maxBytes) | |
| 466 | + | } | |
| 467 | + | return truncateUTF8(value, maxBytes-len(marker)) + marker | |
| 468 | + | } | |
| 469 | + | return value | |
| 470 | + | } | |
| 471 | + | ||
| 199 | 472 | func newAgentID(task string, now time.Time) string { | |
| 200 | 473 | slug := taskSlug(task) | |
| 201 | 474 | return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000) | |
A packages/agent/swarm/aliases.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | // Canonical domain names. The swarm package remains as a source-compatible | |
| 4 | + | // implementation boundary while callers migrate to subagent terminology. | |
| 5 | + | type SubagentManager = Swarm | |
| 6 | + | type SubagentConfig = Config | |
| 7 | + | type Subagent = Agent | |
| 8 | + | type SubagentSnapshot = AgentSnapshot | |
| 9 | + | type SubagentRequest = SpawnRequest | |
| 10 | + | ||
| 11 | + | func NewSubagentManager(cfg SubagentConfig) *SubagentManager { return New(cfg) } | |
A packages/agent/swarm/batch.go +429 -0
| @@ -0,0 +1,429 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // BatchRequest starts independent child tasks under one aggregate id. Batch | |
| 17 | + | // scheduling is deliberately shallow; dependency graphs belong in workflows. | |
| 18 | + | type BatchRequest struct { | |
| 19 | + | Context string | |
| 20 | + | Tasks []string | |
| 21 | + | MaxConcurrent int | |
| 22 | + | Spawn SpawnRequest | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type BatchStatus string | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | BatchQueued BatchStatus = "queued" | |
| 29 | + | BatchRunning BatchStatus = "running" | |
| 30 | + | BatchSucceeded BatchStatus = "succeeded" | |
| 31 | + | BatchFailed BatchStatus = "failed" | |
| 32 | + | BatchCanceled BatchStatus = "canceled" | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | type BatchResult struct { | |
| 36 | + | Version int `json:"version"` | |
| 37 | + | BatchID string `json:"batch_id"` | |
| 38 | + | TaskCount int `json:"task_count,omitempty"` | |
| 39 | + | Status BatchStatus `json:"status"` | |
| 40 | + | ChildIDs []string `json:"child_ids"` | |
| 41 | + | Results map[string]*TurnResult `json:"results,omitempty"` | |
| 42 | + | Error string `json:"error,omitempty"` | |
| 43 | + | CreatedAt time.Time `json:"created_at"` | |
| 44 | + | UpdatedAt time.Time `json:"updated_at"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | const maxBatchFileBytes = 32 * 1024 * 1024 | |
| 48 | + | ||
| 49 | + | type Batch struct { | |
| 50 | + | ID string | |
| 51 | + | Context string | |
| 52 | + | ChildIDs []string | |
| 53 | + | MaxConcurrent int | |
| 54 | + | TaskCount int | |
| 55 | + | CreatedAt time.Time | |
| 56 | + | ||
| 57 | + | mu sync.Mutex | |
| 58 | + | status BatchStatus | |
| 59 | + | result *BatchResult | |
| 60 | + | done chan struct{} | |
| 61 | + | once sync.Once | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (b *Batch) Status() BatchStatus { | |
| 65 | + | b.mu.Lock() | |
| 66 | + | defer b.mu.Unlock() | |
| 67 | + | return b.status | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (b *Batch) Result() *BatchResult { | |
| 71 | + | b.mu.Lock() | |
| 72 | + | defer b.mu.Unlock() | |
| 73 | + | return cloneBatchResult(b.result) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (b *Batch) Wait() { <-b.done } | |
| 77 | + | func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) } | |
| 78 | + | ||
| 79 | + | func (f *Swarm) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) { | |
| 80 | + | if len(req.Tasks) == 0 { | |
| 81 | + | return nil, fmt.Errorf("subagents: batch requires at least one task") | |
| 82 | + | } | |
| 83 | + | f.mu.Lock() | |
| 84 | + | remaining := f.cfg.Policy.MaxTotalSpawned - f.totalSpawned | |
| 85 | + | f.mu.Unlock() | |
| 86 | + | if len(req.Tasks) > remaining { | |
| 87 | + | return nil, fmt.Errorf("subagents: batch needs %d spawn budget entries, only %d remain", len(req.Tasks), remaining) | |
| 88 | + | } | |
| 89 | + | if ctx == nil { | |
| 90 | + | ctx = context.Background() | |
| 91 | + | } | |
| 92 | + | now := f.cfg.Now() | |
| 93 | + | baseID := newAgentID("batch "+now.Format("150405.000"), now) | |
| 94 | + | f.mu.Lock() | |
| 95 | + | if f.batches == nil { | |
| 96 | + | f.batches = make(map[string]*Batch) | |
| 97 | + | } | |
| 98 | + | batchID := baseID | |
| 99 | + | for suffix := 2; ; suffix++ { | |
| 100 | + | if _, exists := f.batches[batchID]; !exists { | |
| 101 | + | break | |
| 102 | + | } | |
| 103 | + | batchID = fmt.Sprintf("%s-%d", baseID, suffix) | |
| 104 | + | } | |
| 105 | + | batch := &Batch{ | |
| 106 | + | ID: batchID, | |
| 107 | + | Context: strings.TrimSpace(req.Context), | |
| 108 | + | MaxConcurrent: req.MaxConcurrent, | |
| 109 | + | TaskCount: len(req.Tasks), | |
| 110 | + | CreatedAt: now, | |
| 111 | + | status: BatchQueued, | |
| 112 | + | result: &BatchResult{ | |
| 113 | + | Version: ProtocolVersion, BatchID: batchID, TaskCount: len(req.Tasks), Status: BatchQueued, | |
| 114 | + | CreatedAt: now, UpdatedAt: now, | |
| 115 | + | }, | |
| 116 | + | done: make(chan struct{}), | |
| 117 | + | } | |
| 118 | + | f.batches[batch.ID] = batch | |
| 119 | + | f.mu.Unlock() | |
| 120 | + | // Write a queued manifest before admitting children so a crash during | |
| 121 | + | // batch admission still leaves an aggregate id to reload and reconcile. | |
| 122 | + | f.persistBatch(batch) | |
| 123 | + | ||
| 124 | + | for _, task := range req.Tasks { | |
| 125 | + | spawnReq := req.Spawn | |
| 126 | + | spawnReq.Task = task | |
| 127 | + | spawnReq.ParentID = batch.ID | |
| 128 | + | spawnReq.BatchID = batch.ID | |
| 129 | + | a, err := f.SpawnReq(ctx, spawnReq) | |
| 130 | + | if err != nil { | |
| 131 | + | childIDs := append([]string(nil), batch.ChildIDs...) | |
| 132 | + | for _, childID := range childIDs { | |
| 133 | + | _ = f.Stop(childID) | |
| 134 | + | } | |
| 135 | + | for _, childID := range childIDs { | |
| 136 | + | if child := f.Get(childID); child != nil { | |
| 137 | + | child.Wait() | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | batch.mu.Lock() | |
| 141 | + | batch.status = BatchFailed | |
| 142 | + | batch.result = &BatchResult{ | |
| 143 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchFailed, | |
| 144 | + | ChildIDs: childIDs, Error: err.Error(), CreatedAt: now, UpdatedAt: f.cfg.Now(), | |
| 145 | + | } | |
| 146 | + | batch.mu.Unlock() | |
| 147 | + | f.persistBatch(batch) | |
| 148 | + | batch.closeDone() | |
| 149 | + | return batch, err | |
| 150 | + | } | |
| 151 | + | batch.ChildIDs = append(batch.ChildIDs, a.ID) | |
| 152 | + | batch.mu.Lock() | |
| 153 | + | batch.result = &BatchResult{ | |
| 154 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchQueued, | |
| 155 | + | ChildIDs: append([]string(nil), batch.ChildIDs...), CreatedAt: batch.CreatedAt, | |
| 156 | + | UpdatedAt: f.cfg.Now(), | |
| 157 | + | } | |
| 158 | + | batch.mu.Unlock() | |
| 159 | + | f.persistBatch(batch) | |
| 160 | + | } | |
| 161 | + | batch.mu.Lock() | |
| 162 | + | batch.status = BatchRunning | |
| 163 | + | batch.mu.Unlock() | |
| 164 | + | f.persistBatch(batch) | |
| 165 | + | go f.collectBatch(batch) | |
| 166 | + | return batch, nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (f *Swarm) collectBatch(batch *Batch) { | |
| 170 | + | if batch.TaskCount > 0 && len(batch.ChildIDs) < batch.TaskCount { | |
| 171 | + | batch.mu.Lock() | |
| 172 | + | batch.status = BatchFailed | |
| 173 | + | batch.result = &BatchResult{ | |
| 174 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, | |
| 175 | + | Status: BatchFailed, ChildIDs: append([]string(nil), batch.ChildIDs...), | |
| 176 | + | Error: "batch admission was incomplete", CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(), | |
| 177 | + | } | |
| 178 | + | batch.mu.Unlock() | |
| 179 | + | f.persistBatch(batch) | |
| 180 | + | batch.closeDone() | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | results := make(map[string]*TurnResult, len(batch.ChildIDs)) | |
| 184 | + | status := BatchSucceeded | |
| 185 | + | var firstErr string | |
| 186 | + | for _, id := range batch.ChildIDs { | |
| 187 | + | a := f.Get(id) | |
| 188 | + | if a == nil { | |
| 189 | + | status = BatchFailed | |
| 190 | + | if firstErr == "" { | |
| 191 | + | firstErr = "missing child " + id | |
| 192 | + | } | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | result, err := f.waitForBatchResult(a) | |
| 196 | + | if err == nil { | |
| 197 | + | results[id] = result | |
| 198 | + | switch result.Status { | |
| 199 | + | case ResultFailed: | |
| 200 | + | if status == BatchSucceeded { | |
| 201 | + | status = BatchFailed | |
| 202 | + | if result.Error != nil { | |
| 203 | + | firstErr = result.Error.Message | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | case ResultCanceled: | |
| 207 | + | if status == BatchSucceeded { | |
| 208 | + | status = BatchCanceled | |
| 209 | + | firstErr = "one or more batch children were canceled" | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } else { | |
| 213 | + | status = BatchFailed | |
| 214 | + | if firstErr == "" { | |
| 215 | + | firstErr = err.Error() | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | batch.mu.Lock() | |
| 220 | + | batch.status = status | |
| 221 | + | batch.result = &BatchResult{ | |
| 222 | + | Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: status, | |
| 223 | + | ChildIDs: append([]string(nil), batch.ChildIDs...), Results: results, | |
| 224 | + | Error: firstErr, CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(), | |
| 225 | + | } | |
| 226 | + | batch.mu.Unlock() | |
| 227 | + | f.persistBatch(batch) | |
| 228 | + | batch.closeDone() | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (f *Swarm) waitForBatchResult(a *Agent) (*TurnResult, error) { | |
| 232 | + | if a == nil { | |
| 233 | + | return nil, errors.New("subagents: missing batch child") | |
| 234 | + | } | |
| 235 | + | if result := a.Result(); result != nil { | |
| 236 | + | return result, nil | |
| 237 | + | } | |
| 238 | + | if a.Status() != StatusDetached { | |
| 239 | + | return a.waitForTurnResult(context.Background()) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // A reloaded worker has a closed supervisor-side done channel even when | |
| 243 | + | // its orphan process is still alive. Reconcile durable result.json/events | |
| 244 | + | // before treating that closed channel as a failed child. | |
| 245 | + | readDurable := func() *TurnResult { | |
| 246 | + | if result, err := readTurnResult(a.stateDirectory(f.cfg.Root)); err == nil && validateTurnResultAgent(result, a.ID) == nil { | |
| 247 | + | result = result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines) | |
| 248 | + | a.setResult(result) | |
| 249 | + | return result | |
| 250 | + | } | |
| 251 | + | events, err := ReadEventLog(a.EventLogPath) | |
| 252 | + | if err != nil { | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | for _, event := range events { | |
| 256 | + | if event.Type != EventTurnResult && event.Type != "turn_result" { | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | result, decodeErr := decodeTurnResultEvent(event, a.ID, f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines) | |
| 260 | + | if decodeErr != nil { | |
| 261 | + | continue | |
| 262 | + | } | |
| 263 | + | a.setResult(result) | |
| 264 | + | return result | |
| 265 | + | } | |
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | if result := readDurable(); result != nil { | |
| 270 | + | return result, nil | |
| 271 | + | } | |
| 272 | + | if !inboxLive(a.InboxPath) { | |
| 273 | + | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | interval := 100 * time.Millisecond | |
| 277 | + | deadline := time.NewTimer(f.cfg.Policy.DefaultTimeout) | |
| 278 | + | defer deadline.Stop() | |
| 279 | + | ticker := time.NewTicker(interval) | |
| 280 | + | defer ticker.Stop() | |
| 281 | + | for { | |
| 282 | + | select { | |
| 283 | + | case <-ticker.C: | |
| 284 | + | if result := readDurable(); result != nil { | |
| 285 | + | return result, nil | |
| 286 | + | } | |
| 287 | + | if !inboxLive(a.InboxPath) { | |
| 288 | + | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |
| 289 | + | } | |
| 290 | + | case <-deadline.C: | |
| 291 | + | return nil, fmt.Errorf("subagents: agent %s batch result timed out", a.ID) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func (f *Swarm) WaitBatch(id string) (*BatchResult, error) { | |
| 297 | + | f.mu.Lock() | |
| 298 | + | batch := f.batches[id] | |
| 299 | + | f.mu.Unlock() | |
| 300 | + | if batch == nil { | |
| 301 | + | return nil, fmt.Errorf("subagents: no such batch %q", id) | |
| 302 | + | } | |
| 303 | + | batch.Wait() | |
| 304 | + | return batch.Result(), nil | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func (f *Swarm) GetBatch(id string) *Batch { | |
| 308 | + | f.mu.Lock() | |
| 309 | + | defer f.mu.Unlock() | |
| 310 | + | return f.batches[id] | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func cloneBatchResult(result *BatchResult) *BatchResult { | |
| 314 | + | if result == nil { | |
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | copy := *result | |
| 318 | + | copy.ChildIDs = append([]string(nil), result.ChildIDs...) | |
| 319 | + | if result.Results != nil { | |
| 320 | + | copy.Results = make(map[string]*TurnResult, len(result.Results)) | |
| 321 | + | for id, child := range result.Results { | |
| 322 | + | copy.Results[id] = cloneTurnResult(child) | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | return © | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func (f *Swarm) reloadBatches(root string) []error { | |
| 329 | + | entries, err := os.ReadDir(filepath.Join(root, "batches")) | |
| 330 | + | if err != nil { | |
| 331 | + | if errors.Is(err, os.ErrNotExist) { | |
| 332 | + | return nil | |
| 333 | + | } | |
| 334 | + | return []error{fmt.Errorf("subagents reload batches %s: %w", root, err)} | |
| 335 | + | } | |
| 336 | + | var errs []error | |
| 337 | + | for _, entry := range entries { | |
| 338 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { | |
| 339 | + | continue | |
| 340 | + | } | |
| 341 | + | fileID := strings.TrimSuffix(entry.Name(), ".json") | |
| 342 | + | if !safeAgentID(fileID) { | |
| 343 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: unsafe batch id", entry.Name())) | |
| 344 | + | continue | |
| 345 | + | } | |
| 346 | + | path := filepath.Join(root, "batches", entry.Name()) | |
| 347 | + | file, openErr := os.Open(path) | |
| 348 | + | if openErr != nil { | |
| 349 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, openErr)) | |
| 350 | + | continue | |
| 351 | + | } | |
| 352 | + | data, readErr := io.ReadAll(io.LimitReader(file, maxBatchFileBytes+1)) | |
| 353 | + | _ = file.Close() | |
| 354 | + | if readErr != nil { | |
| 355 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, readErr)) | |
| 356 | + | continue | |
| 357 | + | } | |
| 358 | + | if len(data) > maxBatchFileBytes { | |
| 359 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: exceeds %d bytes", path, maxBatchFileBytes)) | |
| 360 | + | continue | |
| 361 | + | } | |
| 362 | + | var result BatchResult | |
| 363 | + | if unmarshalErr := json.Unmarshal(data, &result); unmarshalErr != nil { | |
| 364 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, unmarshalErr)) | |
| 365 | + | continue | |
| 366 | + | } | |
| 367 | + | if result.Version == 0 { | |
| 368 | + | result.Version = ProtocolVersion | |
| 369 | + | } | |
| 370 | + | if result.Version != ProtocolVersion || result.BatchID != fileID || !safeAgentID(result.BatchID) || result.TaskCount < 0 || len(result.ChildIDs) > result.TaskCount { | |
| 371 | + | errs = append(errs, fmt.Errorf("subagents reload batch %s: invalid result metadata", path)) | |
| 372 | + | continue | |
| 373 | + | } | |
| 374 | + | batch := &Batch{ | |
| 375 | + | ID: result.BatchID, | |
| 376 | + | ChildIDs: append([]string(nil), result.ChildIDs...), | |
| 377 | + | TaskCount: result.TaskCount, | |
| 378 | + | CreatedAt: result.CreatedAt, | |
| 379 | + | status: result.Status, | |
| 380 | + | result: cloneBatchResult(&result), | |
| 381 | + | done: make(chan struct{}), | |
| 382 | + | } | |
| 383 | + | if batch.CreatedAt.IsZero() { | |
| 384 | + | batch.CreatedAt = result.UpdatedAt | |
| 385 | + | } | |
| 386 | + | f.mu.Lock() | |
| 387 | + | _, exists := f.batches[batch.ID] | |
| 388 | + | if !exists { | |
| 389 | + | f.batches[batch.ID] = batch | |
| 390 | + | } | |
| 391 | + | f.mu.Unlock() | |
| 392 | + | if exists { | |
| 393 | + | continue | |
| 394 | + | } | |
| 395 | + | switch batch.status { | |
| 396 | + | case BatchSucceeded, BatchFailed, BatchCanceled: | |
| 397 | + | batch.closeDone() | |
| 398 | + | default: | |
| 399 | + | go f.collectBatch(batch) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | return errs | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func (f *Swarm) persistBatch(batch *Batch) { | |
| 406 | + | if batch == nil || !safeAgentID(batch.ID) { | |
| 407 | + | return | |
| 408 | + | } | |
| 409 | + | result := batch.Result() | |
| 410 | + | if result == nil { | |
| 411 | + | return | |
| 412 | + | } | |
| 413 | + | data, err := json.MarshalIndent(result, "", " ") | |
| 414 | + | if err != nil || len(data) > maxBatchFileBytes { | |
| 415 | + | return | |
| 416 | + | } | |
| 417 | + | dir := filepath.Join(f.cfg.Root, "batches") | |
| 418 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 419 | + | return | |
| 420 | + | } | |
| 421 | + | path := filepath.Join(dir, batch.ID+".json") | |
| 422 | + | tmp := path + ".tmp" | |
| 423 | + | if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { | |
| 424 | + | return | |
| 425 | + | } | |
| 426 | + | if err := os.Rename(tmp, path); err != nil { | |
| 427 | + | _ = os.Remove(tmp) | |
| 428 | + | } | |
| 429 | + | } | |
A packages/agent/swarm/batch_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) { | |
| 13 | + | now := time.Unix(123, 0) | |
| 14 | + | f := New(Config{ | |
| 15 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now }, | |
| 16 | + | Policy: SubagentPolicy{MaxConcurrent: 2, MaxTotalSpawned: 4}, | |
| 17 | + | NewRunner: func(*Agent) Runner { | |
| 18 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 19 | + | }, | |
| 20 | + | }) | |
| 21 | + | t.Cleanup(f.StopAll) | |
| 22 | + | first, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}}) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | second, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"two"}}) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | first.Wait() | |
| 31 | + | second.Wait() | |
| 32 | + | if first.ID == second.ID { | |
| 33 | + | t.Fatalf("duplicate batch ids: %q", first.ID) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestBatchReloadsDurableResult(t *testing.T) { | |
| 38 | + | root := t.TempDir() | |
| 39 | + | newSwarm := func() *Swarm { | |
| 40 | + | return New(Config{ | |
| 41 | + | Root: root, RepoRoot: root, | |
| 42 | + | NewRunner: func(*Agent) Runner { | |
| 43 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 44 | + | }, | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | first := newSwarm() | |
| 48 | + | t.Cleanup(first.StopAll) | |
| 49 | + | batch, err := first.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}}) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | want, err := first.WaitBatch(batch.ID) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if want.Status != BatchSucceeded { | |
| 58 | + | t.Fatalf("first batch status = %s", want.Status) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | second := newSwarm() | |
| 62 | + | t.Cleanup(second.StopAll) | |
| 63 | + | loaded, errs := second.Reload() | |
| 64 | + | if len(errs) != 0 { | |
| 65 | + | t.Fatalf("reload errors: %v", errs) | |
| 66 | + | } | |
| 67 | + | if loaded != 2 { | |
| 68 | + | t.Fatalf("reload loaded %d agents, want 2", loaded) | |
| 69 | + | } | |
| 70 | + | got, err := second.WaitBatch(batch.ID) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if got.Status != BatchSucceeded || len(got.Results) != 2 { | |
| 75 | + | t.Fatalf("reloaded batch = %#v", got) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestBatchReloadRejectsUnsafeIDs(t *testing.T) { | |
| 80 | + | root := t.TempDir() | |
| 81 | + | batchDir := filepath.Join(root, "batches") | |
| 82 | + | if err := os.MkdirAll(batchDir, 0o700); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | data, err := json.Marshal(BatchResult{ | |
| 86 | + | Version: ProtocolVersion, BatchID: "../escape", TaskCount: 0, Status: BatchRunning, | |
| 87 | + | }) | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | if err := os.WriteFile(filepath.Join(batchDir, "queued.json"), data, 0o600); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 95 | + | if _, errs := f.Reload(); len(errs) == 0 { | |
| 96 | + | t.Fatal("unsafe batch metadata was accepted") | |
| 97 | + | } | |
| 98 | + | if _, err := os.Stat(filepath.Join(root, "escape.json")); !os.IsNotExist(err) { | |
| 99 | + | t.Fatalf("unsafe batch path was written: %v", err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestBatchSpawnWaitAndCollect(t *testing.T) { | |
| 104 | + | root := t.TempDir() | |
| 105 | + | f := New(Config{ | |
| 106 | + | Root: root, RepoRoot: root, | |
| 107 | + | Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, MaxTotalSpawned: 4, DefaultTimeout: 0, IdleTimeout: 0}, | |
| 108 | + | NewRunner: func(*Agent) Runner { | |
| 109 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 110 | + | }, | |
| 111 | + | }) | |
| 112 | + | t.Cleanup(f.StopAll) | |
| 113 | + | batch, err := f.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}, MaxConcurrent: 1}) | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | result, err := f.WaitBatch(batch.ID) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | if result.Status != BatchSucceeded || len(result.ChildIDs) != 2 || len(result.Results) != 2 { | |
| 122 | + | t.Fatalf("batch result = %#v", result) | |
| 123 | + | } | |
| 124 | + | if batch.Status() != BatchSucceeded { | |
| 125 | + | t.Fatalf("batch status = %s", batch.Status()) | |
| 126 | + | } | |
| 127 | + | } | |
M packages/agent/swarm/event.go +121 -19
| @@ -28,21 +28,41 @@ import ( | |||
| 28 | 28 | // stat the file size and read from their last offset on every | |
| 29 | 29 | // poll. | |
| 30 | 30 | type Event struct { | |
| 31 | - | Time time.Time `json:"time"` | |
| 32 | - | Type string `json:"type"` | |
| 33 | - | Data map[string]any `json:"-"` | |
| 34 | - | Raw map[string]any `json:"-"` // includes type+time+data for replay | |
| 31 | + | Time time.Time `json:"time"` | |
| 32 | + | Type string `json:"type"` | |
| 33 | + | Version int `json:"version,omitempty"` | |
| 34 | + | MessageID string `json:"message_id,omitempty"` | |
| 35 | + | AgentID string `json:"agent_id,omitempty"` | |
| 36 | + | TurnID string `json:"turn_id,omitempty"` | |
| 37 | + | Data map[string]any `json:"-"` | |
| 38 | + | Raw map[string]any `json:"-"` // includes type+time+data for replay | |
| 35 | 39 | } | |
| 36 | 40 | ||
| 37 | 41 | // MarshalJSON flattens Data into the top-level object so consumers | |
| 38 | 42 | // see {type, time, ...fields} rather than {type, time, data:{...}}. | |
| 39 | 43 | func (e Event) MarshalJSON() ([]byte, error) { | |
| 40 | - | out := make(map[string]any, len(e.Data)+2) | |
| 44 | + | out := make(map[string]any, len(e.Data)+7) | |
| 41 | 45 | for k, v := range e.Data { | |
| 42 | 46 | out[k] = v | |
| 43 | 47 | } | |
| 44 | 48 | out["type"] = e.Type | |
| 45 | 49 | out["time"] = e.Time | |
| 50 | + | if e.Version != 0 { | |
| 51 | + | out["version"] = e.Version | |
| 52 | + | } | |
| 53 | + | if e.MessageID != "" { | |
| 54 | + | out["message_id"] = e.MessageID | |
| 55 | + | } | |
| 56 | + | if e.AgentID != "" { | |
| 57 | + | out["agent_id"] = e.AgentID | |
| 58 | + | } | |
| 59 | + | if e.TurnID != "" { | |
| 60 | + | out["turn_id"] = e.TurnID | |
| 61 | + | } | |
| 62 | + | if e.Version != 0 { | |
| 63 | + | out["timestamp"] = e.Time | |
| 64 | + | out["payload"] = e.Data | |
| 65 | + | } | |
| 46 | 66 | return json.Marshal(out) | |
| 47 | 67 | } | |
| 48 | 68 | ||
| @@ -56,17 +76,45 @@ func (e *Event) UnmarshalJSON(b []byte) error { | |||
| 56 | 76 | e.Raw = m | |
| 57 | 77 | e.Data = map[string]any{} | |
| 58 | 78 | for k, v := range m { | |
| 59 | - | if k == "type" || k == "time" { | |
| 79 | + | if k == "type" || k == "time" || k == "timestamp" || k == "version" || k == "message_id" || k == "agent_id" || k == "turn_id" || k == "payload" { | |
| 60 | 80 | continue | |
| 61 | 81 | } | |
| 62 | 82 | e.Data[k] = v | |
| 63 | 83 | } | |
| 84 | + | // Versioned events keep their forward-compatible payload under a | |
| 85 | + | // dedicated object. The child also flattens known fields for legacy | |
| 86 | + | // readers, but a newer event may contain payload fields that were not | |
| 87 | + | // flattened by the producer. Merge those fields so replay sees the same | |
| 88 | + | // data regardless of which wire shape was written. | |
| 89 | + | if payload, ok := m["payload"].(map[string]any); ok { | |
| 90 | + | for k, v := range payload { | |
| 91 | + | if _, exists := e.Data[k]; !exists { | |
| 92 | + | e.Data[k] = v | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if version, ok := m["version"].(float64); ok { | |
| 97 | + | e.Version = int(version) | |
| 98 | + | } | |
| 99 | + | if messageID, ok := m["message_id"].(string); ok { | |
| 100 | + | e.MessageID = messageID | |
| 101 | + | } | |
| 102 | + | if agentID, ok := m["agent_id"].(string); ok { | |
| 103 | + | e.AgentID = agentID | |
| 104 | + | } | |
| 105 | + | if turnID, ok := m["turn_id"].(string); ok { | |
| 106 | + | e.TurnID = turnID | |
| 107 | + | } | |
| 64 | 108 | if t, ok := m["type"].(string); ok { | |
| 65 | 109 | e.Type = t | |
| 66 | 110 | } else { | |
| 67 | 111 | return errors.New("swarm event: missing type") | |
| 68 | 112 | } | |
| 69 | - | if ts, ok := m["time"].(string); ok { | |
| 113 | + | ts, _ := m["time"].(string) | |
| 114 | + | if ts == "" { | |
| 115 | + | ts, _ = m["timestamp"].(string) | |
| 116 | + | } | |
| 117 | + | if ts != "" { | |
| 70 | 118 | parsed, err := time.Parse(time.RFC3339Nano, ts) | |
| 71 | 119 | if err == nil { | |
| 72 | 120 | e.Time = parsed | |
| @@ -75,6 +123,14 @@ func (e *Event) UnmarshalJSON(b []byte) error { | |||
| 75 | 123 | return nil | |
| 76 | 124 | } | |
| 77 | 125 | ||
| 126 | + | const ( | |
| 127 | + | // Event log lines are produced by a child process and may contain model | |
| 128 | + | // payloads. Bound both per-line decoding and replay memory so a corrupt or | |
| 129 | + | // hostile log cannot force an unbounded allocation during Reload. | |
| 130 | + | maxEventLogLineBytes = 2 * 1024 * 1024 | |
| 131 | + | maxEventLogEvents = 100_000 | |
| 132 | + | ) | |
| 133 | + | ||
| 78 | 134 | // EventLog is an append-only writer for events.jsonl. Safe for | |
| 79 | 135 | // concurrent writers (the child only has one writer, but tests | |
| 80 | 136 | // occasionally fan out). | |
| @@ -87,13 +143,14 @@ type EventLog struct { | |||
| 87 | 143 | // OpenEventLog opens (or creates) the events.jsonl file at path. | |
| 88 | 144 | // Parent directories are created as needed. | |
| 89 | 145 | func OpenEventLog(path string) (*EventLog, error) { | |
| 90 | - | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 146 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 91 | 147 | return nil, fmt.Errorf("event log dir: %w", err) | |
| 92 | 148 | } | |
| 93 | - | f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |
| 149 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) | |
| 94 | 150 | if err != nil { | |
| 95 | 151 | return nil, fmt.Errorf("event log open: %w", err) | |
| 96 | 152 | } | |
| 153 | + | _ = f.Chmod(0o600) | |
| 97 | 154 | return &EventLog{path: path, f: f}, nil | |
| 98 | 155 | } | |
| 99 | 156 | ||
| @@ -107,6 +164,9 @@ func (l *EventLog) Path() string { return l.path } | |||
| 107 | 164 | func (l *EventLog) Append(ev Event) error { | |
| 108 | 165 | l.mu.Lock() | |
| 109 | 166 | defer l.mu.Unlock() | |
| 167 | + | if l.f == nil { | |
| 168 | + | return errors.New("event log: closed") | |
| 169 | + | } | |
| 110 | 170 | if ev.Time.IsZero() { | |
| 111 | 171 | ev.Time = time.Now() | |
| 112 | 172 | } | |
| @@ -114,8 +174,14 @@ func (l *EventLog) Append(ev Event) error { | |||
| 114 | 174 | if err != nil { | |
| 115 | 175 | return err | |
| 116 | 176 | } | |
| 177 | + | if len(b) > maxEventLogLineBytes { | |
| 178 | + | return fmt.Errorf("event log: event exceeds %d bytes", maxEventLogLineBytes) | |
| 179 | + | } | |
| 117 | 180 | b = append(b, '\n') | |
| 118 | - | _, err = l.f.Write(b) | |
| 181 | + | n, err := l.f.Write(b) | |
| 182 | + | if err == nil && n != len(b) { | |
| 183 | + | return io.ErrShortWrite | |
| 184 | + | } | |
| 119 | 185 | return err | |
| 120 | 186 | } | |
| 121 | 187 | ||
| @@ -156,19 +222,24 @@ func ReadEventLog(path string) ([]Event, error) { | |||
| 156 | 222 | ||
| 157 | 223 | func readEvents(r io.Reader) ([]Event, error) { | |
| 158 | 224 | br := bufio.NewReader(r) | |
| 159 | - | var out []Event | |
| 225 | + | out := make([]Event, 0, 256) | |
| 160 | 226 | for { | |
| 161 | - | line, err := br.ReadBytes('\n') | |
| 162 | - | if len(line) > 0 { | |
| 227 | + | line, err, truncated := readBoundedLine(br, maxEventLogLineBytes) | |
| 228 | + | if len(line) > 0 && !truncated { | |
| 163 | 229 | var ev Event | |
| 164 | 230 | if jerr := json.Unmarshal(line, &ev); jerr == nil { | |
| 165 | 231 | if !isLikelyDoubleWrite(ev, out) { | |
| 166 | - | out = append(out, ev) | |
| 232 | + | if len(out) >= maxEventLogEvents { | |
| 233 | + | copy(out, out[1:]) | |
| 234 | + | out[len(out)-1] = ev | |
| 235 | + | } else { | |
| 236 | + | out = append(out, ev) | |
| 237 | + | } | |
| 167 | 238 | } | |
| 168 | 239 | } | |
| 169 | - | // Malformed lines are skipped silently; the dashboard | |
| 170 | - | // renders only well-formed events and the child is the | |
| 171 | - | // only writer. | |
| 240 | + | // Malformed and oversized lines are skipped silently; the | |
| 241 | + | // dashboard renders only well-formed events and the child is | |
| 242 | + | // the only writer. | |
| 172 | 243 | } | |
| 173 | 244 | if err != nil { | |
| 174 | 245 | if errors.Is(err, io.EOF) { | |
| @@ -179,6 +250,38 @@ func readEvents(r io.Reader) ([]Event, error) { | |||
| 179 | 250 | } | |
| 180 | 251 | } | |
| 181 | 252 | ||
| 253 | + | // readFollowerEvents parses only newline-terminated records from a live log. | |
| 254 | + | // A follower must not advance past an incomplete final line: another process | |
| 255 | + | // may be in the middle of appending that record, and skipping its start would | |
| 256 | + | // make the completed event invisible on the next poll. | |
| 257 | + | func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) { | |
| 258 | + | br := bufio.NewReader(r) | |
| 259 | + | out := make([]Event, 0, 256) | |
| 260 | + | committed := offset | |
| 261 | + | for { | |
| 262 | + | line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes) | |
| 263 | + | if !complete { | |
| 264 | + | break | |
| 265 | + | } | |
| 266 | + | committed += consumed | |
| 267 | + | if len(line) > 0 && !truncated { | |
| 268 | + | var ev Event | |
| 269 | + | if jerr := json.Unmarshal(line, &ev); jerr == nil && !isLikelyDoubleWrite(ev, out) { | |
| 270 | + | if len(out) >= maxEventLogEvents { | |
| 271 | + | copy(out, out[1:]) | |
| 272 | + | out[len(out)-1] = ev | |
| 273 | + | } else { | |
| 274 | + | out = append(out, ev) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | if err != nil { | |
| 279 | + | break | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | return out, committed | |
| 283 | + | } | |
| 284 | + | ||
| 182 | 285 | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | |
| 183 | 286 | // of the most recent event in `tail` — same type and identical Data | |
| 184 | 287 | // payload, with timestamps within a small window (the supervisor and | |
| @@ -296,8 +399,7 @@ func (f *EventFollower) loop() { | |||
| 296 | 399 | _ = fh.Close() | |
| 297 | 400 | continue | |
| 298 | 401 | } | |
| 299 | - | evs, _ := readEvents(fh) | |
| 300 | - | newOffset, _ := fh.Seek(0, io.SeekCurrent) | |
| 402 | + | evs, newOffset := readFollowerEvents(fh, offset) | |
| 301 | 403 | _ = fh.Close() | |
| 302 | 404 | offset = newOffset | |
| 303 | 405 | for _, ev := range evs { | |
M packages/agent/swarm/event_test.go +75 -0
| @@ -1,7 +1,12 @@ | |||
| 1 | 1 | package swarm | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 4 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 5 | 10 | "testing" | |
| 6 | 11 | "time" | |
| 7 | 12 | ) | |
| @@ -48,6 +53,44 @@ func TestEventLogAppendAndRead(t *testing.T) { | |||
| 48 | 53 | // only write well-formed events, but a partial crash could leave | |
| 49 | 54 | // a half-written line; we don't want that to prevent the dashboard | |
| 50 | 55 | // from showing the rest. | |
| 56 | + | func TestReadVersionedEventMergesPayloadFields(t *testing.T) { | |
| 57 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 58 | + | wire := `{"version":1,"type":"turn.progress","message_id":"m1","agent_id":"a1","turn_id":"t1","timestamp":"2026-08-04T12:00:00Z","payload":{"text":"still working","future":{"step":2}}}` + "\n" | |
| 59 | + | if err := os.WriteFile(path, []byte(wire), 0o600); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | got, err := ReadEventLog(path) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | if len(got) != 1 || got[0].Data["text"] != "still working" { | |
| 67 | + | t.Fatalf("versioned payload was not replayed: %+v", got) | |
| 68 | + | } | |
| 69 | + | if future, ok := got[0].Data["future"].(map[string]any); !ok || future["step"] != float64(2) { | |
| 70 | + | t.Fatalf("future payload field was lost: %+v", got[0].Data) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestReadEventLogSkipsOversizedLines(t *testing.T) { | |
| 75 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 76 | + | oversized := fmt.Sprintf("%s\n", strings.Repeat("x", maxEventLogLineBytes+100)) | |
| 77 | + | valid := NewEvent("valid", map[string]any{"ok": true}) | |
| 78 | + | data, err := json.Marshal(valid) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | if err := os.WriteFile(path, append([]byte(oversized), append(data, '\n')...), 0o600); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | got, err := ReadEventLog(path) | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | if len(got) != 1 || got[0].Type != "valid" { | |
| 90 | + | t.Fatalf("oversized line affected replay: %+v", got) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 51 | 94 | func TestReadEventLogIgnoresGarbage(t *testing.T) { | |
| 52 | 95 | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 53 | 96 | log, err := OpenEventLog(path) | |
| @@ -202,6 +245,38 @@ func TestFollowerEmitsNewEvents(t *testing.T) { | |||
| 202 | 245 | } | |
| 203 | 246 | } | |
| 204 | 247 | ||
| 248 | + | func TestFollowerDoesNotSkipPartialFinalEvent(t *testing.T) { | |
| 249 | + | seed := NewEvent("preexisting", nil) | |
| 250 | + | seedBytes, err := json.Marshal(seed) | |
| 251 | + | if err != nil { | |
| 252 | + | t.Fatal(err) | |
| 253 | + | } | |
| 254 | + | seedBytes = append(seedBytes, '\n') | |
| 255 | + | partial := NewEvent("partial", map[string]any{"ok": true}) | |
| 256 | + | partialBytes, err := json.Marshal(partial) | |
| 257 | + | if err != nil { | |
| 258 | + | t.Fatal(err) | |
| 259 | + | } | |
| 260 | + | mid := len(partialBytes) / 2 | |
| 261 | + | incomplete := append(append([]byte(nil), seedBytes...), partialBytes[:mid]...) | |
| 262 | + | events, offset := readFollowerEvents(bytes.NewReader(incomplete), 0) | |
| 263 | + | if len(events) != 1 || events[0].Type != "preexisting" { | |
| 264 | + | t.Fatalf("complete prefix = %+v", events) | |
| 265 | + | } | |
| 266 | + | if offset != int64(len(seedBytes)) { | |
| 267 | + | t.Fatalf("offset = %d, want complete prefix %d", offset, len(seedBytes)) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | complete := append(append([]byte(nil), seedBytes...), append(partialBytes, '\n')...) | |
| 271 | + | events, finalOffset := readFollowerEvents(bytes.NewReader(complete[offset:]), offset) | |
| 272 | + | if len(events) != 1 || events[0].Type != "partial" { | |
| 273 | + | t.Fatalf("completed event = %+v", events) | |
| 274 | + | } | |
| 275 | + | if finalOffset != int64(len(complete)) { | |
| 276 | + | t.Fatalf("final offset = %d, want %d", finalOffset, len(complete)) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 205 | 280 | func drain(ch <-chan Event) { | |
| 206 | 281 | for { | |
| 207 | 282 | select { | |
M packages/agent/swarm/inbox.go +107 -43
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | - | "io" | |
| 8 | 7 | "net" | |
| 9 | 8 | "os" | |
| 10 | 9 | "path/filepath" | |
| @@ -66,6 +65,20 @@ func (b *Inbox) Path() string { return b.path } | |||
| 66 | 65 | // ErrNotReady so the TUI can retry or report "agent not listening | |
| 67 | 66 | // yet" rather than crashing. | |
| 68 | 67 | func (b *Inbox) SendInput(msg string) error { | |
| 68 | + | return b.sendBytes([]byte(msg + "\n")) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // SendCommand sends a versioned JSONL command. SendInput remains available | |
| 72 | + | // for older children and tests; new supervisor paths use this method. | |
| 73 | + | func (b *Inbox) SendCommand(command Envelope) error { | |
| 74 | + | line, err := MarshalJSONL(command) | |
| 75 | + | if err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | return b.sendBytes(line) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (b *Inbox) sendBytes(data []byte) error { | |
| 69 | 82 | b.mu.Lock() | |
| 70 | 83 | defer b.mu.Unlock() | |
| 71 | 84 | if b.conn == nil { | |
| @@ -75,7 +88,7 @@ func (b *Inbox) SendInput(msg string) error { | |||
| 75 | 88 | } | |
| 76 | 89 | b.conn = c | |
| 77 | 90 | } | |
| 78 | - | if _, err := io.WriteString(b.conn, msg+"\n"); err != nil { | |
| 91 | + | if _, err := b.conn.Write(data); err != nil { | |
| 79 | 92 | // Drop the connection so the next call redials. The | |
| 80 | 93 | // previous error is more informative than the redial's | |
| 81 | 94 | // would be, so surface this one. | |
| @@ -147,6 +160,30 @@ func isNoListenerErr(err error) bool { | |||
| 147 | 160 | return errors.Is(err, syscall.ECONNREFUSED) | |
| 148 | 161 | } | |
| 149 | 162 | ||
| 163 | + | // inboxLive reports whether a process currently accepts connections on path. | |
| 164 | + | // A Unix socket inode can survive its owner, so os.Stat alone cannot | |
| 165 | + | // distinguish a live worker from a stale path. Callers use this probe before | |
| 166 | + | // resume/remove operations to avoid racing a detached worker's session and | |
| 167 | + | // workspace ownership. | |
| 168 | + | const inboxProbeLine = "__zot_swarm_probe__" | |
| 169 | + | ||
| 170 | + | func inboxLive(path string) bool { | |
| 171 | + | if path == "" { | |
| 172 | + | return false | |
| 173 | + | } | |
| 174 | + | conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) | |
| 175 | + | if err != nil { | |
| 176 | + | return false | |
| 177 | + | } | |
| 178 | + | // Listener treats this first line as a health probe and does not | |
| 179 | + | // replace the current supervisor connection. A bare connect would be | |
| 180 | + | // enough to detect liveness, but sending an explicit probe lets the | |
| 181 | + | // listener distinguish this side-effect-free check from a takeover. | |
| 182 | + | _, writeErr := conn.Write([]byte(inboxProbeLine + "\n")) | |
| 183 | + | _ = conn.Close() | |
| 184 | + | return writeErr == nil | |
| 185 | + | } | |
| 186 | + | ||
| 150 | 187 | // Listener is the child-side end of the inbox. The swarm-agent | |
| 151 | 188 | // daemon mode constructs one with Listen, then iterates Lines | |
| 152 | 189 | // to receive supervisor messages. Designed so a single goroutine | |
| @@ -158,35 +195,48 @@ type Listener struct { | |||
| 158 | 195 | // only expects one supervisor (the parent zot), so newer | |
| 159 | 196 | // connections preempt older ones — the previous parent | |
| 160 | 197 | // presumably crashed. | |
| 161 | - | mu sync.Mutex | |
| 162 | - | active net.Conn | |
| 163 | - | out chan string | |
| 164 | - | done chan struct{} | |
| 165 | - | wg sync.WaitGroup | |
| 198 | + | mu sync.Mutex | |
| 199 | + | active net.Conn | |
| 200 | + | conns map[net.Conn]struct{} | |
| 201 | + | out chan string | |
| 202 | + | done chan struct{} | |
| 203 | + | wg sync.WaitGroup | |
| 204 | + | closeOnce sync.Once | |
| 166 | 205 | } | |
| 167 | 206 | ||
| 168 | 207 | // Listen creates the socket at path and starts accepting. The | |
| 169 | 208 | // caller must call Close to remove the socket file on shutdown. | |
| 170 | - | // Returns the listener even if a stale socket exists from a | |
| 171 | - | // previous run: we unlink first, mirroring how most unix daemons | |
| 172 | - | // behave. | |
| 209 | + | // A live endpoint is preserved and rejected; only a refused probe | |
| 210 | + | // is treated as a stale socket eligible for removal. | |
| 173 | 211 | func Listen(path string) (*Listener, error) { | |
| 174 | - | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 212 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 175 | 213 | return nil, fmt.Errorf("inbox dir: %w", err) | |
| 176 | 214 | } | |
| 177 | - | // Best-effort cleanup of a stale socket. If the parent | |
| 178 | - | // process is still alive and using it, Listen will fail | |
| 179 | - | // below and the caller surfaces that as a real conflict. | |
| 180 | - | _ = os.Remove(path) | |
| 215 | + | // Probe an existing endpoint before removing it. Unlinking an active | |
| 216 | + | // Unix socket does not stop its listener, so blindly removing the path | |
| 217 | + | // could let Resume start a second worker on the same session and event log. | |
| 218 | + | if _, statErr := os.Stat(path); statErr == nil { | |
| 219 | + | if inboxLive(path) { | |
| 220 | + | return nil, fmt.Errorf("swarm inbox already has a live listener") | |
| 221 | + | } | |
| 222 | + | // A refused probe means the socket inode is stale. Remove only that | |
| 223 | + | // endpoint; a live listener was handled above without unlinking it. | |
| 224 | + | _ = os.Remove(path) | |
| 225 | + | } | |
| 181 | 226 | ln, err := net.Listen("unix", path) | |
| 182 | 227 | if err != nil { | |
| 183 | 228 | return nil, fmt.Errorf("inbox listen: %w", err) | |
| 184 | 229 | } | |
| 230 | + | // Unix sockets inherit the process umask but may otherwise be | |
| 231 | + | // world-writable. The inbox carries prompts and control commands, | |
| 232 | + | // so fail closed for other local users. | |
| 233 | + | _ = os.Chmod(path, 0o600) | |
| 185 | 234 | l := &Listener{ | |
| 186 | - | path: path, | |
| 187 | - | ln: ln, | |
| 188 | - | out: make(chan string, 16), | |
| 189 | - | done: make(chan struct{}), | |
| 235 | + | path: path, | |
| 236 | + | ln: ln, | |
| 237 | + | conns: make(map[net.Conn]struct{}), | |
| 238 | + | out: make(chan string, 16), | |
| 239 | + | done: make(chan struct{}), | |
| 190 | 240 | } | |
| 191 | 241 | go l.acceptLoop() | |
| 192 | 242 | return l, nil | |
| @@ -199,19 +249,16 @@ func (l *Listener) Lines() <-chan string { return l.out } | |||
| 199 | 249 | // Close stops accepting, drops the active connection, removes | |
| 200 | 250 | // the socket file, and closes Lines. Idempotent. | |
| 201 | 251 | func (l *Listener) Close() error { | |
| 202 | - | select { | |
| 203 | - | case <-l.done: | |
| 204 | - | return nil | |
| 205 | - | default: | |
| 252 | + | l.closeOnce.Do(func() { | |
| 206 | 253 | close(l.done) | |
| 207 | - | } | |
| 208 | - | l.mu.Lock() | |
| 209 | - | if l.active != nil { | |
| 210 | - | _ = l.active.Close() | |
| 211 | - | } | |
| 212 | - | l.mu.Unlock() | |
| 213 | - | _ = l.ln.Close() | |
| 214 | - | _ = os.Remove(l.path) | |
| 254 | + | l.mu.Lock() | |
| 255 | + | for conn := range l.conns { | |
| 256 | + | _ = conn.Close() | |
| 257 | + | } | |
| 258 | + | l.mu.Unlock() | |
| 259 | + | _ = l.ln.Close() | |
| 260 | + | _ = os.Remove(l.path) | |
| 261 | + | }) | |
| 215 | 262 | return nil | |
| 216 | 263 | } | |
| 217 | 264 | ||
| @@ -233,10 +280,7 @@ func (l *Listener) acceptLoop() { | |||
| 233 | 280 | } | |
| 234 | 281 | } | |
| 235 | 282 | l.mu.Lock() | |
| 236 | - | if l.active != nil { | |
| 237 | - | _ = l.active.Close() | |
| 238 | - | } | |
| 239 | - | l.active = c | |
| 283 | + | l.conns[c] = struct{}{} | |
| 240 | 284 | l.mu.Unlock() | |
| 241 | 285 | l.wg.Add(1) | |
| 242 | 286 | go func() { | |
| @@ -247,24 +291,44 @@ func (l *Listener) acceptLoop() { | |||
| 247 | 291 | } | |
| 248 | 292 | ||
| 249 | 293 | func (l *Listener) readLoop(c net.Conn) { | |
| 294 | + | defer func() { | |
| 295 | + | _ = c.Close() | |
| 296 | + | l.mu.Lock() | |
| 297 | + | delete(l.conns, c) | |
| 298 | + | if l.active == c { | |
| 299 | + | l.active = nil | |
| 300 | + | } | |
| 301 | + | l.mu.Unlock() | |
| 302 | + | }() | |
| 303 | + | ||
| 250 | 304 | br := bufio.NewReader(c) | |
| 305 | + | firstLine := true | |
| 251 | 306 | for { | |
| 252 | 307 | line, err := br.ReadString('\n') | |
| 253 | 308 | if line != "" { | |
| 309 | + | message := trimRightNL(line) | |
| 310 | + | if firstLine { | |
| 311 | + | firstLine = false | |
| 312 | + | if message == inboxProbeLine { | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | // The first real command claims ownership. Health probes do | |
| 316 | + | // not preempt the current supervisor, while a replacement | |
| 317 | + | // supervisor still gets the historical takeover behavior. | |
| 318 | + | l.mu.Lock() | |
| 319 | + | if l.active != nil && l.active != c { | |
| 320 | + | _ = l.active.Close() | |
| 321 | + | } | |
| 322 | + | l.active = c | |
| 323 | + | l.mu.Unlock() | |
| 324 | + | } | |
| 254 | 325 | select { | |
| 255 | - | case l.out <- trimRightNL(line): | |
| 326 | + | case l.out <- message: | |
| 256 | 327 | case <-l.done: | |
| 257 | - | _ = c.Close() | |
| 258 | 328 | return | |
| 259 | 329 | } | |
| 260 | 330 | } | |
| 261 | 331 | if err != nil { | |
| 262 | - | _ = c.Close() | |
| 263 | - | l.mu.Lock() | |
| 264 | - | if l.active == c { | |
| 265 | - | l.active = nil | |
| 266 | - | } | |
| 267 | - | l.mu.Unlock() | |
| 268 | 332 | return | |
| 269 | 333 | } | |
| 270 | 334 | } | |
A packages/agent/swarm/lease.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ErrAgentLeaseHeld means another supervisor currently owns an agent's | |
| 11 | + | // durable state. The lease is advisory only to old versions; socket probing | |
| 12 | + | // remains the compatibility fence for workers that predate the lease. | |
| 13 | + | var ErrAgentLeaseHeld = errors.New("subagents: agent state is owned by another supervisor") | |
| 14 | + | ||
| 15 | + | type agentLease struct { | |
| 16 | + | file *os.File | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func openAgentLeaseFile(stateDir string) (*os.File, error) { | |
| 20 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 21 | + | return nil, fmt.Errorf("subagents lease directory: %w", err) | |
| 22 | + | } | |
| 23 | + | path := filepath.Join(stateDir, "owner.lock") | |
| 24 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) | |
| 25 | + | if err != nil { | |
| 26 | + | return nil, fmt.Errorf("subagents lease open: %w", err) | |
| 27 | + | } | |
| 28 | + | _ = file.Chmod(0o600) | |
| 29 | + | return file, nil | |
| 30 | + | } | |
A packages/agent/swarm/lease_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestAgentLeaseIsExclusive(t *testing.T) { | |
| 9 | + | stateDir := t.TempDir() | |
| 10 | + | first, err := acquireAgentLease(stateDir) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | defer first.Close() | |
| 15 | + | second, err := acquireAgentLease(stateDir) | |
| 16 | + | if !errors.Is(err, ErrAgentLeaseHeld) { | |
| 17 | + | if second != nil { | |
| 18 | + | _ = second.Close() | |
| 19 | + | } | |
| 20 | + | t.Fatalf("second lease = %v, want ErrAgentLeaseHeld", err) | |
| 21 | + | } | |
| 22 | + | if err := first.Close(); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | third, err := acquireAgentLease(stateDir) | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if err := third.Close(); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | } | |
A packages/agent/swarm/lease_unix.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | //go:build !windows | |
| 2 | + | ||
| 3 | + | package swarm | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "syscall" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func acquireAgentLease(stateDir string) (*agentLease, error) { | |
| 11 | + | file, err := openAgentLeaseFile(stateDir) | |
| 12 | + | if err != nil { | |
| 13 | + | return nil, err | |
| 14 | + | } | |
| 15 | + | if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |
| 16 | + | _ = file.Close() | |
| 17 | + | if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { | |
| 18 | + | return nil, ErrAgentLeaseHeld | |
| 19 | + | } | |
| 20 | + | return nil, err | |
| 21 | + | } | |
| 22 | + | return &agentLease{file: file}, nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (l *agentLease) Close() error { | |
| 26 | + | if l == nil || l.file == nil { | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) | |
| 30 | + | closeErr := l.file.Close() | |
| 31 | + | l.file = nil | |
| 32 | + | if unlockErr != nil { | |
| 33 | + | return unlockErr | |
| 34 | + | } | |
| 35 | + | return closeErr | |
| 36 | + | } | |
A packages/agent/swarm/lease_windows.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package swarm | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | ||
| 8 | + | "golang.org/x/sys/windows" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func acquireAgentLease(stateDir string) (*agentLease, error) { | |
| 12 | + | file, err := openAgentLeaseFile(stateDir) | |
| 13 | + | if err != nil { | |
| 14 | + | return nil, err | |
| 15 | + | } | |
| 16 | + | flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY) | |
| 17 | + | if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, nil); err != nil { | |
| 18 | + | _ = file.Close() | |
| 19 | + | if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { | |
| 20 | + | return nil, ErrAgentLeaseHeld | |
| 21 | + | } | |
| 22 | + | return nil, err | |
| 23 | + | } | |
| 24 | + | return &agentLease{file: file}, nil | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (l *agentLease) Close() error { | |
| 28 | + | if l == nil || l.file == nil { | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, nil) | |
| 32 | + | closeErr := l.file.Close() | |
| 33 | + | l.file = nil | |
| 34 | + | if unlockErr != nil { | |
| 35 | + | return unlockErr | |
| 36 | + | } | |
| 37 | + | return closeErr | |
| 38 | + | } | |
A packages/agent/swarm/model.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // ProcessState describes the lifetime of the supervised child process. It is | |
| 6 | + | // intentionally independent from TurnState: an alive worker can be idle | |
| 7 | + | // between turns, and a disconnected supervisor does not imply that the | |
| 8 | + | // worker's current turn failed. | |
| 9 | + | type ProcessState string | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | ProcessPending ProcessState = "pending" | |
| 13 | + | ProcessStarting ProcessState = "starting" | |
| 14 | + | ProcessAlive ProcessState = "alive" | |
| 15 | + | ProcessDetached ProcessState = "detached" | |
| 16 | + | ProcessExited ProcessState = "exited" | |
| 17 | + | ProcessKilled ProcessState = "killed" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // TurnState describes one delegated prompt inside a worker process. | |
| 21 | + | type TurnState string | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | TurnIdle TurnState = "idle" | |
| 25 | + | TurnQueued TurnState = "queued" | |
| 26 | + | TurnRunning TurnState = "running" | |
| 27 | + | TurnCanceling TurnState = "canceling" | |
| 28 | + | TurnSucceeded TurnState = "succeeded" | |
| 29 | + | TurnFailed TurnState = "failed" | |
| 30 | + | TurnCanceled TurnState = "canceled" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // WorkspaceMode selects how a child is allowed to access the repository. | |
| 34 | + | type WorkspaceMode string | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | WorkspaceShared WorkspaceMode = "shared" | |
| 38 | + | WorkspaceWorktree WorkspaceMode = "worktree" | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // CaptureMode controls which durable worktree output is collected. | |
| 42 | + | type CaptureMode string | |
| 43 | + | ||
| 44 | + | const ( | |
| 45 | + | CapturePatch CaptureMode = "patch" | |
| 46 | + | CaptureDiff CaptureMode = "diff" | |
| 47 | + | ) | |
| 48 | + | ||
| 49 | + | // SubagentPolicy contains manager-owned safety and resource limits. Zero | |
| 50 | + | // values mean "use the manager default" for limits that have a safe default; | |
| 51 | + | // zero remains unlimited for optional output caps and timeouts. | |
| 52 | + | type SubagentPolicy struct { | |
| 53 | + | MaxConcurrent int | |
| 54 | + | MaxConcurrentPerParent int | |
| 55 | + | MaxTotalSpawned int | |
| 56 | + | QueueTimeout time.Duration | |
| 57 | + | DefaultTimeout time.Duration | |
| 58 | + | MaxTurns int | |
| 59 | + | MaxOutputBytes int | |
| 60 | + | MaxOutputLines int | |
| 61 | + | AllowedTools []string | |
| 62 | + | AllowedRoots []string | |
| 63 | + | HeartbeatInterval time.Duration | |
| 64 | + | IdleTimeout time.Duration | |
| 65 | + | ReconnectTimeout time.Duration | |
| 66 | + | CancelGracePeriod time.Duration | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (p *SubagentPolicy) normalize() { | |
| 70 | + | if p.MaxConcurrent <= 0 { | |
| 71 | + | p.MaxConcurrent = 8 | |
| 72 | + | } | |
| 73 | + | if p.MaxConcurrentPerParent <= 0 { | |
| 74 | + | p.MaxConcurrentPerParent = 4 | |
| 75 | + | } | |
| 76 | + | if p.MaxTotalSpawned <= 0 { | |
| 77 | + | p.MaxTotalSpawned = 32 | |
| 78 | + | } | |
| 79 | + | if p.QueueTimeout <= 0 { | |
| 80 | + | p.QueueTimeout = 30 * time.Minute | |
| 81 | + | } | |
| 82 | + | if p.DefaultTimeout <= 0 { | |
| 83 | + | p.DefaultTimeout = 20 * time.Minute | |
| 84 | + | } | |
| 85 | + | if p.MaxTurns <= 0 { | |
| 86 | + | p.MaxTurns = 3 | |
| 87 | + | } | |
| 88 | + | if p.MaxOutputBytes <= 0 { | |
| 89 | + | p.MaxOutputBytes = 500_000 | |
| 90 | + | } | |
| 91 | + | if p.MaxOutputLines <= 0 { | |
| 92 | + | p.MaxOutputLines = 5_000 | |
| 93 | + | } | |
| 94 | + | if p.HeartbeatInterval <= 0 { | |
| 95 | + | p.HeartbeatInterval = 10 * time.Second | |
| 96 | + | } | |
| 97 | + | if p.IdleTimeout <= 0 { | |
| 98 | + | p.IdleTimeout = 7 * time.Minute | |
| 99 | + | } | |
| 100 | + | if p.ReconnectTimeout <= 0 { | |
| 101 | + | p.ReconnectTimeout = 5 * time.Second | |
| 102 | + | } | |
| 103 | + | if p.CancelGracePeriod <= 0 { | |
| 104 | + | p.CancelGracePeriod = 10 * time.Second | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (p SubagentPolicy) allowedTool(name string) bool { | |
| 109 | + | if len(p.AllowedTools) == 0 { | |
| 110 | + | return true | |
| 111 | + | } | |
| 112 | + | for _, allowed := range p.AllowedTools { | |
| 113 | + | if allowed == name { | |
| 114 | + | return true | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return false | |
| 118 | + | } | |
M packages/agent/swarm/persist.go +479 -95
| @@ -38,18 +38,42 @@ import ( | |||
| 38 | 38 | // by encoding/json's permissive decoder when an older meta.json is | |
| 39 | 39 | // loaded; we don't need to keep them in the struct. | |
| 40 | 40 | type agentMeta struct { | |
| 41 | - | ID string `json:"id"` | |
| 42 | - | Task string `json:"task"` | |
| 43 | - | Dir string `json:"dir"` | |
| 44 | - | Started time.Time `json:"started"` | |
| 45 | - | Model string `json:"model,omitempty"` | |
| 46 | - | Provider string `json:"provider,omitempty"` | |
| 47 | - | Reasoning string `json:"reasoning,omitempty"` | |
| 48 | - | FastMode *bool `json:"fast_mode,omitempty"` | |
| 49 | - | Subagent string `json:"subagent,omitempty"` | |
| 50 | - | InboxPath string `json:"inbox_path"` | |
| 51 | - | EventLogPath string `json:"event_log_path"` | |
| 52 | - | SessionPath string `json:"session_path"` | |
| 41 | + | ID string `json:"id"` | |
| 42 | + | Task string `json:"task"` | |
| 43 | + | OriginalTask string `json:"original_task,omitempty"` | |
| 44 | + | Dir string `json:"dir"` | |
| 45 | + | RepositoryRoot string `json:"repository_root,omitempty"` | |
| 46 | + | Started time.Time `json:"started"` | |
| 47 | + | ParentID string `json:"parent_id,omitempty"` | |
| 48 | + | BatchID string `json:"batch_id,omitempty"` | |
| 49 | + | RootSessionID string `json:"root_session_id,omitempty"` | |
| 50 | + | Model string `json:"model,omitempty"` | |
| 51 | + | Provider string `json:"provider,omitempty"` | |
| 52 | + | BaseURL string `json:"base_url,omitempty"` | |
| 53 | + | InsecureTLS bool `json:"insecure_tls,omitempty"` | |
| 54 | + | Reasoning string `json:"reasoning,omitempty"` | |
| 55 | + | FastMode *bool `json:"fast_mode,omitempty"` | |
| 56 | + | Subagent string `json:"subagent,omitempty"` | |
| 57 | + | WorkspaceMode WorkspaceMode `json:"workspace_mode,omitempty"` | |
| 58 | + | WorkspacePath string `json:"workspace_path,omitempty"` | |
| 59 | + | WorkspaceBase string `json:"workspace_base,omitempty"` | |
| 60 | + | WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"` | |
| 61 | + | MaxTurns int `json:"max_turns,omitempty"` | |
| 62 | + | Timeout time.Duration `json:"timeout,omitempty"` | |
| 63 | + | Tools []string `json:"tools,omitempty"` | |
| 64 | + | ProcessState ProcessState `json:"process_state,omitempty"` | |
| 65 | + | TurnState TurnState `json:"turn_state,omitempty"` | |
| 66 | + | CurrentTurnID string `json:"current_turn_id,omitempty"` | |
| 67 | + | Attempt int `json:"attempt,omitempty"` | |
| 68 | + | ProcessPID int `json:"process_pid,omitempty"` | |
| 69 | + | UpdatedAt time.Time `json:"updated_at,omitempty"` | |
| 70 | + | LastActivity time.Time `json:"last_activity,omitempty"` | |
| 71 | + | ResultRef string `json:"result_ref,omitempty"` | |
| 72 | + | PatchRef string `json:"patch_ref,omitempty"` | |
| 73 | + | ChangedFiles []string `json:"changed_files,omitempty"` | |
| 74 | + | InboxPath string `json:"inbox_path"` | |
| 75 | + | EventLogPath string `json:"event_log_path"` | |
| 76 | + | SessionPath string `json:"session_path"` | |
| 53 | 77 | ||
| 54 | 78 | // SessionID, when non-empty, scopes the agent to a particular | |
| 55 | 79 | // host zot session: the dashboard only shows agents whose | |
| @@ -68,32 +92,68 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso | |||
| 68 | 92 | // half-parsable file that fails Reload. | |
| 69 | 93 | func writeAgentMeta(stateDir string, a *Agent) error { | |
| 70 | 94 | fastMode := a.FastMode | |
| 95 | + | a.lifecycleMu.Lock() | |
| 96 | + | processState := a.processState | |
| 97 | + | turnState := a.turnState | |
| 98 | + | currentTurnID := a.currentTurnID | |
| 99 | + | attempt := a.Attempt | |
| 100 | + | processPID := a.ProcessPID | |
| 101 | + | updatedAt := a.updatedAt | |
| 102 | + | lastActivity := a.lastActivity | |
| 103 | + | resultRef := a.resultRef | |
| 104 | + | patchRef := a.patchRef | |
| 105 | + | changedFiles := append([]string(nil), a.changedFiles...) | |
| 106 | + | a.lifecycleMu.Unlock() | |
| 71 | 107 | m := agentMeta{ | |
| 72 | - | ID: a.ID, | |
| 73 | - | Task: a.Task, | |
| 74 | - | Dir: a.Dir, | |
| 75 | - | Started: a.Started, | |
| 76 | - | Model: a.Model, | |
| 77 | - | Provider: a.Provider, | |
| 78 | - | Reasoning: a.Reasoning, | |
| 79 | - | FastMode: &fastMode, | |
| 80 | - | Subagent: a.Subagent, | |
| 81 | - | InboxPath: a.InboxPath, | |
| 82 | - | EventLogPath: a.EventLogPath, | |
| 83 | - | SessionPath: a.SessionPath, | |
| 84 | - | SessionID: a.SessionID, | |
| 108 | + | ID: a.ID, | |
| 109 | + | Task: a.Task, | |
| 110 | + | OriginalTask: a.OriginalTask, | |
| 111 | + | Dir: a.Dir, | |
| 112 | + | RepositoryRoot: a.RepositoryRoot, | |
| 113 | + | Started: a.Started, | |
| 114 | + | ParentID: a.ParentID, | |
| 115 | + | BatchID: a.BatchID, | |
| 116 | + | RootSessionID: a.RootSessionID, | |
| 117 | + | Model: a.Model, | |
| 118 | + | Provider: a.Provider, | |
| 119 | + | BaseURL: a.BaseURL, | |
| 120 | + | InsecureTLS: a.InsecureTLS, | |
| 121 | + | Reasoning: a.Reasoning, | |
| 122 | + | FastMode: &fastMode, | |
| 123 | + | Subagent: a.Subagent, | |
| 124 | + | WorkspaceMode: a.WorkspaceMode, | |
| 125 | + | WorkspacePath: a.WorkspacePath, | |
| 126 | + | WorkspaceBase: a.WorkspaceBase, | |
| 127 | + | WorkspaceCapture: a.WorkspaceCapture, | |
| 128 | + | MaxTurns: a.MaxTurns, | |
| 129 | + | Timeout: a.Timeout, | |
| 130 | + | Tools: append([]string(nil), a.Tools...), | |
| 131 | + | ProcessState: processState, | |
| 132 | + | TurnState: turnState, | |
| 133 | + | CurrentTurnID: currentTurnID, | |
| 134 | + | Attempt: attempt, | |
| 135 | + | ProcessPID: processPID, | |
| 136 | + | UpdatedAt: updatedAt, | |
| 137 | + | LastActivity: lastActivity, | |
| 138 | + | ResultRef: resultRef, | |
| 139 | + | PatchRef: patchRef, | |
| 140 | + | ChangedFiles: changedFiles, | |
| 141 | + | InboxPath: a.InboxPath, | |
| 142 | + | EventLogPath: a.EventLogPath, | |
| 143 | + | SessionPath: a.SessionPath, | |
| 144 | + | SessionID: a.SessionID, | |
| 85 | 145 | } | |
| 86 | 146 | b, err := json.MarshalIndent(m, "", " ") | |
| 87 | 147 | if err != nil { | |
| 88 | 148 | return fmt.Errorf("swarm meta marshal: %w", err) | |
| 89 | 149 | } | |
| 90 | - | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| 91 | - | return fmt.Errorf("swarm meta dir: %w", err) | |
| 150 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 151 | + | return fmt.Errorf("subagents meta dir: %w", err) | |
| 92 | 152 | } | |
| 93 | 153 | final := metaPath(stateDir) | |
| 94 | 154 | tmp := final + ".tmp" | |
| 95 | - | if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { | |
| 96 | - | return fmt.Errorf("swarm meta write: %w", err) | |
| 155 | + | if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { | |
| 156 | + | return fmt.Errorf("subagents meta write: %w", err) | |
| 97 | 157 | } | |
| 98 | 158 | if err := os.Rename(tmp, final); err != nil { | |
| 99 | 159 | _ = os.Remove(tmp) | |
| @@ -136,21 +196,100 @@ func readAgentMeta(stateDir string) (agentMeta, error) { | |||
| 136 | 196 | // error encountered. Malformed entries are skipped rather than | |
| 137 | 197 | // failing the whole reload — one bad meta.json shouldn't hide the | |
| 138 | 198 | // rest of the swarm. | |
| 199 | + | func safeAgentID(id string) bool { | |
| 200 | + | return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (f *Swarm) sanitizeReloadMeta(stateDir, root string, m *agentMeta) error { | |
| 204 | + | if m == nil { | |
| 205 | + | return errors.New("subagents reload: nil metadata") | |
| 206 | + | } | |
| 207 | + | if m.EventLogPath == "" { | |
| 208 | + | m.EventLogPath = filepath.Join(stateDir, "events.jsonl") | |
| 209 | + | } | |
| 210 | + | if m.SessionPath == "" { | |
| 211 | + | m.SessionPath = filepath.Join(stateDir, "session.json") | |
| 212 | + | } | |
| 213 | + | if filepath.Base(m.EventLogPath) != "events.jsonl" || !pathWithin(m.EventLogPath, stateDir) { | |
| 214 | + | return fmt.Errorf("subagents reload %s: event log path escapes agent state", stateDir) | |
| 215 | + | } | |
| 216 | + | if filepath.Base(m.SessionPath) != "session.json" || !pathWithin(m.SessionPath, stateDir) { | |
| 217 | + | return fmt.Errorf("subagents reload %s: session path escapes agent state", stateDir) | |
| 218 | + | } | |
| 219 | + | if m.InboxPath == "" || !validRuntimeInboxPath(m.InboxPath, m.ID) { | |
| 220 | + | // Old metadata may point at a runtime directory from a previous | |
| 221 | + | // environment. Recompute that transient path rather than ever | |
| 222 | + | // dialing an arbitrary socket supplied by metadata. | |
| 223 | + | expectedInbox, err := inboxSocketPath(root, m.ID) | |
| 224 | + | if err != nil { | |
| 225 | + | return fmt.Errorf("subagents reload %s: inbox path: %w", stateDir, err) | |
| 226 | + | } | |
| 227 | + | m.InboxPath = expectedInbox | |
| 228 | + | } | |
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func validRuntimeInboxPath(path, id string) bool { | |
| 233 | + | if !filepath.IsAbs(path) || (filepath.Base(path) != id+".sock" && filepath.Base(path) != shortHash(id)+".sock") { | |
| 234 | + | return false | |
| 235 | + | } | |
| 236 | + | dir := filepath.Dir(path) | |
| 237 | + | if !strings.HasPrefix(filepath.Base(dir), "zot-swarm-") { | |
| 238 | + | return false | |
| 239 | + | } | |
| 240 | + | bases := []string{os.TempDir(), "/tmp"} | |
| 241 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtimeDir) { | |
| 242 | + | bases = append(bases, runtimeDir) | |
| 243 | + | } | |
| 244 | + | for _, base := range bases { | |
| 245 | + | if pathWithin(dir, filepath.Clean(base)) { | |
| 246 | + | return true | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | return false | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func firstNonEmpty(values ...string) string { | |
| 253 | + | for _, value := range values { | |
| 254 | + | if strings.TrimSpace(value) != "" { | |
| 255 | + | return value | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | return "" | |
| 259 | + | } | |
| 260 | + | ||
| 139 | 261 | func (f *Swarm) Reload() (loaded int, errs []error) { | |
| 140 | - | agentsDir := filepath.Join(f.cfg.Root, "agents") | |
| 262 | + | roots := []string{f.cfg.Root} | |
| 263 | + | roots = append(roots, f.cfg.LegacyRoots...) | |
| 264 | + | seenRoots := make(map[string]struct{}, len(roots)) | |
| 265 | + | for _, root := range roots { | |
| 266 | + | root = filepath.Clean(root) | |
| 267 | + | if root == "." { | |
| 268 | + | continue | |
| 269 | + | } | |
| 270 | + | if _, seen := seenRoots[root]; seen { | |
| 271 | + | continue | |
| 272 | + | } | |
| 273 | + | seenRoots[root] = struct{}{} | |
| 274 | + | count, rootErrs := f.reloadRoot(root) | |
| 275 | + | loaded += count | |
| 276 | + | errs = append(errs, rootErrs...) | |
| 277 | + | errs = append(errs, f.reloadBatches(root)...) | |
| 278 | + | } | |
| 279 | + | return loaded, errs | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func (f *Swarm) reloadRoot(root string) (loaded int, errs []error) { | |
| 283 | + | agentsDir := filepath.Join(root, "agents") | |
| 141 | 284 | entries, err := os.ReadDir(agentsDir) | |
| 142 | 285 | if err != nil { | |
| 143 | 286 | if errors.Is(err, os.ErrNotExist) { | |
| 144 | 287 | return 0, nil | |
| 145 | 288 | } | |
| 146 | - | return 0, []error{fmt.Errorf("swarm reload: %w", err)} | |
| 289 | + | return 0, []error{fmt.Errorf("subagents reload %s: %w", root, err)} | |
| 147 | 290 | } | |
| 148 | 291 | ||
| 149 | 292 | // Sort by directory name so the load order is stable across runs. | |
| 150 | - | // agentStateDir uses the id verbatim so name-sort == id-sort, | |
| 151 | - | // which mirrors the creation order well enough for the dashboard | |
| 152 | - | // (we also sort by Started in SnapshotAll, but having a stable | |
| 153 | - | // f.order helps tests). | |
| 154 | 293 | names := make([]string, 0, len(entries)) | |
| 155 | 294 | for _, e := range entries { | |
| 156 | 295 | if e.IsDir() { | |
| @@ -164,14 +303,19 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 164 | 303 | m, err := readAgentMeta(stateDir) | |
| 165 | 304 | if err != nil { | |
| 166 | 305 | if errors.Is(err, os.ErrNotExist) { | |
| 167 | - | // Bare directory with no meta.json — probably a | |
| 168 | - | // leftover from a Spawn that failed before | |
| 169 | - | // writeAgentMeta. Ignore silently. | |
| 170 | 306 | continue | |
| 171 | 307 | } | |
| 172 | 308 | errs = append(errs, err) | |
| 173 | 309 | continue | |
| 174 | 310 | } | |
| 311 | + | if filepath.Base(stateDir) != m.ID || !safeAgentID(m.ID) { | |
| 312 | + | errs = append(errs, fmt.Errorf("subagents reload %s: unsafe or mismatched agent id", stateDir)) | |
| 313 | + | continue | |
| 314 | + | } | |
| 315 | + | if err := f.sanitizeReloadMeta(stateDir, root, &m); err != nil { | |
| 316 | + | errs = append(errs, err) | |
| 317 | + | continue | |
| 318 | + | } | |
| 175 | 319 | ||
| 176 | 320 | f.mu.Lock() | |
| 177 | 321 | if _, exists := f.agents[m.ID]; exists { | |
| @@ -195,43 +339,96 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 195 | 339 | // The returned Agent has a closed `done` channel because Wait should | |
| 196 | 340 | // return instantly: there is nothing to wait for. | |
| 197 | 341 | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |
| 198 | - | // Older meta.json files may still record a per-agent worktree | |
| 199 | - | // path under Dir. They predate the decision to run every agent | |
| 200 | - | // in the host's repo and shouldn't continue editing that stale | |
| 201 | - | // checkout, which most likely no longer matches HEAD. Coerce | |
| 202 | - | // the dir back to the live RepoRoot so resume picks up where | |
| 203 | - | // the host is now. | |
| 342 | + | // Legacy meta.json files may not record a repository identity. Those | |
| 343 | + | // records are migrated to the current RepoRoot; current-format records | |
| 344 | + | // retain their persisted repository so a restart from another checkout | |
| 345 | + | // cannot silently redirect the child. | |
| 204 | 346 | dir := m.Dir | |
| 205 | - | if f.cfg.RepoRoot != "" { | |
| 347 | + | if m.WorkspaceMode != WorkspaceWorktree && m.RepositoryRoot == "" && f.cfg.RepoRoot != "" { | |
| 348 | + | // Legacy metadata did not record the repository identity. Preserve | |
| 349 | + | // the historical best-effort migration only for those records. | |
| 206 | 350 | dir = f.cfg.RepoRoot | |
| 207 | 351 | } | |
| 352 | + | if dir == "" { | |
| 353 | + | dir = firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot) | |
| 354 | + | } | |
| 208 | 355 | fastMode := f.cfg.FastMode | |
| 209 | 356 | if m.FastMode != nil { | |
| 210 | 357 | fastMode = *m.FastMode | |
| 211 | 358 | } | |
| 359 | + | turnState := m.TurnState | |
| 360 | + | if turnState == "" { | |
| 361 | + | turnState = TurnIdle | |
| 362 | + | } | |
| 363 | + | lastActivity := m.LastActivity | |
| 364 | + | if lastActivity.IsZero() { | |
| 365 | + | lastActivity = m.Started | |
| 366 | + | } | |
| 367 | + | processState := ProcessDetached | |
| 368 | + | activity := "detached" | |
| 369 | + | if inboxLive(m.InboxPath) { | |
| 370 | + | processState = ProcessAlive | |
| 371 | + | activity = "live (detached)" | |
| 372 | + | } | |
| 373 | + | agentStateDir := f.agentStateDir(m.ID) | |
| 374 | + | if m.EventLogPath != "" { | |
| 375 | + | // EventLogPath is <state-dir>/events.jsonl; keep the reloaded | |
| 376 | + | // agent's state scoped to its own directory. Using the parent | |
| 377 | + | // agents/ directory here would make Remove delete sibling agents | |
| 378 | + | // and would make resumed result/patch writes collide. | |
| 379 | + | agentStateDir = filepath.Dir(m.EventLogPath) | |
| 380 | + | } | |
| 212 | 381 | a := &Agent{ | |
| 213 | - | ID: m.ID, | |
| 214 | - | Task: m.Task, | |
| 215 | - | Dir: dir, | |
| 216 | - | Started: m.Started, | |
| 217 | - | Model: m.Model, | |
| 218 | - | Provider: m.Provider, | |
| 219 | - | Reasoning: m.Reasoning, | |
| 220 | - | FastMode: fastMode, | |
| 221 | - | Subagent: m.Subagent, | |
| 222 | - | InboxPath: m.InboxPath, | |
| 223 | - | EventLogPath: m.EventLogPath, | |
| 224 | - | SessionPath: m.SessionPath, | |
| 225 | - | SessionID: m.SessionID, | |
| 226 | - | inbox: NewInbox(m.InboxPath), | |
| 227 | - | status: StatusDetached, | |
| 228 | - | activity: "detached", | |
| 229 | - | done: make(chan struct{}), | |
| 230 | - | } | |
| 231 | - | // Wait() must not block for detached agents; close the channel | |
| 232 | - | // immediately. Callers Resuming the agent will replace done with | |
| 233 | - | // a fresh channel before starting the new runner. | |
| 234 | - | close(a.done) | |
| 382 | + | ID: m.ID, | |
| 383 | + | Task: m.Task, | |
| 384 | + | OriginalTask: firstNonEmpty(m.OriginalTask, m.Task), | |
| 385 | + | Dir: dir, | |
| 386 | + | RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot), | |
| 387 | + | Started: m.Started, | |
| 388 | + | ParentID: m.ParentID, | |
| 389 | + | BatchID: m.BatchID, | |
| 390 | + | RootSessionID: m.RootSessionID, | |
| 391 | + | Model: m.Model, | |
| 392 | + | Provider: m.Provider, | |
| 393 | + | BaseURL: m.BaseURL, | |
| 394 | + | InsecureTLS: m.InsecureTLS, | |
| 395 | + | Reasoning: m.Reasoning, | |
| 396 | + | FastMode: fastMode, | |
| 397 | + | Subagent: m.Subagent, | |
| 398 | + | WorkspaceMode: m.WorkspaceMode, | |
| 399 | + | WorkspacePath: m.WorkspacePath, | |
| 400 | + | WorkspaceBase: m.WorkspaceBase, | |
| 401 | + | WorkspaceCapture: m.WorkspaceCapture, | |
| 402 | + | MaxTurns: m.MaxTurns, | |
| 403 | + | Timeout: m.Timeout, | |
| 404 | + | Tools: append([]string(nil), m.Tools...), | |
| 405 | + | Attempt: m.Attempt, | |
| 406 | + | ProcessPID: m.ProcessPID, | |
| 407 | + | InboxPath: m.InboxPath, | |
| 408 | + | EventLogPath: m.EventLogPath, | |
| 409 | + | SessionPath: m.SessionPath, | |
| 410 | + | SessionID: m.SessionID, | |
| 411 | + | inbox: NewInbox(m.InboxPath), | |
| 412 | + | status: StatusDetached, | |
| 413 | + | activity: activity, | |
| 414 | + | processState: processState, | |
| 415 | + | turnState: turnState, | |
| 416 | + | currentTurnID: m.CurrentTurnID, | |
| 417 | + | updatedAt: m.UpdatedAt, | |
| 418 | + | lastActivity: lastActivity, | |
| 419 | + | stateDir: agentStateDir, | |
| 420 | + | resultRef: m.ResultRef, | |
| 421 | + | patchRef: m.PatchRef, | |
| 422 | + | changedFiles: append([]string(nil), m.ChangedFiles...), | |
| 423 | + | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 424 | + | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 425 | + | done: make(chan struct{}), | |
| 426 | + | turnResults: make(chan *TurnResult, 16), | |
| 427 | + | } | |
| 428 | + | if a.updatedAt.IsZero() { | |
| 429 | + | a.updatedAt = lastActivity | |
| 430 | + | } | |
| 431 | + | a.closeDone() | |
| 235 | 432 | ||
| 236 | 433 | // Recover transcript + activity hints from the event log. Best | |
| 237 | 434 | // effort: a missing or unreadable log just leaves the agent | |
| @@ -241,6 +438,24 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 241 | 438 | replayEventsIntoAgent(a, evs) | |
| 242 | 439 | } | |
| 243 | 440 | } | |
| 441 | + | // The append-only log may contain a terminal event from an earlier | |
| 442 | + | // attempt. A live socket is stronger evidence for the current process; | |
| 443 | + | // keep the reloaded entry detached/live so Stop and Remove cannot treat a | |
| 444 | + | // resumed worker as already finished. | |
| 445 | + | if inboxLive(a.InboxPath) { | |
| 446 | + | a.mu.Lock() | |
| 447 | + | a.status = StatusDetached | |
| 448 | + | a.activity = "live (detached)" | |
| 449 | + | a.mu.Unlock() | |
| 450 | + | a.setProcessState(ProcessAlive) | |
| 451 | + | } | |
| 452 | + | resultDir := f.agentStateDir(a.ID) | |
| 453 | + | if a.EventLogPath != "" { | |
| 454 | + | resultDir = filepath.Dir(a.EventLogPath) | |
| 455 | + | } | |
| 456 | + | if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil { | |
| 457 | + | a.setResult(result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)) | |
| 458 | + | } | |
| 244 | 459 | return a | |
| 245 | 460 | } | |
| 246 | 461 | ||
| @@ -256,6 +471,33 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 256 | 471 | terminal := false | |
| 257 | 472 | for _, ev := range evs { | |
| 258 | 473 | switch ev.Type { | |
| 474 | + | case EventTurnStarted, "turn_start": | |
| 475 | + | turnID := ev.TurnID | |
| 476 | + | if turnID == "" { | |
| 477 | + | if step, ok := ev.Data["step"].(float64); ok { | |
| 478 | + | turnID = fmt.Sprintf("turn-%d", int(step)) | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | a.setTurnState(TurnRunning, turnID) | |
| 482 | + | case EventTurnResult, "turn_result": | |
| 483 | + | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | |
| 484 | + | a.setResult(result) | |
| 485 | + | if result.Status == ResultFailed { | |
| 486 | + | a.setTurnState(TurnFailed, result.TurnID) | |
| 487 | + | } else if result.Status == ResultCanceled { | |
| 488 | + | a.setTurnState(TurnCanceled, result.TurnID) | |
| 489 | + | } else { | |
| 490 | + | a.setTurnState(TurnSucceeded, result.TurnID) | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | case EventAgentIdle, "agent_idle": | |
| 494 | + | a.setTurnState(TurnIdle, ev.TurnID) | |
| 495 | + | case EventAgentExited, "agent_exited": | |
| 496 | + | terminal = true | |
| 497 | + | a.mu.Lock() | |
| 498 | + | a.status = StatusDone | |
| 499 | + | a.activity = "done (offline)" | |
| 500 | + | a.mu.Unlock() | |
| 259 | 501 | case "assistant_message", "user_message": | |
| 260 | 502 | var text []string | |
| 261 | 503 | if c, ok := ev.Data["content"].([]any); ok { | |
| @@ -337,6 +579,27 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 337 | 579 | // Killed). Resuming a still-running agent returns an error so two | |
| 338 | 580 | // runners don't race for the same session. | |
| 339 | 581 | func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |
| 582 | + | return f.resume(ctx, id, true) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // ResumeSession continues the existing child session without replaying the | |
| 586 | + | // original task. It is the explicit canonical spelling for Resume. | |
| 587 | + | func (f *Swarm) ResumeSession(ctx context.Context, id string) (*Agent, error) { | |
| 588 | + | return f.resume(ctx, id, true) | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | // RestartTask intentionally runs the stored original task again in a fresh | |
| 592 | + | // worker attempt. Callers should use ResumeSession for normal recovery. | |
| 593 | + | func (f *Swarm) RestartTask(ctx context.Context, id string) (*Agent, error) { | |
| 594 | + | return f.resume(ctx, id, false) | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | func (f *Swarm) resume(ctx context.Context, id string, resuming bool) (*Agent, error) { | |
| 598 | + | f.operationMu.Lock() | |
| 599 | + | defer f.operationMu.Unlock() | |
| 600 | + | if ctx == nil { | |
| 601 | + | ctx = context.Background() | |
| 602 | + | } | |
| 340 | 603 | existing := f.Get(id) | |
| 341 | 604 | if existing == nil { | |
| 342 | 605 | return nil, fmt.Errorf("swarm: no such agent %q", id) | |
| @@ -347,6 +610,30 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 347 | 610 | if st == StatusRunning || st == StatusPending { | |
| 348 | 611 | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | |
| 349 | 612 | } | |
| 613 | + | select { | |
| 614 | + | case <-existing.done: | |
| 615 | + | default: | |
| 616 | + | return nil, fmt.Errorf("swarm: agent %s is still finalizing; wait before resuming", existing.ID) | |
| 617 | + | } | |
| 618 | + | // A reloaded worker may still be alive even though this supervisor has no | |
| 619 | + | // in-process Runner (the old host can disappear after the child daemon | |
| 620 | + | // starts). Fence resume before touching the shared session/worktree: a | |
| 621 | + | // second worker must never own the same inbox and files concurrently. | |
| 622 | + | if inboxLive(existing.InboxPath) { | |
| 623 | + | return nil, fmt.Errorf("swarm: agent %s still has a live worker; send shutdown or stop it first", existing.ID) | |
| 624 | + | } | |
| 625 | + | stateDir := existing.stateDirectory(f.cfg.Root) | |
| 626 | + | lease, err := acquireAgentLease(stateDir) | |
| 627 | + | if err != nil { | |
| 628 | + | return nil, fmt.Errorf("subagents resume lease: %w", err) | |
| 629 | + | } | |
| 630 | + | leaseOwned := true | |
| 631 | + | defer func() { | |
| 632 | + | if leaseOwned { | |
| 633 | + | _ = lease.Close() | |
| 634 | + | } | |
| 635 | + | }() | |
| 636 | + | existingSnapshot := existing.Snapshot() | |
| 350 | 637 | ||
| 351 | 638 | // Rebuild from the meta record so we don't carry stale runner | |
| 352 | 639 | // state from a previous incarnation. The inbox is transient and | |
| @@ -359,34 +646,121 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 359 | 646 | fastMode := existing.FastMode | |
| 360 | 647 | m := agentMeta{ | |
| 361 | 648 | ID: existing.ID, Task: existing.Task, | |
| 362 | - | Dir: existing.Dir, Started: existing.Started, | |
| 649 | + | OriginalTask: existing.OriginalTask, | |
| 650 | + | Dir: existing.Dir, RepositoryRoot: existing.RepositoryRoot, Started: existing.Started, | |
| 651 | + | ParentID: existing.ParentID, BatchID: existing.BatchID, RootSessionID: existing.RootSessionID, | |
| 363 | 652 | Model: existing.Model, Provider: existing.Provider, | |
| 653 | + | BaseURL: existing.BaseURL, InsecureTLS: existing.InsecureTLS, | |
| 364 | 654 | Reasoning: existing.Reasoning, FastMode: &fastMode, | |
| 365 | - | Subagent: existing.Subagent, | |
| 655 | + | Subagent: existing.Subagent, | |
| 656 | + | WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath, | |
| 657 | + | WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture, | |
| 658 | + | MaxTurns: existing.MaxTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...), | |
| 659 | + | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | |
| 366 | 660 | SessionID: existing.SessionID, | |
| 367 | 661 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 368 | 662 | SessionPath: existing.SessionPath, | |
| 369 | 663 | } | |
| 370 | 664 | ||
| 665 | + | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | |
| 666 | + | m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | |
| 667 | + | } | |
| 668 | + | if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil { | |
| 669 | + | return nil, err | |
| 670 | + | } | |
| 671 | + | workspaceMode := m.WorkspaceMode | |
| 672 | + | if workspaceMode == "" { | |
| 673 | + | workspaceMode = WorkspaceShared | |
| 674 | + | } | |
| 675 | + | if workspaceMode != WorkspaceShared && workspaceMode != WorkspaceWorktree { | |
| 676 | + | return nil, fmt.Errorf("subagents: unknown workspace mode %q", workspaceMode) | |
| 677 | + | } | |
| 678 | + | if m.WorkspaceCapture != "" && m.WorkspaceCapture != CapturePatch && m.WorkspaceCapture != CaptureDiff { | |
| 679 | + | return nil, fmt.Errorf("subagents: unknown workspace capture mode %q", m.WorkspaceCapture) | |
| 680 | + | } | |
| 681 | + | f.mu.Lock() | |
| 682 | + | allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...) | |
| 683 | + | f.mu.Unlock() | |
| 684 | + | repositoryRoot := firstNonEmpty(m.RepositoryRoot, m.Dir, f.cfg.RepoRoot) | |
| 685 | + | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | |
| 686 | + | return nil, err | |
| 687 | + | } | |
| 688 | + | now := f.cfg.Now() | |
| 689 | + | maxTurns := m.MaxTurns | |
| 690 | + | if maxTurns <= 0 { | |
| 691 | + | maxTurns = f.cfg.Policy.MaxTurns | |
| 692 | + | } | |
| 693 | + | existingWorkspacePath := "" | |
| 694 | + | if workspaceMode == WorkspaceWorktree { | |
| 695 | + | existingWorkspacePath = m.WorkspacePath | |
| 696 | + | } | |
| 697 | + | workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{ | |
| 698 | + | Mode: workspaceMode, | |
| 699 | + | RepositoryRoot: repositoryRoot, | |
| 700 | + | StateDir: stateDir, | |
| 701 | + | AgentID: existing.ID, | |
| 702 | + | Base: m.WorkspaceBase, | |
| 703 | + | Capture: m.WorkspaceCapture, | |
| 704 | + | AllowedRoots: allowedRoots, | |
| 705 | + | ExistingPath: existingWorkspacePath, | |
| 706 | + | }) | |
| 707 | + | if err != nil { | |
| 708 | + | return nil, err | |
| 709 | + | } | |
| 710 | + | m.Dir = workspace.Dir() | |
| 711 | + | m.RepositoryRoot = workspace.RepositoryRoot() | |
| 712 | + | m.WorkspacePath = workspace.Dir() | |
| 713 | + | m.WorkspaceMode = workspace.Mode() | |
| 714 | + | timeout := m.Timeout | |
| 715 | + | if timeout <= 0 { | |
| 716 | + | timeout = f.cfg.Policy.DefaultTimeout | |
| 717 | + | } | |
| 718 | + | runCtx, cancel := f.workerContext(timeout) | |
| 371 | 719 | a := &Agent{ | |
| 372 | - | ID: m.ID, | |
| 373 | - | Task: m.Task, | |
| 374 | - | Dir: m.Dir, | |
| 375 | - | Started: m.Started, | |
| 376 | - | Model: m.Model, | |
| 377 | - | Provider: m.Provider, | |
| 378 | - | Reasoning: m.Reasoning, | |
| 379 | - | FastMode: fastMode, | |
| 380 | - | Subagent: m.Subagent, | |
| 381 | - | SessionID: m.SessionID, | |
| 382 | - | InboxPath: m.InboxPath, | |
| 383 | - | EventLogPath: m.EventLogPath, | |
| 384 | - | SessionPath: m.SessionPath, | |
| 385 | - | Resuming: true, | |
| 386 | - | inbox: NewInbox(m.InboxPath), | |
| 387 | - | status: StatusPending, | |
| 388 | - | activity: "resuming", | |
| 389 | - | done: make(chan struct{}), | |
| 720 | + | ID: m.ID, | |
| 721 | + | Task: m.Task, | |
| 722 | + | OriginalTask: firstNonEmpty(m.OriginalTask, m.Task), | |
| 723 | + | Dir: m.Dir, | |
| 724 | + | RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot), | |
| 725 | + | Started: m.Started, | |
| 726 | + | ParentID: m.ParentID, | |
| 727 | + | BatchID: m.BatchID, | |
| 728 | + | RootSessionID: m.RootSessionID, | |
| 729 | + | Model: m.Model, | |
| 730 | + | Provider: m.Provider, | |
| 731 | + | BaseURL: m.BaseURL, | |
| 732 | + | InsecureTLS: m.InsecureTLS, | |
| 733 | + | Reasoning: m.Reasoning, | |
| 734 | + | FastMode: fastMode, | |
| 735 | + | Subagent: m.Subagent, | |
| 736 | + | SessionID: m.SessionID, | |
| 737 | + | WorkspaceMode: m.WorkspaceMode, | |
| 738 | + | WorkspacePath: m.WorkspacePath, | |
| 739 | + | WorkspaceBase: m.WorkspaceBase, | |
| 740 | + | WorkspaceCapture: m.WorkspaceCapture, | |
| 741 | + | MaxTurns: maxTurns, | |
| 742 | + | Timeout: timeout, | |
| 743 | + | Attempt: m.Attempt, | |
| 744 | + | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | |
| 745 | + | Tools: append([]string(nil), m.Tools...), | |
| 746 | + | InboxPath: m.InboxPath, | |
| 747 | + | EventLogPath: m.EventLogPath, | |
| 748 | + | SessionPath: m.SessionPath, | |
| 749 | + | Resuming: resuming, | |
| 750 | + | inbox: NewInbox(m.InboxPath), | |
| 751 | + | status: StatusPending, | |
| 752 | + | activity: "resuming", | |
| 753 | + | processState: ProcessPending, | |
| 754 | + | turnState: TurnQueued, | |
| 755 | + | updatedAt: now, | |
| 756 | + | lastActivity: now, | |
| 757 | + | currentTurnID: m.CurrentTurnID, | |
| 758 | + | stateDir: stateDir, | |
| 759 | + | lease: lease, | |
| 760 | + | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 761 | + | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 762 | + | done: make(chan struct{}), | |
| 763 | + | turnResults: make(chan *TurnResult, 16), | |
| 390 | 764 | } | |
| 391 | 765 | // Carry the previous transcript forward so the dashboard doesn't | |
| 392 | 766 | // flash empty between resume and the first new event. | |
| @@ -394,11 +768,22 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 394 | 768 | if len(prev) > 0 { | |
| 395 | 769 | a.appendTranscript(strings.Join(prev, "\n")) | |
| 396 | 770 | } | |
| 397 | - | a.ctx, a.cancel = context.WithCancel(ctx) | |
| 771 | + | a.ctx, a.cancel = runCtx, cancel | |
| 772 | + | a.persistFn = f.persistAgent | |
| 773 | + | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |
| 774 | + | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |
| 398 | 775 | a.runner = f.cfg.NewRunner(a) | |
| 776 | + | if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil { | |
| 777 | + | if a.cancel != nil { | |
| 778 | + | a.cancel() | |
| 779 | + | } | |
| 780 | + | _ = workspace.Cleanup(context.Background()) | |
| 781 | + | return nil, fmt.Errorf("subagents resume metadata: %w", err) | |
| 782 | + | } | |
| 399 | 783 | ||
| 400 | 784 | f.mu.Lock() | |
| 401 | 785 | f.agents[a.ID] = a | |
| 786 | + | f.queue = append(f.queue, a) | |
| 402 | 787 | // Keep the agent's slot in f.order; replacing in-place avoids | |
| 403 | 788 | // reshuffling the dashboard's row ordering on resume. | |
| 404 | 789 | found := false | |
| @@ -412,13 +797,12 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 412 | 797 | f.order = append(f.order, a.ID) | |
| 413 | 798 | } | |
| 414 | 799 | f.mu.Unlock() | |
| 800 | + | leaseOwned = false | |
| 415 | 801 | ||
| 416 | - | // Refresh the meta.json so any new path defaults (e.g. socket | |
| 417 | - | // path moved into /tmp because the root got renamed) get | |
| 418 | - | // persisted. Best-effort; resume still works if the disk is | |
| 419 | - | // read-only because everything the runner needs is in-memory. | |
| 420 | - | _ = writeAgentMeta(f.agentStateDir(a.ID), a) | |
| 421 | - | ||
| 422 | - | go f.run(a) | |
| 802 | + | // Refreshing metadata happened before queue admission, so a later | |
| 803 | + | // supervisor can reconstruct this resumed attempt even if the runner | |
| 804 | + | // has not reached its first event yet. | |
| 805 | + | f.armQueueTimeout(a) | |
| 806 | + | f.schedule() | |
| 423 | 807 | return a, nil | |
| 424 | 808 | } | |
M packages/agent/swarm/persist_test.go +288 -3
| @@ -133,6 +133,81 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||
| 133 | 133 | } | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | + | func TestResumePreservesPerAgentTimeout(t *testing.T) { | |
| 137 | + | root := t.TempDir() | |
| 138 | + | wantTimeout := 37 * time.Minute | |
| 139 | + | first := New(Config{ | |
| 140 | + | Root: root, RepoRoot: root, | |
| 141 | + | NewRunner: func(a *Agent) Runner { | |
| 142 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 143 | + | <-ctx.Done() | |
| 144 | + | return ctx.Err() | |
| 145 | + | }) | |
| 146 | + | }, | |
| 147 | + | }) | |
| 148 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "timeout test", Timeout: wantTimeout}) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatal(err) | |
| 151 | + | } | |
| 152 | + | first.StopAll() | |
| 153 | + | a.Wait() | |
| 154 | + | ||
| 155 | + | var observed time.Duration | |
| 156 | + | second := New(Config{ | |
| 157 | + | Root: root, RepoRoot: root, | |
| 158 | + | Policy: SubagentPolicy{DefaultTimeout: time.Hour}, | |
| 159 | + | NewRunner: func(a *Agent) Runner { | |
| 160 | + | observed = a.Timeout | |
| 161 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 162 | + | <-ctx.Done() | |
| 163 | + | return ctx.Err() | |
| 164 | + | }) | |
| 165 | + | }, | |
| 166 | + | }) | |
| 167 | + | if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 { | |
| 168 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 169 | + | } | |
| 170 | + | resumed, err := second.ResumeSession(context.Background(), a.ID) | |
| 171 | + | if err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | if resumed.Timeout != wantTimeout || observed != wantTimeout { | |
| 175 | + | t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout) | |
| 176 | + | } | |
| 177 | + | second.StopAll() | |
| 178 | + | resumed.Wait() | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) { | |
| 182 | + | root := t.TempDir() | |
| 183 | + | outside := filepath.Join(t.TempDir(), "outside.json") | |
| 184 | + | if err := os.WriteFile(outside, []byte("keep"), 0o600); err != nil { | |
| 185 | + | t.Fatal(err) | |
| 186 | + | } | |
| 187 | + | stateDir := filepath.Join(root, "agents", "agent-1") | |
| 188 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | meta := agentMeta{ | |
| 192 | + | ID: "agent-1", Task: "malicious", Dir: root, | |
| 193 | + | EventLogPath: outside, SessionPath: outside, InboxPath: outside, | |
| 194 | + | } | |
| 195 | + | data, err := json.Marshal(meta) | |
| 196 | + | if err != nil { | |
| 197 | + | t.Fatal(err) | |
| 198 | + | } | |
| 199 | + | if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), data, 0o600); err != nil { | |
| 200 | + | t.Fatal(err) | |
| 201 | + | } | |
| 202 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 203 | + | if loaded, errs := f.Reload(); loaded != 0 || len(errs) == 0 { | |
| 204 | + | t.Fatalf("malicious metadata reload loaded=%d errs=%v", loaded, errs) | |
| 205 | + | } | |
| 206 | + | if got, err := os.ReadFile(outside); err != nil || string(got) != "keep" { | |
| 207 | + | t.Fatalf("outside path was touched: data=%q err=%v", got, err) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 136 | 211 | // TestReloadIsIdempotent calls Reload twice in a row and asserts the | |
| 137 | 212 | // second call neither duplicates rows nor errors. | |
| 138 | 213 | func TestReloadIsIdempotent(t *testing.T) { | |
| @@ -308,6 +383,7 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||
| 308 | 383 | if a.Status() != StatusKilled { | |
| 309 | 384 | t.Fatalf("post-stop status = %q; want killed", a.Status()) | |
| 310 | 385 | } | |
| 386 | + | previousAttempt := a.Snapshot().Attempt | |
| 311 | 387 | ||
| 312 | 388 | // Now resume. | |
| 313 | 389 | a2, err := f.Resume(context.Background(), a.ID) | |
| @@ -326,6 +402,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||
| 326 | 402 | if a2.InboxPath != originalInbox { | |
| 327 | 403 | t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox) | |
| 328 | 404 | } | |
| 405 | + | if got := a2.Snapshot().Attempt; got != previousAttempt+1 { | |
| 406 | + | t.Errorf("resume attempt = %d; want %d", got, previousAttempt+1) | |
| 407 | + | } | |
| 329 | 408 | // Two runner invocations: spawn + resume. | |
| 330 | 409 | deadline = time.Now().Add(time.Second) | |
| 331 | 410 | for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) { | |
| @@ -525,6 +604,180 @@ func TestResumeAfterReload(t *testing.T) { | |||
| 525 | 604 | t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript()) | |
| 526 | 605 | } | |
| 527 | 606 | ||
| 607 | + | func TestStopMustFinishBeforeResumeOrRemove(t *testing.T) { | |
| 608 | + | root := t.TempDir() | |
| 609 | + | started := make(chan struct{}) | |
| 610 | + | release := make(chan struct{}) | |
| 611 | + | f := New(Config{ | |
| 612 | + | Root: root, RepoRoot: root, | |
| 613 | + | NewRunner: func(*Agent) Runner { | |
| 614 | + | return RunnerFunc(func(context.Context, Sink) error { | |
| 615 | + | close(started) | |
| 616 | + | <-release | |
| 617 | + | return nil | |
| 618 | + | }) | |
| 619 | + | }, | |
| 620 | + | }) | |
| 621 | + | a, err := f.Spawn(context.Background(), "slow stop") | |
| 622 | + | if err != nil { | |
| 623 | + | t.Fatal(err) | |
| 624 | + | } | |
| 625 | + | <-started | |
| 626 | + | if err := f.Stop(a.ID); err != nil { | |
| 627 | + | t.Fatal(err) | |
| 628 | + | } | |
| 629 | + | if _, err := f.Resume(context.Background(), a.ID); err == nil || !strings.Contains(err.Error(), "finalizing") { | |
| 630 | + | t.Fatalf("immediate resume error = %v, want finalizing", err) | |
| 631 | + | } | |
| 632 | + | if err := f.Remove(a.ID); err == nil || !strings.Contains(err.Error(), "finalizing") { | |
| 633 | + | t.Fatalf("immediate remove error = %v, want finalizing", err) | |
| 634 | + | } | |
| 635 | + | close(release) | |
| 636 | + | a.Wait() | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | func TestResumeFencesLiveReloadedWorker(t *testing.T) { | |
| 640 | + | root := t.TempDir() | |
| 641 | + | first := New(Config{ | |
| 642 | + | Root: root, RepoRoot: root, | |
| 643 | + | NewRunner: func(*Agent) Runner { | |
| 644 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 645 | + | }, | |
| 646 | + | }) | |
| 647 | + | a, err := first.Spawn(context.Background(), "live worker") | |
| 648 | + | if err != nil { | |
| 649 | + | t.Fatal(err) | |
| 650 | + | } | |
| 651 | + | a.Wait() | |
| 652 | + | ln, err := Listen(a.InboxPath) | |
| 653 | + | if err != nil { | |
| 654 | + | t.Fatal(err) | |
| 655 | + | } | |
| 656 | + | defer ln.Close() | |
| 657 | + | ||
| 658 | + | second := New(Config{Root: root, RepoRoot: root}) | |
| 659 | + | if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 { | |
| 660 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 661 | + | } | |
| 662 | + | if got := second.Get(a.ID).ProcessState(); got != ProcessAlive { | |
| 663 | + | t.Fatalf("reloaded process state = %s, want alive", got) | |
| 664 | + | } | |
| 665 | + | if _, err := second.Resume(context.Background(), a.ID); err == nil || !strings.Contains(err.Error(), "live worker") { | |
| 666 | + | t.Fatalf("resume error = %v, want live-worker fence", err) | |
| 667 | + | } | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | func TestSwarmProviderSettingsPersistAcrossReloadAndResume(t *testing.T) { | |
| 671 | + | root := t.TempDir() | |
| 672 | + | newSwarm := func(baseURL string, insecureTLS bool) *Swarm { | |
| 673 | + | return New(Config{ | |
| 674 | + | Root: root, | |
| 675 | + | RepoRoot: root, | |
| 676 | + | BaseURL: baseURL, | |
| 677 | + | InsecureTLS: insecureTLS, | |
| 678 | + | NewRunner: func(a *Agent) Runner { | |
| 679 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 680 | + | <-ctx.Done() | |
| 681 | + | return ctx.Err() | |
| 682 | + | }) | |
| 683 | + | }, | |
| 684 | + | }) | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | first := newSwarm("https://parent.example.test/v1", true) | |
| 688 | + | a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | |
| 689 | + | if err != nil { | |
| 690 | + | t.Fatalf("spawn: %v", err) | |
| 691 | + | } | |
| 692 | + | if a.BaseURL != "https://parent.example.test/v1" || !a.InsecureTLS { | |
| 693 | + | t.Fatalf("spawned settings = (%q, %v), want inherited config", a.BaseURL, a.InsecureTLS) | |
| 694 | + | } | |
| 695 | + | if snap := a.Snapshot(); snap.BaseURL != a.BaseURL || snap.InsecureTLS != a.InsecureTLS { | |
| 696 | + | t.Fatalf("snapshot settings = (%q, %v), want (%q, %v)", snap.BaseURL, snap.InsecureTLS, a.BaseURL, a.InsecureTLS) | |
| 697 | + | } | |
| 698 | + | if err := first.Stop(a.ID); err != nil { | |
| 699 | + | t.Fatalf("stop: %v", err) | |
| 700 | + | } | |
| 701 | + | a.Wait() | |
| 702 | + | ||
| 703 | + | metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json")) | |
| 704 | + | if err != nil { | |
| 705 | + | t.Fatalf("read meta.json: %v", err) | |
| 706 | + | } | |
| 707 | + | var meta agentMeta | |
| 708 | + | if err := json.Unmarshal(metaBytes, &meta); err != nil { | |
| 709 | + | t.Fatalf("decode meta.json: %v", err) | |
| 710 | + | } | |
| 711 | + | if meta.BaseURL != a.BaseURL || !meta.InsecureTLS { | |
| 712 | + | t.Fatalf("metadata settings = (%q, %v), want (%q, %v)", meta.BaseURL, meta.InsecureTLS, a.BaseURL, a.InsecureTLS) | |
| 713 | + | } | |
| 714 | + | if strings.Contains(string(metaBytes), "api_key") || strings.Contains(string(metaBytes), "parent-secret") { | |
| 715 | + | t.Fatalf("metadata contains credential material: %s", metaBytes) | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | second := newSwarm("https://different.example.test/v1", false) | |
| 719 | + | if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 { | |
| 720 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 721 | + | } | |
| 722 | + | reloaded := second.Get(a.ID) | |
| 723 | + | if reloaded == nil || reloaded.BaseURL != a.BaseURL || !reloaded.InsecureTLS { | |
| 724 | + | t.Fatalf("reloaded settings = (%q, %v), want (%q, %v)", reloaded.BaseURL, reloaded.InsecureTLS, a.BaseURL, a.InsecureTLS) | |
| 725 | + | } | |
| 726 | + | resumed, err := second.Resume(context.Background(), a.ID) | |
| 727 | + | if err != nil { | |
| 728 | + | t.Fatalf("resume: %v", err) | |
| 729 | + | } | |
| 730 | + | if resumed.BaseURL != a.BaseURL || !resumed.InsecureTLS { | |
| 731 | + | t.Fatalf("resumed settings = (%q, %v), want (%q, %v)", resumed.BaseURL, resumed.InsecureTLS, a.BaseURL, a.InsecureTLS) | |
| 732 | + | } | |
| 733 | + | if err := second.Stop(resumed.ID); err != nil { | |
| 734 | + | t.Fatalf("stop resumed: %v", err) | |
| 735 | + | } | |
| 736 | + | resumed.Wait() | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | func TestExplicitChildProviderDoesNotInheritHostEndpoint(t *testing.T) { | |
| 740 | + | f := New(Config{ | |
| 741 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), Provider: "anthropic", | |
| 742 | + | BaseURL: "https://host.example.test/v1", InsecureTLS: true, | |
| 743 | + | NewRunner: func(*Agent) Runner { | |
| 744 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 745 | + | }, | |
| 746 | + | }) | |
| 747 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x", Provider: "openai"}) | |
| 748 | + | if err != nil { | |
| 749 | + | t.Fatal(err) | |
| 750 | + | } | |
| 751 | + | a.Wait() | |
| 752 | + | if a.BaseURL != "" || a.InsecureTLS { | |
| 753 | + | t.Fatalf("explicit provider inherited host settings: (%q, %v)", a.BaseURL, a.InsecureTLS) | |
| 754 | + | } | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | func TestSpawnRequestProviderSettingsOverrideConfig(t *testing.T) { | |
| 758 | + | root := t.TempDir() | |
| 759 | + | f := New(Config{ | |
| 760 | + | Root: root, | |
| 761 | + | RepoRoot: root, | |
| 762 | + | BaseURL: "https://config.example.test/v1", | |
| 763 | + | NewRunner: func(*Agent) Runner { | |
| 764 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 765 | + | }, | |
| 766 | + | }) | |
| 767 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | |
| 768 | + | Task: "x", | |
| 769 | + | BaseURL: "https://request.example.test/v1", | |
| 770 | + | InsecureTLS: true, | |
| 771 | + | }) | |
| 772 | + | if err != nil { | |
| 773 | + | t.Fatalf("spawn: %v", err) | |
| 774 | + | } | |
| 775 | + | a.Wait() | |
| 776 | + | if a.BaseURL != "https://request.example.test/v1" || !a.InsecureTLS { | |
| 777 | + | t.Fatalf("spawned settings = (%q, %v), want request override", a.BaseURL, a.InsecureTLS) | |
| 778 | + | } | |
| 779 | + | } | |
| 780 | + | ||
| 528 | 781 | // TestSpawnReqPersistsModel confirms that the per-agent model | |
| 529 | 782 | // override is captured at Spawn time, surfaced via Snapshot, and | |
| 530 | 783 | // written to meta.json so a later Reload + Resume reuses it. | |
| @@ -727,6 +980,37 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||
| 727 | 980 | } | |
| 728 | 981 | } | |
| 729 | 982 | ||
| 983 | + | func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) { | |
| 984 | + | root := t.TempDir() | |
| 985 | + | first := New(Config{ | |
| 986 | + | Root: root, RepoRoot: root, | |
| 987 | + | NewRunner: func(*Agent) Runner { | |
| 988 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 989 | + | }, | |
| 990 | + | }) | |
| 991 | + | a1, err := first.Spawn(context.Background(), "first") | |
| 992 | + | if err != nil { | |
| 993 | + | t.Fatal(err) | |
| 994 | + | } | |
| 995 | + | a2, err := first.Spawn(context.Background(), "second") | |
| 996 | + | if err != nil { | |
| 997 | + | t.Fatal(err) | |
| 998 | + | } | |
| 999 | + | a1.Wait() | |
| 1000 | + | a2.Wait() | |
| 1001 | + | ||
| 1002 | + | second := New(Config{Root: root, RepoRoot: root}) | |
| 1003 | + | if loaded, errs := second.Reload(); loaded != 2 || len(errs) != 0 { | |
| 1004 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 1005 | + | } | |
| 1006 | + | if err := second.Remove(a1.ID); err != nil { | |
| 1007 | + | t.Fatal(err) | |
| 1008 | + | } | |
| 1009 | + | if _, err := os.Stat(filepath.Join(root, "agents", a2.ID, "meta.json")); err != nil { | |
| 1010 | + | t.Fatalf("removing reloaded sibling deleted surviving metadata: %v", err) | |
| 1011 | + | } | |
| 1012 | + | } | |
| 1013 | + | ||
| 730 | 1014 | // TestActiveSessionScopesSnapshotAll proves the session filter the | |
| 731 | 1015 | // user asked for: a single Swarm with agents spawned under two | |
| 732 | 1016 | // different active sessions only surfaces the agents matching the | |
| @@ -826,10 +1110,11 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||
| 826 | 1110 | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | |
| 827 | 1111 | } | |
| 828 | 1112 | ||
| 829 | - | // Scope to a different session: agent must be hidden. | |
| 1113 | + | // A terminal persisted agent remains visible from another host session | |
| 1114 | + | // so a normal restart does not hide durable recovery state. | |
| 830 | 1115 | g.SetActiveSession("sess-other") | |
| 831 | - | if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 { | |
| 832 | - | t.Errorf("scoped to other session, ids = %v; want []", got) | |
| 1116 | + | if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID { | |
| 1117 | + | t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID) | |
| 833 | 1118 | } | |
| 834 | 1119 | } | |
| 835 | 1120 | ||
A packages/agent/swarm/process_unix.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | //go:build !windows | |
| 2 | + | ||
| 3 | + | package swarm | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os/exec" | |
| 7 | + | "syscall" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // configureWorkerProcess makes cancellation target the worker's process | |
| 11 | + | // group rather than only the worker itself. CommandContext calls cmd.Cancel | |
| 12 | + | // from its context watcher, so descendants that inherited the worker's group | |
| 13 | + | // are terminated before execRunner waits for their output pipes to close. | |
| 14 | + | func configureWorkerProcess(cmd *exec.Cmd) { | |
| 15 | + | setProcessGroup(cmd) | |
| 16 | + | cmd.Cancel = func() error { | |
| 17 | + | return killProcessGroup(cmd) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // setProcessGroup puts a worker in a process group of its own. This keeps a | |
| 22 | + | // canceled worker from sharing a group with the supervising zot process. | |
| 23 | + | func setProcessGroup(cmd *exec.Cmd) { | |
| 24 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // killProcessGroup forcefully terminates the worker group. Cancellation is a | |
| 28 | + | // forceful path: the supervisor already offers a graceful shutdown through | |
| 29 | + | // the worker inbox, while SIGKILL ensures a descendant cannot keep the | |
| 30 | + | // worker's stdout or stderr pipe open indefinitely. | |
| 31 | + | func killProcessGroup(cmd *exec.Cmd) error { | |
| 32 | + | if cmd == nil || cmd.Process == nil { | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | return forceKillProcess(cmd.Process.Pid) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func forceKillProcess(pid int) error { | |
| 39 | + | if pid <= 0 { | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | // With Setpgid, the worker's PID is also the process-group ID. A negative | |
| 43 | + | // PID addresses the whole group rather than just the worker process. | |
| 44 | + | err := syscall.Kill(-pid, syscall.SIGKILL) | |
| 45 | + | if err == syscall.ESRCH { | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | return err | |
| 49 | + | } | |
A packages/agent/swarm/process_unix_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | //go:build !windows | |
| 2 | + | ||
| 3 | + | package swarm | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "syscall" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestWorkerCancellationKillsProcessGroup(t *testing.T) { | |
| 19 | + | shell, err := exec.LookPath("sh") | |
| 20 | + | if err != nil { | |
| 21 | + | t.Skip("sh is not available") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | pidPath := t.TempDir() + "/child.pid" | |
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 26 | + | defer cancel() | |
| 27 | + | ||
| 28 | + | cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath)) | |
| 29 | + | configureWorkerProcess(cmd) | |
| 30 | + | if err := cmd.Start(); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | childPID := waitForWorkerChildPID(t, pidPath) | |
| 35 | + | cancel() | |
| 36 | + | ||
| 37 | + | waitDone := make(chan error, 1) | |
| 38 | + | go func() { waitDone <- cmd.Wait() }() | |
| 39 | + | select { | |
| 40 | + | case <-time.After(2 * time.Second): | |
| 41 | + | t.Fatal("worker did not exit after context cancellation") | |
| 42 | + | case err := <-waitDone: | |
| 43 | + | if err == nil { | |
| 44 | + | t.Fatal("worker exited successfully after context cancellation") | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | deadline := time.Now().Add(2 * time.Second) | |
| 49 | + | for time.Now().Before(deadline) && processExists(childPID) { | |
| 50 | + | time.Sleep(10 * time.Millisecond) | |
| 51 | + | } | |
| 52 | + | if processExists(childPID) { | |
| 53 | + | t.Fatalf("worker descendant pid %d survived process-group cancellation", childPID) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func waitForWorkerChildPID(t *testing.T, path string) int { | |
| 58 | + | t.Helper() | |
| 59 | + | deadline := time.Now().Add(2 * time.Second) | |
| 60 | + | for time.Now().Before(deadline) { | |
| 61 | + | data, err := os.ReadFile(path) | |
| 62 | + | if err == nil { | |
| 63 | + | pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data))) | |
| 64 | + | if parseErr == nil && pid > 0 { | |
| 65 | + | return pid | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | time.Sleep(10 * time.Millisecond) | |
| 69 | + | } | |
| 70 | + | t.Fatalf("timed out waiting for child pid in %s", path) | |
| 71 | + | return 0 | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func processExists(pid int) bool { | |
| 75 | + | err := syscall.Kill(pid, 0) | |
| 76 | + | return err == nil || errors.Is(err, syscall.EPERM) | |
| 77 | + | } | |
A packages/agent/swarm/process_windows.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package swarm | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os/exec" | |
| 7 | + | "strconv" | |
| 8 | + | "syscall" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // configureWorkerProcess makes CommandContext cancellation terminate the | |
| 12 | + | // worker and its descendants on Windows. Windows does not provide a Unix-like | |
| 13 | + | // process-group kill operation, so killProcessGroup uses taskkill's tree mode. | |
| 14 | + | func configureWorkerProcess(cmd *exec.Cmd) { | |
| 15 | + | setProcessGroup(cmd) | |
| 16 | + | cmd.Cancel = func() error { | |
| 17 | + | return killProcessGroup(cmd) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // setProcessGroup gives the worker its own Windows process group. The group | |
| 22 | + | // flag prevents it from being attached to the supervisor's console group; | |
| 23 | + | // taskkill /T below is still needed because Windows group creation alone does | |
| 24 | + | // not terminate descendants. | |
| 25 | + | func setProcessGroup(cmd *exec.Cmd) { | |
| 26 | + | cmd.SysProcAttr = &syscall.SysProcAttr{ | |
| 27 | + | CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // killProcessGroup terminates the worker process tree. taskkill is part of | |
| 32 | + | // Windows and /T follows the parent-child relationships to include workers | |
| 33 | + | // that spawned their own subprocesses. | |
| 34 | + | func killProcessGroup(cmd *exec.Cmd) error { | |
| 35 | + | if cmd == nil || cmd.Process == nil { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | return forceKillProcess(cmd.Process.Pid) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func forceKillProcess(pid int) error { | |
| 42 | + | if pid <= 0 { | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | return exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").Run() | |
| 46 | + | } | |
A packages/agent/swarm/protocol.go +915 -0
| @@ -0,0 +1,915 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/google/uuid" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // ProtocolVersion is the version of the supervisor/worker JSONL protocol. | |
| 17 | + | // Version zero is reserved for the legacy flat event and text-inbox formats; | |
| 18 | + | // it is not a versioned wire format. | |
| 19 | + | const ProtocolVersion = 1 | |
| 20 | + | ||
| 21 | + | // LegacyProtocolVersion identifies a message read from the pre-versioned | |
| 22 | + | // protocol. Legacy messages are accepted by the readers but new messages are | |
| 23 | + | // always emitted at ProtocolVersion. | |
| 24 | + | const LegacyProtocolVersion = 0 | |
| 25 | + | ||
| 26 | + | // Canonical parent-to-worker command names. | |
| 27 | + | const ( | |
| 28 | + | CommandAgentPing = "agent.ping" | |
| 29 | + | CommandTurnStart = "turn.start" | |
| 30 | + | CommandTurnCancel = "turn.cancel" | |
| 31 | + | CommandAgentShutdown = "agent.shutdown" | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // Canonical worker-to-parent event names. | |
| 35 | + | const ( | |
| 36 | + | EventAgentReady = "agent.ready" | |
| 37 | + | EventAgentHeartbeat = "agent.heartbeat" | |
| 38 | + | EventTurnStarted = "turn.started" | |
| 39 | + | EventTurnProgress = "turn.progress" | |
| 40 | + | EventToolStarted = "tool.started" | |
| 41 | + | EventToolFinished = "tool.finished" | |
| 42 | + | EventMessageDelta = "message.delta" | |
| 43 | + | EventTurnResult = "turn.result" | |
| 44 | + | EventTurnFailed = "turn.failed" | |
| 45 | + | EventAgentIdle = "agent.idle" | |
| 46 | + | EventAgentExited = "agent.exited" | |
| 47 | + | ) | |
| 48 | + | ||
| 49 | + | // Short aliases are kept alongside the direction-prefixed names because the | |
| 50 | + | // latter read well at call sites while the former are convenient in switches. | |
| 51 | + | const ( | |
| 52 | + | AgentPing = CommandAgentPing | |
| 53 | + | TurnStart = CommandTurnStart | |
| 54 | + | TurnCancel = CommandTurnCancel | |
| 55 | + | AgentShutdown = CommandAgentShutdown | |
| 56 | + | ||
| 57 | + | AgentReady = EventAgentReady | |
| 58 | + | AgentHeartbeat = EventAgentHeartbeat | |
| 59 | + | TurnStarted = EventTurnStarted | |
| 60 | + | TurnProgress = EventTurnProgress | |
| 61 | + | ToolStarted = EventToolStarted | |
| 62 | + | ToolFinished = EventToolFinished | |
| 63 | + | MessageDelta = EventMessageDelta | |
| 64 | + | AgentIdle = EventAgentIdle | |
| 65 | + | AgentExited = EventAgentExited | |
| 66 | + | ) | |
| 67 | + | ||
| 68 | + | var ( | |
| 69 | + | // ErrEmptyProtocolLine is returned for a blank JSONL line or command. | |
| 70 | + | ErrEmptyProtocolLine = errors.New("swarm protocol: empty line") | |
| 71 | + | // ErrNotCommand is returned when ParseCommand is given a JSON event. | |
| 72 | + | ErrNotCommand = errors.New("swarm protocol: message is not a command") | |
| 73 | + | // ErrNotEvent is returned when ParseEvent is given a JSON command. | |
| 74 | + | ErrNotEvent = errors.New("swarm protocol: message is not an event") | |
| 75 | + | // ErrUnsupportedProtocolVersion is returned by Validate for a version | |
| 76 | + | // this implementation cannot interpret. | |
| 77 | + | ErrUnsupportedProtocolVersion = errors.New("swarm protocol: unsupported version") | |
| 78 | + | ) | |
| 79 | + | ||
| 80 | + | var commandNames = map[string]struct{}{ | |
| 81 | + | CommandAgentPing: {}, | |
| 82 | + | CommandTurnStart: {}, | |
| 83 | + | CommandTurnCancel: {}, | |
| 84 | + | CommandAgentShutdown: {}, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | var eventNames = map[string]struct{}{ | |
| 88 | + | EventAgentReady: {}, | |
| 89 | + | EventAgentHeartbeat: {}, | |
| 90 | + | EventTurnStarted: {}, | |
| 91 | + | EventTurnProgress: {}, | |
| 92 | + | EventToolStarted: {}, | |
| 93 | + | EventToolFinished: {}, | |
| 94 | + | EventMessageDelta: {}, | |
| 95 | + | EventTurnResult: {}, | |
| 96 | + | EventTurnFailed: {}, | |
| 97 | + | EventAgentIdle: {}, | |
| 98 | + | EventAgentExited: {}, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // IsCommandName reports whether name is one of the commands defined by this | |
| 102 | + | // version of the protocol. Readers intentionally do not use this as a parse | |
| 103 | + | // gate: a newer command can still be decoded and forwarded by an older | |
| 104 | + | // supervisor. | |
| 105 | + | func IsCommandName(name string) bool { | |
| 106 | + | _, ok := commandNames[name] | |
| 107 | + | return ok | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // IsEventName reports whether name is one of the events defined by this | |
| 111 | + | // version of the protocol. Unknown event names are valid protocol messages. | |
| 112 | + | func IsEventName(name string) bool { | |
| 113 | + | _, ok := eventNames[name] | |
| 114 | + | return ok | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // CommandName and EventName make APIs which accept only a direction more | |
| 118 | + | // self-documenting without preventing unknown names from being preserved. | |
| 119 | + | type CommandName string | |
| 120 | + | type EventName string | |
| 121 | + | ||
| 122 | + | // MessageID returns a fresh opaque id suitable for correlating a command and | |
| 123 | + | // its result. UUIDs are used rather than a counter so ids remain unique when | |
| 124 | + | // several supervisors or workers are running at once. | |
| 125 | + | func NewMessageID() string { return uuid.NewString() } | |
| 126 | + | ||
| 127 | + | // IsMessageID reports whether id has the UUID form emitted by NewMessageID. | |
| 128 | + | // Message ids are otherwise opaque on the wire, so readers do not require | |
| 129 | + | // this form when accepting messages from a newer implementation. | |
| 130 | + | func IsMessageID(id string) bool { | |
| 131 | + | if id == "" { | |
| 132 | + | return false | |
| 133 | + | } | |
| 134 | + | _, err := uuid.Parse(id) | |
| 135 | + | return err == nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Envelope is one versioned JSONL command or event. Payload is deliberately a | |
| 139 | + | // RawMessage: an old supervisor can retain an event it does not know about, | |
| 140 | + | // including fields added inside that event's payload, and write it back | |
| 141 | + | // without decoding it into a lossy map. | |
| 142 | + | // | |
| 143 | + | // The JSON representation of a current message is: | |
| 144 | + | // | |
| 145 | + | // {"version":1,"type":"...","message_id":"...","agent_id":"...", | |
| 146 | + | // "turn_id":"...","timestamp":"...","payload":{...}} | |
| 147 | + | // | |
| 148 | + | // TurnID is omitted when it is empty. Unknown top-level fields are retained in | |
| 149 | + | // Unknown. A flat legacy event is represented by Legacy=true and uses `time` | |
| 150 | + | // instead of `timestamp` when marshaled. | |
| 151 | + | type Envelope struct { | |
| 152 | + | Version int `json:"version"` | |
| 153 | + | Type string `json:"type"` | |
| 154 | + | MessageID string `json:"message_id,omitempty"` | |
| 155 | + | AgentID string `json:"agent_id,omitempty"` | |
| 156 | + | TurnID string `json:"turn_id,omitempty"` | |
| 157 | + | Timestamp time.Time `json:"timestamp"` | |
| 158 | + | Payload json.RawMessage `json:"payload"` | |
| 159 | + | ||
| 160 | + | // Unknown contains unrecognised top-level fields from a versioned | |
| 161 | + | // envelope. Payload itself retains unrecognised payload fields. | |
| 162 | + | Unknown map[string]json.RawMessage `json:"-"` | |
| 163 | + | ||
| 164 | + | // Legacy is true only for an old flat event read by ParseEnvelope. It | |
| 165 | + | // causes MarshalJSON to retain the old flat shape. It is not set for a | |
| 166 | + | // text inbox command, since text commands are normalised to a new | |
| 167 | + | // versioned command as soon as they are read. | |
| 168 | + | Legacy bool `json:"-"` | |
| 169 | + | ||
| 170 | + | // LegacyCommand and LegacyText record that this envelope came from a | |
| 171 | + | // text inbox command. They are useful to bridges that need to echo the | |
| 172 | + | // original command, and do not affect versioned JSON marshaling. | |
| 173 | + | LegacyCommand bool `json:"-"` | |
| 174 | + | LegacyText string `json:"-"` | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Command and ProtocolEvent are aliases rather than separate structs so a | |
| 178 | + | // caller can pass either kind to the same JSONL transport without a wrapper. | |
| 179 | + | // Direction checks are available through IsCommand and IsEvent. | |
| 180 | + | type Command = Envelope | |
| 181 | + | type ProtocolEvent = Envelope | |
| 182 | + | type EventEnvelope = Envelope | |
| 183 | + | type CommandEnvelope = Envelope | |
| 184 | + | ||
| 185 | + | // NewEnvelope creates a current-protocol envelope and assigns a message id | |
| 186 | + | // and timestamp. payload may be any JSON-marshalable value. A nil payload is | |
| 187 | + | // encoded as an empty object, which keeps command and event payloads uniform. | |
| 188 | + | // | |
| 189 | + | // For a marshalable payload this helper cannot fail. Call | |
| 190 | + | // NewEnvelopeWithPayload when construction errors must be reported instead of | |
| 191 | + | // being represented as a null payload. | |
| 192 | + | func NewEnvelope(typ, agentID, turnID string, payload ...any) Envelope { | |
| 193 | + | e, err := NewEnvelopeWithPayload(typ, agentID, turnID, firstPayload(payload)) | |
| 194 | + | if err == nil { | |
| 195 | + | return e | |
| 196 | + | } | |
| 197 | + | // Keep the constructor useful in error-free call sites. MarshalJSON will | |
| 198 | + | // still succeed with a valid JSON null; callers which need the original | |
| 199 | + | // error should use NewEnvelopeWithPayload. Preserve the metadata even | |
| 200 | + | // when only the caller's payload was not marshalable. | |
| 201 | + | return Envelope{ | |
| 202 | + | Version: ProtocolVersion, | |
| 203 | + | Type: typ, | |
| 204 | + | MessageID: NewMessageID(), | |
| 205 | + | AgentID: agentID, | |
| 206 | + | TurnID: turnID, | |
| 207 | + | Timestamp: time.Now().UTC(), | |
| 208 | + | Payload: json.RawMessage("null"), | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // NewEnvelopeWithPayload is the checked form of NewEnvelope. | |
| 213 | + | func NewEnvelopeWithPayload(typ, agentID, turnID string, payload any) (Envelope, error) { | |
| 214 | + | if strings.TrimSpace(typ) == "" { | |
| 215 | + | return Envelope{}, errors.New("swarm protocol: empty message type") | |
| 216 | + | } | |
| 217 | + | p, err := marshalPayload(payload) | |
| 218 | + | if err != nil { | |
| 219 | + | return Envelope{}, fmt.Errorf("swarm protocol payload: %w", err) | |
| 220 | + | } | |
| 221 | + | return Envelope{ | |
| 222 | + | Version: ProtocolVersion, | |
| 223 | + | Type: typ, | |
| 224 | + | MessageID: NewMessageID(), | |
| 225 | + | AgentID: agentID, | |
| 226 | + | TurnID: turnID, | |
| 227 | + | Timestamp: time.Now().UTC(), | |
| 228 | + | Payload: p, | |
| 229 | + | }, nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // NewCommand creates a current-protocol command. Unknown command names are | |
| 233 | + | // allowed so a bridge can forward commands introduced by a newer peer. | |
| 234 | + | func NewCommand(name, agentID, turnID string, payload ...any) Envelope { | |
| 235 | + | return NewEnvelope(name, agentID, turnID, payload...) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // NewEventEnvelope creates a current-protocol event. The name is not checked | |
| 239 | + | // against EventName so unknown future events remain forward-compatible. | |
| 240 | + | func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope { | |
| 241 | + | return NewEnvelope(name, agentID, turnID, payload...) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // NewProtocolEvent is a more explicit spelling of NewEventEnvelope. NewEvent | |
| 245 | + | // is already the legacy flat-event constructor in this package. | |
| 246 | + | func NewProtocolEvent(name, agentID, turnID string, payload ...any) Envelope { | |
| 247 | + | return NewEventEnvelope(name, agentID, turnID, payload...) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // NewCommandEnvelope is an explicit alias for NewCommand. | |
| 251 | + | func NewCommandEnvelope(name, agentID, turnID string, payload ...any) Envelope { | |
| 252 | + | return NewCommand(name, agentID, turnID, payload...) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // NewEventMessage is an explicit alias for NewEventEnvelope. | |
| 256 | + | func NewEventMessage(name, agentID, turnID string, payload ...any) Envelope { | |
| 257 | + | return NewEventEnvelope(name, agentID, turnID, payload...) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func firstPayload(payload []any) any { | |
| 261 | + | if len(payload) == 0 || payload[0] == nil { | |
| 262 | + | return map[string]any{} | |
| 263 | + | } | |
| 264 | + | return payload[0] | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func marshalPayload(payload any) (json.RawMessage, error) { | |
| 268 | + | if payload == nil { | |
| 269 | + | return json.RawMessage("{}"), nil | |
| 270 | + | } | |
| 271 | + | b, err := json.Marshal(payload) | |
| 272 | + | if err != nil { | |
| 273 | + | return nil, err | |
| 274 | + | } | |
| 275 | + | return append(json.RawMessage(nil), b...), nil | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // Validate checks the required fields of a current-protocol envelope. It is | |
| 279 | + | // intentionally separate from ParseEnvelope: parsing an unknown future event | |
| 280 | + | // should succeed, while a sender can opt into strict validation before write. | |
| 281 | + | func (e Envelope) Validate() error { | |
| 282 | + | if e.Legacy { | |
| 283 | + | if e.Type == "" { | |
| 284 | + | return errors.New("swarm protocol: legacy event has no type") | |
| 285 | + | } | |
| 286 | + | return nil | |
| 287 | + | } | |
| 288 | + | if e.Version != ProtocolVersion { | |
| 289 | + | return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version) | |
| 290 | + | } | |
| 291 | + | if e.Type == "" { | |
| 292 | + | return errors.New("swarm protocol: missing type") | |
| 293 | + | } | |
| 294 | + | if e.MessageID == "" { | |
| 295 | + | return errors.New("swarm protocol: missing message_id") | |
| 296 | + | } | |
| 297 | + | if e.AgentID == "" { | |
| 298 | + | return errors.New("swarm protocol: missing agent_id") | |
| 299 | + | } | |
| 300 | + | if e.Timestamp.IsZero() { | |
| 301 | + | return errors.New("swarm protocol: missing timestamp") | |
| 302 | + | } | |
| 303 | + | if len(e.Payload) == 0 || !json.Valid(e.Payload) { | |
| 304 | + | return errors.New("swarm protocol: invalid payload") | |
| 305 | + | } | |
| 306 | + | return nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // IsCommand reports whether this envelope has a known command name. | |
| 310 | + | func (e Envelope) IsCommand() bool { return IsCommandName(e.Type) } | |
| 311 | + | ||
| 312 | + | // IsEvent reports whether this envelope has a known event name. | |
| 313 | + | func (e Envelope) IsEvent() bool { return IsEventName(e.Type) } | |
| 314 | + | ||
| 315 | + | // DecodePayload decodes the payload into dst without changing the preserved | |
| 316 | + | // raw payload. This is the preferred way for a consumer to inspect a known | |
| 317 | + | // event while still retaining it for a later forward-compatible write. | |
| 318 | + | func (e Envelope) DecodePayload(dst any) error { | |
| 319 | + | if dst == nil { | |
| 320 | + | return errors.New("swarm protocol: nil payload destination") | |
| 321 | + | } | |
| 322 | + | if len(e.Payload) == 0 { | |
| 323 | + | return errors.New("swarm protocol: missing payload") | |
| 324 | + | } | |
| 325 | + | return json.Unmarshal(e.Payload, dst) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // PayloadFields returns an object payload as raw fields. It returns an error | |
| 329 | + | // for scalar or array payloads, rather than silently discarding their shape. | |
| 330 | + | func (e Envelope) PayloadFields() (map[string]json.RawMessage, error) { | |
| 331 | + | var fields map[string]json.RawMessage | |
| 332 | + | if err := e.DecodePayload(&fields); err != nil { | |
| 333 | + | return nil, err | |
| 334 | + | } | |
| 335 | + | if fields == nil { | |
| 336 | + | return nil, errors.New("swarm protocol: payload is not an object") | |
| 337 | + | } | |
| 338 | + | return fields, nil | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // PayloadValue returns a decoded generic payload. PayloadFields is preferable | |
| 342 | + | // when the caller needs to preserve exact nested JSON values. | |
| 343 | + | func (e Envelope) PayloadValue() (any, error) { | |
| 344 | + | var value any | |
| 345 | + | if err := e.DecodePayload(&value); err != nil { | |
| 346 | + | return nil, err | |
| 347 | + | } | |
| 348 | + | return value, nil | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // SetPayload replaces the payload while keeping the envelope metadata and | |
| 352 | + | // message id intact. | |
| 353 | + | func (e *Envelope) SetPayload(payload any) error { | |
| 354 | + | if e == nil { | |
| 355 | + | return errors.New("swarm protocol: nil envelope") | |
| 356 | + | } | |
| 357 | + | p, err := marshalPayload(payload) | |
| 358 | + | if err != nil { | |
| 359 | + | return fmt.Errorf("swarm protocol payload: %w", err) | |
| 360 | + | } | |
| 361 | + | e.Payload = p | |
| 362 | + | return nil | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // ParseEnvelope parses either a versioned envelope or a legacy flat event. | |
| 366 | + | // Unknown event types and unknown payload fields are valid and retained. | |
| 367 | + | func ParseEnvelope(data []byte) (Envelope, error) { | |
| 368 | + | data = bytes.TrimSpace(data) | |
| 369 | + | if len(data) == 0 { | |
| 370 | + | return Envelope{}, ErrEmptyProtocolLine | |
| 371 | + | } | |
| 372 | + | var raw map[string]json.RawMessage | |
| 373 | + | if err := json.Unmarshal(data, &raw); err != nil { | |
| 374 | + | return Envelope{}, fmt.Errorf("swarm protocol: decode envelope: %w", err) | |
| 375 | + | } | |
| 376 | + | if raw == nil { | |
| 377 | + | return Envelope{}, errors.New("swarm protocol: envelope must be an object") | |
| 378 | + | } | |
| 379 | + | var e Envelope | |
| 380 | + | if hasNumericVersion(raw) { | |
| 381 | + | if err := unmarshalVersionedEnvelope(raw, &e); err != nil { | |
| 382 | + | return Envelope{}, err | |
| 383 | + | } | |
| 384 | + | return e, nil | |
| 385 | + | } | |
| 386 | + | if err := unmarshalLegacyEvent(raw, &e); err != nil { | |
| 387 | + | return Envelope{}, err | |
| 388 | + | } | |
| 389 | + | return e, nil | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // DecodeEnvelope is an alias for ParseEnvelope for callers using decoder | |
| 393 | + | // terminology. | |
| 394 | + | func DecodeEnvelope(data []byte) (Envelope, error) { return ParseEnvelope(data) } | |
| 395 | + | ||
| 396 | + | // ParseJSONL parses one newline-delimited JSON message. It accepts a final | |
| 397 | + | // line without a newline, as is customary for a stream after a clean EOF. | |
| 398 | + | func ParseJSONL(line []byte) (Envelope, error) { return ParseEnvelope(line) } | |
| 399 | + | ||
| 400 | + | // ParseEnvelopeLine is the string form of ParseJSONL. | |
| 401 | + | func ParseEnvelopeLine(line string) (Envelope, error) { | |
| 402 | + | return ParseEnvelope([]byte(line)) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // ParseEvent parses a versioned or legacy JSON event. A known command is | |
| 406 | + | // rejected; unknown types are accepted because an older peer cannot know | |
| 407 | + | // whether a future event name is present in its event registry. | |
| 408 | + | func ParseEvent(data []byte) (Envelope, error) { | |
| 409 | + | e, err := ParseEnvelope(data) | |
| 410 | + | if err != nil { | |
| 411 | + | return Envelope{}, err | |
| 412 | + | } | |
| 413 | + | if e.IsCommand() { | |
| 414 | + | return Envelope{}, ErrNotEvent | |
| 415 | + | } | |
| 416 | + | return e, nil | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // ParseProtocolEvent is an explicit alias for ParseEvent. | |
| 420 | + | func ParseProtocolEvent(data []byte) (Envelope, error) { return ParseEvent(data) } | |
| 421 | + | ||
| 422 | + | // ParseEventLine parses one JSONL event line. | |
| 423 | + | func ParseEventLine(line string) (Envelope, error) { return ParseEvent([]byte(line)) } | |
| 424 | + | ||
| 425 | + | // ParseCommand parses either a versioned JSON command or one of the legacy | |
| 426 | + | // text inbox commands: `user <prompt>`, `cancel`, and `shutdown`. The text | |
| 427 | + | // form is normalised to a versioned envelope with a fresh message id. | |
| 428 | + | func ParseCommand(line string) (Envelope, error) { | |
| 429 | + | raw := strings.TrimRight(line, "\r\n") | |
| 430 | + | if strings.TrimSpace(raw) == "" { | |
| 431 | + | return Envelope{}, ErrEmptyProtocolLine | |
| 432 | + | } | |
| 433 | + | if strings.HasPrefix(strings.TrimSpace(raw), "{") { | |
| 434 | + | e, err := ParseEnvelope([]byte(raw)) | |
| 435 | + | if err != nil { | |
| 436 | + | return Envelope{}, err | |
| 437 | + | } | |
| 438 | + | if IsEventName(e.Type) { | |
| 439 | + | return Envelope{}, ErrNotCommand | |
| 440 | + | } | |
| 441 | + | return e, nil | |
| 442 | + | } | |
| 443 | + | return ParseLegacyCommand(raw) | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | // ParseCommandLine is an alias for ParseCommand. | |
| 447 | + | func ParseCommandLine(line string) (Envelope, error) { return ParseCommand(line) } | |
| 448 | + | ||
| 449 | + | // ParseLegacyCommand converts the old text inbox command syntax to a current | |
| 450 | + | // envelope. The old syntax had no agent or turn id, so those fields remain | |
| 451 | + | // empty until the owning supervisor supplies them. | |
| 452 | + | func ParseLegacyCommand(line string) (Envelope, error) { | |
| 453 | + | if strings.TrimSpace(line) == "" { | |
| 454 | + | return Envelope{}, ErrEmptyProtocolLine | |
| 455 | + | } | |
| 456 | + | var e Envelope | |
| 457 | + | switch { | |
| 458 | + | case strings.HasPrefix(line, "user "): | |
| 459 | + | e = NewCommand(CommandTurnStart, "", "", TurnStartPayload{Prompt: strings.TrimPrefix(line, "user ")}) | |
| 460 | + | case strings.TrimSpace(line) == "cancel": | |
| 461 | + | e = NewCommand(CommandTurnCancel, "", "", TurnCancelPayload{}) | |
| 462 | + | case strings.TrimSpace(line) == "shutdown": | |
| 463 | + | e = NewCommand(CommandAgentShutdown, "", "", AgentShutdownPayload{}) | |
| 464 | + | case strings.TrimSpace(line) == "ping": | |
| 465 | + | e = NewCommand(CommandAgentPing, "", "", AgentPingPayload{}) | |
| 466 | + | default: | |
| 467 | + | return Envelope{}, fmt.Errorf("swarm protocol: unknown legacy command %q", line) | |
| 468 | + | } | |
| 469 | + | e.LegacyCommand = true | |
| 470 | + | e.LegacyText = line | |
| 471 | + | return e, nil | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // MarshalEnvelope returns one JSON object without a trailing newline. | |
| 475 | + | func MarshalEnvelope(e Envelope) ([]byte, error) { | |
| 476 | + | return json.Marshal(e) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // MarshalJSONL returns one newline-terminated JSONL message. | |
| 480 | + | func MarshalJSONL(e Envelope) ([]byte, error) { | |
| 481 | + | b, err := MarshalEnvelope(e) | |
| 482 | + | if err != nil { | |
| 483 | + | return nil, err | |
| 484 | + | } | |
| 485 | + | return append(b, '\n'), nil | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // EncodeEnvelope is the stream-friendly spelling of MarshalJSONL. | |
| 489 | + | func EncodeEnvelope(e Envelope) ([]byte, error) { return MarshalJSONL(e) } | |
| 490 | + | ||
| 491 | + | // WriteEnvelope writes one complete newline-terminated JSONL message. | |
| 492 | + | func WriteEnvelope(w io.Writer, e Envelope) error { | |
| 493 | + | if w == nil { | |
| 494 | + | return errors.New("swarm protocol: nil writer") | |
| 495 | + | } | |
| 496 | + | b, err := MarshalJSONL(e) | |
| 497 | + | if err != nil { | |
| 498 | + | return err | |
| 499 | + | } | |
| 500 | + | n, err := w.Write(b) | |
| 501 | + | if err == nil && n != len(b) { | |
| 502 | + | return io.ErrShortWrite | |
| 503 | + | } | |
| 504 | + | return err | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | // ReadEnvelope reads exactly one JSONL message from a buffered reader. A | |
| 508 | + | // caller reading many messages should reuse the same bufio.Reader. | |
| 509 | + | func ReadEnvelope(r *bufio.Reader) (Envelope, error) { | |
| 510 | + | if r == nil { | |
| 511 | + | return Envelope{}, errors.New("swarm protocol: nil reader") | |
| 512 | + | } | |
| 513 | + | line, err := r.ReadBytes('\n') | |
| 514 | + | if len(bytes.TrimSpace(line)) == 0 && err == io.EOF { | |
| 515 | + | return Envelope{}, io.EOF | |
| 516 | + | } | |
| 517 | + | if len(line) != 0 { | |
| 518 | + | parsed, parseErr := ParseJSONL(line) | |
| 519 | + | if parseErr != nil { | |
| 520 | + | return Envelope{}, parseErr | |
| 521 | + | } | |
| 522 | + | return parsed, nil | |
| 523 | + | } | |
| 524 | + | return Envelope{}, err | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | // MarshalLegacyCommand converts a normalised command back to the text inbox | |
| 528 | + | // syntax. It is intended for compatibility bridges, not for new transports. | |
| 529 | + | func MarshalLegacyCommand(e Envelope) (string, error) { | |
| 530 | + | if !e.IsCommand() { | |
| 531 | + | return "", ErrNotCommand | |
| 532 | + | } | |
| 533 | + | if e.LegacyCommand && e.LegacyText != "" { | |
| 534 | + | return e.LegacyText, nil | |
| 535 | + | } | |
| 536 | + | fields, err := e.PayloadFields() | |
| 537 | + | if err != nil { | |
| 538 | + | return "", err | |
| 539 | + | } | |
| 540 | + | switch e.Type { | |
| 541 | + | case CommandTurnStart: | |
| 542 | + | var p TurnStartPayload | |
| 543 | + | if err := e.DecodePayload(&p); err != nil { | |
| 544 | + | return "", err | |
| 545 | + | } | |
| 546 | + | return "user " + p.Prompt, nil | |
| 547 | + | case CommandTurnCancel: | |
| 548 | + | return "cancel", nil | |
| 549 | + | case CommandAgentShutdown: | |
| 550 | + | return "shutdown", nil | |
| 551 | + | case CommandAgentPing: | |
| 552 | + | return "ping", nil | |
| 553 | + | default: | |
| 554 | + | _ = fields // retain the object check for useful errors above | |
| 555 | + | return "", fmt.Errorf("swarm protocol: no legacy spelling for command %q", e.Type) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // LegacyEvent converts an envelope to the package's existing flat Event | |
| 560 | + | // representation. This is the compatibility bridge for the durable event | |
| 561 | + | // reader while callers migrate to Envelope. | |
| 562 | + | func (e Envelope) LegacyEvent() (Event, error) { | |
| 563 | + | if e.IsCommand() { | |
| 564 | + | return Event{}, ErrNotEvent | |
| 565 | + | } | |
| 566 | + | fields, err := e.PayloadFields() | |
| 567 | + | if err != nil { | |
| 568 | + | return Event{}, err | |
| 569 | + | } | |
| 570 | + | data := make(map[string]any, len(fields)) | |
| 571 | + | for key, raw := range fields { | |
| 572 | + | var value any | |
| 573 | + | if err := json.Unmarshal(raw, &value); err != nil { | |
| 574 | + | return Event{}, fmt.Errorf("swarm protocol payload field %q: %w", key, err) | |
| 575 | + | } | |
| 576 | + | data[key] = value | |
| 577 | + | } | |
| 578 | + | return Event{ | |
| 579 | + | Time: e.Timestamp, Type: e.Type, Version: e.Version, | |
| 580 | + | MessageID: e.MessageID, AgentID: e.AgentID, TurnID: e.TurnID, | |
| 581 | + | Data: data, | |
| 582 | + | }, nil | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // EnvelopeFromEvent upgrades an existing flat Event to the current protocol. | |
| 586 | + | // The event's Data is copied into a raw payload and the generated envelope | |
| 587 | + | // receives fresh metadata. | |
| 588 | + | func EnvelopeFromEvent(ev Event, agentID, turnID string) Envelope { | |
| 589 | + | return NewEventEnvelope(ev.Type, agentID, turnID, ev.Data) | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // EventFromEnvelope is an alias for LegacyEvent. | |
| 593 | + | func EventFromEnvelope(e Envelope) (Event, error) { return e.LegacyEvent() } | |
| 594 | + | ||
| 595 | + | func (e Envelope) MarshalJSON() ([]byte, error) { | |
| 596 | + | if e.Legacy { | |
| 597 | + | return e.marshalLegacyJSON() | |
| 598 | + | } | |
| 599 | + | return e.marshalVersionedJSON() | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func (e Envelope) marshalVersionedJSON() ([]byte, error) { | |
| 603 | + | version := e.Version | |
| 604 | + | if version == LegacyProtocolVersion { | |
| 605 | + | version = ProtocolVersion | |
| 606 | + | } | |
| 607 | + | messageID := e.MessageID | |
| 608 | + | if messageID == "" { | |
| 609 | + | messageID = NewMessageID() | |
| 610 | + | } | |
| 611 | + | timestamp := e.Timestamp | |
| 612 | + | if timestamp.IsZero() { | |
| 613 | + | timestamp = time.Now().UTC() | |
| 614 | + | } | |
| 615 | + | payload := e.Payload | |
| 616 | + | if len(payload) == 0 { | |
| 617 | + | payload = json.RawMessage("{}") | |
| 618 | + | } | |
| 619 | + | if !json.Valid(payload) { | |
| 620 | + | return nil, errors.New("swarm protocol: invalid payload JSON") | |
| 621 | + | } | |
| 622 | + | out := make(map[string]json.RawMessage, len(e.Unknown)+7) | |
| 623 | + | for key, value := range e.Unknown { | |
| 624 | + | if isEnvelopeField(key) { | |
| 625 | + | continue | |
| 626 | + | } | |
| 627 | + | out[key] = cloneRaw(value) | |
| 628 | + | } | |
| 629 | + | putRaw(out, "version", version) | |
| 630 | + | putRaw(out, "type", e.Type) | |
| 631 | + | putRaw(out, "message_id", messageID) | |
| 632 | + | putRaw(out, "agent_id", e.AgentID) | |
| 633 | + | if e.TurnID != "" { | |
| 634 | + | putRaw(out, "turn_id", e.TurnID) | |
| 635 | + | } | |
| 636 | + | putRaw(out, "timestamp", timestamp) | |
| 637 | + | out["payload"] = cloneRaw(payload) | |
| 638 | + | return json.Marshal(out) | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func (e Envelope) marshalLegacyJSON() ([]byte, error) { | |
| 642 | + | fields, err := e.PayloadFields() | |
| 643 | + | if err != nil { | |
| 644 | + | return nil, err | |
| 645 | + | } | |
| 646 | + | out := make(map[string]json.RawMessage, len(fields)+5) | |
| 647 | + | for key, value := range fields { | |
| 648 | + | out[key] = cloneRaw(value) | |
| 649 | + | } | |
| 650 | + | for key, value := range e.Unknown { | |
| 651 | + | if isLegacyEnvelopeField(key) { | |
| 652 | + | continue | |
| 653 | + | } | |
| 654 | + | if _, exists := out[key]; !exists { | |
| 655 | + | out[key] = cloneRaw(value) | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | putRaw(out, "type", e.Type) | |
| 659 | + | if !e.Timestamp.IsZero() { | |
| 660 | + | putRaw(out, "time", e.Timestamp) | |
| 661 | + | } | |
| 662 | + | if e.MessageID != "" { | |
| 663 | + | putRaw(out, "message_id", e.MessageID) | |
| 664 | + | } | |
| 665 | + | if e.AgentID != "" { | |
| 666 | + | putRaw(out, "agent_id", e.AgentID) | |
| 667 | + | } | |
| 668 | + | if e.TurnID != "" { | |
| 669 | + | putRaw(out, "turn_id", e.TurnID) | |
| 670 | + | } | |
| 671 | + | return json.Marshal(out) | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | func (e *Envelope) UnmarshalJSON(data []byte) error { | |
| 675 | + | var raw map[string]json.RawMessage | |
| 676 | + | if err := json.Unmarshal(data, &raw); err != nil { | |
| 677 | + | return err | |
| 678 | + | } | |
| 679 | + | if raw == nil { | |
| 680 | + | return errors.New("swarm protocol: envelope must be an object") | |
| 681 | + | } | |
| 682 | + | var parsed Envelope | |
| 683 | + | if hasNumericVersion(raw) { | |
| 684 | + | if err := unmarshalVersionedEnvelope(raw, &parsed); err != nil { | |
| 685 | + | return err | |
| 686 | + | } | |
| 687 | + | } else if err := unmarshalLegacyEvent(raw, &parsed); err != nil { | |
| 688 | + | return err | |
| 689 | + | } | |
| 690 | + | *e = parsed | |
| 691 | + | return nil | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | func hasNumericVersion(raw map[string]json.RawMessage) bool { | |
| 695 | + | value, ok := raw["version"] | |
| 696 | + | if !ok { | |
| 697 | + | return false | |
| 698 | + | } | |
| 699 | + | var version int | |
| 700 | + | return json.Unmarshal(value, &version) == nil | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | func unmarshalVersionedEnvelope(raw map[string]json.RawMessage, e *Envelope) error { | |
| 704 | + | if err := decodeRequired(raw, "version", &e.Version); err != nil { | |
| 705 | + | return err | |
| 706 | + | } | |
| 707 | + | if e.Version != ProtocolVersion { | |
| 708 | + | return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version) | |
| 709 | + | } | |
| 710 | + | if err := decodeRequired(raw, "type", &e.Type); err != nil { | |
| 711 | + | return err | |
| 712 | + | } | |
| 713 | + | if err := decodeOptionalString(raw, "message_id", &e.MessageID); err != nil { | |
| 714 | + | return err | |
| 715 | + | } | |
| 716 | + | if err := decodeOptionalString(raw, "agent_id", &e.AgentID); err != nil { | |
| 717 | + | return err | |
| 718 | + | } | |
| 719 | + | if err := decodeOptionalString(raw, "turn_id", &e.TurnID); err != nil { | |
| 720 | + | return err | |
| 721 | + | } | |
| 722 | + | if timestamp, ok := raw["timestamp"]; ok { | |
| 723 | + | if err := json.Unmarshal(timestamp, &e.Timestamp); err != nil { | |
| 724 | + | return fmt.Errorf("swarm protocol: timestamp: %w", err) | |
| 725 | + | } | |
| 726 | + | } | |
| 727 | + | if payload, ok := raw["payload"]; ok { | |
| 728 | + | e.Payload = cloneRaw(payload) | |
| 729 | + | } else { | |
| 730 | + | e.Payload = json.RawMessage("{}") | |
| 731 | + | } | |
| 732 | + | e.Unknown = unknownFields(raw, map[string]struct{}{ | |
| 733 | + | "version": {}, "type": {}, "message_id": {}, "agent_id": {}, | |
| 734 | + | "turn_id": {}, "timestamp": {}, "payload": {}, | |
| 735 | + | }) | |
| 736 | + | return nil | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | func unmarshalLegacyEvent(raw map[string]json.RawMessage, e *Envelope) error { | |
| 740 | + | if err := decodeRequired(raw, "type", &e.Type); err != nil { | |
| 741 | + | return err | |
| 742 | + | } | |
| 743 | + | e.Version = LegacyProtocolVersion | |
| 744 | + | e.Legacy = true | |
| 745 | + | if err := decodeOptionalString(raw, "message_id", &e.MessageID); err != nil { | |
| 746 | + | return err | |
| 747 | + | } | |
| 748 | + | if err := decodeOptionalString(raw, "agent_id", &e.AgentID); err != nil { | |
| 749 | + | return err | |
| 750 | + | } | |
| 751 | + | if err := decodeOptionalString(raw, "turn_id", &e.TurnID); err != nil { | |
| 752 | + | return err | |
| 753 | + | } | |
| 754 | + | if value, ok := raw["time"]; ok { | |
| 755 | + | // Legacy Event historically ignored an invalid timestamp. Keep that | |
| 756 | + | // permissive behaviour while still retaining all other fields. | |
| 757 | + | _ = json.Unmarshal(value, &e.Timestamp) | |
| 758 | + | } | |
| 759 | + | payload := make(map[string]json.RawMessage) | |
| 760 | + | for key, value := range raw { | |
| 761 | + | switch key { | |
| 762 | + | case "type", "time", "message_id", "agent_id", "turn_id": | |
| 763 | + | continue | |
| 764 | + | } | |
| 765 | + | payload[key] = cloneRaw(value) | |
| 766 | + | } | |
| 767 | + | if len(payload) == 0 { | |
| 768 | + | e.Payload = json.RawMessage("{}") | |
| 769 | + | } else { | |
| 770 | + | encoded, err := json.Marshal(payload) | |
| 771 | + | if err != nil { | |
| 772 | + | return err | |
| 773 | + | } | |
| 774 | + | e.Payload = encoded | |
| 775 | + | } | |
| 776 | + | return nil | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | func decodeRequired(raw map[string]json.RawMessage, name string, dst any) error { | |
| 780 | + | value, ok := raw[name] | |
| 781 | + | if !ok { | |
| 782 | + | return fmt.Errorf("swarm protocol: missing %s", name) | |
| 783 | + | } | |
| 784 | + | if err := json.Unmarshal(value, dst); err != nil { | |
| 785 | + | return fmt.Errorf("swarm protocol: %s: %w", name, err) | |
| 786 | + | } | |
| 787 | + | if name == "type" { | |
| 788 | + | if text, ok := dst.(*string); ok && strings.TrimSpace(*text) == "" { | |
| 789 | + | return errors.New("swarm protocol: empty message type") | |
| 790 | + | } | |
| 791 | + | } | |
| 792 | + | return nil | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | func decodeOptionalString(raw map[string]json.RawMessage, name string, dst *string) error { | |
| 796 | + | value, ok := raw[name] | |
| 797 | + | if !ok { | |
| 798 | + | return nil | |
| 799 | + | } | |
| 800 | + | if err := json.Unmarshal(value, dst); err != nil { | |
| 801 | + | return fmt.Errorf("swarm protocol: %s: %w", name, err) | |
| 802 | + | } | |
| 803 | + | return nil | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | func unknownFields(raw map[string]json.RawMessage, known map[string]struct{}) map[string]json.RawMessage { | |
| 807 | + | var out map[string]json.RawMessage | |
| 808 | + | for key, value := range raw { | |
| 809 | + | if _, ok := known[key]; ok { | |
| 810 | + | continue | |
| 811 | + | } | |
| 812 | + | if out == nil { | |
| 813 | + | out = make(map[string]json.RawMessage) | |
| 814 | + | } | |
| 815 | + | out[key] = cloneRaw(value) | |
| 816 | + | } | |
| 817 | + | return out | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | func isEnvelopeField(key string) bool { | |
| 821 | + | switch key { | |
| 822 | + | case "version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload": | |
| 823 | + | return true | |
| 824 | + | default: | |
| 825 | + | return false | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | func isLegacyEnvelopeField(key string) bool { | |
| 830 | + | switch key { | |
| 831 | + | case "type", "time", "message_id", "agent_id", "turn_id", "version", "timestamp", "payload": | |
| 832 | + | return true | |
| 833 | + | default: | |
| 834 | + | return false | |
| 835 | + | } | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | func putRaw(out map[string]json.RawMessage, key string, value any) { | |
| 839 | + | b, err := json.Marshal(value) | |
| 840 | + | if err == nil { | |
| 841 | + | out[key] = b | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | func cloneRaw(value json.RawMessage) json.RawMessage { | |
| 846 | + | return append(json.RawMessage(nil), value...) | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | // The following small payload types document the stable fields of the | |
| 850 | + | // protocol without forcing a receiver to use them. Receivers can always use | |
| 851 | + | // Envelope.Payload to retain fields added in later versions. | |
| 852 | + | type AgentPingPayload struct{} | |
| 853 | + | type AgentShutdownPayload struct{} | |
| 854 | + | type AgentReadyPayload struct { | |
| 855 | + | // Version is the worker/application version used by the legacy ready | |
| 856 | + | // event. WorkerVersion is the unambiguous spelling for new producers. | |
| 857 | + | Version string `json:"version,omitempty"` | |
| 858 | + | WorkerVersion string `json:"worker_version,omitempty"` | |
| 859 | + | CWD string `json:"cwd,omitempty"` | |
| 860 | + | Model string `json:"model,omitempty"` | |
| 861 | + | Provider string `json:"provider,omitempty"` | |
| 862 | + | } | |
| 863 | + | type AgentHeartbeatPayload struct { | |
| 864 | + | Activity string `json:"activity,omitempty"` | |
| 865 | + | } | |
| 866 | + | type TurnStartPayload struct { | |
| 867 | + | Prompt string `json:"prompt"` | |
| 868 | + | } | |
| 869 | + | type TurnCancelPayload struct { | |
| 870 | + | Reason string `json:"reason,omitempty"` | |
| 871 | + | } | |
| 872 | + | type TurnProgressPayload struct { | |
| 873 | + | Text string `json:"text,omitempty"` | |
| 874 | + | Percent float64 `json:"percent,omitempty"` | |
| 875 | + | CurrentTool string `json:"current_tool,omitempty"` | |
| 876 | + | } | |
| 877 | + | type ToolStartedPayload struct { | |
| 878 | + | ToolID string `json:"tool_id,omitempty"` | |
| 879 | + | Name string `json:"name"` | |
| 880 | + | } | |
| 881 | + | type ToolFinishedPayload struct { | |
| 882 | + | ToolID string `json:"tool_id,omitempty"` | |
| 883 | + | Name string `json:"name,omitempty"` | |
| 884 | + | Error string `json:"error,omitempty"` | |
| 885 | + | } | |
| 886 | + | type MessageDeltaPayload struct { | |
| 887 | + | Text string `json:"text,omitempty"` | |
| 888 | + | } | |
| 889 | + | type ArtifactReference struct { | |
| 890 | + | Name string `json:"name,omitempty"` | |
| 891 | + | Ref string `json:"ref"` | |
| 892 | + | Mime string `json:"mime,omitempty"` | |
| 893 | + | Size int64 `json:"size,omitempty"` | |
| 894 | + | } | |
| 895 | + | type ProtocolError struct { | |
| 896 | + | Code string `json:"code,omitempty"` | |
| 897 | + | Message string `json:"message,omitempty"` | |
| 898 | + | } | |
| 899 | + | type TurnResultPayload struct { | |
| 900 | + | Status string `json:"status"` | |
| 901 | + | Summary string `json:"summary,omitempty"` | |
| 902 | + | Output string `json:"output,omitempty"` | |
| 903 | + | Structured json.RawMessage `json:"structured,omitempty"` | |
| 904 | + | Artifacts []ArtifactReference `json:"artifacts,omitempty"` | |
| 905 | + | ChangedFiles []string `json:"changed_files,omitempty"` | |
| 906 | + | Usage map[string]any `json:"usage,omitempty"` | |
| 907 | + | Error *ProtocolError `json:"error"` | |
| 908 | + | } | |
| 909 | + | type TurnFailedPayload struct { | |
| 910 | + | Error *ProtocolError `json:"error,omitempty"` | |
| 911 | + | } | |
| 912 | + | type AgentExitedPayload struct { | |
| 913 | + | Code int `json:"code,omitempty"` | |
| 914 | + | Reason string `json:"reason,omitempty"` | |
| 915 | + | } | |
A packages/agent/swarm/protocol_test.go +332 -0
| @@ -0,0 +1,332 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestProtocolEnvelopeRoundTrip(t *testing.T) { | |
| 14 | + | e := NewEventEnvelope(EventTurnResult, "agent-123", "turn-4", map[string]any{ | |
| 15 | + | "status": "succeeded", | |
| 16 | + | "output": "done", | |
| 17 | + | }) | |
| 18 | + | if e.Version != ProtocolVersion { | |
| 19 | + | t.Fatalf("version = %d, want %d", e.Version, ProtocolVersion) | |
| 20 | + | } | |
| 21 | + | if e.MessageID == "" || !IsMessageID(e.MessageID) { | |
| 22 | + | t.Fatalf("message id = %q, want a UUID", e.MessageID) | |
| 23 | + | } | |
| 24 | + | if e.Timestamp.IsZero() { | |
| 25 | + | t.Fatal("constructor did not set timestamp") | |
| 26 | + | } | |
| 27 | + | if err := e.Validate(); err != nil { | |
| 28 | + | t.Fatalf("Validate: %v", err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | line, err := MarshalJSONL(e) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatalf("MarshalJSONL: %v", err) | |
| 34 | + | } | |
| 35 | + | if !bytes.HasSuffix(line, []byte{'\n'}) { | |
| 36 | + | t.Fatal("JSONL message is not newline terminated") | |
| 37 | + | } | |
| 38 | + | var wire map[string]any | |
| 39 | + | if err := json.Unmarshal(bytes.TrimSpace(line), &wire); err != nil { | |
| 40 | + | t.Fatalf("wire JSON: %v", err) | |
| 41 | + | } | |
| 42 | + | for _, field := range []string{"version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload"} { | |
| 43 | + | if _, ok := wire[field]; !ok { | |
| 44 | + | t.Errorf("wire message lacks %q", field) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | got, err := ParseJSONL(line) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatalf("ParseJSONL: %v", err) | |
| 51 | + | } | |
| 52 | + | if got.Version != e.Version || got.Type != e.Type || got.MessageID != e.MessageID || | |
| 53 | + | got.AgentID != e.AgentID || got.TurnID != e.TurnID { | |
| 54 | + | t.Fatalf("metadata changed on round trip: got %+v, want %+v", got, e) | |
| 55 | + | } | |
| 56 | + | var result TurnResultPayload | |
| 57 | + | if err := got.DecodePayload(&result); err != nil { | |
| 58 | + | t.Fatalf("DecodePayload: %v", err) | |
| 59 | + | } | |
| 60 | + | if result.Status != "succeeded" || result.Output != "done" { | |
| 61 | + | t.Fatalf("decoded payload = %+v", result) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestProtocolPreservesUnknownEventAndPayload(t *testing.T) { | |
| 66 | + | input := `{"version":1,"type":"future.event","message_id":"msg-future","agent_id":"agent-1","turn_id":"turn-1","timestamp":"2026-08-04T12:00:00Z","future_top":{"enabled":true},"payload":{"known":"yes","future":{"nested":[1,2,{"x":true}]}}}` | |
| 67 | + | e, err := ParseEnvelope([]byte(input)) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatalf("ParseEnvelope: %v", err) | |
| 70 | + | } | |
| 71 | + | if e.Type != "future.event" { | |
| 72 | + | t.Fatalf("type = %q", e.Type) | |
| 73 | + | } | |
| 74 | + | if e.IsEvent() { | |
| 75 | + | t.Fatal("unknown event was reported as a known event") | |
| 76 | + | } | |
| 77 | + | if _, ok := e.Unknown["future_top"]; !ok { | |
| 78 | + | t.Fatalf("unknown top-level field was not retained: %#v", e.Unknown) | |
| 79 | + | } | |
| 80 | + | fields, err := e.PayloadFields() | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatalf("PayloadFields: %v", err) | |
| 83 | + | } | |
| 84 | + | if string(fields["future"]) != `{"nested":[1,2,{"x":true}]}` { | |
| 85 | + | t.Fatalf("unknown payload field changed: %s", fields["future"]) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | encoded, err := MarshalEnvelope(e) | |
| 89 | + | if err != nil { | |
| 90 | + | t.Fatalf("MarshalEnvelope: %v", err) | |
| 91 | + | } | |
| 92 | + | var roundTripped map[string]any | |
| 93 | + | if err := json.Unmarshal(encoded, &roundTripped); err != nil { | |
| 94 | + | t.Fatalf("round-trip JSON: %v", err) | |
| 95 | + | } | |
| 96 | + | if _, ok := roundTripped["future_top"]; !ok { | |
| 97 | + | t.Fatal("unknown top-level field was dropped") | |
| 98 | + | } | |
| 99 | + | payload, ok := roundTripped["payload"].(map[string]any) | |
| 100 | + | if !ok || payload["future"] == nil { | |
| 101 | + | t.Fatalf("unknown payload was dropped: %#v", roundTripped["payload"]) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestProtocolReadsAndRetainsLegacyFlatEvent(t *testing.T) { | |
| 106 | + | // Legacy agent.ready events historically used a string `version` | |
| 107 | + | // payload field, so its presence must not be mistaken for the numeric | |
| 108 | + | // protocol version field. | |
| 109 | + | input := `{"type":"assistant_message","time":"2026-08-04T12:00:00.123Z","version":"zot-test","content":[{"type":"text","text":"hello"}],"new_field":{"v":7}}` | |
| 110 | + | e, err := ParseEvent([]byte(input)) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatalf("ParseEvent: %v", err) | |
| 113 | + | } | |
| 114 | + | if !e.Legacy || e.Version != LegacyProtocolVersion { | |
| 115 | + | t.Fatalf("legacy marker/version = %v/%d", e.Legacy, e.Version) | |
| 116 | + | } | |
| 117 | + | if e.Type != "assistant_message" || e.Timestamp.IsZero() { | |
| 118 | + | t.Fatalf("legacy metadata = %+v", e) | |
| 119 | + | } | |
| 120 | + | legacy, err := e.LegacyEvent() | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatalf("LegacyEvent: %v", err) | |
| 123 | + | } | |
| 124 | + | if legacy.Type != e.Type || legacy.Data["version"] != "zot-test" || legacy.Data["new_field"] == nil { | |
| 125 | + | t.Fatalf("legacy conversion lost data: %+v", legacy) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | encoded, err := MarshalEnvelope(e) | |
| 129 | + | if err != nil { | |
| 130 | + | t.Fatalf("MarshalEnvelope legacy: %v", err) | |
| 131 | + | } | |
| 132 | + | var got map[string]any | |
| 133 | + | if err := json.Unmarshal(encoded, &got); err != nil { | |
| 134 | + | t.Fatalf("legacy output JSON: %v", err) | |
| 135 | + | } | |
| 136 | + | if got["version"] != "zot-test" { | |
| 137 | + | t.Fatalf("legacy payload version was changed: %#v", got["version"]) | |
| 138 | + | } | |
| 139 | + | if got["type"] != "assistant_message" || got["new_field"] == nil { | |
| 140 | + | t.Fatalf("legacy fields not retained: %#v", got) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestProtocolParsesLegacyTextCommands(t *testing.T) { | |
| 145 | + | tests := []struct { | |
| 146 | + | line string | |
| 147 | + | kind string | |
| 148 | + | want string | |
| 149 | + | }{ | |
| 150 | + | {"user inspect the parser ", CommandTurnStart, "inspect the parser "}, | |
| 151 | + | {"cancel", CommandTurnCancel, ""}, | |
| 152 | + | {"shutdown", CommandAgentShutdown, ""}, | |
| 153 | + | } | |
| 154 | + | for _, tt := range tests { | |
| 155 | + | t.Run(tt.line, func(t *testing.T) { | |
| 156 | + | e, err := ParseCommand(tt.line) | |
| 157 | + | if err != nil { | |
| 158 | + | t.Fatalf("ParseCommand: %v", err) | |
| 159 | + | } | |
| 160 | + | if e.Type != tt.kind || e.Version != ProtocolVersion || e.MessageID == "" { | |
| 161 | + | t.Fatalf("command metadata = %+v", e) | |
| 162 | + | } | |
| 163 | + | if !e.LegacyCommand || e.LegacyText != tt.line { | |
| 164 | + | t.Fatalf("legacy source was not retained: %+v", e) | |
| 165 | + | } | |
| 166 | + | if tt.want != "" { | |
| 167 | + | var payload TurnStartPayload | |
| 168 | + | if err := e.DecodePayload(&payload); err != nil { | |
| 169 | + | t.Fatalf("DecodePayload: %v", err) | |
| 170 | + | } | |
| 171 | + | if payload.Prompt != tt.want { | |
| 172 | + | t.Fatalf("prompt = %q, want %q", payload.Prompt, tt.want) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | legacy, err := MarshalLegacyCommand(e) | |
| 176 | + | if err != nil || legacy != tt.line { | |
| 177 | + | t.Fatalf("MarshalLegacyCommand = %q, %v; want %q", legacy, err, tt.line) | |
| 178 | + | } | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) { | |
| 184 | + | command := NewCommand(CommandTurnStart, "agent-1", "turn-2", TurnStartPayload{Prompt: "hello"}) | |
| 185 | + | line, err := MarshalJSONL(command) | |
| 186 | + | if err != nil { | |
| 187 | + | t.Fatalf("MarshalJSONL: %v", err) | |
| 188 | + | } | |
| 189 | + | got, err := ParseCommandLine(string(line)) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Fatalf("ParseCommandLine: %v", err) | |
| 192 | + | } | |
| 193 | + | if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart { | |
| 194 | + | t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type) | |
| 195 | + | } | |
| 196 | + | if _, err := ParseEvent(line); !errors.Is(err, ErrNotEvent) { | |
| 197 | + | t.Fatalf("ParseEvent(command) = %v, want ErrNotEvent", err) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | event := NewEventEnvelope(EventAgentHeartbeat, "agent-1", "", AgentHeartbeatPayload{Activity: "idle"}) | |
| 201 | + | eventLine, err := MarshalJSONL(event) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatalf("Marshal event: %v", err) | |
| 204 | + | } | |
| 205 | + | if _, err := ParseCommandLine(string(eventLine)); !errors.Is(err, ErrNotCommand) { | |
| 206 | + | t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestProtocolStreamReadWrite(t *testing.T) { | |
| 211 | + | first := NewCommand(CommandAgentPing, "agent-1", "", nil) | |
| 212 | + | second := NewEventEnvelope(EventAgentReady, "agent-1", "", nil) | |
| 213 | + | var stream bytes.Buffer | |
| 214 | + | if err := WriteEnvelope(&stream, first); err != nil { | |
| 215 | + | t.Fatalf("write first: %v", err) | |
| 216 | + | } | |
| 217 | + | if err := WriteEnvelope(&stream, second); err != nil { | |
| 218 | + | t.Fatalf("write second: %v", err) | |
| 219 | + | } | |
| 220 | + | reader := bufio.NewReader(&stream) | |
| 221 | + | gotFirst, err := ReadEnvelope(reader) | |
| 222 | + | if err != nil { | |
| 223 | + | t.Fatalf("read first: %v", err) | |
| 224 | + | } | |
| 225 | + | gotSecond, err := ReadEnvelope(reader) | |
| 226 | + | if err != nil { | |
| 227 | + | t.Fatalf("read second: %v", err) | |
| 228 | + | } | |
| 229 | + | if gotFirst.Type != CommandAgentPing || gotSecond.Type != EventAgentReady { | |
| 230 | + | t.Fatalf("stream types = %q, %q", gotFirst.Type, gotSecond.Type) | |
| 231 | + | } | |
| 232 | + | if _, err := ReadEnvelope(reader); !errors.Is(err, io.EOF) { | |
| 233 | + | t.Fatalf("read after stream = %v, want EOF", err) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestProtocolMessageIDsAreUnique(t *testing.T) { | |
| 238 | + | seen := make(map[string]struct{}, 100) | |
| 239 | + | for i := 0; i < 100; i++ { | |
| 240 | + | id := NewMessageID() | |
| 241 | + | if !IsMessageID(id) { | |
| 242 | + | t.Fatalf("NewMessageID returned non-UUID %q", id) | |
| 243 | + | } | |
| 244 | + | if _, exists := seen[id]; exists { | |
| 245 | + | t.Fatalf("duplicate message id %q", id) | |
| 246 | + | } | |
| 247 | + | seen[id] = struct{}{} | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestProtocolPayloadMutationKeepsMessageID(t *testing.T) { | |
| 252 | + | e := NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "before"}) | |
| 253 | + | id := e.MessageID | |
| 254 | + | if err := e.SetPayload(TurnStartPayload{Prompt: "after"}); err != nil { | |
| 255 | + | t.Fatalf("SetPayload: %v", err) | |
| 256 | + | } | |
| 257 | + | if e.MessageID != id { | |
| 258 | + | t.Fatalf("SetPayload changed message id from %q to %q", id, e.MessageID) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestProtocolUnsupportedVersion(t *testing.T) { | |
| 263 | + | _, err := ParseEnvelope([]byte(`{"version":2,"type":"future.event","message_id":"m","agent_id":"a","timestamp":"2026-08-04T12:00:00Z","payload":{}}`)) | |
| 264 | + | if !errors.Is(err, ErrUnsupportedProtocolVersion) { | |
| 265 | + | t.Fatalf("ParseEnvelope version 2 = %v, want unsupported version", err) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestProtocolLegacyEmptyAndMalformedInputs(t *testing.T) { | |
| 270 | + | for _, input := range []string{"", " ", "not a command"} { | |
| 271 | + | if _, err := ParseCommand(input); err == nil { | |
| 272 | + | t.Errorf("ParseCommand(%q) succeeded", input) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | if _, err := ParseEnvelope([]byte(`{"payload":{}}`)); err == nil { | |
| 276 | + | t.Fatal("ParseEnvelope without type succeeded") | |
| 277 | + | } | |
| 278 | + | if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil { | |
| 279 | + | t.Fatal("ParseEnvelope malformed JSON succeeded") | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestProtocolResultShape(t *testing.T) { | |
| 284 | + | result := TurnResultPayload{ | |
| 285 | + | Status: "succeeded", | |
| 286 | + | Summary: "implemented parser", | |
| 287 | + | Output: "full output", | |
| 288 | + | Structured: json.RawMessage(`{"ok":true}`), | |
| 289 | + | Artifacts: []ArtifactReference{{Name: "patch", Ref: "subagent://a/patch"}}, | |
| 290 | + | ChangedFiles: []string{"protocol.go"}, | |
| 291 | + | Usage: map[string]any{"input_tokens": 3}, | |
| 292 | + | } | |
| 293 | + | e := NewEventEnvelope(EventTurnResult, "a", "t", result) | |
| 294 | + | var decoded TurnResultPayload | |
| 295 | + | if err := e.DecodePayload(&decoded); err != nil { | |
| 296 | + | t.Fatalf("DecodePayload: %v", err) | |
| 297 | + | } | |
| 298 | + | if decoded.Status != "succeeded" || len(decoded.Artifacts) != 1 || decoded.Artifacts[0].Ref != "subagent://a/patch" { | |
| 299 | + | t.Fatalf("decoded result = %+v", decoded) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestProtocolTimestampIsRFC3339(t *testing.T) { | |
| 304 | + | e := NewEventEnvelope(EventAgentReady, "a", "", nil) | |
| 305 | + | wire, err := MarshalEnvelope(e) | |
| 306 | + | if err != nil { | |
| 307 | + | t.Fatalf("MarshalEnvelope: %v", err) | |
| 308 | + | } | |
| 309 | + | var fields map[string]json.RawMessage | |
| 310 | + | if err := json.Unmarshal(wire, &fields); err != nil { | |
| 311 | + | t.Fatal(err) | |
| 312 | + | } | |
| 313 | + | var timestamp string | |
| 314 | + | if err := json.Unmarshal(fields["timestamp"], ×tamp); err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil { | |
| 318 | + | t.Fatalf("timestamp %q is not RFC3339Nano: %v", timestamp, err) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func TestProtocolKnownNames(t *testing.T) { | |
| 323 | + | if !IsCommandName(CommandAgentPing) || !IsCommandName(CommandTurnStart) || !IsCommandName(CommandTurnCancel) || !IsCommandName(CommandAgentShutdown) { | |
| 324 | + | t.Fatal("one or more canonical command names are not registered") | |
| 325 | + | } | |
| 326 | + | if !IsEventName(EventAgentReady) || !IsEventName(EventAgentHeartbeat) || !IsEventName(EventTurnStarted) || !IsEventName(EventTurnProgress) || !IsEventName(EventToolStarted) || !IsEventName(EventToolFinished) || !IsEventName(EventMessageDelta) || !IsEventName(EventTurnResult) || !IsEventName(EventTurnFailed) || !IsEventName(EventAgentIdle) || !IsEventName(EventAgentExited) { | |
| 327 | + | t.Fatal("one or more canonical event names are not registered") | |
| 328 | + | } | |
| 329 | + | if IsEventName("future.event") || IsCommandName("future.command") { | |
| 330 | + | t.Fatal("future names should not be reported as known names") | |
| 331 | + | } | |
| 332 | + | } | |
A packages/agent/swarm/result.go +420 -0
| @@ -0,0 +1,420 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | "unicode/utf8" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // TurnStatus is the durable outcome of one delegated turn. | |
| 17 | + | type TurnStatus string | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | ResultSucceeded TurnStatus = "succeeded" | |
| 21 | + | ResultFailed TurnStatus = "failed" | |
| 22 | + | ResultCanceled TurnStatus = "canceled" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // ArtifactRef points to a durable file without exposing the supervisor's | |
| 26 | + | // local state layout to callers. | |
| 27 | + | type ArtifactRef struct { | |
| 28 | + | Name string `json:"name,omitempty"` | |
| 29 | + | Ref string `json:"ref"` | |
| 30 | + | MediaType string `json:"media_type,omitempty"` | |
| 31 | + | Size int64 `json:"size,omitempty"` | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // ResultError is intentionally free of credentials and argv details. | |
| 35 | + | type ResultError struct { | |
| 36 | + | Code string `json:"code,omitempty"` | |
| 37 | + | Message string `json:"message,omitempty"` | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // TurnResult is the protocol-level result for a delegated turn. Output is a | |
| 41 | + | // bounded inline preview; the full session/history remains available through | |
| 42 | + | // HistoryRef and the complete JSON result through ResultRef. | |
| 43 | + | type TurnResult struct { | |
| 44 | + | Version int `json:"version"` | |
| 45 | + | AgentID string `json:"agent_id,omitempty"` | |
| 46 | + | TurnID string `json:"turn_id,omitempty"` | |
| 47 | + | Status TurnStatus `json:"status"` | |
| 48 | + | Summary string `json:"summary,omitempty"` | |
| 49 | + | Output string `json:"output,omitempty"` | |
| 50 | + | Structured json.RawMessage `json:"structured,omitempty"` | |
| 51 | + | Artifacts []ArtifactRef `json:"artifacts,omitempty"` | |
| 52 | + | ChangedFiles []string `json:"changed_files,omitempty"` | |
| 53 | + | Usage map[string]any `json:"usage,omitempty"` | |
| 54 | + | Error *ResultError `json:"error,omitempty"` | |
| 55 | + | CreatedAt time.Time `json:"created_at"` | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (r *TurnResult) Validate(maxBytes, maxLines int) error { | |
| 59 | + | if r == nil { | |
| 60 | + | return errors.New("subagents: nil turn result") | |
| 61 | + | } | |
| 62 | + | if r.Version == 0 { | |
| 63 | + | r.Version = ProtocolVersion | |
| 64 | + | } | |
| 65 | + | if r.Version != ProtocolVersion { | |
| 66 | + | return fmt.Errorf("subagents: unsupported result version %d", r.Version) | |
| 67 | + | } | |
| 68 | + | if r.Status != ResultSucceeded && r.Status != ResultFailed && r.Status != ResultCanceled { | |
| 69 | + | return fmt.Errorf("subagents: invalid result status %q", r.Status) | |
| 70 | + | } | |
| 71 | + | if r.AgentID == "" { | |
| 72 | + | return errors.New("subagents: result missing agent id") | |
| 73 | + | } | |
| 74 | + | if r.TurnID == "" { | |
| 75 | + | return errors.New("subagents: result missing turn id") | |
| 76 | + | } | |
| 77 | + | if len(r.Structured) > 0 && !json.Valid(r.Structured) { | |
| 78 | + | return errors.New("subagents: result structured payload is invalid JSON") | |
| 79 | + | } | |
| 80 | + | if maxBytes > 0 && len([]byte(r.Output)) > maxBytes { | |
| 81 | + | return fmt.Errorf("subagents: result output exceeds %d bytes", maxBytes) | |
| 82 | + | } | |
| 83 | + | if maxLines > 0 && countLines(r.Output) > maxLines { | |
| 84 | + | return fmt.Errorf("subagents: result output exceeds %d lines", maxLines) | |
| 85 | + | } | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Bounded returns a copy with inline output capped by bytes and lines. It | |
| 90 | + | // never mutates the durable result's caller-owned value. | |
| 91 | + | func (r *TurnResult) Bounded(maxBytes, maxLines int) *TurnResult { | |
| 92 | + | out := cloneTurnResult(r) | |
| 93 | + | if out == nil { | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | const marker = "...[output truncated]" | |
| 97 | + | if len([]byte(out.Summary)) > 4*1024 { | |
| 98 | + | out.Summary = truncateUTF8(out.Summary, 4*1024) | |
| 99 | + | } | |
| 100 | + | if maxLines > 0 { | |
| 101 | + | lines := strings.Split(out.Output, "\n") | |
| 102 | + | if len(lines) > maxLines { | |
| 103 | + | if maxLines == 1 { | |
| 104 | + | out.Output = marker | |
| 105 | + | } else { | |
| 106 | + | out.Output = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | if maxBytes > 0 && len([]byte(out.Output)) > maxBytes { | |
| 111 | + | if maxBytes <= len(marker) { | |
| 112 | + | out.Output = truncateUTF8(out.Output, maxBytes) | |
| 113 | + | } else { | |
| 114 | + | out.Output = truncateUTF8(out.Output, maxBytes-len(marker)) + marker | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return out | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func truncateUTF8(value string, maxBytes int) string { | |
| 121 | + | if maxBytes <= 0 { | |
| 122 | + | return "" | |
| 123 | + | } | |
| 124 | + | data := []byte(value) | |
| 125 | + | if len(data) <= maxBytes { | |
| 126 | + | return value | |
| 127 | + | } | |
| 128 | + | data = data[:maxBytes] | |
| 129 | + | for len(data) > 0 && !utf8.Valid(data) { | |
| 130 | + | data = data[:len(data)-1] | |
| 131 | + | } | |
| 132 | + | return string(data) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func validateTurnResultAgent(result *TurnResult, agentID string) error { | |
| 136 | + | if result == nil { | |
| 137 | + | return errors.New("subagents: nil turn result") | |
| 138 | + | } | |
| 139 | + | if agentID != "" && result.AgentID != agentID { | |
| 140 | + | return fmt.Errorf("subagents: turn result belongs to agent %q, want %q", result.AgentID, agentID) | |
| 141 | + | } | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func decodeTurnResultEvent(ev Event, agentID string, maxBytes, maxLines int) (*TurnResult, error) { | |
| 146 | + | data, err := json.Marshal(ev.Data) | |
| 147 | + | if err != nil { | |
| 148 | + | return nil, fmt.Errorf("subagents: encode turn result event: %w", err) | |
| 149 | + | } | |
| 150 | + | var result TurnResult | |
| 151 | + | if err := json.Unmarshal(data, &result); err != nil { | |
| 152 | + | return nil, fmt.Errorf("subagents: decode turn result event: %w", err) | |
| 153 | + | } | |
| 154 | + | if result.AgentID == "" { | |
| 155 | + | result.AgentID = firstNonEmpty(ev.AgentID, agentID) | |
| 156 | + | } | |
| 157 | + | if err := validateTurnResultAgent(&result, agentID); err != nil { | |
| 158 | + | return nil, err | |
| 159 | + | } | |
| 160 | + | if result.TurnID == "" { | |
| 161 | + | result.TurnID = ev.TurnID | |
| 162 | + | } | |
| 163 | + | if result.Version == 0 { | |
| 164 | + | result.Version = ProtocolVersion | |
| 165 | + | } | |
| 166 | + | if result.CreatedAt.IsZero() { | |
| 167 | + | result.CreatedAt = ev.Time | |
| 168 | + | if result.CreatedAt.IsZero() { | |
| 169 | + | result.CreatedAt = time.Now().UTC() | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | resultPtr := result.Bounded(maxBytes, maxLines) | |
| 173 | + | if err := resultPtr.Validate(0, 0); err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 176 | + | return resultPtr, nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func cloneTurnResult(r *TurnResult) *TurnResult { | |
| 180 | + | if r == nil { | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | copy := *r | |
| 184 | + | copy.Structured = append(json.RawMessage(nil), r.Structured...) | |
| 185 | + | copy.Artifacts = append([]ArtifactRef(nil), r.Artifacts...) | |
| 186 | + | copy.ChangedFiles = append([]string(nil), r.ChangedFiles...) | |
| 187 | + | if r.Usage != nil { | |
| 188 | + | copy.Usage = make(map[string]any, len(r.Usage)) | |
| 189 | + | for key, value := range r.Usage { | |
| 190 | + | copy.Usage[key] = value | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | if r.Error != nil { | |
| 194 | + | errCopy := *r.Error | |
| 195 | + | copy.Error = &errCopy | |
| 196 | + | } | |
| 197 | + | return © | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func countLines(s string) int { | |
| 201 | + | if s == "" { | |
| 202 | + | return 0 | |
| 203 | + | } | |
| 204 | + | return 1 + strings.Count(s, "\n") | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func AgentRef(id string) string { return "subagent://" + id } | |
| 208 | + | func HistoryRef(id string) string { return AgentRef(id) + "/history" } | |
| 209 | + | func ResultRef(id string) string { return AgentRef(id) + "/result" } | |
| 210 | + | func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |
| 211 | + | ||
| 212 | + | const maxResultFileBytes = 8 * 1024 * 1024 | |
| 213 | + | ||
| 214 | + | func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") } | |
| 215 | + | func patchPath(stateDir string) string { return filepath.Join(stateDir, "patch.diff") } | |
| 216 | + | ||
| 217 | + | func writeTurnResult(stateDir string, result *TurnResult) error { | |
| 218 | + | if result == nil { | |
| 219 | + | return errors.New("subagents: nil result") | |
| 220 | + | } | |
| 221 | + | if result.CreatedAt.IsZero() { | |
| 222 | + | result.CreatedAt = time.Now().UTC() | |
| 223 | + | } | |
| 224 | + | if result.Version == 0 { | |
| 225 | + | result.Version = ProtocolVersion | |
| 226 | + | } | |
| 227 | + | if err := result.Validate(0, 0); err != nil { | |
| 228 | + | return err | |
| 229 | + | } | |
| 230 | + | data, err := json.MarshalIndent(result, "", " ") | |
| 231 | + | if err != nil { | |
| 232 | + | return fmt.Errorf("subagents result marshal: %w", err) | |
| 233 | + | } | |
| 234 | + | if len(data) > maxResultFileBytes { | |
| 235 | + | return fmt.Errorf("subagents result exceeds %d bytes", maxResultFileBytes) | |
| 236 | + | } | |
| 237 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 238 | + | return fmt.Errorf("subagents result dir: %w", err) | |
| 239 | + | } | |
| 240 | + | tmp := resultPath(stateDir) + ".tmp" | |
| 241 | + | if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { | |
| 242 | + | return fmt.Errorf("subagents result write: %w", err) | |
| 243 | + | } | |
| 244 | + | if err := os.Rename(tmp, resultPath(stateDir)); err != nil { | |
| 245 | + | _ = os.Remove(tmp) | |
| 246 | + | return fmt.Errorf("subagents result rename: %w", err) | |
| 247 | + | } | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (f *Swarm) captureWorkspace(a *Agent) { | |
| 252 | + | if a == nil || a.workspaceCapture == nil { | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | stateDir := a.stateDirectory(f.cfg.Root) | |
| 256 | + | if _, err := os.Stat(stateDir); err != nil { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | capture, err := a.workspaceCapture() | |
| 260 | + | if err != nil { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | a.lifecycleMu.Lock() | |
| 264 | + | a.changedFiles = append([]string(nil), capture.ChangedFiles...) | |
| 265 | + | a.lifecycleMu.Unlock() | |
| 266 | + | if len(capture.Patch) == 0 { | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | path := patchPath(a.stateDirectory(f.cfg.Root)) | |
| 270 | + | tmp := path + ".tmp" | |
| 271 | + | if err := os.WriteFile(tmp, capture.Patch, 0o600); err != nil { | |
| 272 | + | return | |
| 273 | + | } | |
| 274 | + | if err := os.Rename(tmp, path); err != nil { | |
| 275 | + | _ = os.Remove(tmp) | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | a.lifecycleMu.Lock() | |
| 279 | + | a.patchRef = PatchRef(a.ID) | |
| 280 | + | a.lifecycleMu.Unlock() | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (f *Swarm) ensureResult(a *Agent, status Status, runErr error) { | |
| 284 | + | if a == nil { | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | result := a.Result() | |
| 288 | + | if result != nil { | |
| 289 | + | if err := validateTurnResultAgent(result, a.ID); err != nil { | |
| 290 | + | result = nil | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | if result != nil { | |
| 294 | + | // A child may omit agent/turn metadata in a result event; the | |
| 295 | + | // supervisor supplies it before validating the durable fallback. | |
| 296 | + | if result.AgentID == "" { | |
| 297 | + | result.AgentID = a.ID | |
| 298 | + | } | |
| 299 | + | if result.TurnID == "" { | |
| 300 | + | result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", a.Attempt)) | |
| 301 | + | } | |
| 302 | + | if result.Version == 0 { | |
| 303 | + | result.Version = ProtocolVersion | |
| 304 | + | } | |
| 305 | + | if result.CreatedAt.IsZero() { | |
| 306 | + | result.CreatedAt = time.Now().UTC() | |
| 307 | + | } | |
| 308 | + | if err := result.Validate(0, 0); err != nil { | |
| 309 | + | result = nil | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | if result == nil { | |
| 313 | + | turnID := a.CurrentTurnID() | |
| 314 | + | if turnID == "" { | |
| 315 | + | turnID = fmt.Sprintf("turn-%d", a.Attempt) | |
| 316 | + | } | |
| 317 | + | a.mu.Lock() | |
| 318 | + | output := a.lastAssistant | |
| 319 | + | a.mu.Unlock() | |
| 320 | + | resultStatus := ResultSucceeded | |
| 321 | + | switch { | |
| 322 | + | case status == StatusFailed && errors.Is(runErr, context.Canceled): | |
| 323 | + | resultStatus = ResultCanceled | |
| 324 | + | case status == StatusFailed: | |
| 325 | + | resultStatus = ResultFailed | |
| 326 | + | case status == StatusKilled: | |
| 327 | + | resultStatus = ResultCanceled | |
| 328 | + | } | |
| 329 | + | result = &TurnResult{ | |
| 330 | + | Version: ProtocolVersion, | |
| 331 | + | AgentID: a.ID, | |
| 332 | + | TurnID: turnID, | |
| 333 | + | Status: resultStatus, | |
| 334 | + | Output: output, | |
| 335 | + | CreatedAt: time.Now().UTC(), | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | if result.AgentID == "" { | |
| 339 | + | result.AgentID = a.ID | |
| 340 | + | } | |
| 341 | + | if result.TurnID == "" { | |
| 342 | + | result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", a.Attempt)) | |
| 343 | + | } | |
| 344 | + | if result.Version == 0 { | |
| 345 | + | result.Version = ProtocolVersion | |
| 346 | + | } | |
| 347 | + | if result.CreatedAt.IsZero() { | |
| 348 | + | result.CreatedAt = time.Now().UTC() | |
| 349 | + | } | |
| 350 | + | if runErr != nil && result.Error == nil { | |
| 351 | + | result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)} | |
| 352 | + | } | |
| 353 | + | if len(result.ChangedFiles) == 0 { | |
| 354 | + | a.lifecycleMu.Lock() | |
| 355 | + | result.ChangedFiles = append([]string(nil), a.changedFiles...) | |
| 356 | + | a.lifecycleMu.Unlock() | |
| 357 | + | } | |
| 358 | + | if result.Summary == "" && result.Output != "" { | |
| 359 | + | result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0] | |
| 360 | + | } | |
| 361 | + | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | |
| 362 | + | a.setResult(result) | |
| 363 | + | stateDir := a.stateDirectory(f.cfg.Root) | |
| 364 | + | if _, err := os.Stat(stateDir); err == nil { | |
| 365 | + | if err := writeTurnResult(stateDir, result); err == nil { | |
| 366 | + | a.lifecycleMu.Lock() | |
| 367 | + | a.resultRef = ResultRef(a.ID) | |
| 368 | + | a.lifecycleMu.Unlock() | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // ReadResult returns the durable structured result for an agent. | |
| 374 | + | func (f *Swarm) ReadResult(id string) (*TurnResult, error) { | |
| 375 | + | a := f.Get(id) | |
| 376 | + | if a == nil { | |
| 377 | + | return nil, fmt.Errorf("subagents: no such agent %q", id) | |
| 378 | + | } | |
| 379 | + | if result := a.Result(); result != nil { | |
| 380 | + | if err := validateTurnResultAgent(result, a.ID); err != nil { | |
| 381 | + | return nil, err | |
| 382 | + | } | |
| 383 | + | return result, nil | |
| 384 | + | } | |
| 385 | + | result, err := readTurnResult(a.stateDirectory(f.cfg.Root)) | |
| 386 | + | if err != nil { | |
| 387 | + | return nil, err | |
| 388 | + | } | |
| 389 | + | if err := validateTurnResultAgent(result, a.ID); err != nil { | |
| 390 | + | return nil, err | |
| 391 | + | } | |
| 392 | + | return result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines), nil | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func (f *Swarm) ResultReference(id string) string { return ResultRef(id) } | |
| 396 | + | func (f *Swarm) HistoryReference(id string) string { return HistoryRef(id) } | |
| 397 | + | func (f *Swarm) PatchReference(id string) string { return PatchRef(id) } | |
| 398 | + | ||
| 399 | + | func readTurnResult(stateDir string) (*TurnResult, error) { | |
| 400 | + | file, err := os.Open(resultPath(stateDir)) | |
| 401 | + | if err != nil { | |
| 402 | + | return nil, err | |
| 403 | + | } | |
| 404 | + | defer file.Close() | |
| 405 | + | data, err := io.ReadAll(io.LimitReader(file, maxResultFileBytes+1)) | |
| 406 | + | if err != nil { | |
| 407 | + | return nil, err | |
| 408 | + | } | |
| 409 | + | if len(data) > maxResultFileBytes { | |
| 410 | + | return nil, fmt.Errorf("subagents result exceeds %d bytes", maxResultFileBytes) | |
| 411 | + | } | |
| 412 | + | var result TurnResult | |
| 413 | + | if err := json.Unmarshal(data, &result); err != nil { | |
| 414 | + | return nil, fmt.Errorf("subagents result parse: %w", err) | |
| 415 | + | } | |
| 416 | + | if err := result.Validate(0, 0); err != nil { | |
| 417 | + | return nil, err | |
| 418 | + | } | |
| 419 | + | return &result, nil | |
| 420 | + | } | |
A packages/agent/swarm/result_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestTurnResultValidationAndBoundedOutput(t *testing.T) { | |
| 13 | + | result := &TurnResult{ | |
| 14 | + | Version: ProtocolVersion, | |
| 15 | + | AgentID: "agent-1", | |
| 16 | + | TurnID: "turn-1", | |
| 17 | + | Status: ResultSucceeded, | |
| 18 | + | Output: "one\ntwo\nthree", | |
| 19 | + | Structured: json.RawMessage(`{"ok":true}`), | |
| 20 | + | } | |
| 21 | + | if err := result.Validate(100, 3); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | bounded := result.Bounded(32, 2) | |
| 25 | + | if bounded == result || !strings.Contains(bounded.Output, "truncated") { | |
| 26 | + | t.Fatalf("bounded result = %#v", bounded) | |
| 27 | + | } | |
| 28 | + | if result.Output != "one\ntwo\nthree" { | |
| 29 | + | t.Fatal("Bounded mutated the original result") | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) { | |
| 34 | + | result := &TurnResult{ | |
| 35 | + | Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded, | |
| 36 | + | Output: "😀\n第二行\n第三行", | |
| 37 | + | } | |
| 38 | + | bounded := result.Bounded(10, 2) | |
| 39 | + | if countLines(bounded.Output) > 2 { | |
| 40 | + | t.Fatalf("bounded lines = %d: %q", countLines(bounded.Output), bounded.Output) | |
| 41 | + | } | |
| 42 | + | if len([]byte(bounded.Output)) > 10 { | |
| 43 | + | t.Fatalf("bounded bytes = %d: %q", len([]byte(bounded.Output)), bounded.Output) | |
| 44 | + | } | |
| 45 | + | if !utf8.ValidString(bounded.Output) { | |
| 46 | + | t.Fatal("bounded output is not valid UTF-8") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) { | |
| 51 | + | invalid := NewEvent(EventTurnResult, map[string]any{ | |
| 52 | + | "status": "unknown", | |
| 53 | + | "turn_id": "turn-1", | |
| 54 | + | }) | |
| 55 | + | if _, err := decodeTurnResultEvent(invalid, "agent-1", 100, 10); err == nil { | |
| 56 | + | t.Fatal("invalid result status was accepted") | |
| 57 | + | } | |
| 58 | + | mismatched := NewEvent(EventTurnResult, map[string]any{ | |
| 59 | + | "agent_id": "other", | |
| 60 | + | "status": string(ResultSucceeded), | |
| 61 | + | "turn_id": "turn-1", | |
| 62 | + | }) | |
| 63 | + | if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil { | |
| 64 | + | t.Fatal("mismatched result agent was accepted") | |
| 65 | + | } | |
| 66 | + | valid := NewEvent(EventTurnResult, map[string]any{"status": string(ResultSucceeded)}) | |
| 67 | + | valid.AgentID = "agent-1" | |
| 68 | + | valid.TurnID = "turn-1" | |
| 69 | + | result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10) | |
| 70 | + | if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" { | |
| 71 | + | t.Fatalf("valid result = %#v, err=%v", result, err) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestTurnResultReferencesAndPersistence(t *testing.T) { | |
| 76 | + | if got := AgentRef("a"); got != "subagent://a" { | |
| 77 | + | t.Fatal(got) | |
| 78 | + | } | |
| 79 | + | if got := HistoryRef("a"); got != "subagent://a/history" { | |
| 80 | + | t.Fatal(got) | |
| 81 | + | } | |
| 82 | + | if got := ResultRef("a"); got != "subagent://a/result" { | |
| 83 | + | t.Fatal(got) | |
| 84 | + | } | |
| 85 | + | if got := PatchRef("a"); got != "subagent://a/patch" { | |
| 86 | + | t.Fatal(got) | |
| 87 | + | } | |
| 88 | + | state := t.TempDir() | |
| 89 | + | want := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultFailed, Error: &ResultError{Code: "x", Message: "failed"}} | |
| 90 | + | if err := writeTurnResult(state, want); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | got, err := readTurnResult(state) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | if got.Status != want.Status || got.Error == nil || got.Error.Code != "x" { | |
| 98 | + | t.Fatalf("got = %#v", got) | |
| 99 | + | } | |
| 100 | + | info, err := os.Stat(filepath.Join(state, "result.json")) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | if info.Mode().Perm() != 0o600 { | |
| 105 | + | t.Fatalf("result permissions = %o, want 600", info.Mode().Perm()) | |
| 106 | + | } | |
| 107 | + | } | |
M packages/agent/swarm/runner.go +305 -46
| @@ -11,12 +11,13 @@ import ( | |||
| 11 | 11 | "os/exec" | |
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strings" | |
| 14 | + | "sync" | |
| 14 | 15 | "time" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | - | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | |
| 18 | - | // the host's working directory (Agent.Dir, which is always the parent | |
| 19 | - | // zot's RepoRoot) and consumes its JSONL event stream on stdout. | |
| 18 | + | // execRunner spawns `zot --subagent-worker <inbox> --session <path>` in | |
| 19 | + | // Agent.Dir (the shared repository root or an isolated worktree) and consumes | |
| 20 | + | // its JSONL event stream on stdout. | |
| 20 | 21 | // | |
| 21 | 22 | // Why a long-lived daemon and not `zot --print`: the supervisor and | |
| 22 | 23 | // the user expect agents to keep accepting follow-up prompts. A | |
| @@ -31,13 +32,13 @@ import ( | |||
| 31 | 32 | // | |
| 32 | 33 | // The on-disk log is the durable record. The Sink updates are an | |
| 33 | 34 | // in-memory mirror so the dashboard doesn't have to tail the file | |
| 34 | - | // for the parent's own agents. /swarm open in a separate zot would | |
| 35 | + | // for the parent's own agents. /subagents open in a separate zot would | |
| 35 | 36 | // read the log directly. | |
| 36 | 37 | type execRunner struct { | |
| 37 | 38 | agent *Agent | |
| 38 | 39 | resolveCredential func(context.Context, string) (Credential, error) | |
| 39 | 40 | ||
| 40 | - | // Command overrides the default `zot --swarm-agent ...` | |
| 41 | + | // Command overrides the default `zot --subagent-worker ...` | |
| 41 | 42 | // invocation. Tests set this to a fake binary (or `go run` | |
| 42 | 43 | // against a tiny stub program) so the supervisor logic can be | |
| 43 | 44 | // tested without a real child. Production code leaves it nil. | |
| @@ -66,10 +67,14 @@ type swarmAgentArgsOpts struct { | |||
| 66 | 67 | Task string | |
| 67 | 68 | Model string | |
| 68 | 69 | Provider string | |
| 70 | + | BaseURL string | |
| 71 | + | InsecureTLS bool | |
| 69 | 72 | Reasoning string | |
| 70 | 73 | FastMode bool | |
| 71 | 74 | FastModeSet bool | |
| 72 | 75 | Subagent string | |
| 76 | + | MaxTurns int | |
| 77 | + | Tools []string | |
| 73 | 78 | } | |
| 74 | 79 | ||
| 75 | 80 | // defaultChildArgs builds the argv execRunner uses when its Command | |
| @@ -99,10 +104,14 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||
| 99 | 104 | Task: task, | |
| 100 | 105 | Model: a.Model, | |
| 101 | 106 | Provider: a.Provider, | |
| 107 | + | BaseURL: a.BaseURL, | |
| 108 | + | InsecureTLS: a.InsecureTLS, | |
| 102 | 109 | Reasoning: a.Reasoning, | |
| 103 | 110 | FastMode: a.FastMode, | |
| 104 | 111 | FastModeSet: true, | |
| 105 | 112 | Subagent: a.Subagent, | |
| 113 | + | MaxTurns: a.MaxTurns, | |
| 114 | + | Tools: a.Tools, | |
| 106 | 115 | }) | |
| 107 | 116 | } | |
| 108 | 117 | ||
| @@ -112,6 +121,9 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||
| 112 | 121 | func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |
| 113 | 122 | args := []string{ | |
| 114 | 123 | opts.Exe, | |
| 124 | + | "--subagent-worker", opts.InboxPath, | |
| 125 | + | // Keep the old flag during the compatibility window. The child | |
| 126 | + | // parser accepts both and canonicalises the mode internally. | |
| 115 | 127 | "--swarm-agent", opts.InboxPath, | |
| 116 | 128 | "--session", opts.SessionPath, | |
| 117 | 129 | "--cwd", opts.Dir, | |
| @@ -122,6 +134,12 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 122 | 134 | if opts.Provider != "" { | |
| 123 | 135 | args = append(args, "--provider", opts.Provider) | |
| 124 | 136 | } | |
| 137 | + | if opts.BaseURL != "" { | |
| 138 | + | args = append(args, "--base-url", opts.BaseURL) | |
| 139 | + | } | |
| 140 | + | if opts.InsecureTLS { | |
| 141 | + | args = append(args, "--insecure") | |
| 142 | + | } | |
| 125 | 143 | if opts.Reasoning != "" { | |
| 126 | 144 | args = append(args, "--reasoning", opts.Reasoning) | |
| 127 | 145 | } | |
| @@ -135,10 +153,17 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 135 | 153 | if opts.Subagent != "" { | |
| 136 | 154 | args = append(args, "--subagent", opts.Subagent) | |
| 137 | 155 | } | |
| 156 | + | if opts.MaxTurns > 0 { | |
| 157 | + | args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns)) | |
| 158 | + | } | |
| 159 | + | if len(opts.Tools) > 0 { | |
| 160 | + | args = append(args, "--tools", strings.Join(opts.Tools, ",")) | |
| 161 | + | } | |
| 138 | 162 | if opts.Task != "" { | |
| 139 | 163 | // First task is positional so the child treats it as the | |
| 140 | - | // initial user turn; subsequent turns arrive via the inbox. | |
| 141 | - | args = append(args, opts.Task) | |
| 164 | + | // initial user turn; the terminator keeps flag-like task text | |
| 165 | + | // from being parsed as a child CLI option. | |
| 166 | + | args = append(args, "--", opts.Task) | |
| 142 | 167 | } | |
| 143 | 168 | return args | |
| 144 | 169 | } | |
| @@ -191,11 +216,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 191 | 216 | } | |
| 192 | 217 | ||
| 193 | 218 | cmd := exec.CommandContext(ctx, args[0], args[1:]...) | |
| 219 | + | configureWorkerProcess(cmd) | |
| 194 | 220 | cmd.Dir = r.agent.Dir | |
| 195 | - | cmd.Env = append(os.Environ(), | |
| 221 | + | cmd.Env = append(workerEnvironment(r.agent.Provider), | |
| 196 | 222 | "ZOT_SWARM_AGENT_ID="+r.agent.ID, | |
| 197 | 223 | "ZOT_SWARM_EVENT_LOG="+logPath, | |
| 198 | 224 | ) | |
| 225 | + | if r.agent.HeartbeatInterval > 0 { | |
| 226 | + | cmd.Env = append(cmd.Env, "ZOT_SWARM_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String()) | |
| 227 | + | } | |
| 228 | + | if r.agent.maxOutputBytes > 0 { | |
| 229 | + | cmd.Env = append(cmd.Env, fmt.Sprintf("ZOT_SWARM_MAX_OUTPUT_BYTES=%d", r.agent.maxOutputBytes)) | |
| 230 | + | } | |
| 231 | + | if r.agent.maxOutputLines > 0 { | |
| 232 | + | cmd.Env = append(cmd.Env, fmt.Sprintf("ZOT_SWARM_MAX_OUTPUT_LINES=%d", r.agent.maxOutputLines)) | |
| 233 | + | } | |
| 199 | 234 | if r.resolveCredential != nil { | |
| 200 | 235 | credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider) | |
| 201 | 236 | if resolveErr != nil { | |
| @@ -226,6 +261,32 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 226 | 261 | if err := cmd.Start(); err != nil { | |
| 227 | 262 | return err | |
| 228 | 263 | } | |
| 264 | + | r.agent.setProcessPID(cmd.Process.Pid) | |
| 265 | + | if r.agent.persistFn != nil { | |
| 266 | + | r.agent.persistFn(r.agent) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | var logErrMu sync.Mutex | |
| 270 | + | var logErr error | |
| 271 | + | var stopOnLogErr sync.Once | |
| 272 | + | appendLog := func(ev Event) { | |
| 273 | + | if err := log.Append(ev); err != nil { | |
| 274 | + | logErrMu.Lock() | |
| 275 | + | if logErr == nil { | |
| 276 | + | logErr = err | |
| 277 | + | } | |
| 278 | + | logErrMu.Unlock() | |
| 279 | + | // A durable event log is part of the runner's recovery contract. | |
| 280 | + | // Stop the worker rather than letting it continue in a state the | |
| 281 | + | // supervisor cannot reconstruct after restart. | |
| 282 | + | stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) }) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | firstLogErr := func() error { | |
| 286 | + | logErrMu.Lock() | |
| 287 | + | defer logErrMu.Unlock() | |
| 288 | + | return logErr | |
| 289 | + | } | |
| 229 | 290 | ||
| 230 | 291 | // stdout: parsed as JSONL. Every well-formed event is appended | |
| 231 | 292 | // to the durable log AND forwarded to the in-memory sink so the | |
| @@ -235,34 +296,39 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 235 | 296 | go func() { | |
| 236 | 297 | defer func() { done <- struct{}{} }() | |
| 237 | 298 | dec := bufio.NewReader(stdout) | |
| 299 | + | lineLimit := r.agent.maxOutputBytes + 64*1024 | |
| 300 | + | if lineLimit <= 0 { | |
| 301 | + | lineLimit = 512 * 1024 | |
| 302 | + | } | |
| 303 | + | if lineLimit > maxEventLogLineBytes { | |
| 304 | + | lineLimit = maxEventLogLineBytes | |
| 305 | + | } | |
| 238 | 306 | for { | |
| 239 | - | line, err := dec.ReadBytes('\n') | |
| 307 | + | line, err, truncated := readBoundedLine(dec, lineLimit) | |
| 240 | 308 | if len(line) > 0 { | |
| 241 | 309 | trimmed := strings.TrimRight(string(line), "\r\n") | |
| 242 | - | if trimmed == "" { | |
| 243 | - | goto next | |
| 244 | - | } | |
| 245 | - | if ev, ok := parseEventLine(trimmed); ok { | |
| 246 | - | _ = log.Append(ev) | |
| 247 | - | applyEventToSink(ev, sink) | |
| 248 | - | // Fan prompt-level task completions up to any | |
| 249 | - | // subscriber on the supervised Agent. The child | |
| 250 | - | // also forwards provider/tool-loop turn_end | |
| 251 | - | // events (for example stop=tool_use); those do | |
| 252 | - | // not contain step and must not be treated as | |
| 253 | - | // swarm task completion. | |
| 254 | - | notifyPromptTurnEnd(r.agent, ev) | |
| 255 | - | } else { | |
| 256 | - | // Non-JSON output. Keep it as transcript so an | |
| 257 | - | // accidental fmt.Println in the child still | |
| 258 | - | // shows up in the dashboard, and log a | |
| 259 | - | // lifecycle event so the durable record stays | |
| 260 | - | // in sync. | |
| 310 | + | if trimmed != "" && !truncated { | |
| 311 | + | if ev, ok := parseEventLine(trimmed); ok { | |
| 312 | + | appendLog(ev) | |
| 313 | + | updateAgentFromEvent(r.agent, ev) | |
| 314 | + | applyEventToSink(ev, sink) | |
| 315 | + | // Fan prompt-level task completions up to any | |
| 316 | + | // subscriber on the supervised Agent. The child | |
| 317 | + | // also forwards provider/tool-loop turn_end | |
| 318 | + | // events (for example stop=tool_use); those do | |
| 319 | + | // not contain step and must not be treated as | |
| 320 | + | // swarm task completion. | |
| 321 | + | notifyPromptTurnEnd(r.agent, ev) | |
| 322 | + | } else { | |
| 323 | + | sink.Transcript(trimmed) | |
| 324 | + | appendLog(NewEvent("stdout", map[string]any{"text": trimmed})) | |
| 325 | + | } | |
| 326 | + | } else if trimmed != "" { | |
| 327 | + | trimmed += "\n...[line truncated]" | |
| 261 | 328 | sink.Transcript(trimmed) | |
| 262 | - | _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed})) | |
| 329 | + | appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true})) | |
| 263 | 330 | } | |
| 264 | 331 | } | |
| 265 | - | next: | |
| 266 | 332 | if err != nil { | |
| 267 | 333 | return | |
| 268 | 334 | } | |
| @@ -276,12 +342,22 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 276 | 342 | go func() { | |
| 277 | 343 | defer func() { done <- struct{}{} }() | |
| 278 | 344 | br := bufio.NewReader(stderr) | |
| 345 | + | lineLimit := r.agent.maxOutputBytes + 64*1024 | |
| 346 | + | if lineLimit <= 0 { | |
| 347 | + | lineLimit = 512 * 1024 | |
| 348 | + | } | |
| 349 | + | if lineLimit > maxEventLogLineBytes { | |
| 350 | + | lineLimit = maxEventLogLineBytes | |
| 351 | + | } | |
| 279 | 352 | for { | |
| 280 | - | line, err := br.ReadString('\n') | |
| 281 | - | if line != "" { | |
| 282 | - | txt := strings.TrimRight(line, "\r\n") | |
| 353 | + | line, err, truncated := readBoundedLine(br, lineLimit) | |
| 354 | + | if len(line) > 0 { | |
| 355 | + | txt := strings.TrimRight(string(line), "\r\n") | |
| 356 | + | if truncated { | |
| 357 | + | txt += "\n...[line truncated]" | |
| 358 | + | } | |
| 283 | 359 | sink.Transcript("stderr: " + txt) | |
| 284 | - | _ = log.Append(NewEvent("stderr", map[string]any{"text": txt})) | |
| 360 | + | appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated})) | |
| 285 | 361 | } | |
| 286 | 362 | if err != nil { | |
| 287 | 363 | return | |
| @@ -298,24 +374,125 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 298 | 374 | exit = ee.ExitCode() | |
| 299 | 375 | } | |
| 300 | 376 | if err != nil && ctx.Err() != nil { | |
| 301 | - | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})) | |
| 377 | + | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})) | |
| 378 | + | if logErr := firstLogErr(); logErr != nil { | |
| 379 | + | return fmt.Errorf("swarm event log: %w", logErr) | |
| 380 | + | } | |
| 302 | 381 | return ctx.Err() | |
| 303 | 382 | } | |
| 304 | 383 | if err != nil { | |
| 305 | - | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |
| 384 | + | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |
| 385 | + | if logErr := firstLogErr(); logErr != nil { | |
| 386 | + | return fmt.Errorf("swarm event log: %v; worker: %w", logErr, err) | |
| 387 | + | } | |
| 306 | 388 | return err | |
| 307 | 389 | } | |
| 308 | - | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |
| 390 | + | appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |
| 391 | + | if logErr := firstLogErr(); logErr != nil { | |
| 392 | + | return fmt.Errorf("swarm event log: %w", logErr) | |
| 393 | + | } | |
| 309 | 394 | sink.Activity("done") | |
| 310 | 395 | return nil | |
| 311 | 396 | } | |
| 312 | 397 | ||
| 398 | + | func workerEnvironment(provider string) []string { | |
| 399 | + | // Provider credentials are resolved by the supervisor and transferred | |
| 400 | + | // over the child's stdin. Do not inherit API-key/token variables into the | |
| 401 | + | // worker environment, where child tools, crash reports, or diagnostics | |
| 402 | + | // could expose them. Bedrock's current client still resolves bearer/IAM | |
| 403 | + | // credentials directly from AWS environment variables, so preserve that | |
| 404 | + | // provider's ambient AWS chain until it has a structured stdin handoff. | |
| 405 | + | provider = strings.ToLower(strings.TrimSpace(provider)) | |
| 406 | + | var env []string | |
| 407 | + | for _, entry := range os.Environ() { | |
| 408 | + | name, _, ok := strings.Cut(entry, "=") | |
| 409 | + | if ok && workerSecretEnvName(name) && !(provider == "amazon-bedrock" && isBedrockCredentialEnv(name)) { | |
| 410 | + | continue | |
| 411 | + | } | |
| 412 | + | env = append(env, entry) | |
| 413 | + | } | |
| 414 | + | return env | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func isBedrockCredentialEnv(name string) bool { | |
| 418 | + | switch strings.ToUpper(strings.TrimSpace(name)) { | |
| 419 | + | case "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN": | |
| 420 | + | return true | |
| 421 | + | default: | |
| 422 | + | return false | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func workerSecretEnvName(name string) bool { | |
| 427 | + | name = strings.ToUpper(strings.TrimSpace(name)) | |
| 428 | + | for _, suffix := range []string{"_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} { | |
| 429 | + | if strings.HasSuffix(name, suffix) { | |
| 430 | + | return true | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | switch name { | |
| 434 | + | case "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS": | |
| 435 | + | return true | |
| 436 | + | default: | |
| 437 | + | return false | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func readBoundedLine(r *bufio.Reader, limit int) ([]byte, error, bool) { | |
| 442 | + | line, err, truncated, _, _ := readBoundedLineStats(r, limit) | |
| 443 | + | return line, err, truncated | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | // readBoundedLineStats reads through one newline or EOF while retaining the | |
| 447 | + | // number of source bytes consumed and whether a newline terminated the record. | |
| 448 | + | // The follower uses those extra values to avoid advancing past a partial final | |
| 449 | + | // JSONL record that may be completed by a concurrent append. | |
| 450 | + | func readBoundedLineStats(r *bufio.Reader, limit int) ([]byte, error, bool, int64, bool) { | |
| 451 | + | if limit <= 0 { | |
| 452 | + | limit = 512 * 1024 | |
| 453 | + | } | |
| 454 | + | var line []byte | |
| 455 | + | var consumed int64 | |
| 456 | + | truncated := false | |
| 457 | + | for { | |
| 458 | + | chunk, err := r.ReadSlice('\n') | |
| 459 | + | consumed += int64(len(chunk)) | |
| 460 | + | if len(chunk) > 0 { | |
| 461 | + | if len(line) < limit { | |
| 462 | + | keep := len(chunk) | |
| 463 | + | if remaining := limit - len(line); keep > remaining { | |
| 464 | + | keep = remaining | |
| 465 | + | truncated = true | |
| 466 | + | } | |
| 467 | + | line = append(line, chunk[:keep]...) | |
| 468 | + | } else { | |
| 469 | + | truncated = true | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | if err != bufio.ErrBufferFull { | |
| 473 | + | return line, err, truncated, consumed, len(chunk) > 0 && chunk[len(chunk)-1] == '\n' | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | ||
| 313 | 478 | // parseEventLine attempts to decode one JSONL line as an Event. | |
| 314 | 479 | // Returns ok=false for non-JSON or JSON without a "type" field. | |
| 315 | 480 | func parseEventLine(line string) (Event, bool) { | |
| 316 | 481 | if len(line) == 0 || line[0] != '{' { | |
| 317 | 482 | return Event{}, false | |
| 318 | 483 | } | |
| 484 | + | if env, err := ParseEvent([]byte(line)); err == nil && !env.Legacy { | |
| 485 | + | if ev, convertErr := env.LegacyEvent(); convertErr == nil { | |
| 486 | + | ev.Version = env.Version | |
| 487 | + | ev.MessageID = env.MessageID | |
| 488 | + | ev.AgentID = env.AgentID | |
| 489 | + | ev.TurnID = env.TurnID | |
| 490 | + | if ev.Time.IsZero() { | |
| 491 | + | ev.Time = time.Now() | |
| 492 | + | } | |
| 493 | + | return ev, true | |
| 494 | + | } | |
| 495 | + | } | |
| 319 | 496 | var ev Event | |
| 320 | 497 | if err := json.Unmarshal([]byte(line), &ev); err != nil { | |
| 321 | 498 | return Event{}, false | |
| @@ -329,6 +506,85 @@ func parseEventLine(line string) (Event, bool) { | |||
| 329 | 506 | return ev, true | |
| 330 | 507 | } | |
| 331 | 508 | ||
| 509 | + | func updateAgentFromEvent(a *Agent, ev Event) { | |
| 510 | + | if a == nil { | |
| 511 | + | return | |
| 512 | + | } | |
| 513 | + | now := ev.Time | |
| 514 | + | if now.IsZero() { | |
| 515 | + | now = time.Now() | |
| 516 | + | } | |
| 517 | + | if ev.Version > 0 { | |
| 518 | + | a.setProtocolVersion(ev.Version) | |
| 519 | + | } | |
| 520 | + | a.markActivity(now) | |
| 521 | + | persist := false | |
| 522 | + | switch ev.Type { | |
| 523 | + | case EventAgentReady, "agent_ready": | |
| 524 | + | a.setProcessState(ProcessAlive) | |
| 525 | + | a.setTurnState(TurnIdle, ev.TurnID) | |
| 526 | + | persist = true | |
| 527 | + | case EventAgentHeartbeat, "agent_heartbeat": | |
| 528 | + | a.setProcessState(ProcessAlive) | |
| 529 | + | persist = true | |
| 530 | + | case EventTurnStarted, "turn_start": | |
| 531 | + | turnID := ev.TurnID | |
| 532 | + | if turnID == "" { | |
| 533 | + | if step, ok := ev.Data["step"].(float64); ok { | |
| 534 | + | turnID = fmt.Sprintf("turn-%d", int(step)) | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | a.setProcessState(ProcessAlive) | |
| 538 | + | a.setTurnState(TurnRunning, turnID) | |
| 539 | + | persist = true | |
| 540 | + | case EventTurnResult, "turn_result": | |
| 541 | + | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | |
| 542 | + | a.setResult(result) | |
| 543 | + | if a.turnResults != nil { | |
| 544 | + | select { | |
| 545 | + | case a.turnResults <- result: | |
| 546 | + | default: | |
| 547 | + | } | |
| 548 | + | } | |
| 549 | + | if a.stateDir != "" { | |
| 550 | + | if err := writeTurnResult(a.stateDir, result); err == nil { | |
| 551 | + | a.lifecycleMu.Lock() | |
| 552 | + | a.resultRef = ResultRef(a.ID) | |
| 553 | + | a.lifecycleMu.Unlock() | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | switch result.Status { | |
| 557 | + | case ResultCanceled: | |
| 558 | + | a.setTurnState(TurnCanceled, result.TurnID) | |
| 559 | + | case ResultFailed: | |
| 560 | + | a.setTurnState(TurnFailed, result.TurnID) | |
| 561 | + | default: | |
| 562 | + | a.setTurnState(TurnSucceeded, result.TurnID) | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | persist = true | |
| 566 | + | case EventTurnFailed, "turn_failed": | |
| 567 | + | a.setTurnState(TurnFailed, ev.TurnID) | |
| 568 | + | persist = true | |
| 569 | + | case EventAgentIdle, "agent_idle": | |
| 570 | + | a.setTurnState(TurnIdle, ev.TurnID) | |
| 571 | + | persist = true | |
| 572 | + | case EventAgentExited, "agent_exited", "agent_stopped": | |
| 573 | + | a.setProcessState(ProcessExited) | |
| 574 | + | persist = true | |
| 575 | + | case "turn_end": | |
| 576 | + | if message, _ := ev.Data["error"].(string); message != "" { | |
| 577 | + | a.setTurnState(TurnFailed, ev.TurnID) | |
| 578 | + | } else { | |
| 579 | + | a.setTurnState(TurnSucceeded, ev.TurnID) | |
| 580 | + | } | |
| 581 | + | persist = true | |
| 582 | + | } | |
| 583 | + | if persist && a.persistFn != nil { | |
| 584 | + | a.persistFn(a) | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | ||
| 332 | 588 | // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the swarm | |
| 333 | 589 | // daemon's prompt-level completion event. Provider/tool-loop | |
| 334 | 590 | // turn_end events (such as stop=tool_use) do not include step and | |
| @@ -342,13 +598,16 @@ func notifyPromptTurnEnd(a *Agent, ev Event) { | |||
| 342 | 598 | return | |
| 343 | 599 | } | |
| 344 | 600 | ||
| 601 | + | errMsg, _ := ev.Data["error"].(string) | |
| 345 | 602 | a.mu.Lock() | |
| 346 | 603 | fn := a.OnTurnEnd | |
| 347 | - | a.mu.Unlock() | |
| 348 | - | if fn != nil { | |
| 349 | - | errMsg, _ := ev.Data["error"].(string) | |
| 350 | - | go fn(int(step), errMsg) | |
| 604 | + | if fn == nil { | |
| 605 | + | a.pendingTurnEnds = append(a.pendingTurnEnds, turnEndNotice{step: int(step), errMsg: errMsg}) | |
| 606 | + | a.mu.Unlock() | |
| 607 | + | return | |
| 351 | 608 | } | |
| 609 | + | a.mu.Unlock() | |
| 610 | + | go fn(int(step), errMsg) | |
| 352 | 611 | } | |
| 353 | 612 | ||
| 354 | 613 | // applyEventToSink translates an Event into Sink updates. Only a | |
| @@ -395,17 +654,17 @@ func applyEventToSink(ev Event, sink Sink) { | |||
| 395 | 654 | if ev.Type == "assistant_message" { | |
| 396 | 655 | sink.Activity("idle") | |
| 397 | 656 | } | |
| 398 | - | case "turn_start": | |
| 657 | + | case "turn_start", EventTurnStarted: | |
| 399 | 658 | sink.Activity("thinking") | |
| 400 | - | case "tool_call": | |
| 659 | + | case "tool_call", EventToolStarted: | |
| 401 | 660 | if name, _ := ev.Data["name"].(string); name != "" { | |
| 402 | 661 | sink.Activity("tool: " + truncate(name, 60)) | |
| 403 | 662 | } | |
| 404 | - | case "tool_result": | |
| 663 | + | case "tool_result", EventToolFinished: | |
| 405 | 664 | sink.Activity("idle") | |
| 406 | - | case "turn_end": | |
| 665 | + | case "turn_end", EventTurnResult, EventAgentIdle: | |
| 407 | 666 | sink.Activity("idle") | |
| 408 | - | case "agent_ready": | |
| 667 | + | case "agent_ready", EventAgentReady, EventAgentHeartbeat: | |
| 409 | 668 | sink.Activity("idle") | |
| 410 | 669 | case "agent_stopped": | |
| 411 | 670 | // terminal status is decided by Swarm.run from the runner's | |
M packages/agent/swarm/runner_e2e_test.go +1 -0
| @@ -40,6 +40,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | exe := buildStubChild(t) | |
| 43 | + | t.Setenv("ZOT_STUB_PROTOCOL", "1") | |
| 43 | 44 | ||
| 44 | 45 | root := t.TempDir() | |
| 45 | 46 | repo := t.TempDir() | |
M packages/agent/swarm/runner_test.go +69 -0
| @@ -58,6 +58,38 @@ func TestCredentialStdinHelperProcess(t *testing.T) { | |||
| 58 | 58 | os.Exit(0) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) { | |
| 62 | + | t.Setenv("OPENAI_API_KEY", "openai-secret") | |
| 63 | + | t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret") | |
| 64 | + | t.Setenv("HF_TOKEN", "hf-secret") | |
| 65 | + | t.Setenv("GITHUB_TOKEN", "github-secret") | |
| 66 | + | t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret") | |
| 67 | + | t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json") | |
| 68 | + | t.Setenv("ZOT_SWARM_TEST_VALUE", "not-secret") | |
| 69 | + | env := strings.Join(workerEnvironment("openai"), "\n") | |
| 70 | + | for _, secret := range []string{ | |
| 71 | + | "OPENAI_API_KEY=openai-secret", | |
| 72 | + | "CUSTOM_PROVIDER_API_KEY=custom-secret", | |
| 73 | + | "HF_TOKEN=hf-secret", | |
| 74 | + | "GITHUB_TOKEN=github-secret", | |
| 75 | + | "CUSTOM_PROVIDER_TOKEN=token-secret", | |
| 76 | + | "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json", | |
| 77 | + | } { | |
| 78 | + | if strings.Contains(env, secret) { | |
| 79 | + | t.Fatalf("worker environment leaked %s", secret) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | if !strings.Contains(env, "ZOT_SWARM_TEST_VALUE=not-secret") { | |
| 83 | + | t.Fatal("worker environment dropped unrelated variables") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret") | |
| 87 | + | bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n") | |
| 88 | + | if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") { | |
| 89 | + | t.Fatal("Bedrock ambient credential chain was unexpectedly removed") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 61 | 93 | func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) { | |
| 62 | 94 | t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1") | |
| 63 | 95 | root := t.TempDir() | |
| @@ -155,6 +187,43 @@ func TestSwarmAgentArgs(t *testing.T) { | |||
| 155 | 187 | } | |
| 156 | 188 | } | |
| 157 | 189 | ||
| 190 | + | func TestSwarmAgentArgsPropagatesProviderConnectionSettings(t *testing.T) { | |
| 191 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |
| 192 | + | Exe: "/zot", | |
| 193 | + | Dir: "/work", | |
| 194 | + | SessionPath: "/state/session.json", | |
| 195 | + | InboxPath: "/state/inbox.sock", | |
| 196 | + | BaseURL: "https://gateway.example.test/v1", | |
| 197 | + | InsecureTLS: true, | |
| 198 | + | }) | |
| 199 | + | if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" { | |
| 200 | + | t.Fatalf("argv = %v, want --base-url with inherited endpoint", args) | |
| 201 | + | } | |
| 202 | + | if !containsArg(args, "--insecure") { | |
| 203 | + | t.Fatalf("argv = %v, want inherited --insecure", args) | |
| 204 | + | } | |
| 205 | + | for _, arg := range args { | |
| 206 | + | if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") { | |
| 207 | + | t.Fatalf("argv contains credential material: %v", args) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) { | |
| 213 | + | a := &Agent{ | |
| 214 | + | Dir: "/work", | |
| 215 | + | BaseURL: "https://gateway.example.test/v1", | |
| 216 | + | InsecureTLS: true, | |
| 217 | + | } | |
| 218 | + | args := defaultChildArgs("/zot", a, "/state/session.json", "/state/inbox.sock") | |
| 219 | + | if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL { | |
| 220 | + | t.Fatalf("argv = %v, want Agent.BaseURL propagated", args) | |
| 221 | + | } | |
| 222 | + | if !containsArg(args, "--insecure") { | |
| 223 | + | t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 158 | 227 | // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the | |
| 159 | 228 | // agent is being adopted (no fresh task) we don't pass an empty | |
| 160 | 229 | // positional which the arg parser would treat as a real prompt. | |
A packages/agent/swarm/scheduler.go +348 -0
| @@ -0,0 +1,348 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func (f *Swarm) validateSpawnRequest(req SpawnRequest) error { | |
| 14 | + | if err := f.validateSpawnOptions(req); err != nil { | |
| 15 | + | return err | |
| 16 | + | } | |
| 17 | + | mode := req.WorkspaceMode | |
| 18 | + | if mode == "" { | |
| 19 | + | mode = WorkspaceShared | |
| 20 | + | } | |
| 21 | + | if mode != WorkspaceShared && mode != WorkspaceWorktree { | |
| 22 | + | return fmt.Errorf("subagents: unknown workspace mode %q", mode) | |
| 23 | + | } | |
| 24 | + | if req.WorkspaceCapture != "" && req.WorkspaceCapture != CapturePatch && req.WorkspaceCapture != CaptureDiff { | |
| 25 | + | return fmt.Errorf("subagents: unknown workspace capture mode %q", req.WorkspaceCapture) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | f.mu.Lock() | |
| 29 | + | root := f.cfg.RepoRoot | |
| 30 | + | allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...) | |
| 31 | + | f.mu.Unlock() | |
| 32 | + | return validateWorkspaceRoot(root, mode, allowedRoots) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (f *Swarm) validateSpawnOptions(req SpawnRequest) error { | |
| 36 | + | p := f.cfg.Policy | |
| 37 | + | if req.MaxTurns < 0 || req.MaxTurns > p.MaxTurns { | |
| 38 | + | return fmt.Errorf("subagents: max_turns must be between 1 and %d", p.MaxTurns) | |
| 39 | + | } | |
| 40 | + | if req.Timeout < 0 { | |
| 41 | + | return errorsInvalid("timeout must not be negative") | |
| 42 | + | } | |
| 43 | + | for _, tool := range req.Tools { | |
| 44 | + | name := strings.TrimSpace(tool) | |
| 45 | + | if name == "" { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | if !p.allowedTool(name) { | |
| 49 | + | return fmt.Errorf("subagents: tool %q is not allowed by policy", name) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func validateWorkspaceRoot(root string, mode WorkspaceMode, allowedRoots []string) error { | |
| 56 | + | if root == "" { | |
| 57 | + | return errorsInvalid("repository root is empty") | |
| 58 | + | } | |
| 59 | + | if len(allowedRoots) > 0 { | |
| 60 | + | ok := false | |
| 61 | + | for _, allowed := range allowedRoots { | |
| 62 | + | if pathWithin(root, allowed) { | |
| 63 | + | ok = true | |
| 64 | + | break | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | if !ok { | |
| 68 | + | return fmt.Errorf("subagents: repository root is outside allowed roots") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if mode == WorkspaceWorktree { | |
| 72 | + | if _, err := os.Stat(filepath.Join(root, ".git")); err != nil { | |
| 73 | + | // Worktrees can be attached to repositories with a .git file or | |
| 74 | + | // a parent checkout. Let the workspace adapter provide the | |
| 75 | + | // detailed Git error, but fail obvious non-checkouts here. | |
| 76 | + | if _, statErr := os.Stat(filepath.Join(root, "HEAD")); statErr != nil { | |
| 77 | + | return fmt.Errorf("subagents: worktree mode requires a Git checkout: %w", err) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func errorsInvalid(message string) error { return fmt.Errorf("subagents: %s", message) } | |
| 85 | + | ||
| 86 | + | func pathWithin(path, root string) bool { | |
| 87 | + | pathAbs, err := containmentPath(path) | |
| 88 | + | if err != nil { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | rootAbs, err := containmentPath(root) | |
| 92 | + | if err != nil { | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | pathAbs = filepath.Clean(pathAbs) | |
| 96 | + | rootAbs = filepath.Clean(rootAbs) | |
| 97 | + | if pathAbs == rootAbs { | |
| 98 | + | return true | |
| 99 | + | } | |
| 100 | + | prefix := rootAbs + string(filepath.Separator) | |
| 101 | + | return strings.HasPrefix(pathAbs, prefix) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func containmentPath(path string) (string, error) { | |
| 105 | + | absolute, err := filepath.Abs(path) | |
| 106 | + | if err != nil { | |
| 107 | + | return "", err | |
| 108 | + | } | |
| 109 | + | if evaluated, evalErr := filepath.EvalSymlinks(absolute); evalErr == nil { | |
| 110 | + | return evaluated, nil | |
| 111 | + | } | |
| 112 | + | return filepath.Clean(absolute), nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (f *Swarm) schedule() { | |
| 116 | + | var starts []*Agent | |
| 117 | + | f.mu.Lock() | |
| 118 | + | for len(f.queue) > 0 && f.active < f.cfg.Policy.MaxConcurrent { | |
| 119 | + | idx := f.nextRunnableIndexLocked() | |
| 120 | + | if idx < 0 { | |
| 121 | + | break | |
| 122 | + | } | |
| 123 | + | a := f.queue[idx] | |
| 124 | + | f.queue = append(f.queue[:idx], f.queue[idx+1:]...) | |
| 125 | + | ||
| 126 | + | a.mu.Lock() | |
| 127 | + | if a.status != StatusPending { | |
| 128 | + | a.mu.Unlock() | |
| 129 | + | continue | |
| 130 | + | } | |
| 131 | + | a.status = StatusRunning | |
| 132 | + | a.activity = "starting" | |
| 133 | + | a.mu.Unlock() | |
| 134 | + | a.setProcessState(ProcessStarting) | |
| 135 | + | a.setTurnState(TurnIdle, "") | |
| 136 | + | a.incrementAttempt() | |
| 137 | + | ||
| 138 | + | f.active++ | |
| 139 | + | key := parentKey(a) | |
| 140 | + | f.activeByParent[key]++ | |
| 141 | + | if a.BatchID != "" { | |
| 142 | + | f.activeByBatch[a.BatchID]++ | |
| 143 | + | } | |
| 144 | + | starts = append(starts, a) | |
| 145 | + | } | |
| 146 | + | f.mu.Unlock() | |
| 147 | + | ||
| 148 | + | for _, a := range starts { | |
| 149 | + | f.persistAgent(a) | |
| 150 | + | f.startLifecycleMonitor(a) | |
| 151 | + | go f.run(a) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (f *Swarm) startLifecycleMonitor(a *Agent) { | |
| 156 | + | if a == nil || f.cfg.Policy.IdleTimeout <= 0 { | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | interval := f.cfg.Policy.HeartbeatInterval / 2 | |
| 160 | + | if interval <= 0 || interval > time.Minute { | |
| 161 | + | interval = time.Minute | |
| 162 | + | } | |
| 163 | + | go func() { | |
| 164 | + | ticker := time.NewTicker(interval) | |
| 165 | + | defer ticker.Stop() | |
| 166 | + | heartbeatGrace := f.cfg.Policy.HeartbeatInterval + f.cfg.Policy.ReconnectTimeout | |
| 167 | + | for { | |
| 168 | + | select { | |
| 169 | + | case <-a.done: | |
| 170 | + | return | |
| 171 | + | case <-ticker.C: | |
| 172 | + | if a.ProcessState() != ProcessAlive { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | last := a.LastActivity() | |
| 176 | + | if a.protocolReady() && !last.IsZero() && heartbeatGrace > 0 && time.Since(last) >= heartbeatGrace { | |
| 177 | + | if err := f.Ping(a.ID); err != nil { | |
| 178 | + | a.setProcessState(ProcessDetached) | |
| 179 | + | a.setActivity("detached") | |
| 180 | + | f.persistAgent(a) | |
| 181 | + | continue | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if a.TurnState() == TurnIdle && !last.IsZero() && time.Since(last) >= f.cfg.Policy.IdleTimeout { | |
| 185 | + | _ = f.Stop(a.ID) | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | }() | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // Ping asks a live worker for a heartbeat. A detached or terminal worker | |
| 194 | + | // returns the same not-ready error as other inbox operations. | |
| 195 | + | func (f *Swarm) Ping(id string) error { | |
| 196 | + | a := f.Get(id) | |
| 197 | + | if a == nil { | |
| 198 | + | return fmt.Errorf("subagents: no such agent %q", id) | |
| 199 | + | } | |
| 200 | + | if a.inbox == nil { | |
| 201 | + | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |
| 202 | + | } | |
| 203 | + | return a.inbox.SendCommand(NewCommand(CommandAgentPing, a.ID, a.CurrentTurnID(), AgentPingPayload{})) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (f *Swarm) nextRunnableIndexLocked() int { | |
| 207 | + | if f.active >= f.cfg.Policy.MaxConcurrent { | |
| 208 | + | return -1 | |
| 209 | + | } | |
| 210 | + | for i, a := range f.queue { | |
| 211 | + | if a == nil { | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | a.mu.Lock() | |
| 215 | + | status := a.status | |
| 216 | + | a.mu.Unlock() | |
| 217 | + | if status != StatusPending { | |
| 218 | + | continue | |
| 219 | + | } | |
| 220 | + | if f.cfg.Policy.MaxConcurrentPerParent > 0 && f.activeByParent[parentKey(a)] >= f.cfg.Policy.MaxConcurrentPerParent { | |
| 221 | + | continue | |
| 222 | + | } | |
| 223 | + | if a.BatchID != "" { | |
| 224 | + | if batch := f.batches[a.BatchID]; batch != nil && batch.MaxConcurrent > 0 && f.activeByBatch[a.BatchID] >= batch.MaxConcurrent { | |
| 225 | + | continue | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | return i | |
| 229 | + | } | |
| 230 | + | return -1 | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func parentKey(a *Agent) string { | |
| 234 | + | if a == nil || strings.TrimSpace(a.ParentID) == "" { | |
| 235 | + | return "supervisor" | |
| 236 | + | } | |
| 237 | + | return a.ParentID | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func (f *Swarm) armQueueTimeout(a *Agent) { | |
| 241 | + | if a == nil { | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | if d := f.cfg.Policy.QueueTimeout; d > 0 { | |
| 245 | + | time.AfterFunc(d, func() { f.expireQueued(a.ID) }) | |
| 246 | + | } | |
| 247 | + | go func() { | |
| 248 | + | <-a.ctx.Done() | |
| 249 | + | f.cancelQueued(a.ID, a.ctx.Err()) | |
| 250 | + | }() | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (f *Swarm) expireQueued(id string) { f.cancelQueued(id, context.DeadlineExceeded) } | |
| 254 | + | ||
| 255 | + | func (f *Swarm) removeQueued(a *Agent) { | |
| 256 | + | if a == nil { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | f.mu.Lock() | |
| 260 | + | for i, queued := range f.queue { | |
| 261 | + | if queued == a { | |
| 262 | + | f.queue = append(f.queue[:i], f.queue[i+1:]...) | |
| 263 | + | break | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | f.mu.Unlock() | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (f *Swarm) cancelQueued(id string, cause error) { | |
| 270 | + | a := f.Get(id) | |
| 271 | + | if a == nil { | |
| 272 | + | return | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // schedule and Stop use f.mu as the admission lock. Make queued | |
| 276 | + | // cancellation take the same lock while it changes status and removes the | |
| 277 | + | // entry, so a timeout/cancel cannot finalize an agent concurrently with | |
| 278 | + | // scheduler admission. | |
| 279 | + | f.mu.Lock() | |
| 280 | + | a.mu.Lock() | |
| 281 | + | if a.status != StatusPending { | |
| 282 | + | a.mu.Unlock() | |
| 283 | + | f.mu.Unlock() | |
| 284 | + | return | |
| 285 | + | } | |
| 286 | + | a.status = StatusFailed | |
| 287 | + | atomic.CompareAndSwapInt32(&a.launchState, 0, 2) | |
| 288 | + | if cause == context.Canceled { | |
| 289 | + | a.activity = "cancelled while queued" | |
| 290 | + | } else { | |
| 291 | + | a.activity = "queue timeout" | |
| 292 | + | } | |
| 293 | + | a.lastErr = cause | |
| 294 | + | a.finished = f.cfg.Now() | |
| 295 | + | for i, queued := range f.queue { | |
| 296 | + | if queued == a { | |
| 297 | + | f.queue = append(f.queue[:i], f.queue[i+1:]...) | |
| 298 | + | break | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | a.mu.Unlock() | |
| 302 | + | f.mu.Unlock() | |
| 303 | + | a.setProcessState(ProcessExited) | |
| 304 | + | if cause == context.Canceled { | |
| 305 | + | a.setTurnState(TurnCanceled, "") | |
| 306 | + | } else { | |
| 307 | + | a.setTurnState(TurnFailed, "") | |
| 308 | + | } | |
| 309 | + | f.captureWorkspace(a) | |
| 310 | + | f.ensureResult(a, StatusFailed, cause) | |
| 311 | + | if a.cancel != nil { | |
| 312 | + | a.cancel() | |
| 313 | + | } | |
| 314 | + | f.persistAgent(a) | |
| 315 | + | if a.workspaceCleanup != nil { | |
| 316 | + | _ = a.workspaceCleanup() | |
| 317 | + | } | |
| 318 | + | _ = a.releaseLease() | |
| 319 | + | a.closeDone() | |
| 320 | + | f.schedule() | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (f *Swarm) releaseCapacity(a *Agent) { | |
| 324 | + | f.mu.Lock() | |
| 325 | + | if f.active > 0 { | |
| 326 | + | f.active-- | |
| 327 | + | } | |
| 328 | + | key := parentKey(a) | |
| 329 | + | if f.activeByParent[key] > 0 { | |
| 330 | + | f.activeByParent[key]-- | |
| 331 | + | } | |
| 332 | + | if a != nil && a.BatchID != "" && f.activeByBatch[a.BatchID] > 0 { | |
| 333 | + | f.activeByBatch[a.BatchID]-- | |
| 334 | + | } | |
| 335 | + | f.mu.Unlock() | |
| 336 | + | f.schedule() | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func (f *Swarm) persistAgent(a *Agent) { | |
| 340 | + | if a == nil { | |
| 341 | + | return | |
| 342 | + | } | |
| 343 | + | stateDir := a.stateDirectory(f.cfg.Root) | |
| 344 | + | if _, err := os.Stat(stateDir); err != nil { | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | _ = writeAgentMeta(stateDir, a) | |
| 348 | + | } | |
A packages/agent/swarm/scheduler_test.go +247 -0
| @@ -0,0 +1,247 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) { | |
| 14 | + | root := t.TempDir() | |
| 15 | + | started := make(chan string, 3) | |
| 16 | + | var active, maxActive int32 | |
| 17 | + | f := New(Config{ | |
| 18 | + | Root: root, RepoRoot: root, | |
| 19 | + | Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, MaxTotalSpawned: 3, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour}, | |
| 20 | + | NewRunner: func(a *Agent) Runner { | |
| 21 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 22 | + | current := atomic.AddInt32(&active, 1) | |
| 23 | + | for { | |
| 24 | + | old := atomic.LoadInt32(&maxActive) | |
| 25 | + | if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) { | |
| 26 | + | break | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | started <- a.ID | |
| 30 | + | <-ctx.Done() | |
| 31 | + | atomic.AddInt32(&active, -1) | |
| 32 | + | return ctx.Err() | |
| 33 | + | }) | |
| 34 | + | }, | |
| 35 | + | }) | |
| 36 | + | ||
| 37 | + | first, err := f.Spawn(context.Background(), "first") | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | second, err := f.Spawn(context.Background(), "second") | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | third, err := f.Spawn(context.Background(), "third") | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | select { | |
| 50 | + | case got := <-started: | |
| 51 | + | if got != first.ID { | |
| 52 | + | t.Fatalf("first started = %q, want %q", got, first.ID) | |
| 53 | + | } | |
| 54 | + | case <-time.After(time.Second): | |
| 55 | + | t.Fatal("first worker did not start") | |
| 56 | + | } | |
| 57 | + | if second.Status() != StatusPending || third.Status() != StatusPending { | |
| 58 | + | t.Fatalf("queued statuses = %s/%s", second.Status(), third.Status()) | |
| 59 | + | } | |
| 60 | + | if got := atomic.LoadInt32(&maxActive); got > 1 { | |
| 61 | + | t.Fatalf("max active = %d, want <= 1", got) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | if err := f.Stop(first.ID); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | first.Wait() | |
| 68 | + | select { | |
| 69 | + | case got := <-started: | |
| 70 | + | if got != second.ID { | |
| 71 | + | t.Fatalf("second started = %q, want %q", got, second.ID) | |
| 72 | + | } | |
| 73 | + | case <-time.After(time.Second): | |
| 74 | + | t.Fatal("scheduler did not release capacity") | |
| 75 | + | } | |
| 76 | + | f.StopAll() | |
| 77 | + | if got := atomic.LoadInt32(&maxActive); got > 1 { | |
| 78 | + | t.Fatalf("max active = %d after release, want <= 1", got) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestSchedulerRejectsChildSpawningAndTotalBudget(t *testing.T) { | |
| 83 | + | f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxTotalSpawned: 1}, NewRunner: func(*Agent) Runner { | |
| 84 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 85 | + | }}) | |
| 86 | + | if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil { | |
| 87 | + | t.Fatal("child-originated spawn was accepted") | |
| 88 | + | } | |
| 89 | + | first, err := f.Spawn(context.Background(), "one") | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | first.Wait() | |
| 94 | + | if _, err := f.Spawn(context.Background(), "two"); err == nil || !strings.Contains(err.Error(), "budget") { | |
| 95 | + | t.Fatalf("second spawn error = %v, want budget rejection", err) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestStopQueuedAgentBeforeRunnerAdmission(t *testing.T) { | |
| 100 | + | root := t.TempDir() | |
| 101 | + | started := make(chan string, 2) | |
| 102 | + | f := New(Config{ | |
| 103 | + | Root: root, RepoRoot: root, | |
| 104 | + | Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 3, IdleTimeout: time.Hour}, | |
| 105 | + | NewRunner: func(a *Agent) Runner { | |
| 106 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 107 | + | started <- a.ID | |
| 108 | + | <-ctx.Done() | |
| 109 | + | return ctx.Err() | |
| 110 | + | }) | |
| 111 | + | }, | |
| 112 | + | }) | |
| 113 | + | first, err := f.Spawn(context.Background(), "first") | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | select { | |
| 118 | + | case got := <-started: | |
| 119 | + | if got != first.ID { | |
| 120 | + | t.Fatalf("first runner = %q, want %q", got, first.ID) | |
| 121 | + | } | |
| 122 | + | case <-time.After(time.Second): | |
| 123 | + | t.Fatal("first runner did not start") | |
| 124 | + | } | |
| 125 | + | queued, err := f.Spawn(context.Background(), "queued") | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | if err := f.Stop(queued.ID); err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | queued.Wait() | |
| 133 | + | if queued.Status() != StatusKilled { | |
| 134 | + | t.Fatalf("queued status = %s, want killed", queued.Status()) | |
| 135 | + | } | |
| 136 | + | select { | |
| 137 | + | case got := <-started: | |
| 138 | + | t.Fatalf("stopped queued agent still reached runner: %q", got) | |
| 139 | + | case <-time.After(50 * time.Millisecond): | |
| 140 | + | } | |
| 141 | + | if err := f.Stop(first.ID); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | first.Wait() | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestCanceledQueuedAgentIsRemovedAndGetsResult(t *testing.T) { | |
| 148 | + | root := t.TempDir() | |
| 149 | + | started := make(chan struct{}) | |
| 150 | + | f := New(Config{ | |
| 151 | + | Root: root, RepoRoot: root, | |
| 152 | + | Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 4, IdleTimeout: time.Hour}, | |
| 153 | + | NewRunner: func(*Agent) Runner { | |
| 154 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 155 | + | select { | |
| 156 | + | case started <- struct{}{}: | |
| 157 | + | default: | |
| 158 | + | } | |
| 159 | + | <-ctx.Done() | |
| 160 | + | return ctx.Err() | |
| 161 | + | }) | |
| 162 | + | }, | |
| 163 | + | }) | |
| 164 | + | first, err := f.Spawn(context.Background(), "first") | |
| 165 | + | if err != nil { | |
| 166 | + | t.Fatal(err) | |
| 167 | + | } | |
| 168 | + | <-started | |
| 169 | + | queued, err := f.Spawn(context.Background(), "queued") | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | if queued.Status() != StatusPending { | |
| 174 | + | t.Fatalf("queued status = %s, want pending", queued.Status()) | |
| 175 | + | } | |
| 176 | + | if err := f.Stop(queued.ID); err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | queued.Wait() | |
| 180 | + | f.mu.Lock() | |
| 181 | + | queueLen := len(f.queue) | |
| 182 | + | f.mu.Unlock() | |
| 183 | + | if queueLen != 0 { | |
| 184 | + | t.Fatalf("canceled queue entries = %d, want 0", queueLen) | |
| 185 | + | } | |
| 186 | + | result, err := f.ReadResult(queued.ID) | |
| 187 | + | if err != nil { | |
| 188 | + | t.Fatal(err) | |
| 189 | + | } | |
| 190 | + | if result.Status != ResultCanceled { | |
| 191 | + | t.Fatalf("queued cancellation result = %s, want canceled", result.Status) | |
| 192 | + | } | |
| 193 | + | if err := f.Stop(first.ID); err != nil { | |
| 194 | + | t.Fatal(err) | |
| 195 | + | } | |
| 196 | + | first.Wait() | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestSpawnIDsRemainUniqueWithSameClock(t *testing.T) { | |
| 200 | + | now := time.Unix(123, 0) | |
| 201 | + | f := New(Config{ | |
| 202 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now }, | |
| 203 | + | NewRunner: func(*Agent) Runner { | |
| 204 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 205 | + | }, | |
| 206 | + | }) | |
| 207 | + | first, err := f.Spawn(context.Background(), "same task") | |
| 208 | + | if err != nil { | |
| 209 | + | t.Fatal(err) | |
| 210 | + | } | |
| 211 | + | second, err := f.Spawn(context.Background(), "same task") | |
| 212 | + | if err != nil { | |
| 213 | + | t.Fatal(err) | |
| 214 | + | } | |
| 215 | + | first.Wait() | |
| 216 | + | second.Wait() | |
| 217 | + | if first.ID == second.ID { | |
| 218 | + | t.Fatalf("duplicate agent ids: %q", first.ID) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestStructuredFallbackResultIsDurable(t *testing.T) { | |
| 223 | + | root := t.TempDir() | |
| 224 | + | f := New(Config{ | |
| 225 | + | Root: root, RepoRoot: root, | |
| 226 | + | NewRunner: func(*Agent) Runner { | |
| 227 | + | return RunnerFunc(func(context.Context, Sink) error { | |
| 228 | + | return nil | |
| 229 | + | }) | |
| 230 | + | }, | |
| 231 | + | }) | |
| 232 | + | a, err := f.Spawn(context.Background(), "result fallback") | |
| 233 | + | if err != nil { | |
| 234 | + | t.Fatal(err) | |
| 235 | + | } | |
| 236 | + | a.Wait() | |
| 237 | + | result, err := f.ReadResult(a.ID) | |
| 238 | + | if err != nil { | |
| 239 | + | t.Fatal(err) | |
| 240 | + | } | |
| 241 | + | if result.Status != ResultSucceeded || result.AgentID != a.ID || result.TurnID == "" { | |
| 242 | + | t.Fatalf("result = %+v", result) | |
| 243 | + | } | |
| 244 | + | if _, err := os.Stat(filepath.Join(root, "agents", a.ID, "result.json")); err != nil { | |
| 245 | + | t.Fatalf("result file missing: %v", err) | |
| 246 | + | } | |
| 247 | + | } | |
M packages/agent/swarm/swarm.go +643 -147
| @@ -1,26 +1,16 @@ | |||
| 1 | - | // Package swarm implements zot's multi-agent supervisor. | |
| 1 | + | // Package swarm implements zot's subagent supervisor. | |
| 2 | 2 | // | |
| 3 | - | // A Swarm manages a set of headless zot subprocesses ("agents") | |
| 4 | - | // that share the host's working directory. The interactive TUI | |
| 5 | - | // exposes the supervisor through the /swarm slash command and a | |
| 6 | - | // dashboard dialog; non-TUI code can drive it directly through | |
| 7 | - | // this package. | |
| 3 | + | // A Swarm manages headless, long-lived zot subprocesses and keeps their | |
| 4 | + | // process/turn state, event logs, sessions, results, and optional worktree | |
| 5 | + | // artifacts durable across supervisor restarts. The canonical user-facing | |
| 6 | + | // name is "subagents"; this package and the /swarm command remain for | |
| 7 | + | // compatibility with existing callers. | |
| 8 | 8 | // | |
| 9 | - | // Every agent runs with cwd == the parent zot's RepoRoot — the | |
| 10 | - | // same files the user sees, the same files the main agent edits. | |
| 11 | - | // There is no git worktree, no per-agent branch, no isolation. If | |
| 12 | - | // you want parallel edits on a separate branch, use normal git | |
| 13 | - | // tooling (a real worktree, a different terminal) yourself. | |
| 14 | - | // | |
| 15 | - | // Each Agent has: | |
| 16 | - | // - a unique id (short slug + nanoseconds) | |
| 17 | - | // - a Runner (the thing that actually executes the task) | |
| 18 | - | // - a Status string + Activity string that the dashboard reads | |
| 19 | - | // | |
| 20 | - | // The Runner abstraction means tests can swap a fake in instead of | |
| 21 | - | // really spawning a subprocess; the production Runner shells out to | |
| 22 | - | // `zot --swarm-agent ...` so we reuse zot's own model resolution | |
| 23 | - | // and tooling without re-implementing the agent loop. | |
| 9 | + | // Shared workspaces preserve the historical behavior, while an explicit | |
| 10 | + | // worktree request gives a child an isolated checkout whose patch is captured | |
| 11 | + | // before cleanup. The Runner abstraction lets tests inject a fake process; | |
| 12 | + | // production uses `zot --subagent-worker ...` with the legacy flag accepted as | |
| 13 | + | // a compatibility alias. | |
| 24 | 14 | package swarm | |
| 25 | 15 | ||
| 26 | 16 | import ( | |
| @@ -32,6 +22,7 @@ import ( | |||
| 32 | 22 | "sort" | |
| 33 | 23 | "strings" | |
| 34 | 24 | "sync" | |
| 25 | + | "sync/atomic" | |
| 35 | 26 | "time" | |
| 36 | 27 | ) | |
| 37 | 28 | ||
| @@ -49,19 +40,43 @@ const ( | |||
| 49 | 40 | ||
| 50 | 41 | // Config configures a Swarm. | |
| 51 | 42 | type Config struct { | |
| 43 | + | // Context is the supervisor lifetime. Child workers use it as their | |
| 44 | + | // parent context rather than inheriting an individual tool/model-turn | |
| 45 | + | // context. A nil value means context.Background(). | |
| 46 | + | Context context.Context | |
| 47 | + | ||
| 52 | 48 | // Root is the directory under which per-agent state files live. | |
| 53 | 49 | // Typically <ZotHome>/swarm, but tests pass a tempdir. | |
| 54 | 50 | Root string | |
| 55 | 51 | ||
| 56 | - | // RepoRoot is the working directory every spawned agent runs | |
| 57 | - | // in — the same cwd the parent zot is using. There is no | |
| 58 | - | // per-agent isolation: agents edit the host's files directly. | |
| 52 | + | // LegacyRoots are older state roots that Reload also reads. New | |
| 53 | + | // agents are always written below Root; this keeps the swarm -> | |
| 54 | + | // subagents rename from making existing sessions disappear. | |
| 55 | + | LegacyRoots []string | |
| 56 | + | ||
| 57 | + | // Policy owns concurrency, timeout, output, and path safety limits. | |
| 58 | + | Policy SubagentPolicy | |
| 59 | + | ||
| 60 | + | // RepoRoot is the repository used by spawned agents. Shared-mode children | |
| 61 | + | // run here; worktree-mode children use an isolated checkout created from | |
| 62 | + | // this root. | |
| 59 | 63 | RepoRoot string | |
| 60 | 64 | ||
| 65 | + | // Provider is the active host provider. It is used only to decide whether | |
| 66 | + | // an explicit child provider may inherit the host endpoint settings. | |
| 67 | + | Provider string | |
| 68 | + | ||
| 61 | 69 | // FastMode is copied to every newly spawned child and persisted so | |
| 62 | 70 | // child subprocesses use the same OpenAI service-tier setting. | |
| 63 | 71 | FastMode bool | |
| 64 | 72 | ||
| 73 | + | // BaseURL and InsecureTLS are the effective provider connection | |
| 74 | + | // settings inherited by newly spawned children. They are copied to | |
| 75 | + | // each Agent and persisted so a reload/resume does not silently fall | |
| 76 | + | // back to the child's independent provider configuration. | |
| 77 | + | BaseURL string | |
| 78 | + | InsecureTLS bool | |
| 79 | + | ||
| 65 | 80 | // NewRunner produces the Runner for an Agent. If nil, the default | |
| 66 | 81 | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake | |
| 67 | 82 | // here. | |
| @@ -110,9 +125,18 @@ type Sink interface { | |||
| 110 | 125 | type Swarm struct { | |
| 111 | 126 | cfg Config | |
| 112 | 127 | ||
| 113 | - | mu sync.Mutex | |
| 114 | - | agents map[string]*Agent | |
| 115 | - | order []string // creation order for stable listing | |
| 128 | + | mu sync.Mutex | |
| 129 | + | operationMu sync.Mutex | |
| 130 | + | lifetimeCtx context.Context | |
| 131 | + | lifetimeCancel context.CancelFunc | |
| 132 | + | agents map[string]*Agent | |
| 133 | + | order []string // creation order for stable listing | |
| 134 | + | queue []*Agent | |
| 135 | + | active int | |
| 136 | + | activeByParent map[string]int | |
| 137 | + | activeByBatch map[string]int | |
| 138 | + | totalSpawned int | |
| 139 | + | batches map[string]*Batch | |
| 116 | 140 | ||
| 117 | 141 | // activeSession is the host session id the dashboard is | |
| 118 | 142 | // currently scoped to. When non-empty, SnapshotAll filters out | |
| @@ -137,15 +161,37 @@ func New(cfg Config) *Swarm { | |||
| 137 | 161 | return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential} | |
| 138 | 162 | } | |
| 139 | 163 | } | |
| 164 | + | cfg.Policy.normalize() | |
| 165 | + | lifetimeParent := cfg.Context | |
| 166 | + | if lifetimeParent == nil { | |
| 167 | + | lifetimeParent = context.Background() | |
| 168 | + | } | |
| 169 | + | lifetimeCtx, lifetimeCancel := context.WithCancel(lifetimeParent) | |
| 140 | 170 | return &Swarm{ | |
| 141 | - | cfg: cfg, | |
| 142 | - | agents: map[string]*Agent{}, | |
| 171 | + | cfg: cfg, | |
| 172 | + | lifetimeCtx: lifetimeCtx, | |
| 173 | + | lifetimeCancel: lifetimeCancel, | |
| 174 | + | agents: map[string]*Agent{}, | |
| 175 | + | activeByParent: map[string]int{}, | |
| 176 | + | activeByBatch: map[string]int{}, | |
| 177 | + | batches: map[string]*Batch{}, | |
| 143 | 178 | } | |
| 144 | 179 | } | |
| 145 | 180 | ||
| 146 | 181 | // SetRepoRoot updates the shared working directory for subsequently | |
| 147 | 182 | // spawned agents. Existing agents keep the directory they were started | |
| 148 | 183 | // with; callers use this when the host session changes cwd. | |
| 184 | + | func (f *Swarm) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) { | |
| 185 | + | base := f.lifetimeCtx | |
| 186 | + | if base == nil { | |
| 187 | + | base = context.Background() | |
| 188 | + | } | |
| 189 | + | if timeout > 0 { | |
| 190 | + | return context.WithTimeout(base, timeout) | |
| 191 | + | } | |
| 192 | + | return context.WithCancel(base) | |
| 193 | + | } | |
| 194 | + | ||
| 149 | 195 | func (f *Swarm) SetRepoRoot(root string) { | |
| 150 | 196 | f.mu.Lock() | |
| 151 | 197 | defer f.mu.Unlock() | |
| @@ -176,6 +222,32 @@ func (f *Swarm) FastMode() bool { | |||
| 176 | 222 | return f.cfg.FastMode | |
| 177 | 223 | } | |
| 178 | 224 | ||
| 225 | + | // SetProviderSettings updates the effective connection settings used by | |
| 226 | + | // subsequently spawned children. Existing agents keep their persisted values | |
| 227 | + | // so a model/provider switch cannot silently change a running or resumed | |
| 228 | + | // worker's endpoint. | |
| 229 | + | func (f *Swarm) SetProvider(provider string) { | |
| 230 | + | f.mu.Lock() | |
| 231 | + | f.cfg.Provider = strings.TrimSpace(provider) | |
| 232 | + | f.mu.Unlock() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (f *Swarm) SetProviderSettings(baseURL string, insecureTLS bool) { | |
| 236 | + | f.mu.Lock() | |
| 237 | + | f.cfg.BaseURL = strings.TrimSpace(baseURL) | |
| 238 | + | f.cfg.InsecureTLS = insecureTLS | |
| 239 | + | f.mu.Unlock() | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // ProviderSettings returns the settings used by subsequently spawned | |
| 243 | + | // children. It is primarily useful to host integrations that need to verify | |
| 244 | + | // the manager follows the active provider selection. | |
| 245 | + | func (f *Swarm) ProviderSettings() (string, bool) { | |
| 246 | + | f.mu.Lock() | |
| 247 | + | defer f.mu.Unlock() | |
| 248 | + | return f.cfg.BaseURL, f.cfg.InsecureTLS | |
| 249 | + | } | |
| 250 | + | ||
| 179 | 251 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |
| 180 | 252 | // to a particular host zot session id. Pass empty to clear the | |
| 181 | 253 | // scope and revert to "show every agent" (the original behaviour). | |
| @@ -211,6 +283,18 @@ func (f *Swarm) agentStateDir(id string) string { | |||
| 211 | 283 | return filepath.Join(f.cfg.Root, "agents", id) | |
| 212 | 284 | } | |
| 213 | 285 | ||
| 286 | + | func (f *Swarm) uniqueAgentIDLocked(base string) string { | |
| 287 | + | id := base | |
| 288 | + | for suffix := 2; ; suffix++ { | |
| 289 | + | _, inMemory := f.agents[id] | |
| 290 | + | _, onDiskErr := os.Stat(f.agentStateDir(id)) | |
| 291 | + | if !inMemory && onDiskErr != nil { | |
| 292 | + | return id | |
| 293 | + | } | |
| 294 | + | id = fmt.Sprintf("%s-%d", base, suffix) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 214 | 298 | // SpawnRequest configures a Spawn. Only Task is required; the rest | |
| 215 | 299 | // are optional. Model + Provider, when set, get baked into the | |
| 216 | 300 | // child argv as --model / --provider so the agent runs against the | |
| @@ -220,57 +304,116 @@ func (f *Swarm) agentStateDir(id string) string { | |||
| 220 | 304 | // as --reasoning when set. FastMode restricts the host setting when a | |
| 221 | 305 | // selected profile declares a fastMode preference. | |
| 222 | 306 | type SpawnRequest struct { | |
| 223 | - | Task string | |
| 224 | - | Model string // optional override; child resolves default if empty | |
| 225 | - | Provider string // optional override; usually paired with Model | |
| 226 | - | Reasoning string // optional reasoning/thinking level override | |
| 307 | + | Task string | |
| 308 | + | Model string // optional override; child resolves default if empty | |
| 309 | + | Provider string // optional override; usually paired with Model | |
| 310 | + | BaseURL string // optional provider endpoint override | |
| 311 | + | // InsecureTLS enables the child to skip TLS verification for its | |
| 312 | + | // effective custom provider endpoint. | |
| 313 | + | InsecureTLS bool | |
| 314 | + | Reasoning string // optional reasoning/thinking level override | |
| 227 | 315 | // FastMode is an optional profile restriction. A non-nil false value | |
| 228 | 316 | // disables fast mode for this child; true still cannot enable fast mode | |
| 229 | 317 | // when Config.FastMode is false. Nil inherits Config.FastMode. | |
| 230 | 318 | FastMode *bool | |
| 231 | - | Subagent string // optional named markdown profile | |
| 319 | + | Subagent string // optional named markdown profile | |
| 320 | + | ||
| 321 | + | // ParentID is metadata used for per-parent scheduling limits. A | |
| 322 | + | // non-empty RequesterAgentID identifies an untrusted child request; | |
| 323 | + | // recursive spawning is rejected in v1. | |
| 324 | + | ParentID string | |
| 325 | + | BatchID string | |
| 326 | + | RootSessionID string | |
| 327 | + | RequesterAgentID string | |
| 328 | + | ||
| 329 | + | Timeout time.Duration | |
| 330 | + | MaxTurns int | |
| 331 | + | Tools []string | |
| 332 | + | WorkspaceMode WorkspaceMode | |
| 333 | + | WorkspaceBase string | |
| 334 | + | WorkspaceCapture CaptureMode | |
| 232 | 335 | } | |
| 233 | 336 | ||
| 234 | - | // Spawn creates a new Agent for the given task, allocates its | |
| 235 | - | // on-disk state directory (events log, inbox socket path, session | |
| 236 | - | // file path), and starts the Runner on a background goroutine. The | |
| 237 | - | // returned Agent is already in StatusRunning (or StatusFailed if | |
| 238 | - | // state setup failed before the goroutine started). This is the | |
| 239 | - | // historical signature; callers that want to override the child's | |
| 240 | - | // model use SpawnReq instead. | |
| 337 | + | // Spawn creates a new Agent for the given task, allocates its on-disk | |
| 338 | + | // state directory (events log, inbox socket path, session file path), and | |
| 339 | + | // queues it for the manager-owned scheduler. The returned Agent is pending | |
| 340 | + | // when the concurrency budget is full and transitions to running when a slot | |
| 341 | + | // opens. This is the historical signature; callers that want to override the | |
| 342 | + | // child's model or workspace use SpawnReq instead. | |
| 241 | 343 | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |
| 242 | 344 | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | |
| 243 | 345 | } | |
| 244 | 346 | ||
| 245 | - | // SpawnReq is the full-fat variant of Spawn that accepts a | |
| 246 | - | // SpawnRequest. Existing callers can keep using Spawn; new code that | |
| 247 | - | // wants to pin the child's model uses this. | |
| 248 | - | // | |
| 249 | - | // Every spawned agent runs with cwd == cfg.RepoRoot — the same | |
| 250 | - | // working directory as the host. No per-agent worktree, no branch, | |
| 251 | - | // no isolation. The user explicitly opted out of the worktree flow. | |
| 347 | + | // SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest. | |
| 348 | + | // Existing callers can keep using Spawn; new code that wants to pin the | |
| 349 | + | // child's model, policy, or workspace uses this. | |
| 252 | 350 | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | |
| 253 | 351 | task := strings.TrimSpace(req.Task) | |
| 254 | 352 | if task == "" { | |
| 255 | - | return nil, errors.New("swarm: empty task") | |
| 353 | + | return nil, errors.New("subagents: empty task") | |
| 354 | + | } | |
| 355 | + | if req.RequesterAgentID != "" { | |
| 356 | + | return nil, errors.New("subagents: child-originated spawning is disabled") | |
| 357 | + | } | |
| 358 | + | if err := f.validateSpawnRequest(req); err != nil { | |
| 359 | + | return nil, err | |
| 256 | 360 | } | |
| 257 | - | id := newAgentID(task, f.cfg.Now()) | |
| 361 | + | if ctx == nil { | |
| 362 | + | ctx = context.Background() | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | now := f.cfg.Now() | |
| 366 | + | baseID := newAgentID(task, now) | |
| 258 | 367 | ||
| 259 | - | // Snapshot the shared cwd together with the active session so a | |
| 260 | - | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | |
| 368 | + | // Reserve a spawn budget before touching disk. This makes the total | |
| 369 | + | // budget race-free across slash commands, tools, and batch callers. | |
| 261 | 370 | f.mu.Lock() | |
| 371 | + | id := f.uniqueAgentIDLocked(baseID) | |
| 372 | + | if f.totalSpawned >= f.cfg.Policy.MaxTotalSpawned { | |
| 373 | + | f.mu.Unlock() | |
| 374 | + | return nil, fmt.Errorf("subagents: total spawn budget exhausted (%d)", f.cfg.Policy.MaxTotalSpawned) | |
| 375 | + | } | |
| 376 | + | f.totalSpawned++ | |
| 262 | 377 | dir := f.cfg.RepoRoot | |
| 263 | 378 | fastMode := f.cfg.FastMode | |
| 379 | + | configProvider := f.cfg.Provider | |
| 380 | + | configBaseURL := f.cfg.BaseURL | |
| 381 | + | configInsecureTLS := f.cfg.InsecureTLS | |
| 264 | 382 | sessionID := f.activeSession | |
| 265 | 383 | f.mu.Unlock() | |
| 266 | 384 | if req.FastMode != nil { | |
| 267 | 385 | fastMode = fastMode && *req.FastMode | |
| 268 | 386 | } | |
| 387 | + | childProvider := strings.TrimSpace(req.Provider) | |
| 388 | + | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | |
| 389 | + | baseURL := strings.TrimSpace(req.BaseURL) | |
| 390 | + | if baseURL == "" && inheritProviderSettings { | |
| 391 | + | baseURL = strings.TrimSpace(configBaseURL) | |
| 392 | + | } | |
| 393 | + | insecureTLS := req.InsecureTLS || (inheritProviderSettings && configInsecureTLS) | |
| 394 | + | reserved := true | |
| 395 | + | defer func() { | |
| 396 | + | if reserved { | |
| 397 | + | f.mu.Lock() | |
| 398 | + | f.totalSpawned-- | |
| 399 | + | f.mu.Unlock() | |
| 400 | + | } | |
| 401 | + | }() | |
| 269 | 402 | ||
| 270 | 403 | stateDir := f.agentStateDir(id) | |
| 271 | - | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| 272 | - | return nil, fmt.Errorf("swarm state dir: %w", err) | |
| 404 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 405 | + | return nil, fmt.Errorf("subagents state dir: %w", err) | |
| 273 | 406 | } | |
| 407 | + | lease, err := acquireAgentLease(stateDir) | |
| 408 | + | if err != nil { | |
| 409 | + | return nil, fmt.Errorf("subagents agent lease: %w", err) | |
| 410 | + | } | |
| 411 | + | leaseOwned := true | |
| 412 | + | defer func() { | |
| 413 | + | if leaseOwned { | |
| 414 | + | _ = lease.Close() | |
| 415 | + | } | |
| 416 | + | }() | |
| 274 | 417 | logPath := filepath.Join(stateDir, "events.jsonl") | |
| 275 | 418 | sessionPath := filepath.Join(stateDir, "session.json") | |
| 276 | 419 | // Keep the transient unix socket outside the durable state root. | |
| @@ -278,43 +421,120 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 278 | 421 | // regular state files but cannot host unix socket nodes. | |
| 279 | 422 | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | |
| 280 | 423 | if err != nil { | |
| 281 | - | return nil, fmt.Errorf("swarm inbox path: %w", err) | |
| 424 | + | _ = lease.Close() | |
| 425 | + | leaseOwned = false | |
| 426 | + | _ = os.RemoveAll(stateDir) | |
| 427 | + | return nil, fmt.Errorf("subagents inbox path: %w", err) | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | workspaceMode := req.WorkspaceMode | |
| 431 | + | if workspaceMode == "" { | |
| 432 | + | workspaceMode = WorkspaceShared | |
| 433 | + | } | |
| 434 | + | workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{ | |
| 435 | + | Mode: workspaceMode, | |
| 436 | + | RepositoryRoot: dir, | |
| 437 | + | StateDir: stateDir, | |
| 438 | + | AgentID: id, | |
| 439 | + | Base: req.WorkspaceBase, | |
| 440 | + | Capture: req.WorkspaceCapture, | |
| 441 | + | AllowedRoots: append([]string(nil), f.cfg.Policy.AllowedRoots...), | |
| 442 | + | }) | |
| 443 | + | if err != nil { | |
| 444 | + | _ = lease.Close() | |
| 445 | + | leaseOwned = false | |
| 446 | + | _ = os.RemoveAll(stateDir) | |
| 447 | + | return nil, err | |
| 448 | + | } | |
| 449 | + | dir = workspace.Dir() | |
| 450 | + | maxTurns := req.MaxTurns | |
| 451 | + | if maxTurns <= 0 { | |
| 452 | + | maxTurns = f.cfg.Policy.MaxTurns | |
| 453 | + | } | |
| 454 | + | timeout := req.Timeout | |
| 455 | + | if timeout <= 0 { | |
| 456 | + | timeout = f.cfg.Policy.DefaultTimeout | |
| 457 | + | } | |
| 458 | + | runCtx, cancel := f.workerContext(timeout) | |
| 459 | + | tools := append([]string(nil), req.Tools...) | |
| 460 | + | if len(tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | |
| 461 | + | tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | |
| 282 | 462 | } | |
| 283 | 463 | ||
| 284 | 464 | a := &Agent{ | |
| 285 | - | ID: id, | |
| 286 | - | Task: task, | |
| 287 | - | Dir: dir, | |
| 288 | - | Started: f.cfg.Now(), | |
| 289 | - | Model: strings.TrimSpace(req.Model), | |
| 290 | - | Provider: strings.TrimSpace(req.Provider), | |
| 291 | - | Reasoning: strings.TrimSpace(req.Reasoning), | |
| 292 | - | FastMode: fastMode, | |
| 293 | - | Subagent: strings.TrimSpace(req.Subagent), | |
| 294 | - | SessionID: sessionID, | |
| 295 | - | InboxPath: inboxPath, | |
| 296 | - | EventLogPath: logPath, | |
| 297 | - | SessionPath: sessionPath, | |
| 298 | - | inbox: NewInbox(inboxPath), | |
| 299 | - | status: StatusPending, | |
| 300 | - | activity: "queued", | |
| 301 | - | done: make(chan struct{}), | |
| 302 | - | } | |
| 303 | - | a.ctx, a.cancel = context.WithCancel(ctx) | |
| 465 | + | ID: id, | |
| 466 | + | Task: task, | |
| 467 | + | OriginalTask: task, | |
| 468 | + | Dir: dir, | |
| 469 | + | RepositoryRoot: workspace.RepositoryRoot(), | |
| 470 | + | WorkspacePath: workspace.Dir(), | |
| 471 | + | Started: now, | |
| 472 | + | ParentID: strings.TrimSpace(req.ParentID), | |
| 473 | + | BatchID: strings.TrimSpace(req.BatchID), | |
| 474 | + | RootSessionID: strings.TrimSpace(req.RootSessionID), | |
| 475 | + | Model: strings.TrimSpace(req.Model), | |
| 476 | + | Provider: strings.TrimSpace(req.Provider), | |
| 477 | + | BaseURL: baseURL, | |
| 478 | + | InsecureTLS: insecureTLS, | |
| 479 | + | Reasoning: strings.TrimSpace(req.Reasoning), | |
| 480 | + | FastMode: fastMode, | |
| 481 | + | Subagent: strings.TrimSpace(req.Subagent), | |
| 482 | + | SessionID: sessionID, | |
| 483 | + | WorkspaceMode: workspaceMode, | |
| 484 | + | WorkspaceBase: strings.TrimSpace(req.WorkspaceBase), | |
| 485 | + | WorkspaceCapture: req.WorkspaceCapture, | |
| 486 | + | MaxTurns: maxTurns, | |
| 487 | + | Timeout: timeout, | |
| 488 | + | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | |
| 489 | + | Tools: tools, | |
| 490 | + | InboxPath: inboxPath, | |
| 491 | + | EventLogPath: logPath, | |
| 492 | + | SessionPath: sessionPath, | |
| 493 | + | inbox: NewInbox(inboxPath), | |
| 494 | + | status: StatusPending, | |
| 495 | + | activity: "queued", | |
| 496 | + | processState: ProcessPending, | |
| 497 | + | turnState: TurnQueued, | |
| 498 | + | updatedAt: now, | |
| 499 | + | lastActivity: now, | |
| 500 | + | stateDir: stateDir, | |
| 501 | + | lease: lease, | |
| 502 | + | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 503 | + | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 504 | + | done: make(chan struct{}), | |
| 505 | + | turnResults: make(chan *TurnResult, 16), | |
| 506 | + | } | |
| 507 | + | if a.RootSessionID == "" { | |
| 508 | + | a.RootSessionID = sessionID | |
| 509 | + | } | |
| 510 | + | a.ctx, a.cancel = runCtx, cancel | |
| 511 | + | a.persistFn = f.persistAgent | |
| 512 | + | a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) } | |
| 513 | + | a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) } | |
| 304 | 514 | a.runner = f.cfg.NewRunner(a) | |
| 515 | + | if err := writeAgentMeta(stateDir, a); err != nil { | |
| 516 | + | if a.cancel != nil { | |
| 517 | + | a.cancel() | |
| 518 | + | } | |
| 519 | + | _ = workspace.Cleanup(context.Background()) | |
| 520 | + | _ = lease.Close() | |
| 521 | + | leaseOwned = false | |
| 522 | + | _ = os.RemoveAll(stateDir) | |
| 523 | + | return nil, fmt.Errorf("subagents initial metadata: %w", err) | |
| 524 | + | } | |
| 305 | 525 | ||
| 306 | 526 | f.mu.Lock() | |
| 307 | 527 | f.agents[id] = a | |
| 308 | 528 | f.order = append(f.order, id) | |
| 529 | + | f.queue = append(f.queue, a) | |
| 309 | 530 | f.mu.Unlock() | |
| 531 | + | leaseOwned = false | |
| 310 | 532 | ||
| 311 | - | // Persist the agent's identity so a later `zot` invocation can | |
| 312 | - | // reload it from disk via Swarm.Reload. Best-effort: if the disk | |
| 313 | - | // is read-only we still let the runner start, the user just won't | |
| 314 | - | // see this agent on the next launch. | |
| 315 | - | _ = writeAgentMeta(stateDir, a) | |
| 316 | - | ||
| 317 | - | go f.run(a) | |
| 533 | + | // Persisted metadata was written before queue admission, so a later | |
| 534 | + | // supervisor can reconstruct work that never reached a process. | |
| 535 | + | f.armQueueTimeout(a) | |
| 536 | + | f.schedule() | |
| 537 | + | reserved = false | |
| 318 | 538 | return a, nil | |
| 319 | 539 | } | |
| 320 | 540 | ||
| @@ -336,22 +556,77 @@ func (f *Swarm) SendInput(id, msg string) error { | |||
| 336 | 556 | // SendUserTurn is sugar for the common "send the next user turn" | |
| 337 | 557 | // case. It quotes nothing and forwards verbatim; callers are | |
| 338 | 558 | // expected to have already trimmed and expanded the text. | |
| 559 | + | // CancelTurn requests cancellation of the current turn while keeping the | |
| 560 | + | // worker process alive for a later follow-up. | |
| 561 | + | func (f *Swarm) CancelTurn(id string) error { | |
| 562 | + | a := f.Get(id) | |
| 563 | + | if a == nil { | |
| 564 | + | return fmt.Errorf("subagents: no such agent %q", id) | |
| 565 | + | } | |
| 566 | + | if a.inbox == nil { | |
| 567 | + | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |
| 568 | + | } | |
| 569 | + | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | |
| 570 | + | if !a.protocolReady() { | |
| 571 | + | return a.inbox.SendInput("cancel") | |
| 572 | + | } | |
| 573 | + | return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"})) | |
| 574 | + | } | |
| 575 | + | ||
| 339 | 576 | func (f *Swarm) SendUserTurn(id, text string) error { | |
| 340 | - | return f.SendInput(id, "user "+text) | |
| 577 | + | a := f.Get(id) | |
| 578 | + | if a == nil { | |
| 579 | + | return fmt.Errorf("subagents: no such agent %q", id) | |
| 580 | + | } | |
| 581 | + | if a.inbox == nil { | |
| 582 | + | return fmt.Errorf("subagents: agent %s has no inbox", a.ID) | |
| 583 | + | } | |
| 584 | + | if !a.protocolReady() { | |
| 585 | + | // Until agent.ready arrives, use the legacy spelling so a prompt | |
| 586 | + | // sent immediately after Spawn is not lost during compatibility | |
| 587 | + | // negotiation. The worker accepts both forms. | |
| 588 | + | return a.inbox.SendInput("user " + text) | |
| 589 | + | } | |
| 590 | + | return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text})) | |
| 341 | 591 | } | |
| 342 | 592 | ||
| 343 | 593 | func (f *Swarm) run(a *Agent) { | |
| 344 | - | a.setStatus(StatusRunning) | |
| 345 | - | a.setActivity("starting") | |
| 346 | - | err := a.runner.Run(a.ctx, agentSink{a: a}) | |
| 594 | + | a.mu.Lock() | |
| 595 | + | killedBeforeStart := a.status == StatusKilled | |
| 596 | + | ctxErr := error(nil) | |
| 597 | + | if a.ctx != nil { | |
| 598 | + | ctxErr = a.ctx.Err() | |
| 599 | + | } | |
| 600 | + | a.mu.Unlock() | |
| 601 | + | var err error | |
| 602 | + | if killedBeforeStart || ctxErr != nil || !atomic.CompareAndSwapInt32(&a.launchState, 0, 1) { | |
| 603 | + | if ctxErr != nil { | |
| 604 | + | err = ctxErr | |
| 605 | + | } else { | |
| 606 | + | err = context.Canceled | |
| 607 | + | } | |
| 608 | + | } else { | |
| 609 | + | a.setActivity("starting") | |
| 610 | + | if a.runner == nil { | |
| 611 | + | err = errors.New("subagents: agent has no runner") | |
| 612 | + | } else { | |
| 613 | + | err = a.runner.Run(a.ctx, agentSink{a: a}) | |
| 614 | + | } | |
| 615 | + | } | |
| 616 | + | ||
| 347 | 617 | a.mu.Lock() | |
| 348 | 618 | a.finished = f.cfg.Now() | |
| 349 | 619 | switch { | |
| 350 | 620 | case a.status == StatusKilled: | |
| 351 | - | // Already finalised by Stop. | |
| 621 | + | // Stop requested a terminal state; preserve it. | |
| 352 | 622 | case errors.Is(err, context.Canceled): | |
| 353 | 623 | a.status = StatusKilled | |
| 354 | 624 | a.activity = "cancelled" | |
| 625 | + | a.lastErr = err | |
| 626 | + | case errors.Is(err, context.DeadlineExceeded): | |
| 627 | + | a.status = StatusFailed | |
| 628 | + | a.activity = "timeout" | |
| 629 | + | a.lastErr = err | |
| 355 | 630 | case err != nil: | |
| 356 | 631 | a.status = StatusFailed | |
| 357 | 632 | a.activity = "error: " + truncate(err.Error(), 120) | |
| @@ -360,8 +635,46 @@ func (f *Swarm) run(a *Agent) { | |||
| 360 | 635 | a.status = StatusDone | |
| 361 | 636 | a.activity = "done" | |
| 362 | 637 | } | |
| 638 | + | finalStatus := a.status | |
| 363 | 639 | a.mu.Unlock() | |
| 364 | - | close(a.done) | |
| 640 | + | ||
| 641 | + | switch finalStatus { | |
| 642 | + | case StatusKilled: | |
| 643 | + | a.setProcessState(ProcessKilled) | |
| 644 | + | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |
| 645 | + | case StatusFailed: | |
| 646 | + | a.setProcessState(ProcessExited) | |
| 647 | + | a.setTurnState(TurnFailed, a.CurrentTurnID()) | |
| 648 | + | default: | |
| 649 | + | a.setProcessState(ProcessExited) | |
| 650 | + | if a.TurnState() == TurnRunning || a.TurnState() == TurnIdle { | |
| 651 | + | a.setTurnState(TurnSucceeded, a.CurrentTurnID()) | |
| 652 | + | } | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | // The process slot is no longer occupied once Runner.Run has returned. | |
| 656 | + | // Release it before potentially slow artifact capture and workspace | |
| 657 | + | // cleanup so queued work can start without waiting on git/filesystem I/O. | |
| 658 | + | f.releaseCapacity(a) | |
| 659 | + | ||
| 660 | + | // Capture isolated output and write the structured result before the | |
| 661 | + | // temporary workspace is removed. This ordering is the recovery | |
| 662 | + | // invariant: a user can always inspect a durable result after failure. | |
| 663 | + | f.captureWorkspace(a) | |
| 664 | + | f.ensureResult(a, finalStatus, err) | |
| 665 | + | if a.cancel != nil { | |
| 666 | + | a.cancel() | |
| 667 | + | } | |
| 668 | + | if a.inbox != nil { | |
| 669 | + | _ = a.inbox.Close() | |
| 670 | + | } | |
| 671 | + | a.setProcessPID(0) | |
| 672 | + | f.persistAgent(a) | |
| 673 | + | if a.workspaceCleanup != nil { | |
| 674 | + | _ = a.workspaceCleanup() | |
| 675 | + | } | |
| 676 | + | _ = a.releaseLease() | |
| 677 | + | a.closeDone() | |
| 365 | 678 | } | |
| 366 | 679 | ||
| 367 | 680 | // List returns a snapshot of every agent in creation order. The | |
| @@ -407,42 +720,170 @@ func (f *Swarm) Get(id string) *Agent { | |||
| 407 | 720 | // socket about to be unlinked. | |
| 408 | 721 | // | |
| 409 | 722 | // Stop is a no-op for any agent that's not in a live runnable state | |
| 410 | - | // — Done / Failed / Killed (already finalised) and Detached (no | |
| 411 | - | // in-process runner; reloaded from disk). Calling Stop on a detached | |
| 412 | - | // agent must not crash: buildDetachedAgent doesn't allocate a | |
| 413 | - | // context/cancel pair because there's nothing to cancel. | |
| 723 | + | // — Done / Failed / Killed (already finalised). Reloaded Detached agents | |
| 724 | + | // are probed first: if their worker survived the supervisor, Stop sends the | |
| 725 | + | // shutdown command through the durable inbox instead of pretending no | |
| 726 | + | // process exists. | |
| 414 | 727 | func (f *Swarm) Stop(id string) error { | |
| 728 | + | f.operationMu.Lock() | |
| 729 | + | defer f.operationMu.Unlock() | |
| 415 | 730 | a := f.Get(id) | |
| 416 | 731 | if a == nil { | |
| 417 | 732 | return fmt.Errorf("no such agent %q", id) | |
| 418 | 733 | } | |
| 734 | + | ||
| 735 | + | // Coordinate the status transition with schedule. Holding f.mu while | |
| 736 | + | // checking and changing a.status closes the small window where Stop could | |
| 737 | + | // observe pending, schedule could promote the same agent to running, and | |
| 738 | + | // the pending cleanup below could then finalize an agent that was already | |
| 739 | + | // admitted to the runner. | |
| 740 | + | f.mu.Lock() | |
| 419 | 741 | a.mu.Lock() | |
| 420 | - | switch a.status { | |
| 421 | - | case StatusDone, StatusFailed, StatusKilled, StatusDetached: | |
| 742 | + | status := a.status | |
| 743 | + | if status == StatusDone || status == StatusFailed || status == StatusKilled { | |
| 744 | + | a.mu.Unlock() | |
| 745 | + | f.mu.Unlock() | |
| 746 | + | return nil | |
| 747 | + | } | |
| 748 | + | if status == StatusDetached { | |
| 749 | + | path := a.InboxPath | |
| 750 | + | inbox := a.inbox | |
| 751 | + | pid := a.ProcessPIDValue() | |
| 752 | + | protocolReady := a.protocolReady() | |
| 753 | + | a.mu.Unlock() | |
| 754 | + | f.mu.Unlock() | |
| 755 | + | if inbox == nil || !inboxLive(path) { | |
| 756 | + | return nil | |
| 757 | + | } | |
| 758 | + | var err error | |
| 759 | + | if protocolReady { | |
| 760 | + | err = inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | |
| 761 | + | } else { | |
| 762 | + | err = inbox.SendInput("shutdown") | |
| 763 | + | } | |
| 764 | + | if err != nil { | |
| 765 | + | return err | |
| 766 | + | } | |
| 767 | + | a.setActivity("shutdown requested") | |
| 768 | + | grace := f.cfg.Policy.CancelGracePeriod | |
| 769 | + | if grace <= 0 { | |
| 770 | + | grace = 10 * time.Second | |
| 771 | + | } | |
| 772 | + | deadline := time.Now().Add(grace) | |
| 773 | + | for inboxLive(path) { | |
| 774 | + | if time.Now().After(deadline) { | |
| 775 | + | if pid <= 0 { | |
| 776 | + | return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace) | |
| 777 | + | } | |
| 778 | + | if killErr := forceKillProcess(pid); killErr != nil { | |
| 779 | + | return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr) | |
| 780 | + | } | |
| 781 | + | forceDeadline := time.Now().Add(time.Second) | |
| 782 | + | for inboxLive(path) && time.Now().Before(forceDeadline) { | |
| 783 | + | time.Sleep(20 * time.Millisecond) | |
| 784 | + | } | |
| 785 | + | if inboxLive(path) { | |
| 786 | + | return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID) | |
| 787 | + | } | |
| 788 | + | } | |
| 789 | + | time.Sleep(20 * time.Millisecond) | |
| 790 | + | } | |
| 791 | + | a.mu.Lock() | |
| 792 | + | a.status = StatusKilled | |
| 793 | + | a.activity = "killed" | |
| 422 | 794 | a.mu.Unlock() | |
| 795 | + | a.setProcessState(ProcessKilled) | |
| 796 | + | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |
| 797 | + | a.setProcessPID(0) | |
| 798 | + | f.persistAgent(a) | |
| 423 | 799 | return nil | |
| 424 | 800 | } | |
| 425 | 801 | a.status = StatusKilled | |
| 426 | - | a.activity = "stopped" | |
| 802 | + | a.activity = "canceling" | |
| 803 | + | atomic.CompareAndSwapInt32(&a.launchState, 0, 2) | |
| 804 | + | if status == StatusPending { | |
| 805 | + | for i, queued := range f.queue { | |
| 806 | + | if queued == a { | |
| 807 | + | f.queue = append(f.queue[:i], f.queue[i+1:]...) | |
| 808 | + | break | |
| 809 | + | } | |
| 810 | + | } | |
| 811 | + | } | |
| 427 | 812 | a.mu.Unlock() | |
| 428 | - | // Belt-and-braces guard against the cancel-less case (detached | |
| 429 | - | // agents skip this branch above, but a future code path that | |
| 430 | - | // builds an Agent without a runner shouldn't crash the | |
| 431 | - | // supervisor). | |
| 432 | - | if a.cancel != nil { | |
| 433 | - | a.cancel() | |
| 813 | + | f.mu.Unlock() | |
| 814 | + | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | |
| 815 | + | ||
| 816 | + | if status == StatusPending { | |
| 817 | + | a.setProcessState(ProcessKilled) | |
| 818 | + | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |
| 819 | + | f.captureWorkspace(a) | |
| 820 | + | f.ensureResult(a, StatusKilled, context.Canceled) | |
| 821 | + | if a.cancel != nil { | |
| 822 | + | a.cancel() | |
| 823 | + | } | |
| 824 | + | f.persistAgent(a) | |
| 825 | + | if a.workspaceCleanup != nil { | |
| 826 | + | _ = a.workspaceCleanup() | |
| 827 | + | } | |
| 828 | + | _ = a.releaseLease() | |
| 829 | + | a.closeDone() | |
| 830 | + | f.schedule() | |
| 831 | + | return nil | |
| 434 | 832 | } | |
| 833 | + | ||
| 834 | + | // Ask a live worker to shut down cleanly first. If the socket is not | |
| 835 | + | // ready, fall back immediately; a forceful context cancellation is | |
| 836 | + | // safer than leaving a supervisor entry running forever. | |
| 837 | + | graceful := false | |
| 435 | 838 | if a.inbox != nil { | |
| 436 | - | _ = a.inbox.Close() | |
| 839 | + | var err error | |
| 840 | + | if a.protocolReady() { | |
| 841 | + | err = a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | |
| 842 | + | } else { | |
| 843 | + | err = a.inbox.SendInput("shutdown") | |
| 844 | + | } | |
| 845 | + | if err == nil { | |
| 846 | + | graceful = true | |
| 847 | + | } | |
| 848 | + | } | |
| 849 | + | if !graceful && a.cancel != nil { | |
| 850 | + | a.cancel() | |
| 851 | + | } | |
| 852 | + | if graceful { | |
| 853 | + | grace := f.cfg.Policy.CancelGracePeriod | |
| 854 | + | if grace <= 0 { | |
| 855 | + | grace = 10 * time.Second | |
| 856 | + | } | |
| 857 | + | go func() { | |
| 858 | + | timer := time.NewTimer(grace) | |
| 859 | + | defer timer.Stop() | |
| 860 | + | select { | |
| 861 | + | case <-a.done: | |
| 862 | + | case <-timer.C: | |
| 863 | + | if a.cancel != nil { | |
| 864 | + | a.cancel() | |
| 865 | + | } | |
| 866 | + | if a.inbox != nil { | |
| 867 | + | _ = a.inbox.Close() | |
| 868 | + | } | |
| 869 | + | } | |
| 870 | + | }() | |
| 437 | 871 | } | |
| 438 | 872 | return nil | |
| 439 | 873 | } | |
| 440 | 874 | ||
| 441 | 875 | // StopAll cancels every running agent. Used on shutdown. | |
| 442 | 876 | func (f *Swarm) StopAll() { | |
| 443 | - | for _, a := range f.List() { | |
| 877 | + | agents := f.List() | |
| 878 | + | for _, a := range agents { | |
| 444 | 879 | _ = f.Stop(a.ID) | |
| 445 | 880 | } | |
| 881 | + | for _, a := range agents { | |
| 882 | + | a.Wait() | |
| 883 | + | } | |
| 884 | + | if f.lifetimeCancel != nil { | |
| 885 | + | f.lifetimeCancel() | |
| 886 | + | } | |
| 446 | 887 | } | |
| 447 | 888 | ||
| 448 | 889 | // Remove tears down the per-agent state for a terminated agent. It | |
| @@ -451,25 +892,42 @@ func (f *Swarm) StopAll() { | |||
| 451 | 892 | // (reloaded from disk) remove cleanly because they have no live | |
| 452 | 893 | // runner racing for the same files. | |
| 453 | 894 | // | |
| 454 | - | // Agents share the host's working tree, so Remove never touches | |
| 455 | - | // any source file — it only deletes the agent's state directory | |
| 895 | + | // Shared-workspace agents never touch source files during Remove; worktree | |
| 896 | + | // agents have already captured their output and removed the isolated checkout | |
| 897 | + | // during finalization. Remove then deletes the agent's durable state directory | |
| 456 | 898 | // under <root>/agents/<id>/. | |
| 457 | 899 | func (f *Swarm) Remove(id string) error { | |
| 900 | + | f.operationMu.Lock() | |
| 901 | + | defer f.operationMu.Unlock() | |
| 458 | 902 | a := f.Get(id) | |
| 459 | 903 | if a == nil { | |
| 460 | 904 | return fmt.Errorf("no such agent %q", id) | |
| 461 | 905 | } | |
| 462 | 906 | a.mu.Lock() | |
| 463 | 907 | st := a.status | |
| 908 | + | inboxPath := a.InboxPath | |
| 464 | 909 | a.mu.Unlock() | |
| 465 | 910 | if st == StatusRunning || st == StatusPending { | |
| 466 | 911 | return fmt.Errorf("agent %s still %s", a.ID, st) | |
| 467 | 912 | } | |
| 913 | + | if inboxLive(inboxPath) { | |
| 914 | + | return fmt.Errorf("agent %s still has a live worker; stop it first", a.ID) | |
| 915 | + | } | |
| 916 | + | select { | |
| 917 | + | case <-a.done: | |
| 918 | + | default: | |
| 919 | + | return fmt.Errorf("agent %s is still finalizing", a.ID) | |
| 920 | + | } | |
| 921 | + | lease, err := acquireAgentLease(a.stateDirectory(f.cfg.Root)) | |
| 922 | + | if err != nil { | |
| 923 | + | return fmt.Errorf("agent %s state is owned by another supervisor: %w", a.ID, err) | |
| 924 | + | } | |
| 925 | + | _ = lease.Close() | |
| 468 | 926 | // Best-effort cleanup of the per-agent state directory | |
| 469 | 927 | // (meta.json, events.jsonl, session.json). Failing here would leave | |
| 470 | 928 | // the user with no recourse, | |
| 471 | 929 | // so swallow the error. | |
| 472 | - | _ = os.RemoveAll(f.agentStateDir(a.ID)) | |
| 930 | + | _ = os.RemoveAll(a.stateDirectory(f.cfg.Root)) | |
| 473 | 931 | f.mu.Lock() | |
| 474 | 932 | delete(f.agents, a.ID) | |
| 475 | 933 | for i, k := range f.order { | |
| @@ -485,32 +943,48 @@ func (f *Swarm) Remove(id string) error { | |||
| 485 | 943 | // Snapshot returns a read-only view of one agent. Safe for the TUI | |
| 486 | 944 | // goroutine to call repeatedly; never blocks on the Runner. | |
| 487 | 945 | type AgentSnapshot struct { | |
| 488 | - | ID string | |
| 489 | - | Task string | |
| 490 | - | Dir string | |
| 491 | - | Status Status | |
| 492 | - | Activity string | |
| 493 | - | Started time.Time | |
| 494 | - | Finished time.Time | |
| 495 | - | Err string | |
| 496 | - | Tail string // last few transcript lines, joined with "\n" | |
| 497 | - | Lines []string // full transcript (already capped by Agent.appendTranscript) | |
| 498 | - | LastAssistant string // complete final assistant message, without role formatting | |
| 499 | - | ||
| 500 | - | // Model, Provider, and Reasoning expose the per-agent overrides set | |
| 501 | - | // at Spawn time (empty when the agent inherits the child's default | |
| 502 | - | // resolution). Subagent is the selected named markdown profile. | |
| 503 | - | // The dashboard surfaces these so the user can confirm the child | |
| 504 | - | // configuration. | |
| 505 | - | Model string | |
| 506 | - | Provider string | |
| 507 | - | Reasoning string | |
| 508 | - | FastMode bool | |
| 509 | - | Subagent string | |
| 946 | + | ID string | |
| 947 | + | Task string | |
| 948 | + | Dir string | |
| 949 | + | Status Status | |
| 950 | + | ProcessState ProcessState | |
| 951 | + | TurnState TurnState | |
| 952 | + | CurrentTurnID string | |
| 953 | + | Attempt int | |
| 954 | + | Activity string | |
| 955 | + | Started time.Time | |
| 956 | + | Finished time.Time | |
| 957 | + | CreatedAt time.Time | |
| 958 | + | UpdatedAt time.Time | |
| 959 | + | LastActivity time.Time | |
| 960 | + | Err string | |
| 961 | + | Tail string // last few transcript lines, joined with "\n" | |
| 962 | + | Lines []string // full transcript (already capped by Agent.appendTranscript) | |
| 963 | + | LastAssistant string // complete final assistant message, without role formatting | |
| 964 | + | Result *TurnResult | |
| 965 | + | ResultRef string | |
| 966 | + | PatchRef string | |
| 967 | + | ChangedFiles []string | |
| 968 | + | OutputTruncated bool | |
| 969 | + | ||
| 970 | + | // Model, Provider, Reasoning, and the provider connection settings | |
| 971 | + | // expose the per-agent configuration captured at Spawn time. Empty | |
| 972 | + | // values inherit the child's defaults. Subagent is the selected named | |
| 973 | + | // markdown profile. The dashboard surfaces these so the user can | |
| 974 | + | // confirm the child configuration. | |
| 975 | + | Model string | |
| 976 | + | Provider string | |
| 977 | + | BaseURL string | |
| 978 | + | InsecureTLS bool | |
| 979 | + | Reasoning string | |
| 980 | + | FastMode bool | |
| 981 | + | Subagent string | |
| 982 | + | WorkspaceMode WorkspaceMode | |
| 983 | + | WorkspacePath string | |
| 510 | 984 | ||
| 511 | 985 | // Paths to the agent's durable state. Surface them in the | |
| 512 | - | // snapshot so the dashboard / /swarm open can read events.jsonl | |
| 513 | - | // or resume the session without going back through the Agent. | |
| 986 | + | // snapshot so the dashboard /subagents (or compatibility /swarm) view can | |
| 987 | + | // read events.jsonl or resume the session without going back through the Agent. | |
| 514 | 988 | InboxPath string | |
| 515 | 989 | EventLogPath string | |
| 516 | 990 | SessionPath string | |
| @@ -520,7 +994,6 @@ type AgentSnapshot struct { | |||
| 520 | 994 | // inspect at leisure. | |
| 521 | 995 | func (a *Agent) Snapshot() AgentSnapshot { | |
| 522 | 996 | a.mu.Lock() | |
| 523 | - | defer a.mu.Unlock() | |
| 524 | 997 | tail := strings.Join(lastN(a.transcript, 6), "\n") | |
| 525 | 998 | lines := make([]string, len(a.transcript)) | |
| 526 | 999 | copy(lines, a.transcript) | |
| @@ -528,20 +1001,38 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 528 | 1001 | if a.lastErr != nil { | |
| 529 | 1002 | errStr = a.lastErr.Error() | |
| 530 | 1003 | } | |
| 1004 | + | status := a.status | |
| 1005 | + | activity := a.activity | |
| 1006 | + | lastAssistant := a.lastAssistant | |
| 1007 | + | finished := a.finished | |
| 1008 | + | a.mu.Unlock() | |
| 1009 | + | ||
| 1010 | + | a.lifecycleMu.Lock() | |
| 1011 | + | processState := a.processState | |
| 1012 | + | turnState := a.turnState | |
| 1013 | + | currentTurnID := a.currentTurnID | |
| 1014 | + | attempt := a.Attempt | |
| 1015 | + | lastActivity := a.lastActivity | |
| 1016 | + | updatedAt := a.updatedAt | |
| 1017 | + | result := cloneTurnResult(a.result) | |
| 1018 | + | resultRef := a.resultRef | |
| 1019 | + | patchRef := a.patchRef | |
| 1020 | + | changedFiles := append([]string(nil), a.changedFiles...) | |
| 1021 | + | outputTruncated := a.outputTruncated | |
| 1022 | + | a.lifecycleMu.Unlock() | |
| 531 | 1023 | return AgentSnapshot{ | |
| 532 | 1024 | ID: a.ID, Task: a.Task, Dir: a.Dir, | |
| 533 | - | Status: a.status, Activity: a.activity, | |
| 534 | - | Started: a.Started, Finished: a.finished, | |
| 1025 | + | Status: status, ProcessState: processState, TurnState: turnState, | |
| 1026 | + | CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity, | |
| 1027 | + | Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity, | |
| 535 | 1028 | Err: errStr, Tail: tail, Lines: lines, | |
| 536 | - | LastAssistant: a.lastAssistant, | |
| 537 | - | Model: a.Model, | |
| 538 | - | Provider: a.Provider, | |
| 539 | - | Reasoning: a.Reasoning, | |
| 540 | - | FastMode: a.FastMode, | |
| 541 | - | Subagent: a.Subagent, | |
| 542 | - | InboxPath: a.InboxPath, | |
| 543 | - | EventLogPath: a.EventLogPath, | |
| 544 | - | SessionPath: a.SessionPath, | |
| 1029 | + | LastAssistant: lastAssistant, Result: result, ResultRef: resultRef, | |
| 1030 | + | PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated, | |
| 1031 | + | Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL, | |
| 1032 | + | InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning, | |
| 1033 | + | FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode, | |
| 1034 | + | WorkspacePath: a.WorkspacePath, InboxPath: a.InboxPath, | |
| 1035 | + | EventLogPath: a.EventLogPath, SessionPath: a.SessionPath, | |
| 545 | 1036 | } | |
| 546 | 1037 | } | |
| 547 | 1038 | ||
| @@ -551,11 +1042,10 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 551 | 1042 | // Scoping rules: | |
| 552 | 1043 | // - activeSession == "": no filter, every agent is returned | |
| 553 | 1044 | // (historical behaviour; used by tests and scripted callers). | |
| 554 | - | // - activeSession != "": include only agents whose SessionID | |
| 555 | - | // matches activeSession OR is empty. The empty-id pass-through | |
| 556 | - | // keeps pre-upgrade agents (their meta.json was written before | |
| 557 | - | // session_id existed) visible from any session so the user | |
| 558 | - | // doesn't lose access after the schema bump. | |
| 1045 | + | // - activeSession != "": include live agents whose SessionID matches | |
| 1046 | + | // activeSession, plus unscoped legacy agents. Detached and terminal | |
| 1047 | + | // agents from another host session remain visible so restart/recovery | |
| 1048 | + | // does not hide durable work. | |
| 559 | 1049 | func (f *Swarm) SnapshotAll() []AgentSnapshot { | |
| 560 | 1050 | agents := f.List() | |
| 561 | 1051 | f.mu.Lock() | |
| @@ -565,7 +1055,13 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot { | |||
| 565 | 1055 | out := make([]AgentSnapshot, 0, len(agents)) | |
| 566 | 1056 | for _, a := range agents { | |
| 567 | 1057 | if active != "" && a.SessionID != "" && a.SessionID != active { | |
| 568 | - | continue | |
| 1058 | + | // Keep detached/terminal entries visible after a normal host | |
| 1059 | + | // restart so durable recovery remains discoverable. Hide only | |
| 1060 | + | // live work belonging to another in-process host session. | |
| 1061 | + | status := a.Status() | |
| 1062 | + | if status == StatusRunning || status == StatusPending { | |
| 1063 | + | continue | |
| 1064 | + | } | |
| 569 | 1065 | } | |
| 570 | 1066 | out = append(out, a.Snapshot()) | |
| 571 | 1067 | } | |
M packages/agent/swarm/testdata/cmd/stubchild/main.go +37 -11
| @@ -27,6 +27,8 @@ import ( | |||
| 27 | 27 | "os" | |
| 28 | 28 | "sync" | |
| 29 | 29 | "time" | |
| 30 | + | ||
| 31 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 30 | 32 | ) | |
| 31 | 33 | ||
| 32 | 34 | func main() { | |
| @@ -35,7 +37,8 @@ func main() { | |||
| 35 | 37 | sessionPath string | |
| 36 | 38 | cwd string | |
| 37 | 39 | ) | |
| 38 | - | flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path") | |
| 40 | + | flag.StringVar(&inboxPath, "subagent-worker", "", "inbox socket path") | |
| 41 | + | flag.StringVar(&inboxPath, "swarm-agent", "", "compatibility inbox socket path") | |
| 39 | 42 | flag.StringVar(&sessionPath, "session", "", "session file path") | |
| 40 | 43 | flag.StringVar(&cwd, "cwd", "", "working directory") | |
| 41 | 44 | flag.Parse() | |
| @@ -81,16 +84,33 @@ func main() { | |||
| 81 | 84 | line, err := br.ReadString('\n') | |
| 82 | 85 | if line != "" { | |
| 83 | 86 | msg := trimNL(line) | |
| 84 | - | switch { | |
| 85 | - | case msg == "shutdown": | |
| 86 | - | emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |
| 87 | - | _ = c.Close() | |
| 88 | - | return | |
| 89 | - | case msg == "cancel": | |
| 90 | - | emit("turn_end", map[string]any{"stop": "cancelled"}) | |
| 91 | - | case len(msg) > 5 && msg[:5] == "user ": | |
| 92 | - | runTurn(emit, msg[5:], turn) | |
| 93 | - | turn++ | |
| 87 | + | if command, parseErr := swarm.ParseCommand(msg); parseErr == nil { | |
| 88 | + | switch command.Type { | |
| 89 | + | case swarm.CommandAgentShutdown: | |
| 90 | + | emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |
| 91 | + | _ = c.Close() | |
| 92 | + | return | |
| 93 | + | case swarm.CommandTurnCancel: | |
| 94 | + | emit("turn_end", map[string]any{"stop": "cancelled"}) | |
| 95 | + | case swarm.CommandTurnStart: | |
| 96 | + | var payload swarm.TurnStartPayload | |
| 97 | + | if command.DecodePayload(&payload) == nil { | |
| 98 | + | runTurn(emit, payload.Prompt, turn) | |
| 99 | + | turn++ | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } else { | |
| 103 | + | switch { | |
| 104 | + | case msg == "shutdown": | |
| 105 | + | emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |
| 106 | + | _ = c.Close() | |
| 107 | + | return | |
| 108 | + | case msg == "cancel": | |
| 109 | + | emit("turn_end", map[string]any{"stop": "cancelled"}) | |
| 110 | + | case len(msg) > 5 && msg[:5] == "user ": | |
| 111 | + | runTurn(emit, msg[5:], turn) | |
| 112 | + | turn++ | |
| 113 | + | } | |
| 94 | 114 | } | |
| 95 | 115 | } | |
| 96 | 116 | if err != nil { | |
| @@ -125,12 +145,18 @@ type emitter = func(string, map[string]any) | |||
| 125 | 145 | func newEmitter() emitter { | |
| 126 | 146 | var mu sync.Mutex | |
| 127 | 147 | enc := json.NewEncoder(os.Stdout) | |
| 148 | + | versioned := os.Getenv("ZOT_STUB_PROTOCOL") == "1" | |
| 128 | 149 | return func(typ string, data map[string]any) { | |
| 129 | 150 | mu.Lock() | |
| 130 | 151 | defer mu.Unlock() | |
| 131 | 152 | if data == nil { | |
| 132 | 153 | data = map[string]any{} | |
| 133 | 154 | } | |
| 155 | + | if versioned { | |
| 156 | + | envelope := swarm.NewEventEnvelope(typ, "stub-agent", "", data) | |
| 157 | + | _ = enc.Encode(envelope) | |
| 158 | + | return | |
| 159 | + | } | |
| 134 | 160 | data["type"] = typ | |
| 135 | 161 | data["time"] = time.Now().Format(time.RFC3339Nano) | |
| 136 | 162 | _ = enc.Encode(data) | |
A packages/agent/swarm/workspace.go +370 -0
| @@ -0,0 +1,370 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // WorkspaceCapture is durable output collected before an isolated workspace | |
| 15 | + | // is removed. The patch is intentionally kept as bytes so the caller can | |
| 16 | + | // write it atomically beside the agent result. | |
| 17 | + | type WorkspaceCapture struct { | |
| 18 | + | Patch []byte | |
| 19 | + | ChangedFiles []string | |
| 20 | + | Base string | |
| 21 | + | Branch string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // WorkspaceRequest describes one workspace allocation. | |
| 25 | + | type WorkspaceRequest struct { | |
| 26 | + | Mode WorkspaceMode | |
| 27 | + | RepositoryRoot string | |
| 28 | + | StateDir string | |
| 29 | + | AgentID string | |
| 30 | + | Base string | |
| 31 | + | Capture CaptureMode | |
| 32 | + | AllowedRoots []string | |
| 33 | + | ||
| 34 | + | // ExistingPath is set only when resuming a detached worktree. A valid | |
| 35 | + | // path is reused so uncommitted edits survive a supervisor restart; | |
| 36 | + | // invalid or out-of-scope paths are rejected rather than treated as a | |
| 37 | + | // request to remove arbitrary files. | |
| 38 | + | ExistingPath string | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // WorkspaceHandle is the narrow seam used by the manager. SharedWorkspace | |
| 42 | + | // and GitWorktreeWorkspace are the two supported implementations in v1. | |
| 43 | + | type WorkspaceHandle interface { | |
| 44 | + | Dir() string | |
| 45 | + | RepositoryRoot() string | |
| 46 | + | Mode() WorkspaceMode | |
| 47 | + | Capture(context.Context) (WorkspaceCapture, error) | |
| 48 | + | Cleanup(context.Context) error | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // SharedWorkspace leaves the host checkout untouched by the supervisor; a | |
| 52 | + | // child may edit it exactly as older swarm workers did. | |
| 53 | + | type SharedWorkspace struct { | |
| 54 | + | Root string | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (w SharedWorkspace) Prepare(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | |
| 58 | + | root := w.Root | |
| 59 | + | if root == "" { | |
| 60 | + | return nil, errors.New("subagents: shared workspace root is empty") | |
| 61 | + | } | |
| 62 | + | return &sharedWorkspaceHandle{root: root}, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type sharedWorkspaceHandle struct{ root string } | |
| 66 | + | ||
| 67 | + | func (w *sharedWorkspaceHandle) Dir() string { return w.root } | |
| 68 | + | func (w *sharedWorkspaceHandle) RepositoryRoot() string { return w.root } | |
| 69 | + | func (w *sharedWorkspaceHandle) Mode() WorkspaceMode { return WorkspaceShared } | |
| 70 | + | func (w *sharedWorkspaceHandle) Capture(context.Context) (WorkspaceCapture, error) { | |
| 71 | + | return WorkspaceCapture{}, nil | |
| 72 | + | } | |
| 73 | + | func (w *sharedWorkspaceHandle) Cleanup(context.Context) error { return nil } | |
| 74 | + | ||
| 75 | + | // GitWorktreeWorkspace creates a detached temporary worktree from Base and | |
| 76 | + | // captures its diff before cleanup. It never merges or changes the host | |
| 77 | + | // checkout's branch. | |
| 78 | + | type GitWorktreeWorkspace struct{} | |
| 79 | + | ||
| 80 | + | func (GitWorktreeWorkspace) Prepare(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) { | |
| 81 | + | if ctx == nil { | |
| 82 | + | ctx = context.Background() | |
| 83 | + | } | |
| 84 | + | root, err := filepath.Abs(req.RepositoryRoot) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, fmt.Errorf("subagents worktree root: %w", err) | |
| 87 | + | } | |
| 88 | + | if _, err := os.Stat(root); err != nil { | |
| 89 | + | return nil, fmt.Errorf("subagents worktree root: %w", err) | |
| 90 | + | } | |
| 91 | + | actualRoot, err := gitOutput(ctx, root, "rev-parse", "--show-toplevel") | |
| 92 | + | if err != nil { | |
| 93 | + | return nil, fmt.Errorf("subagents worktree verify repository: %w", err) | |
| 94 | + | } | |
| 95 | + | actualRoot = filepath.Clean(actualRoot) | |
| 96 | + | if len(req.AllowedRoots) > 0 { | |
| 97 | + | allowed := false | |
| 98 | + | for _, root := range req.AllowedRoots { | |
| 99 | + | if pathWithin(actualRoot, root) { | |
| 100 | + | allowed = true | |
| 101 | + | break | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | if !allowed { | |
| 105 | + | return nil, errors.New("subagents worktree repository is outside allowed roots") | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | base := strings.TrimSpace(req.Base) | |
| 109 | + | if base == "" { | |
| 110 | + | base = "HEAD" | |
| 111 | + | } | |
| 112 | + | if _, err := gitOutput(ctx, actualRoot, "rev-parse", "--verify", base+"^{commit}"); err != nil { | |
| 113 | + | return nil, fmt.Errorf("subagents worktree verify base %q: %w", base, err) | |
| 114 | + | } | |
| 115 | + | stateDir := req.StateDir | |
| 116 | + | if stateDir == "" { | |
| 117 | + | return nil, errors.New("subagents: worktree state directory is empty") | |
| 118 | + | } | |
| 119 | + | worktreeDir := filepath.Join(stateDir, "worktree") | |
| 120 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 121 | + | return nil, fmt.Errorf("subagents worktree state dir: %w", err) | |
| 122 | + | } | |
| 123 | + | reuse := false | |
| 124 | + | if existing := strings.TrimSpace(req.ExistingPath); existing != "" { | |
| 125 | + | var err error | |
| 126 | + | reuse, err = validateReusableWorktree(ctx, actualRoot, stateDir, worktreeDir, existing) | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | if reuse { | |
| 131 | + | worktreeDir = filepath.Clean(existing) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | if !reuse { | |
| 135 | + | _ = os.RemoveAll(worktreeDir) | |
| 136 | + | if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil { | |
| 137 | + | _ = os.RemoveAll(worktreeDir) | |
| 138 | + | _, _ = gitOutput(ctx, actualRoot, "worktree", "prune") | |
| 139 | + | return nil, fmt.Errorf("subagents worktree create: %w", err) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | return &gitWorktreeHandle{ | |
| 143 | + | root: actualRoot, | |
| 144 | + | dir: worktreeDir, | |
| 145 | + | base: base, | |
| 146 | + | capture: req.Capture, | |
| 147 | + | agentID: req.AgentID, | |
| 148 | + | }, nil | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func validateReusableWorktree(ctx context.Context, repositoryRoot, stateDir, expectedPath, existingPath string) (bool, error) { | |
| 152 | + | if filepath.Clean(existingPath) != filepath.Clean(expectedPath) { | |
| 153 | + | return false, errors.New("subagents worktree resume path is outside the agent state directory") | |
| 154 | + | } | |
| 155 | + | info, err := os.Stat(existingPath) | |
| 156 | + | if errors.Is(err, os.ErrNotExist) { | |
| 157 | + | return false, nil | |
| 158 | + | } | |
| 159 | + | if err != nil { | |
| 160 | + | return false, fmt.Errorf("subagents worktree resume path: %w", err) | |
| 161 | + | } | |
| 162 | + | if !info.IsDir() { | |
| 163 | + | return false, errors.New("subagents worktree resume path is not a directory") | |
| 164 | + | } | |
| 165 | + | stateCanonical, err := containmentPath(stateDir) | |
| 166 | + | if err != nil { | |
| 167 | + | return false, fmt.Errorf("subagents worktree resume state path: %w", err) | |
| 168 | + | } | |
| 169 | + | expectedCanonical, err := containmentPath(expectedPath) | |
| 170 | + | if err != nil { | |
| 171 | + | return false, fmt.Errorf("subagents worktree resume expected path: %w", err) | |
| 172 | + | } | |
| 173 | + | existingCanonical, err := containmentPath(existingPath) | |
| 174 | + | if err != nil { | |
| 175 | + | return false, fmt.Errorf("subagents worktree resume path: %w", err) | |
| 176 | + | } | |
| 177 | + | if existingCanonical != expectedCanonical || !pathWithin(existingCanonical, stateCanonical) { | |
| 178 | + | return false, errors.New("subagents worktree resume path escapes the agent state directory") | |
| 179 | + | } | |
| 180 | + | top, err := gitOutput(ctx, existingPath, "rev-parse", "--show-toplevel") | |
| 181 | + | if err != nil { | |
| 182 | + | return false, fmt.Errorf("subagents worktree resume verify checkout: %w", err) | |
| 183 | + | } | |
| 184 | + | topCanonical, err := containmentPath(top) | |
| 185 | + | if err != nil { | |
| 186 | + | return false, fmt.Errorf("subagents worktree resume checkout path: %w", err) | |
| 187 | + | } | |
| 188 | + | if topCanonical != existingCanonical { | |
| 189 | + | return false, errors.New("subagents worktree resume checkout path mismatch") | |
| 190 | + | } | |
| 191 | + | registered, err := gitOutput(ctx, repositoryRoot, "worktree", "list", "--porcelain") | |
| 192 | + | if err != nil { | |
| 193 | + | return false, fmt.Errorf("subagents worktree resume list: %w", err) | |
| 194 | + | } | |
| 195 | + | for _, line := range strings.Split(registered, "\n") { | |
| 196 | + | if !strings.HasPrefix(line, "worktree ") { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | registeredPath, pathErr := containmentPath(strings.TrimPrefix(line, "worktree ")) | |
| 200 | + | if pathErr == nil && registeredPath == existingCanonical { | |
| 201 | + | return true, nil | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | return false, errors.New("subagents worktree resume checkout is not registered with Git") | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | type gitWorktreeHandle struct { | |
| 208 | + | root string | |
| 209 | + | dir string | |
| 210 | + | base string | |
| 211 | + | capture CaptureMode | |
| 212 | + | agentID string | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (w *gitWorktreeHandle) Dir() string { return w.dir } | |
| 216 | + | func (w *gitWorktreeHandle) RepositoryRoot() string { return w.root } | |
| 217 | + | func (w *gitWorktreeHandle) Mode() WorkspaceMode { return WorkspaceWorktree } | |
| 218 | + | ||
| 219 | + | func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, error) { | |
| 220 | + | if ctx == nil { | |
| 221 | + | ctx = context.Background() | |
| 222 | + | } | |
| 223 | + | statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain") | |
| 224 | + | if err != nil { | |
| 225 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err) | |
| 226 | + | } | |
| 227 | + | changed := changedFilesFromStatus(string(statusBytes)) | |
| 228 | + | args := []string{"diff"} | |
| 229 | + | if w.capture == CapturePatch || w.capture == "" { | |
| 230 | + | args = append(args, "--binary") | |
| 231 | + | } | |
| 232 | + | args = append(args, w.base, "--") | |
| 233 | + | patch, err := gitOutputBytes(ctx, w.dir, args...) | |
| 234 | + | if err != nil { | |
| 235 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree diff: %w", err) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // `git diff <base>` does not include untracked files. Add a normal | |
| 239 | + | // no-index creation patch for each untracked file so a worktree result can | |
| 240 | + | // reproduce new files as well as edits to tracked files. | |
| 241 | + | untracked, err := gitOutputBytes(ctx, w.dir, "ls-files", "--others", "--exclude-standard", "-z") | |
| 242 | + | if err != nil { | |
| 243 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked files: %w", err) | |
| 244 | + | } | |
| 245 | + | for _, name := range splitNUL(untracked) { | |
| 246 | + | extra, diffErr := gitUntrackedDiff(ctx, w.dir, name, w.capture == CapturePatch || w.capture == "") | |
| 247 | + | if diffErr != nil { | |
| 248 | + | return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked diff %q: %w", name, diffErr) | |
| 249 | + | } | |
| 250 | + | if len(extra) == 0 { | |
| 251 | + | continue | |
| 252 | + | } | |
| 253 | + | if len(patch) > 0 && patch[len(patch)-1] != '\n' { | |
| 254 | + | patch = append(patch, '\n') | |
| 255 | + | } | |
| 256 | + | patch = append(patch, extra...) | |
| 257 | + | } | |
| 258 | + | return WorkspaceCapture{Patch: patch, ChangedFiles: changed, Base: w.base}, nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func (w *gitWorktreeHandle) Cleanup(ctx context.Context) error { | |
| 262 | + | if ctx == nil { | |
| 263 | + | ctx = context.Background() | |
| 264 | + | } | |
| 265 | + | if w.dir == "" { | |
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | _, err := gitOutput(ctx, w.root, "worktree", "remove", "--force", w.dir) | |
| 269 | + | if err != nil { | |
| 270 | + | // A failed child can leave a partially registered worktree. Remove | |
| 271 | + | // only the isolated path; never delete files in the host checkout, | |
| 272 | + | // then prune stale registration metadata. | |
| 273 | + | _ = os.RemoveAll(w.dir) | |
| 274 | + | _, _ = gitOutput(ctx, w.root, "worktree", "prune") | |
| 275 | + | return fmt.Errorf("subagents worktree cleanup: %w", err) | |
| 276 | + | } | |
| 277 | + | return nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func PrepareWorkspace(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) { | |
| 281 | + | mode := req.Mode | |
| 282 | + | if mode == "" { | |
| 283 | + | mode = WorkspaceShared | |
| 284 | + | } | |
| 285 | + | switch mode { | |
| 286 | + | case WorkspaceShared: | |
| 287 | + | return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(ctx, req) | |
| 288 | + | case WorkspaceWorktree: | |
| 289 | + | return GitWorktreeWorkspace{}.Prepare(ctx, req) | |
| 290 | + | default: | |
| 291 | + | return nil, fmt.Errorf("subagents: unknown workspace mode %q", mode) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func gitOutput(ctx context.Context, dir string, args ...string) (string, error) { | |
| 296 | + | b, err := gitOutputBytes(ctx, dir, args...) | |
| 297 | + | return strings.TrimSpace(string(b)), err | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func gitOutputBytes(ctx context.Context, dir string, args ...string) ([]byte, error) { | |
| 301 | + | cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) | |
| 302 | + | var stdout, stderr bytes.Buffer | |
| 303 | + | cmd.Stdout = &stdout | |
| 304 | + | cmd.Stderr = &stderr | |
| 305 | + | if err := cmd.Run(); err != nil { | |
| 306 | + | message := strings.TrimSpace(stderr.String()) | |
| 307 | + | if message == "" { | |
| 308 | + | message = err.Error() | |
| 309 | + | } | |
| 310 | + | return nil, errors.New(message) | |
| 311 | + | } | |
| 312 | + | return stdout.Bytes(), nil | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byte, error) { | |
| 316 | + | args := []string{"-C", dir, "diff", "--no-index"} | |
| 317 | + | if binary { | |
| 318 | + | args = append(args, "--binary") | |
| 319 | + | } | |
| 320 | + | args = append(args, "/dev/null", "--", name) | |
| 321 | + | cmd := exec.CommandContext(ctx, "git", args...) | |
| 322 | + | var stdout, stderr bytes.Buffer | |
| 323 | + | cmd.Stdout = &stdout | |
| 324 | + | cmd.Stderr = &stderr | |
| 325 | + | err := cmd.Run() | |
| 326 | + | if err == nil { | |
| 327 | + | return stdout.Bytes(), nil | |
| 328 | + | } | |
| 329 | + | var exitErr *exec.ExitError | |
| 330 | + | if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { | |
| 331 | + | return stdout.Bytes(), nil | |
| 332 | + | } | |
| 333 | + | message := strings.TrimSpace(stderr.String()) | |
| 334 | + | if message == "" { | |
| 335 | + | message = err.Error() | |
| 336 | + | } | |
| 337 | + | return nil, errors.New(message) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func splitNUL(data []byte) []string { | |
| 341 | + | parts := strings.Split(string(data), "\x00") | |
| 342 | + | out := make([]string, 0, len(parts)) | |
| 343 | + | for _, part := range parts { | |
| 344 | + | if part != "" { | |
| 345 | + | out = append(out, part) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | return out | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func changedFilesFromStatus(status string) []string { | |
| 352 | + | var files []string | |
| 353 | + | for _, line := range strings.Split(status, "\n") { | |
| 354 | + | line = strings.TrimRight(line, "\r") | |
| 355 | + | if len(line) < 4 { | |
| 356 | + | continue | |
| 357 | + | } | |
| 358 | + | path := strings.TrimSpace(line[3:]) | |
| 359 | + | if path == "" { | |
| 360 | + | continue | |
| 361 | + | } | |
| 362 | + | // Porcelain rename entries are `old -> new`; the new path is the | |
| 363 | + | // useful reference to callers. | |
| 364 | + | if idx := strings.LastIndex(path, " -> "); idx >= 0 { | |
| 365 | + | path = strings.TrimSpace(path[idx+4:]) | |
| 366 | + | } | |
| 367 | + | files = append(files, strings.Trim(path, "\"")) | |
| 368 | + | } | |
| 369 | + | return files | |
| 370 | + | } | |
A packages/agent/swarm/workspace_manager_test.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestSpawnWorktreeCapturesBeforeCleanup(t *testing.T) { | |
| 13 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 14 | + | t.Skip("git is required") | |
| 15 | + | } | |
| 16 | + | repo := t.TempDir() | |
| 17 | + | runGit(t, repo, "init") | |
| 18 | + | runGit(t, repo, "config", "user.email", "test@example.invalid") | |
| 19 | + | runGit(t, repo, "config", "user.name", "Test User") | |
| 20 | + | if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | runGit(t, repo, "add", "main.txt") | |
| 24 | + | runGit(t, repo, "commit", "-m", "initial") | |
| 25 | + | ||
| 26 | + | state := t.TempDir() | |
| 27 | + | f := New(Config{ | |
| 28 | + | Root: state, RepoRoot: repo, | |
| 29 | + | Policy: SubagentPolicy{DefaultTimeout: 0, IdleTimeout: 0}, | |
| 30 | + | NewRunner: func(a *Agent) Runner { | |
| 31 | + | return RunnerFunc(func(context.Context, Sink) error { | |
| 32 | + | if err := os.WriteFile(filepath.Join(a.Dir, "main.txt"), []byte("child\n"), 0o600); err != nil { | |
| 33 | + | return err | |
| 34 | + | } | |
| 35 | + | return os.WriteFile(filepath.Join(a.Dir, "new.txt"), []byte("created\n"), 0o600) | |
| 36 | + | }) | |
| 37 | + | }, | |
| 38 | + | }) | |
| 39 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "isolate", WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD"}) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | a.Wait() | |
| 44 | + | result, err := f.ReadResult(a.ID) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | if result.Status != ResultSucceeded || len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "main.txt" || result.ChangedFiles[1] != "new.txt" { | |
| 49 | + | t.Fatalf("result = %#v", result) | |
| 50 | + | } | |
| 51 | + | patch, err := os.ReadFile(filepath.Join(state, "agents", a.ID, "patch.diff")) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | if !strings.Contains(string(patch), "child") || !strings.Contains(string(patch), "created") { | |
| 56 | + | t.Fatalf("patch = %q", patch) | |
| 57 | + | } | |
| 58 | + | host, err := os.ReadFile(filepath.Join(repo, "main.txt")) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if string(host) != "host\n" { | |
| 63 | + | t.Fatalf("host checkout changed: %q", host) | |
| 64 | + | } | |
| 65 | + | if _, err := os.Stat(a.WorkspacePath); !os.IsNotExist(err) { | |
| 66 | + | t.Fatalf("worktree still exists: %v", err) | |
| 67 | + | } | |
| 68 | + | } | |
A packages/agent/swarm/workspace_test.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestGitWorktreeWorkspaceIsolatesAndCapturesPatch(t *testing.T) { | |
| 13 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 14 | + | t.Skip("git is required") | |
| 15 | + | } | |
| 16 | + | repo := t.TempDir() | |
| 17 | + | runGit(t, repo, "init") | |
| 18 | + | runGit(t, repo, "config", "user.email", "test@example.invalid") | |
| 19 | + | runGit(t, repo, "config", "user.name", "Test User") | |
| 20 | + | if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | runGit(t, repo, "add", "README.md") | |
| 24 | + | runGit(t, repo, "commit", "-m", "initial") | |
| 25 | + | ||
| 26 | + | state := t.TempDir() | |
| 27 | + | handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |
| 28 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch, | |
| 29 | + | }) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if handle.Mode() != WorkspaceWorktree || handle.Dir() == repo { | |
| 34 | + | t.Fatalf("handle = mode %s dir %q repo %q", handle.Mode(), handle.Dir(), repo) | |
| 35 | + | } | |
| 36 | + | if err := os.WriteFile(filepath.Join(handle.Dir(), "README.md"), []byte("after\n"), 0o600); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | before, err := os.ReadFile(filepath.Join(repo, "README.md")) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | if string(before) != "before\n" { | |
| 44 | + | t.Fatalf("host checkout changed: %q", before) | |
| 45 | + | } | |
| 46 | + | capture, err := handle.Capture(context.Background()) | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | if len(capture.Patch) == 0 || !strings.Contains(string(capture.Patch), "after") { | |
| 51 | + | t.Fatalf("patch = %q", capture.Patch) | |
| 52 | + | } | |
| 53 | + | if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != "README.md" { | |
| 54 | + | t.Fatalf("changed files = %#v", capture.ChangedFiles) | |
| 55 | + | } | |
| 56 | + | if err := handle.Cleanup(context.Background()); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if _, err := os.Stat(handle.Dir()); !os.IsNotExist(err) { | |
| 60 | + | t.Fatalf("worktree still exists: %v", err) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) { | |
| 65 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 66 | + | t.Skip("git is required") | |
| 67 | + | } | |
| 68 | + | repo := t.TempDir() | |
| 69 | + | runGit(t, repo, "init") | |
| 70 | + | runGit(t, repo, "config", "user.email", "test@example.invalid") | |
| 71 | + | runGit(t, repo, "config", "user.name", "Test User") | |
| 72 | + | if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | runGit(t, repo, "add", "README.md") | |
| 76 | + | runGit(t, repo, "commit", "-m", "initial") | |
| 77 | + | ||
| 78 | + | state := t.TempDir() | |
| 79 | + | first, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |
| 80 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch, | |
| 81 | + | }) | |
| 82 | + | if err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | if err := os.WriteFile(filepath.Join(first.Dir(), "draft.txt"), []byte("keep me\n"), 0o600); err != nil { | |
| 86 | + | t.Fatal(err) | |
| 87 | + | } | |
| 88 | + | resumed, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |
| 89 | + | Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch, | |
| 90 | + | ExistingPath: first.Dir(), | |
| 91 | + | }) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | if resumed.Dir() != first.Dir() { | |
| 96 | + | t.Fatalf("resume dir = %q, want %q", resumed.Dir(), first.Dir()) | |
| 97 | + | } | |
| 98 | + | if _, err := os.Stat(filepath.Join(resumed.Dir(), "draft.txt")); err != nil { | |
| 99 | + | t.Fatalf("resumed worktree lost uncommitted file: %v", err) | |
| 100 | + | } | |
| 101 | + | if err := resumed.Cleanup(context.Background()); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) { | |
| 107 | + | root := t.TempDir() | |
| 108 | + | handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{Mode: WorkspaceShared, RepositoryRoot: root}) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | if handle.Dir() != root || handle.RepositoryRoot() != root || handle.Mode() != WorkspaceShared { | |
| 113 | + | t.Fatalf("shared handle = %#v", handle) | |
| 114 | + | } | |
| 115 | + | if err := handle.Cleanup(context.Background()); err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func runGit(t *testing.T, dir string, args ...string) { | |
| 121 | + | t.Helper() | |
| 122 | + | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 123 | + | if output, err := cmd.CombinedOutput(); err != nil { | |
| 124 | + | t.Fatalf("git %v: %v\n%s", args, err, output) | |
| 125 | + | } | |
| 126 | + | } | |
M packages/agent/swarm_agent.go +341 -67
| @@ -1,14 +1,18 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "io" | |
| 8 | 10 | "os" | |
| 11 | + | "strconv" | |
| 9 | 12 | "strings" | |
| 10 | 13 | "sync" | |
| 11 | 14 | "time" | |
| 15 | + | "unicode/utf8" | |
| 12 | 16 | ||
| 13 | 17 | "github.com/patriceckhart/zot/packages/agent/modes" | |
| 14 | 18 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| @@ -108,28 +112,76 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 108 | 112 | } | |
| 109 | 113 | ||
| 110 | 114 | em := newSwarmEmitter(os.Stdout, logMirror) | |
| 111 | - | ||
| 112 | - | em.emit("agent_ready", map[string]any{ | |
| 115 | + | em.setProtocolIdentity(os.Getenv("ZOT_SWARM_AGENT_ID")) | |
| 116 | + | em.emit("agent.ready", map[string]any{ | |
| 113 | 117 | "version": version, | |
| 114 | 118 | "cwd": r.CWD, | |
| 115 | 119 | "model": r.Model, | |
| 116 | 120 | }) | |
| 117 | 121 | ||
| 118 | - | // turnCtx is the per-turn context. We keep a per-process | |
| 119 | - | // cancel so the "cancel" inbox message can interrupt an | |
| 120 | - | // in-flight turn without tearing down the whole daemon. | |
| 122 | + | // Heartbeats make a live-but-detached child distinguishable from a | |
| 123 | + | // failed turn after supervisor reconnect. The parent still treats | |
| 124 | + | // unknown event types as forward-compatible. | |
| 125 | + | heartbeatDone := make(chan struct{}) | |
| 126 | + | defer close(heartbeatDone) | |
| 127 | + | go func() { | |
| 128 | + | interval := 10 * time.Second | |
| 129 | + | if raw := os.Getenv("ZOT_SWARM_HEARTBEAT_INTERVAL"); raw != "" { | |
| 130 | + | if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 { | |
| 131 | + | interval = parsed | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ticker := time.NewTicker(interval) | |
| 135 | + | defer ticker.Stop() | |
| 136 | + | for { | |
| 137 | + | select { | |
| 138 | + | case <-heartbeatDone: | |
| 139 | + | return | |
| 140 | + | case <-ticker.C: | |
| 141 | + | em.emit("agent.heartbeat", map[string]any{"activity": "alive"}) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | }() | |
| 145 | + | ||
| 146 | + | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | |
| 147 | + | // an in-flight turn without tearing down the whole daemon. | |
| 121 | 148 | var ( | |
| 122 | - | mu sync.Mutex | |
| 123 | - | turnCtx context.Context = ctx | |
| 124 | - | cancelFn context.CancelFunc | |
| 125 | - | busyTurn bool | |
| 126 | - | stopping bool | |
| 127 | - | turnNo int | |
| 128 | - | turns sync.WaitGroup | |
| 149 | + | mu sync.Mutex | |
| 150 | + | cancelFn context.CancelFunc | |
| 151 | + | busyTurn bool | |
| 152 | + | turnNo = initialTurnNumber(os.Getenv("ZOT_SWARM_EVENT_LOG")) | |
| 153 | + | turnWG sync.WaitGroup | |
| 154 | + | closing bool | |
| 155 | + | shutdown = make(chan struct{}) | |
| 156 | + | shutdownOnce sync.Once | |
| 129 | 157 | ) | |
| 158 | + | maxOutputBytes, maxOutputLines := workerOutputLimits() | |
| 130 | 159 | ||
| 131 | 160 | runOne := func(prompt string) { | |
| 132 | 161 | mu.Lock() | |
| 162 | + | // launchTurn admits the goroutine to turnWG before it starts so | |
| 163 | + | // shutdown can join it. A shutdown can win the scheduling race | |
| 164 | + | // after that admission but before this goroutine acquires mu; in | |
| 165 | + | // that case do not start a new Prompt after waitForActiveTurn has | |
| 166 | + | // declared the worker closing. | |
| 167 | + | if closing { | |
| 168 | + | mu.Unlock() | |
| 169 | + | return | |
| 170 | + | } | |
| 171 | + | if args.SubagentMaxTurns > 0 && turnNo >= args.SubagentMaxTurns { | |
| 172 | + | // Consume a unique rejected attempt so repeated prompts after | |
| 173 | + | // the limit cannot reuse the same turn id in the event log. | |
| 174 | + | turnNo++ | |
| 175 | + | step := turnNo | |
| 176 | + | mu.Unlock() | |
| 177 | + | turnID := fmt.Sprintf("turn-%d", step) | |
| 178 | + | errPayload := map[string]any{"code": "max_turns", "message": "maximum subagent turns reached"} | |
| 179 | + | em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload}) | |
| 180 | + | em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload}) | |
| 181 | + | em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]}) | |
| 182 | + | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 183 | + | return | |
| 184 | + | } | |
| 133 | 185 | if busyTurn { | |
| 134 | 186 | // Drop concurrent turns rather than queuing. The | |
| 135 | 187 | // supervisor protocol assumes one outstanding turn per | |
| @@ -142,11 +194,12 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 142 | 194 | busyTurn = true | |
| 143 | 195 | turnNo++ | |
| 144 | 196 | step := turnNo | |
| 145 | - | turnCtx, cancelFn = context.WithCancel(ctx) | |
| 146 | - | c := turnCtx | |
| 197 | + | c, cancel := context.WithCancel(ctx) | |
| 198 | + | cancelFn = cancel | |
| 147 | 199 | mu.Unlock() | |
| 148 | 200 | ||
| 149 | - | em.emit("turn_start", map[string]any{"step": step}) | |
| 201 | + | turnID := fmt.Sprintf("turn-%d", step) | |
| 202 | + | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | |
| 150 | 203 | ||
| 151 | 204 | sink := func(ev core.AgentEvent) { | |
| 152 | 205 | em.emit(ev.Type(), modes.EventToJSON(ev)) | |
| @@ -156,10 +209,32 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 156 | 209 | err := ag.Prompt(c, prompt, nil, sink) | |
| 157 | 210 | WriteNewTranscript(ag, sess, start) | |
| 158 | 211 | ||
| 212 | + | output := finalAssistantText(ag.Messages()[start:]) | |
| 213 | + | resultStatus := "succeeded" | |
| 214 | + | if err != nil { | |
| 215 | + | if errors.Is(err, context.Canceled) { | |
| 216 | + | resultStatus = "canceled" | |
| 217 | + | } else { | |
| 218 | + | resultStatus = "failed" | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | resultPayload := map[string]any{ | |
| 222 | + | "status": resultStatus, | |
| 223 | + | "turn_id": turnID, | |
| 224 | + | "summary": boundedResultSummary(output), | |
| 225 | + | "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines), | |
| 226 | + | "error": resultErrorPayload(err), | |
| 227 | + | } | |
| 228 | + | em.emit("turn.result", resultPayload) | |
| 229 | + | if err != nil { | |
| 230 | + | em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err)}) | |
| 231 | + | } | |
| 159 | 232 | em.emit("turn_end", map[string]any{ | |
| 160 | - | "step": step, | |
| 161 | - | "error": errString(err), | |
| 233 | + | "step": step, | |
| 234 | + | "turn_id": turnID, | |
| 235 | + | "error": errString(err), | |
| 162 | 236 | }) | |
| 237 | + | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 163 | 238 | ||
| 164 | 239 | mu.Lock() | |
| 165 | 240 | busyTurn = false | |
| @@ -167,35 +242,46 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 167 | 242 | mu.Unlock() | |
| 168 | 243 | } | |
| 169 | 244 | ||
| 170 | - | startTurn := func(prompt string) { | |
| 245 | + | launchTurn := func(prompt string) { | |
| 171 | 246 | mu.Lock() | |
| 172 | - | if stopping { | |
| 247 | + | if closing { | |
| 173 | 248 | mu.Unlock() | |
| 174 | 249 | return | |
| 175 | 250 | } | |
| 176 | - | turns.Add(1) | |
| 251 | + | // Register the goroutine before launching it. Shutdown can then | |
| 252 | + | // close the gate and wait for every turn that was admitted, even | |
| 253 | + | // if runOne has not acquired mu and set busyTurn yet. | |
| 254 | + | turnWG.Add(1) | |
| 177 | 255 | mu.Unlock() | |
| 178 | 256 | go func() { | |
| 179 | - | defer turns.Done() | |
| 257 | + | defer turnWG.Done() | |
| 180 | 258 | runOne(prompt) | |
| 181 | 259 | }() | |
| 182 | 260 | } | |
| 183 | - | stopAndWait := func(reason string) { | |
| 261 | + | ||
| 262 | + | waitForActiveTurn := func(cancel bool) { | |
| 184 | 263 | mu.Lock() | |
| 185 | - | stopping = true | |
| 186 | - | if cancelFn != nil { | |
| 187 | - | cancelFn() | |
| 264 | + | closing = true | |
| 265 | + | activeCancel := cancelFn | |
| 266 | + | mu.Unlock() | |
| 267 | + | if cancel && activeCancel != nil { | |
| 268 | + | activeCancel() | |
| 188 | 269 | } | |
| 270 | + | turnWG.Wait() | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | requestShutdown := func() { | |
| 274 | + | mu.Lock() | |
| 275 | + | closing = true | |
| 189 | 276 | mu.Unlock() | |
| 190 | - | turns.Wait() | |
| 191 | - | em.emit("agent_stopped", map[string]any{"reason": reason}) | |
| 277 | + | shutdownOnce.Do(func() { close(shutdown) }) | |
| 192 | 278 | } | |
| 193 | 279 | ||
| 194 | 280 | // Initial task: run before processing the inbox so the agent | |
| 195 | 281 | // "starts working" the moment it boots, matching what users | |
| 196 | 282 | // expect from `/swarm new <task>`. | |
| 197 | 283 | if args.Prompt != "" { | |
| 198 | - | startTurn(args.Prompt) | |
| 284 | + | launchTurn(args.Prompt) | |
| 199 | 285 | } | |
| 200 | 286 | ||
| 201 | 287 | // Inbox loop: one supervisor message at a time. We don't spawn | |
| @@ -205,30 +291,50 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 205 | 291 | for { | |
| 206 | 292 | select { | |
| 207 | 293 | case <-ctx.Done(): | |
| 208 | - | stopAndWait("cancelled") | |
| 294 | + | waitForActiveTurn(true) | |
| 295 | + | em.emit("agent.exited", map[string]any{"reason": "cancelled"}) | |
| 209 | 296 | return ctx.Err() | |
| 297 | + | case <-shutdown: | |
| 298 | + | // Shutdown is an explicit supervisor stop. Cancel an active | |
| 299 | + | // turn before joining it so a detached worker cannot remain | |
| 300 | + | // alive indefinitely while the host waits for its socket to | |
| 301 | + | // disappear. | |
| 302 | + | waitForActiveTurn(true) | |
| 303 | + | em.emit("agent.exited", map[string]any{"reason": "shutdown"}) | |
| 304 | + | return nil | |
| 210 | 305 | case msg, ok := <-ln.Lines(): | |
| 211 | 306 | if !ok { | |
| 212 | - | stopAndWait("inbox-closed") | |
| 307 | + | waitForActiveTurn(true) | |
| 308 | + | em.emit("agent.exited", map[string]any{"reason": "inbox-closed"}) | |
| 213 | 309 | return nil | |
| 214 | 310 | } | |
| 215 | - | switch { | |
| 216 | - | case msg == "shutdown": | |
| 217 | - | stopAndWait("shutdown") | |
| 218 | - | return nil | |
| 219 | - | case msg == "cancel": | |
| 311 | + | command, parseErr := swarm.ParseCommand(msg) | |
| 312 | + | if parseErr != nil { | |
| 313 | + | em.emit("error", map[string]any{"message": "invalid supervisor command"}) | |
| 314 | + | continue | |
| 315 | + | } | |
| 316 | + | switch command.Type { | |
| 317 | + | case swarm.CommandAgentShutdown: | |
| 318 | + | requestShutdown() | |
| 319 | + | case swarm.CommandTurnCancel: | |
| 220 | 320 | mu.Lock() | |
| 221 | 321 | if cancelFn != nil { | |
| 222 | 322 | cancelFn() | |
| 223 | 323 | } | |
| 224 | 324 | mu.Unlock() | |
| 225 | - | case strings.HasPrefix(msg, "user "): | |
| 226 | - | prompt := strings.TrimPrefix(msg, "user ") | |
| 227 | - | startTurn(prompt) | |
| 325 | + | case swarm.CommandAgentPing: | |
| 326 | + | em.emit("agent.heartbeat", map[string]any{"activity": "alive"}) | |
| 327 | + | case swarm.CommandTurnStart: | |
| 328 | + | var payload swarm.TurnStartPayload | |
| 329 | + | if err := command.DecodePayload(&payload); err != nil { | |
| 330 | + | em.emit("error", map[string]any{"message": "invalid turn.start payload"}) | |
| 331 | + | continue | |
| 332 | + | } | |
| 333 | + | launchTurn(payload.Prompt) | |
| 228 | 334 | default: | |
| 229 | - | em.emit("error", map[string]any{ | |
| 230 | - | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | |
| 231 | - | }) | |
| 335 | + | // Unknown commands are rejected rather than interpreted as | |
| 336 | + | // child-originated control requests. | |
| 337 | + | em.emit("error", map[string]any{"message": "unknown supervisor command"}) | |
| 232 | 338 | } | |
| 233 | 339 | } | |
| 234 | 340 | } | |
| @@ -238,9 +344,11 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 238 | 344 | // durable log file. Concurrent goroutines call emit so we have to | |
| 239 | 345 | // hold a mutex around the encoder. | |
| 240 | 346 | type swarmEmitter struct { | |
| 241 | - | mu sync.Mutex | |
| 242 | - | w *os.File | |
| 243 | - | mirror *swarm.EventLog | |
| 347 | + | mu sync.Mutex | |
| 348 | + | w *os.File | |
| 349 | + | mirror *swarm.EventLog | |
| 350 | + | agentID string | |
| 351 | + | versioned bool | |
| 244 | 352 | ||
| 245 | 353 | // orphan flips true the first time a stdout write fails (broken | |
| 246 | 354 | // pipe — the supervisor died). Until then the mirror stays | |
| @@ -258,46 +366,190 @@ func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter { | |||
| 258 | 366 | return &swarmEmitter{w: w, mirror: mirror} | |
| 259 | 367 | } | |
| 260 | 368 | ||
| 369 | + | func (e *swarmEmitter) setProtocolIdentity(agentID string) { | |
| 370 | + | e.mu.Lock() | |
| 371 | + | e.agentID = agentID | |
| 372 | + | e.versioned = true | |
| 373 | + | e.mu.Unlock() | |
| 374 | + | } | |
| 375 | + | ||
| 261 | 376 | func (e *swarmEmitter) emit(typ string, data map[string]any) { | |
| 262 | 377 | if data == nil { | |
| 263 | 378 | data = map[string]any{} | |
| 264 | 379 | } | |
| 265 | - | data["type"] = typ | |
| 266 | - | data["time"] = time.Now().Format(time.RFC3339Nano) | |
| 380 | + | payload := make(map[string]any, len(data)) | |
| 381 | + | for key, value := range data { | |
| 382 | + | payload[key] = value | |
| 383 | + | } | |
| 384 | + | turnID, _ := payload["turn_id"].(string) | |
| 267 | 385 | ||
| 268 | 386 | e.mu.Lock() | |
| 269 | 387 | defer e.mu.Unlock() | |
| 270 | 388 | ||
| 271 | - | if !e.orphan { | |
| 272 | - | // Encode to a buffer first so we can detect the stdout write | |
| 273 | - | // error — json.Encoder swallows partial writes silently, but | |
| 274 | - | // a direct Write on *os.File returns broken-pipe immediately. | |
| 275 | - | line, err := json.Marshal(data) | |
| 389 | + | wireType := typ | |
| 390 | + | if e.versioned { | |
| 391 | + | wireType = canonicalWorkerEvent(typ) | |
| 392 | + | } | |
| 393 | + | var line []byte | |
| 394 | + | var err error | |
| 395 | + | if e.versioned { | |
| 396 | + | envelope := swarm.NewEventEnvelope(wireType, e.agentID, turnID, payload) | |
| 397 | + | line, err = swarm.MarshalJSONL(envelope) | |
| 276 | 398 | if err == nil { | |
| 277 | - | line = append(line, '\n') | |
| 278 | - | if _, werr := e.w.Write(line); werr != nil { | |
| 279 | - | // Supervisor's stdout pipe is gone (parent zot exited | |
| 280 | - | // but we kept running). Switch to mirror-only mode so | |
| 281 | - | // subsequent events still get persisted; also retro- | |
| 282 | - | // actively log this very event to the mirror so it | |
| 283 | - | // doesn't get lost in the handoff. | |
| 284 | - | e.orphan = true | |
| 399 | + | // Flatten payload fields as a compatibility courtesy. New peers | |
| 400 | + | // read `payload`; old peers can still inspect `step`, `content`, | |
| 401 | + | // and similar fields at the top level. | |
| 402 | + | var object map[string]any | |
| 403 | + | if json.Unmarshal(bytes.TrimSpace(line), &object) == nil { | |
| 404 | + | for key, value := range payload { | |
| 405 | + | if _, exists := object[key]; exists { | |
| 406 | + | continue | |
| 407 | + | } | |
| 408 | + | // Keep the compatibility flattening useful for old peers, | |
| 409 | + | // but do not duplicate large result/output fields on the | |
| 410 | + | // wire. New peers read the canonical payload object; a | |
| 411 | + | // duplicated 500 KiB output would otherwise exceed the | |
| 412 | + | // supervisor's bounded JSONL frame budget. | |
| 413 | + | if !compatibilityFlatValue(value) { | |
| 414 | + | continue | |
| 415 | + | } | |
| 416 | + | object[key] = value | |
| 417 | + | } | |
| 418 | + | line, err = json.Marshal(object) | |
| 419 | + | line = append(line, '\n') | |
| 285 | 420 | } | |
| 286 | 421 | } | |
| 422 | + | } else { | |
| 423 | + | payload["type"] = typ | |
| 424 | + | payload["time"] = time.Now().Format(time.RFC3339Nano) | |
| 425 | + | line, err = json.Marshal(payload) | |
| 426 | + | line = append(line, '\n') | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | if !e.orphan && err == nil { | |
| 430 | + | if _, werr := e.w.Write(line); werr != nil { | |
| 431 | + | // Supervisor's stdout pipe is gone (parent zot exited but we | |
| 432 | + | // kept running). Switch to mirror-only mode and preserve this | |
| 433 | + | // event in the durable log. | |
| 434 | + | e.orphan = true | |
| 435 | + | } | |
| 287 | 436 | } | |
| 288 | 437 | ||
| 289 | 438 | if e.orphan && e.mirror != nil { | |
| 290 | - | // Drop "type" + "time" out of data into Event fields so the | |
| 291 | - | // mirror file matches what the supervisor would have written. | |
| 292 | - | flat := map[string]any{} | |
| 293 | - | for k, v := range data { | |
| 294 | - | if k == "type" || k == "time" { | |
| 295 | - | continue | |
| 439 | + | _ = e.mirror.Append(swarm.NewEvent(wireType, payload)) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | const compatibilityFlatValueBytes = 64 * 1024 | |
| 444 | + | ||
| 445 | + | func compatibilityFlatValue(value any) bool { | |
| 446 | + | switch v := value.(type) { | |
| 447 | + | case string: | |
| 448 | + | return len(v) <= compatibilityFlatValueBytes | |
| 449 | + | case json.RawMessage: | |
| 450 | + | return len(v) <= compatibilityFlatValueBytes | |
| 451 | + | default: | |
| 452 | + | return true | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func canonicalWorkerEvent(typ string) string { | |
| 457 | + | switch typ { | |
| 458 | + | case "agent_ready": | |
| 459 | + | return swarm.EventAgentReady | |
| 460 | + | case "agent_stopped": | |
| 461 | + | return swarm.EventAgentExited | |
| 462 | + | case "turn_start": | |
| 463 | + | return swarm.EventTurnStarted | |
| 464 | + | case "turn_progress": | |
| 465 | + | return swarm.EventTurnProgress | |
| 466 | + | case "tool_call": | |
| 467 | + | return swarm.EventToolStarted | |
| 468 | + | case "tool_result": | |
| 469 | + | return swarm.EventToolFinished | |
| 470 | + | case "text_delta": | |
| 471 | + | return swarm.EventMessageDelta | |
| 472 | + | default: | |
| 473 | + | return typ | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func finalAssistantText(messages []provider.Message) string { | |
| 478 | + | var out strings.Builder | |
| 479 | + | for _, message := range messages { | |
| 480 | + | if message.Role != provider.RoleAssistant { | |
| 481 | + | continue | |
| 482 | + | } | |
| 483 | + | for _, block := range message.Content { | |
| 484 | + | if text, ok := block.(provider.TextBlock); ok { | |
| 485 | + | if out.Len() > 0 { | |
| 486 | + | out.WriteByte('\n') | |
| 487 | + | } | |
| 488 | + | out.WriteString(text.Text) | |
| 296 | 489 | } | |
| 297 | - | flat[k] = v | |
| 298 | 490 | } | |
| 299 | - | _ = e.mirror.Append(swarm.NewEvent(typ, flat)) | |
| 300 | 491 | } | |
| 492 | + | return out.String() | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func firstLine(text string) string { | |
| 496 | + | return strings.Split(strings.TrimSpace(text), "\n")[0] | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | func boundedResultSummary(text string) string { | |
| 500 | + | return boundedResultOutput(firstLine(text), 4*1024, 1) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func workerOutputLimits() (maxBytes, maxLines int) { | |
| 504 | + | maxBytes, maxLines = 500_000, 5_000 | |
| 505 | + | if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZOT_SWARM_MAX_OUTPUT_BYTES"))); err == nil && value > 0 { | |
| 506 | + | maxBytes = value | |
| 507 | + | } | |
| 508 | + | if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZOT_SWARM_MAX_OUTPUT_LINES"))); err == nil && value > 0 { | |
| 509 | + | maxLines = value | |
| 510 | + | } | |
| 511 | + | return maxBytes, maxLines | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func boundedResultOutput(text string, maxBytes, maxLines int) string { | |
| 515 | + | const marker = "...[output truncated]" | |
| 516 | + | lines := strings.Split(text, "\n") | |
| 517 | + | if maxLines > 0 && len(lines) > maxLines { | |
| 518 | + | if maxLines == 1 { | |
| 519 | + | text = marker | |
| 520 | + | } else { | |
| 521 | + | text = strings.Join(append(lines[:maxLines-1], marker), "\n") | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | if maxBytes > 0 && len([]byte(text)) > maxBytes { | |
| 525 | + | if maxBytes <= len(marker) { | |
| 526 | + | return truncateOutputUTF8(text, maxBytes) | |
| 527 | + | } | |
| 528 | + | return truncateOutputUTF8(text, maxBytes-len(marker)) + marker | |
| 529 | + | } | |
| 530 | + | return text | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func truncateOutputUTF8(value string, maxBytes int) string { | |
| 534 | + | if maxBytes <= 0 { | |
| 535 | + | return "" | |
| 536 | + | } | |
| 537 | + | data := []byte(value) | |
| 538 | + | if len(data) <= maxBytes { | |
| 539 | + | return value | |
| 540 | + | } | |
| 541 | + | data = data[:maxBytes] | |
| 542 | + | for len(data) > 0 && !utf8.Valid(data) { | |
| 543 | + | data = data[:len(data)-1] | |
| 544 | + | } | |
| 545 | + | return string(data) | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func resultErrorPayload(err error) map[string]any { | |
| 549 | + | if err == nil { | |
| 550 | + | return nil | |
| 551 | + | } | |
| 552 | + | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | |
| 301 | 553 | } | |
| 302 | 554 | ||
| 303 | 555 | func errString(err error) string { | |
| @@ -307,6 +559,28 @@ func errString(err error) string { | |||
| 307 | 559 | return err.Error() | |
| 308 | 560 | } | |
| 309 | 561 | ||
| 562 | + | func initialTurnNumber(path string) int { | |
| 563 | + | if strings.TrimSpace(path) == "" { | |
| 564 | + | return 0 | |
| 565 | + | } | |
| 566 | + | events, err := swarm.ReadEventLog(path) | |
| 567 | + | if err != nil { | |
| 568 | + | return 0 | |
| 569 | + | } | |
| 570 | + | maxStep := 0 | |
| 571 | + | for _, ev := range events { | |
| 572 | + | if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep { | |
| 573 | + | maxStep = int(step) | |
| 574 | + | } | |
| 575 | + | if strings.HasPrefix(ev.TurnID, "turn-") { | |
| 576 | + | if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep { | |
| 577 | + | maxStep = n | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | return maxStep | |
| 582 | + | } | |
| 583 | + | ||
| 310 | 584 | func truncateForLog(s string, n int) string { | |
| 311 | 585 | if len(s) <= n { | |
| 312 | 586 | return s | |
M packages/agent/swarm_agent_test.go +64 -0
| @@ -6,11 +6,30 @@ import ( | |||
| 6 | 6 | "io" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | |
| 16 | + | // the effective output caps propagated by the supervisor and retains safe | |
| 17 | + | // defaults for standalone workers. | |
| 18 | + | func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) { | |
| 19 | + | t.Setenv("ZOT_SWARM_MAX_OUTPUT_BYTES", "123") | |
| 20 | + | t.Setenv("ZOT_SWARM_MAX_OUTPUT_LINES", "7") | |
| 21 | + | bytesLimit, linesLimit := workerOutputLimits() | |
| 22 | + | if bytesLimit != 123 || linesLimit != 7 { | |
| 23 | + | t.Fatalf("worker output limits = (%d, %d), want (123, 7)", bytesLimit, linesLimit) | |
| 24 | + | } | |
| 25 | + | t.Setenv("ZOT_SWARM_MAX_OUTPUT_BYTES", "invalid") | |
| 26 | + | t.Setenv("ZOT_SWARM_MAX_OUTPUT_LINES", "0") | |
| 27 | + | bytesLimit, linesLimit = workerOutputLimits() | |
| 28 | + | if bytesLimit != 500_000 || linesLimit != 5_000 { | |
| 29 | + | t.Fatalf("invalid worker output limits = (%d, %d), want defaults", bytesLimit, linesLimit) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 14 | 33 | // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the | |
| 15 | 34 | // "everything is doubled after reopening a swarm agent" bug. | |
| 16 | 35 | // | |
| @@ -84,6 +103,51 @@ func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |||
| 84 | 103 | // wire-format contract: each emitted event lands on stdout as one | |
| 85 | 104 | // JSON object per line with type+time at top level alongside the | |
| 86 | 105 | // data fields. The supervisor's runner parses this exact shape. | |
| 106 | + | func TestSwarmEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | |
| 107 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 108 | + | file, err := os.Create(path) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | defer file.Close() | |
| 113 | + | ||
| 114 | + | em := newSwarmEmitter(file, nil) | |
| 115 | + | em.setProtocolIdentity("agent-1") | |
| 116 | + | em.emit("turn.result", map[string]any{ | |
| 117 | + | "status": "succeeded", | |
| 118 | + | "turn_id": "turn-1", | |
| 119 | + | "output": strings.Repeat("x", 500_000), | |
| 120 | + | }) | |
| 121 | + | if err := file.Close(); err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | wire, err := os.ReadFile(path) | |
| 125 | + | if err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | if len(wire) >= 900_000 { | |
| 129 | + | t.Fatalf("large result appears duplicated on wire: %d bytes", len(wire)) | |
| 130 | + | } | |
| 131 | + | var object map[string]any | |
| 132 | + | if err := json.Unmarshal(bytes.TrimSpace(wire), &object); err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | if _, exists := object["output"]; exists { | |
| 136 | + | t.Fatal("large output was flattened a second time") | |
| 137 | + | } | |
| 138 | + | payload, ok := object["payload"].(map[string]any) | |
| 139 | + | if !ok || len(payload["output"].(string)) != 500_000 { | |
| 140 | + | t.Fatal("canonical payload lost the complete bounded output") | |
| 141 | + | } | |
| 142 | + | events, err := swarm.ReadEventLog(path) | |
| 143 | + | if err != nil || len(events) != 1 { | |
| 144 | + | t.Fatalf("supervisor event parser could not recover the large result: events=%d err=%v", len(events), err) | |
| 145 | + | } | |
| 146 | + | if output, ok := events[0].Data["output"].(string); !ok || len(output) != 500_000 { | |
| 147 | + | t.Fatal("supervisor parser lost the large result payload") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 87 | 151 | func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |
| 88 | 152 | // Pipe so we can read what the emitter wrote. | |
| 89 | 153 | r, w, err := os.Pipe() | |
M packages/agent/tools/swarm_spawn.go +109 -41
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| @@ -22,6 +23,10 @@ import ( | |||
| 22 | 23 | // can flip it off mid-session and the next call refuses cleanly | |
| 23 | 24 | // without re-registering the tool. | |
| 24 | 25 | type SwarmSpawnTool struct { | |
| 26 | + | // ToolName selects the public spelling. Empty preserves the historical | |
| 27 | + | // swarm_spawn alias; the CLI registers the canonical subagent_spawn name. | |
| 28 | + | ToolName string | |
| 29 | + | ||
| 25 | 30 | // Swarm is the supervisor used to spawn agents. Nil means | |
| 26 | 31 | // "auto-swarm not available in this mode" and the tool always | |
| 27 | 32 | // errors. | |
| @@ -51,6 +56,10 @@ type SwarmSpawnTool struct { | |||
| 51 | 56 | OnSpawned func(agent *swarm.Agent, task string) | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 59 | + | // SubagentSpawnTool is the canonical spelling; SwarmSpawnTool remains an | |
| 60 | + | // alias for integrations that used the original API. | |
| 61 | + | type SubagentSpawnTool = SwarmSpawnTool | |
| 62 | + | ||
| 54 | 63 | type swarmSpawnArgs struct { | |
| 55 | 64 | Task string `json:"task"` | |
| 56 | 65 | Agent string `json:"agent,omitempty"` | |
| @@ -58,6 +67,9 @@ type swarmSpawnArgs struct { | |||
| 58 | 67 | Provider string `json:"provider,omitempty"` | |
| 59 | 68 | Reasoning string `json:"reasoning,omitempty"` | |
| 60 | 69 | Thinking string `json:"thinking,omitempty"` | |
| 70 | + | Isolation string `json:"isolation,omitempty"` | |
| 71 | + | Timeout string `json:"timeout,omitempty"` | |
| 72 | + | MaxTurns int `json:"max_turns,omitempty"` | |
| 61 | 73 | } | |
| 62 | 74 | ||
| 63 | 75 | const swarmSpawnSchema = `{ | |
| @@ -88,23 +100,43 @@ const swarmSpawnSchema = `{ | |||
| 88 | 100 | "type": "string", | |
| 89 | 101 | "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], | |
| 90 | 102 | "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available." | |
| 103 | + | }, | |
| 104 | + | "isolation": { | |
| 105 | + | "type": "string", | |
| 106 | + | "enum": ["shared", "worktree"], | |
| 107 | + | "description": "Workspace mode. Shared preserves legacy behavior; worktree captures a patch without merging it." | |
| 108 | + | }, | |
| 109 | + | "timeout": { | |
| 110 | + | "type": "string", | |
| 111 | + | "description": "Optional Go duration such as 20m." | |
| 112 | + | }, | |
| 113 | + | "max_turns": { | |
| 114 | + | "type": "integer", | |
| 115 | + | "minimum": 1, | |
| 116 | + | "description": "Maximum prompt-level turns for this worker." | |
| 91 | 117 | } | |
| 92 | 118 | }, | |
| 93 | 119 | "required": ["task"] | |
| 94 | 120 | }` | |
| 95 | 121 | ||
| 96 | - | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | |
| 122 | + | func (t *SwarmSpawnTool) Name() string { | |
| 123 | + | if strings.TrimSpace(t.ToolName) != "" { | |
| 124 | + | return strings.TrimSpace(t.ToolName) | |
| 125 | + | } | |
| 126 | + | return "swarm_spawn" | |
| 127 | + | } | |
| 97 | 128 | func (t *SwarmSpawnTool) Description() string { | |
| 98 | - | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Named profiles may restrict fast mode but cannot enable it when the host setting is off. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." | |
| 129 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path." | |
| 99 | 130 | } | |
| 100 | 131 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | |
| 101 | 132 | ||
| 102 | 133 | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 134 | + | prefix := t.Name() | |
| 103 | 135 | if t.Swarm == nil { | |
| 104 | - | return protocolToolError("swarm_spawn: swarm supervisor not available in this mode") | |
| 136 | + | return protocolToolError(prefix + ": swarm supervisor not available in this mode") | |
| 105 | 137 | } | |
| 106 | 138 | if t.Enabled == nil || !t.Enabled() { | |
| 107 | - | return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.") | |
| 139 | + | return protocolToolError(prefix + ": auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.") | |
| 108 | 140 | } | |
| 109 | 141 | var a swarmSpawnArgs | |
| 110 | 142 | if err := json.Unmarshal(raw, &a); err != nil { | |
| @@ -112,7 +144,26 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 112 | 144 | } | |
| 113 | 145 | task := strings.TrimSpace(a.Task) | |
| 114 | 146 | if task == "" { | |
| 115 | - | return protocolToolError("swarm_spawn: task is required") | |
| 147 | + | return protocolToolError(prefix + ": task is required") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | workspaceMode := swarm.WorkspaceShared | |
| 151 | + | if value := strings.TrimSpace(a.Isolation); value != "" { | |
| 152 | + | workspaceMode = swarm.WorkspaceMode(value) | |
| 153 | + | if workspaceMode != swarm.WorkspaceShared && workspaceMode != swarm.WorkspaceWorktree { | |
| 154 | + | return protocolToolError(prefix + ": isolation must be shared or worktree") | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | var timeout time.Duration | |
| 158 | + | if value := strings.TrimSpace(a.Timeout); value != "" { | |
| 159 | + | parsed, parseErr := time.ParseDuration(value) | |
| 160 | + | if parseErr != nil || parsed <= 0 { | |
| 161 | + | return protocolToolError(prefix + ": timeout must be a positive duration") | |
| 162 | + | } | |
| 163 | + | timeout = parsed | |
| 164 | + | } | |
| 165 | + | if a.MaxTurns < 0 { | |
| 166 | + | return protocolToolError(prefix + ": max_turns must be positive") | |
| 116 | 167 | } | |
| 117 | 168 | ||
| 118 | 169 | agentName := strings.TrimSpace(a.Agent) | |
| @@ -120,15 +171,15 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 120 | 171 | var fastModeOverride *bool | |
| 121 | 172 | if agentName != "" { | |
| 122 | 173 | if t.ResolveSubagent == nil { | |
| 123 | - | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") | |
| 174 | + | return protocolToolError(prefix + ": named subagent profiles are unavailable") | |
| 124 | 175 | } | |
| 125 | 176 | var err error | |
| 126 | 177 | profile, err = t.ResolveSubagent(agentName) | |
| 127 | 178 | if err != nil { | |
| 128 | - | return protocolToolError("swarm_spawn: " + err.Error()) | |
| 179 | + | return protocolToolError(prefix + ": " + err.Error()) | |
| 129 | 180 | } | |
| 130 | 181 | if profile == nil { | |
| 131 | - | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) | |
| 182 | + | return protocolToolError(prefix + ": unknown subagent profile " + agentName) | |
| 132 | 183 | } | |
| 133 | 184 | agentName = profile.Name | |
| 134 | 185 | fastModeOverride = profile.FastMode | |
| @@ -136,55 +187,63 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 136 | 187 | ||
| 137 | 188 | model := strings.TrimSpace(a.Model) | |
| 138 | 189 | providerID := strings.TrimSpace(a.Provider) | |
| 190 | + | var profileTools []string | |
| 191 | + | if profile != nil { | |
| 192 | + | profileTools = append([]string(nil), profile.Tools...) | |
| 193 | + | } | |
| 139 | 194 | if (model == "") != (providerID == "") { | |
| 140 | - | return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly") | |
| 195 | + | return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly") | |
| 141 | 196 | } | |
| 142 | - | if model == "" && providerID == "" && profile != nil { | |
| 143 | - | // A fully-qualified profile model is useful metadata for the | |
| 144 | - | // supervisor and also lets it inherit the right credential route. | |
| 145 | - | // Bare profile model ids remain child-resolved so the configured | |
| 146 | - | // provider is preserved. | |
| 197 | + | if profile != nil { | |
| 198 | + | // A profile may specify a qualified model, a bare model, a provider, | |
| 199 | + | // or neither. Fill only missing pieces so explicit spawn options win; | |
| 200 | + | // the parent session supplies whatever the profile leaves unspecified. | |
| 147 | 201 | profileProvider, profileModel := profile.ModelSelection() | |
| 148 | - | if profileProvider != "" && profileModel != "" { | |
| 149 | - | providerID, model = profileProvider, profileModel | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | if model == "" && providerID == "" && profile == nil { | |
| 153 | - | if t.DefaultModel != nil { | |
| 154 | - | model = strings.TrimSpace(t.DefaultModel()) | |
| 202 | + | if model == "" { | |
| 203 | + | model = profileModel | |
| 155 | 204 | } | |
| 156 | - | if t.DefaultProvider != nil { | |
| 157 | - | providerID = strings.TrimSpace(t.DefaultProvider()) | |
| 205 | + | if providerID == "" { | |
| 206 | + | providerID = profileProvider | |
| 158 | 207 | } | |
| 159 | 208 | } | |
| 209 | + | if model == "" && t.DefaultModel != nil { | |
| 210 | + | model = strings.TrimSpace(t.DefaultModel()) | |
| 211 | + | } | |
| 212 | + | if providerID == "" && t.DefaultProvider != nil { | |
| 213 | + | providerID = strings.TrimSpace(t.DefaultProvider()) | |
| 214 | + | } | |
| 160 | 215 | ||
| 161 | 216 | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | |
| 162 | 217 | if err != nil { | |
| 163 | - | return protocolToolError("swarm_spawn: " + err.Error()) | |
| 218 | + | return protocolToolError(prefix + ": " + err.Error()) | |
| 164 | 219 | } | |
| 165 | 220 | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | |
| 166 | 221 | reasoning, err = reasoningOverride(profile.Thinking, "") | |
| 167 | 222 | if err != nil { | |
| 168 | - | return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error()) | |
| 223 | + | return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error()) | |
| 169 | 224 | } | |
| 170 | 225 | } | |
| 171 | 226 | if reasoning == "" && t.DefaultReasoning != nil { | |
| 172 | 227 | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | |
| 173 | 228 | if err != nil { | |
| 174 | - | return protocolToolError("swarm_spawn: host " + err.Error()) | |
| 229 | + | return protocolToolError(prefix + ": host " + err.Error()) | |
| 175 | 230 | } | |
| 176 | 231 | } | |
| 177 | 232 | ||
| 178 | 233 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 179 | - | Task: task, | |
| 180 | - | Model: model, | |
| 181 | - | Provider: providerID, | |
| 182 | - | Reasoning: reasoning, | |
| 183 | - | FastMode: fastModeOverride, | |
| 184 | - | Subagent: agentName, | |
| 234 | + | Task: task, | |
| 235 | + | Model: model, | |
| 236 | + | Provider: providerID, | |
| 237 | + | Reasoning: reasoning, | |
| 238 | + | FastMode: fastModeOverride, | |
| 239 | + | Subagent: agentName, | |
| 240 | + | Timeout: timeout, | |
| 241 | + | MaxTurns: a.MaxTurns, | |
| 242 | + | WorkspaceMode: workspaceMode, | |
| 243 | + | Tools: profileTools, | |
| 185 | 244 | }) | |
| 186 | 245 | if err != nil { | |
| 187 | - | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | |
| 246 | + | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 188 | 247 | } | |
| 189 | 248 | if t.OnSpawned != nil { | |
| 190 | 249 | t.OnSpawned(agent, task) | |
| @@ -192,6 +251,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 192 | 251 | ||
| 193 | 252 | var sb strings.Builder | |
| 194 | 253 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |
| 254 | + | fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState()) | |
| 255 | + | fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode) | |
| 195 | 256 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |
| 196 | 257 | if agentName != "" { | |
| 197 | 258 | fmt.Fprintf(&sb, "agent: %s\n", agentName) | |
| @@ -208,18 +269,25 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 208 | 269 | if agent.FastMode { | |
| 209 | 270 | sb.WriteString("fast mode: enabled\n") | |
| 210 | 271 | } | |
| 211 | - | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | |
| 272 | + | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | |
| 212 | 273 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| 213 | 274 | return core.ToolResult{ | |
| 214 | 275 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 215 | 276 | Details: map[string]any{ | |
| 216 | - | "agent_id": agent.ID, | |
| 217 | - | "task": task, | |
| 218 | - | "agent": agentName, | |
| 219 | - | "model": model, | |
| 220 | - | "provider": providerID, | |
| 221 | - | "reasoning": reasoning, | |
| 222 | - | "fast_mode": agent.FastMode, | |
| 277 | + | "agent_id": agent.ID, | |
| 278 | + | "task": task, | |
| 279 | + | "agent": agentName, | |
| 280 | + | "model": model, | |
| 281 | + | "provider": providerID, | |
| 282 | + | "reasoning": reasoning, | |
| 283 | + | "fast_mode": agent.FastMode, | |
| 284 | + | "isolation": string(workspaceMode), | |
| 285 | + | "timeout": timeout.String(), | |
| 286 | + | "max_turns": agent.MaxTurns, | |
| 287 | + | "state": agent.Status(), | |
| 288 | + | "process_state": string(agent.ProcessState()), | |
| 289 | + | "turn_state": string(agent.TurnState()), | |
| 290 | + | "result_ref": swarm.ResultRef(agent.ID), | |
| 223 | 291 | }, | |
| 224 | 292 | }, nil | |
| 225 | 293 | } | |
M packages/agent/tools/swarm_spawn_test.go +3 -1
| @@ -19,13 +19,15 @@ func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil } | |||
| 19 | 19 | func newTestSwarm(t *testing.T) *swarm.Swarm { | |
| 20 | 20 | t.Helper() | |
| 21 | 21 | root := t.TempDir() | |
| 22 | - | return swarm.New(swarm.Config{ | |
| 22 | + | manager := swarm.New(swarm.Config{ | |
| 23 | 23 | Root: filepath.Join(root, "swarm"), | |
| 24 | 24 | RepoRoot: root, | |
| 25 | 25 | NewRunner: func(*swarm.Agent) swarm.Runner { | |
| 26 | 26 | return noopSwarmRunner{} | |
| 27 | 27 | }, | |
| 28 | 28 | }) | |
| 29 | + | t.Cleanup(manager.StopAll) | |
| 30 | + | return manager | |
| 29 | 31 | } | |
| 30 | 32 | ||
| 31 | 33 | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |
M packages/tui/statusbar_test.go +18 -0
| @@ -57,6 +57,24 @@ func TestStatusBarNoCWD(t *testing.T) { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | func TestStatusBarShowsFastMode(t *testing.T) { | |
| 61 | + | lines := StatusBar(StatusBarParams{ | |
| 62 | + | Theme: Dark, | |
| 63 | + | Provider: "openai-codex", | |
| 64 | + | Model: "gpt-5.6-luna", | |
| 65 | + | FastMode: true, | |
| 66 | + | CWD: "/tmp/x", | |
| 67 | + | Cols: 200, | |
| 68 | + | }) | |
| 69 | + | if len(lines) != 2 { | |
| 70 | + | t.Fatalf("want 2 lines, got %d: %q", len(lines), lines) | |
| 71 | + | } | |
| 72 | + | plain := stripANSI(lines[0]) | |
| 73 | + | if !strings.Contains(plain, "fast mode") { | |
| 74 | + | t.Fatalf("fast mode should be visible in status bar: %q", plain) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 60 | 78 | func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) { | |
| 61 | 79 | lines := StatusBar(StatusBarParams{ | |
| 62 | 80 | Theme: Dark, | |
M packages/tui/view.go +34 -10
| @@ -2408,6 +2408,7 @@ type StatusBarParams struct { | |||
| 2408 | 2408 | Provider string | |
| 2409 | 2409 | Model string | |
| 2410 | 2410 | Reasoning string // "" means thinking off | |
| 2411 | + | FastMode bool // show the provider's opt-in fast tier when enabled | |
| 2411 | 2412 | Busy bool | |
| 2412 | 2413 | BusyPrefix string // spinner + funny line when busy | |
| 2413 | 2414 | CWD string | |
| @@ -2509,8 +2510,15 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2509 | 2510 | if reasoning != "" { | |
| 2510 | 2511 | reasoningText = "reasoning: " + reasoning | |
| 2511 | 2512 | } | |
| 2513 | + | fastText := "" | |
| 2514 | + | if p.FastMode { | |
| 2515 | + | fastText = "fast mode" | |
| 2516 | + | } | |
| 2512 | 2517 | statsText := strings.Join(stats, " ") | |
| 2513 | - | middleParts := make([]string, 0, 2) | |
| 2518 | + | middleParts := make([]string, 0, 3) | |
| 2519 | + | if fastText != "" { | |
| 2520 | + | middleParts = append(middleParts, fastText) | |
| 2521 | + | } | |
| 2514 | 2522 | if reasoningText != "" { | |
| 2515 | 2523 | middleParts = append(middleParts, reasoningText) | |
| 2516 | 2524 | } | |
| @@ -2570,7 +2578,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2570 | 2578 | modelLine := pad + th.FG256(th.Muted, left) | |
| 2571 | 2579 | lines := []string{busyLine} | |
| 2572 | 2580 | if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols { | |
| 2573 | - | lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols) | |
| 2581 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | |
| 2574 | 2582 | } else { | |
| 2575 | 2583 | var infoBuilder strings.Builder | |
| 2576 | 2584 | infoBuilder.WriteString(modelLine) | |
| @@ -2592,7 +2600,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2592 | 2600 | // into an awkward position on small widths. | |
| 2593 | 2601 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | |
| 2594 | 2602 | var lines []string | |
| 2595 | - | lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols) | |
| 2603 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | |
| 2596 | 2604 | if cwd != "" { | |
| 2597 | 2605 | lines = append(lines, pad+th.FG256(th.Muted, cwd)) | |
| 2598 | 2606 | } | |
| @@ -2609,9 +2617,17 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2609 | 2617 | return []string{primary, cwdRendered} | |
| 2610 | 2618 | } | |
| 2611 | 2619 | ||
| 2612 | - | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasoningText, statsText string, cols int) []string { | |
| 2620 | + | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string { | |
| 2613 | 2621 | modelLine := pad + th.FG256(th.Muted, modelText) | |
| 2614 | - | if reasoningText == "" { | |
| 2622 | + | infoParts := make([]string, 0, 2) | |
| 2623 | + | if fastText != "" { | |
| 2624 | + | infoParts = append(infoParts, fastText) | |
| 2625 | + | } | |
| 2626 | + | if reasoningText != "" { | |
| 2627 | + | infoParts = append(infoParts, reasoningText) | |
| 2628 | + | } | |
| 2629 | + | infoText := strings.Join(infoParts, pad) | |
| 2630 | + | if infoText == "" { | |
| 2615 | 2631 | lines = append(lines, modelLine) | |
| 2616 | 2632 | if statsText != "" { | |
| 2617 | 2633 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | |
| @@ -2619,13 +2635,21 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasonin | |||
| 2619 | 2635 | return lines | |
| 2620 | 2636 | } | |
| 2621 | 2637 | ||
| 2622 | - | modelReasoningPlain := pad + modelText + pad + reasoningText | |
| 2623 | - | reasoningColor := reasoningStatusColor(th, reasoningText) | |
| 2624 | - | if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols { | |
| 2625 | - | lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+reasoningText)) | |
| 2638 | + | modelInfoPlain := pad + modelText + pad + infoText | |
| 2639 | + | infoColor := th.Muted | |
| 2640 | + | if reasoningText != "" { | |
| 2641 | + | infoColor = reasoningStatusColor(th, reasoningText) | |
| 2642 | + | } | |
| 2643 | + | if cols <= 0 || visibleWidth(modelInfoPlain) <= cols { | |
| 2644 | + | lines = append(lines, pad+th.FG256(infoColor, modelText+pad+infoText)) | |
| 2626 | 2645 | } else { | |
| 2627 | 2646 | lines = append(lines, modelLine) | |
| 2628 | - | lines = append(lines, pad+th.FG256(reasoningColor, reasoningText)) | |
| 2647 | + | if fastText != "" { | |
| 2648 | + | lines = append(lines, pad+th.FG256(th.Muted, fastText)) | |
| 2649 | + | } | |
| 2650 | + | if reasoningText != "" { | |
| 2651 | + | lines = append(lines, pad+th.FG256(reasoningStatusColor(th, reasoningText), reasoningText)) | |
| 2652 | + | } | |
| 2629 | 2653 | } | |
| 2630 | 2654 | if statsText != "" { | |
| 2631 | 2655 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | |