detached 3463e9c

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 | `/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. |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 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |291 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
292 | `/btw` | Side chat with full context that doesn't add to the main thread. |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 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |294 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
295 | `/compact` | Summarize the transcript into one message to free up context. |295 | `/compact` | Summarize the transcript into one message to free up context. |
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`. |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 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.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 dashboard356+/subagents # open the dashboard
357-/swarm new <task> # spawn an agent357+/subagents new <task> # spawn an agent (shared mode)
358-/swarm new --agent reviewer <task> # use a named markdown profile358+/subagents new --agent reviewer <task> # use a named markdown profile
359-/swarm new --model gpt-5 <task> # pin the new agent to a specific model359+/subagents new --reasoning high <task> # set child reasoning
360-/swarm new --reasoning high <task> # set the child reasoning level360+/subagents logs <id> # open one agent's transcript
361-/swarm logs <id> # jump straight into one agent's transcript361+/subagents send <id> <text> # send a follow-up turn
362-/swarm send <id> <text> # send a follow-up without opening the dashboard362+/subagents result <id> # show the structured result reference
363-/swarm resume # pick a stopped agent to bring back363+/subagents resume-session <id> # continue without replaying the task
364-/swarm resume <id> # bring a specific agent back364+/subagents restart-task <id> # intentionally run the stored task again
365-/swarm kill <id> # stop a running agent (its state stays)365+/subagents kill <id> # cancel gracefully, then force-stop
366-/swarm remove <id> # delete the agent's session and state366+/subagents remove <id> # delete durable state
367-/swarm list # alias for opening the dashboard367+# /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 | Key | Action |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 **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.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 **`/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.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 ### Named subagent profiles397 ### 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 ### `/settings`401 ### `/settings`
402 402
@@ -405,7 +405,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
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.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 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.406 - **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 - **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`.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 - **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.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 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
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.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 | Field | Meaning |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 | `description` | Short description shown to the main agent. |43 | `description` | Short description shown to the main agent. |
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. |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 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |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 | `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. |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 | `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. |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 ## Selecting a profile55 ## 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 ```json59 ```json
60 {60 {
@@ -80,8 +80,29 @@ A per-spawn reasoning override is also accepted:
80 The interactive command also supports the same selection explicitly:80 The interactive command also supports the same selection explicitly:
81 81
82 ```text82 ```text
83-/swarm new --agent reviewer Review the authentication package83+/subagents new --agent reviewer Review the authentication package
84-/swarm new --agent implementer --reasoning high Implement the parser change84+/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 ModeRPC Mode = "rpc"22 ModeRPC Mode = "rpc"
23 // ModeSwarmAgent is the long-lived, headless daemon mode used by23 // ModeSwarmAgent is the long-lived, headless daemon mode used by
24 // swarm-spawned agents. The binary opens a unix-socket inbox at24 // swarm-spawned agents. The binary opens a unix-socket inbox at
25- // the path provided by --swarm-agent, reads supervisor messages25+ // the path provided by --subagent-worker, reads supervisor messages
26- // off it ("user ...", "cancel", "shutdown"), runs each user turn26+ // off it (versioned JSONL, with a legacy text reader), runs each user
27- // against a persistent session, and streams JSONL events on27+ // turn against a persistent session, and streams JSONL events on
28- // stdout. See packages/agent/swarm/inbox.go for the wire protocol.28+ // stdout. --swarm-agent remains an input compatibility alias.
29- ModeSwarmAgent Mode = "swarm-agent"29+ ModeSubagentWorker Mode = "subagent-worker"
30+ ModeSwarmAgent Mode = ModeSubagentWorker
30 )31 )
31 32
32 // Args holds parsed command-line options.33 // Args holds parsed command-line options.
@@ -124,10 +125,13 @@ type Args struct {
124 StartupPre string125 StartupPre string
125 126
126 // SwarmAgent is the inbox-socket path when this process is a127 // SwarmAgent is the inbox-socket path when this process is a
127- // swarm-spawned agent. Empty in every other mode. Set by128+ // subagent worker. Empty in every other mode. Set by either
128- // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.129+ // --subagent-worker or the --swarm-agent compatibility alias.
129 SwarmAgent string130 SwarmAgent string
130 131
132+ // SubagentMaxTurns limits prompt-level turns in worker mode.
133+ SubagentMaxTurns int
134+
131 // Subagent selects a named markdown profile for a swarm child.135 // Subagent selects a named markdown profile for a swarm child.
132 // It is intentionally an internal child flag; the parent swarm tool136 // It is intentionally an internal child flag; the parent swarm tool
133 // passes only the profile name and the child discovers the definition137 // passes only the profile name and the child discovers the definition
@@ -158,6 +162,12 @@ func ParseArgs(in []string) (Args, error) {
158 for i := 0; i < len(in); i++ {162 for i := 0; i < len(in); i++ {
159 arg := in[i]163 arg := in[i]
160 switch arg {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 case "-h", "--help":171 case "-h", "--help":
162 a.Help = true172 a.Help = true
163 case "-v", "--version":173 case "-v", "--version":
@@ -289,13 +299,13 @@ func ParseArgs(in []string) (Args, error) {
289 return a, err299 return a, err
290 }300 }
291 a.CWD = v301 a.CWD = v
292- case "--swarm-agent":302+ case "--subagent-worker", "--swarm-agent":
293 v, err := want(&i, arg)303 v, err := want(&i, arg)
294 if err != nil {304 if err != nil {
295 return a, err305 return a, err
296 }306 }
297 a.SwarmAgent = v307 a.SwarmAgent = v
298- a.Mode = ModeSwarmAgent308+ a.Mode = ModeSubagentWorker
299 case "--subagent":309 case "--subagent":
300 v, err := want(&i, arg)310 v, err := want(&i, arg)
301 if err != nil {311 if err != nil {
@@ -323,6 +333,16 @@ func ParseArgs(in []string) (Args, error) {
323 return a, fmt.Errorf("--max-steps must be a positive integer")333 return a, fmt.Errorf("--max-steps must be a positive integer")
324 }334 }
325 a.MaxSteps = n335 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 default:346 default:
327 if strings.HasPrefix(arg, "-") && arg != "-" {347 if strings.HasPrefix(arg, "-") && arg != "-" {
328 return a, fmt.Errorf("unknown flag %q", arg)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 func TestParseArgsTemperatureAllowsZero(t *testing.T) {35 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
26 args, err := ParseArgs([]string{"--temperature", "0"})36 args, err := ParseArgs([]string{"--temperature", "0"})
27 if err != nil {37 if err != nil {
M packages/agent/build.go +16 -1
@@ -737,7 +737,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
737 append_ = append(append_, skillAddendum)737 append_ = append(append_, skillAddendum)
738 }738 }
739 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive739 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 homeDir, _ := os.UserHomeDir()741 homeDir, _ := os.UserHomeDir()
742 profiles, _ := subagents.Discover(args.CWD, homeDir)742 profiles, _ := subagents.Discover(args.CWD, homeDir)
743 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {743 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
@@ -1188,6 +1188,21 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
1188 return false1188 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 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1206 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1192 order := []string{"read", "write", "edit", "bash", "lsp"}1207 order := []string{"read", "write", "edit", "bash", "lsp"}
1193 var out []ToolSummary1208 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 // ---- interactive mode: opens the TUI even without credentials ----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 func runInteractive(ctx context.Context, args Args, version string) error {1215 func runInteractive(ctx context.Context, args Args, version string) error {
1216+ initialCfg, _ := LoadConfig()
1187 // Resolve WITHOUT requiring credentials.1217 // Resolve WITHOUT requiring credentials.
1188 r, err := Resolve(args, false)1218 r, err := Resolve(args, false)
1189 if err != nil {1219 if err != nil {
@@ -1256,18 +1286,40 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1256 // in this outer scope rather than scoping it tighter.1286 // in this outer scope rather than scoping it tighter.
1257 var swarmMgr *swarm.Swarm1287 var swarmMgr *swarm.Swarm
1258 swarmMgr = swarm.New(swarm.Config{1288 swarmMgr = swarm.New(swarm.Config{
1259- Root: filepath.Join(ZotHome(), "swarm"),1289+ Context: ctx,
1260- RepoRoot: r.CWD,1290+ Root: filepath.Join(ZotHome(), "swarm"),
1261- FastMode: r.FastMode,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 ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {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 return swarm.Credential{Value: "ollama", Method: "apikey"}, nil1316 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 if err != nil {1319 if err != nil {
1268 // Providers backed only by a local endpoint do not need a key.1320 // Providers backed only by a local endpoint do not need a key.
1269 // Leave stdin untouched and let the child resolve that endpoint.1321 // Leave stdin untouched and let the child resolve that endpoint.
1270- if !CredentialAvailable(providerID) {1322+ if !CredentialAvailable(childProvider) {
1271 return swarm.Credential{}, nil1323 return swarm.Credential{}, nil
1272 }1324 }
1273 return swarm.Credential{}, err1325 return swarm.Credential{}, err
@@ -1278,6 +1330,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1278 // Pull any previously-spawned agents off disk so the dashboard1330 // Pull any previously-spawned agents off disk so the dashboard
1279 // shows them as detached and the user can resume / remove them.1331 // shows them as detached and the user can resume / remove them.
1280 _, _ = swarmMgr.Reload()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 // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool1341 // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
1283 // fires after every successful spawn. It hands the agent off to1342 // 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 // toggle live-mutates the running agent's registry separately so1359 // toggle live-mutates the running agent's registry separately so
1301 // flipping the flag mid-session takes effect on the next turn.1360 // flipping the flag mid-session takes effect on the next turn.
1302 injectSwarmSpawn := func(reg core.Registry) core.Registry {1361 injectSwarmSpawn := func(reg core.Registry) core.Registry {
1303- if reg == nil {1362+ if reg == nil || swarmMgr == nil || !autoSwarmToolAllowed(args) {
1304 return reg1363 return reg
1305 }1364 }
1306 if !AutoSwarmEnabled() {1365 if !AutoSwarmEnabled() {
1307 return reg1366 return reg
1308 }1367 }
1309- reg["swarm_spawn"] = &tools.SwarmSpawnTool{1368+ canonical := &tools.SwarmSpawnTool{
1310- Swarm: swarmMgr,1369+ ToolName: "subagent_spawn",
1311- Enabled: AutoSwarmEnabled,1370+ Swarm: swarmMgr,
1371+ Enabled: AutoSwarmEnabled,
1312 DefaultModel: func() string {1372 DefaultModel: func() string {
1313 persistMu.Lock()1373 persistMu.Lock()
1314 defer persistMu.Unlock()1374 defer persistMu.Unlock()
@@ -1323,6 +1383,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1323 ResolveSubagent: resolveSubagent,1383 ResolveSubagent: resolveSubagent,
1324 OnSpawned: onSpawnedSwarm,1384 OnSpawned: onSpawnedSwarm,
1325 }1385 }
1386+ reg[canonical.Name()] = canonical
1387+ legacy := *canonical
1388+ legacy.ToolName = "swarm_spawn"
1389+ reg[legacy.Name()] = &legacy
1326 return reg1390 return reg
1327 }1391 }
1328 injectSwarmSpawn(r.ToolRegistry)1392 injectSwarmSpawn(r.ToolRegistry)
@@ -1407,6 +1471,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1407 return nil, "", "", err1471 return nil, "", "", err
1408 }1472 }
1409 resolved.UseSandbox(sharedSandbox)1473 resolved.UseSandbox(sharedSandbox)
1474+ if swarmMgr != nil {
1475+ swarmMgr.SetProvider(resolved.Provider)
1476+ swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1477+ }
1410 resolved.MergeExtensionTools(extToolAdapter)1478 resolved.MergeExtensionTools(extToolAdapter)
1411 injectSwarmSpawn(resolved.ToolRegistry)1479 injectSwarmSpawn(resolved.ToolRegistry)
1412 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1480 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1426,6 +1494,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1426 return nil, "", "", err1494 return nil, "", "", err
1427 }1495 }
1428 resolved.UseSandbox(sharedSandbox)1496 resolved.UseSandbox(sharedSandbox)
1497+ if swarmMgr != nil {
1498+ swarmMgr.SetProvider(resolved.Provider)
1499+ swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1500+ }
1429 resolved.MergeExtensionTools(extToolAdapter)1501 resolved.MergeExtensionTools(extToolAdapter)
1430 injectSwarmSpawn(resolved.ToolRegistry)1502 injectSwarmSpawn(resolved.ToolRegistry)
1431 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1503 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1453,6 +1525,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1453 return nil, "", "", err1525 return nil, "", "", err
1454 }1526 }
1455 resolved.UseSandbox(sharedSandbox)1527 resolved.UseSandbox(sharedSandbox)
1528+ if swarmMgr != nil {
1529+ swarmMgr.SetProvider(resolved.Provider)
1530+ swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1531+ }
1456 resolved.MergeExtensionTools(extToolAdapter)1532 resolved.MergeExtensionTools(extToolAdapter)
1457 injectSwarmSpawn(resolved.ToolRegistry)1533 injectSwarmSpawn(resolved.ToolRegistry)
1458 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1534 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 fastMode := r.FastMode2022 fastMode := r.FastMode
1948 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))2023 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
1949 for idx, s := range initialCfg.QuickModelShortcuts {2024 for idx, s := range initialCfg.QuickModelShortcuts {
@@ -1964,12 +2039,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1964 // shows agents this session spawned (and any pre-upgrade unscoped2039 // shows agents this session spawned (and any pre-upgrade unscoped
1965 // agents — see SnapshotAll docs). Updated again whenever the2040 // agents — see SnapshotAll docs). Updated again whenever the
1966 // user swaps sessions via loadSession below.2041 // user swaps sessions via loadSession below.
1967- if sess != nil {2042+ if sess != nil && swarmMgr != nil {
1968 swarmMgr.SetActiveSession(sess.ID)2043 swarmMgr.SetActiveSession(sess.ID)
1969 }2044 }
1970 // Best-effort shutdown on interactive exit: stop all running2045 // Best-effort shutdown on interactive exit: stop all running
1971 // agents so they don't outlive their parent zot.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 var startupSkills []*skills.Skill2051 var startupSkills []*skills.Skill
1975 if r.SkillTool != nil {2052 if r.SkillTool != nil {
@@ -1978,6 +2055,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1978 homeDir, _ := os.UserHomeDir()2055 homeDir, _ := os.UserHomeDir()
1979 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)2056 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
1980 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)2057 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2058+ autoSwarmToolAllowedForSession := autoSwarmToolAllowed(args)
1981 2059
1982 iv = modes.NewInteractive(modes.InteractiveConfig{2060 iv = modes.NewInteractive(modes.InteractiveConfig{
1983 Terminal: term,2061 Terminal: term,
@@ -1986,6 +2064,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1986 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,2064 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1987 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,2065 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
1988 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,2066 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
2067+ AutoSwarmToolAllowed: &autoSwarmToolAllowedForSession,
1989 FastMode: &fastMode,2068 FastMode: &fastMode,
1990 LSPEnabled: initialCfg.LSPEnabled,2069 LSPEnabled: initialCfg.LSPEnabled,
1991 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,2070 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
@@ -2238,9 +2317,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2238 }2317 }
2239 closeAgentLSP(finalAg)2318 closeAgentLSP(finalAg)
2240 closeResolvedLSP(r)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 // Exit cleanly. Re-raising the signal would skip os.Exit's2327 // Exit cleanly. Re-raising the signal would skip os.Exit's
2242 // at-exit hooks; explicit exit is fine because we've already2328 // 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 os.Exit(0)2331 os.Exit(0)
2245 }()2332 }()
2246 2333
M packages/agent/config.go +22 -0
@@ -22,6 +22,25 @@ type QuickModelShortcut struct {
22 Model string `json:"model"`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 // Config is the persisted user configuration.44 // Config is the persisted user configuration.
26 type Config struct {45 type Config struct {
27 Provider string `json:"provider"`46 Provider string `json:"provider"`
@@ -68,6 +87,9 @@ type Config struct {
68 // both so no extra provider request is made. Toggle from /settings.87 // both so no extra provider request is made. Toggle from /settings.
69 TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`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 // AutoSwarmEnabled lets the main agent spawn background sub-agents93 // AutoSwarmEnabled lets the main agent spawn background sub-agents
72 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by94 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
73 // default; nil/missing means disabled. Toggle from /settings.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 // re-reading config.json on every open.73 // re-reading config.json on every open.
74 AutoSwarmEnabled *bool74 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 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.80 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
77 // nil/missing means disabled. Unsupported providers reject attempts81 // nil/missing means disabled. Unsupported providers reject attempts
78 // to enable it and reject requests when it remains enabled.82 // to enable it and reject requests when it remains enabled.
@@ -1692,6 +1696,7 @@ func (i *Interactive) redraw() {
1692 Provider: i.cfg.Provider,1696 Provider: i.cfg.Provider,
1693 Model: i.cfg.Model,1697 Model: i.cfg.Model,
1694 Reasoning: i.cfg.Reasoning,1698 Reasoning: i.cfg.Reasoning,
1699+ FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
1695 Busy: i.busy,1700 Busy: i.busy,
1696 BusyPrefix: statusBusyPrefix,1701 BusyPrefix: statusBusyPrefix,
1697 CWD: i.cfg.CWD,1702 CWD: i.cfg.CWD,
@@ -3979,7 +3984,7 @@ func (i *Interactive) openSettingsDialog() {
3979 {3984 {
3980 key: "auto_swarm_enabled",3985 key: "auto_swarm_enabled",
3981 label: "auto-swarm",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 value: autoSwarm,3988 value: autoSwarm,
3984 disabled: autoSwarmDisabled,3989 disabled: autoSwarmDisabled,
3985 hint: autoSwarmHint,3990 hint: autoSwarmHint,
@@ -5210,7 +5215,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5210 break5215 break
5211 }5216 }
5212 i.openSessionOpsDialog()5217 i.openSessionOpsDialog()
5213- case "/swarm":5218+ case "/subagents", "/swarm":
5214 i.runSwarm(ctx, parts[1:])5219 i.runSwarm(ctx, parts[1:])
5215 default:5220 default:
5216 // Last-resort fallback: try the extension manager. Built-in5221 // Last-resort fallback: try the extension manager. Built-in
@@ -7419,13 +7424,15 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
7419 current := i.agent.Tools7424 current := i.agent.Tools
7420 next := core.Registry{}7425 next := core.Registry{}
7421 for name, t := range current {7426 for name, t := range current {
7422- if name == "swarm_spawn" {7427+ if name == "swarm_spawn" || name == "subagent_spawn" {
7423 continue7428 continue
7424 }7429 }
7425 next[name] = t7430 next[name] = t
7426 }7431 }
7427- if active && i.cfg.Swarm != nil {7432+ toolAllowed := i.cfg.AutoSwarmToolAllowed == nil || *i.cfg.AutoSwarmToolAllowed
7428- next["swarm_spawn"] = &tools.SwarmSpawnTool{7433+ if active && toolAllowed && i.cfg.Swarm != nil {
7434+ canonical := &tools.SwarmSpawnTool{
7435+ ToolName: "subagent_spawn",
7429 Swarm: i.cfg.Swarm,7436 Swarm: i.cfg.Swarm,
7430 Enabled: func() bool { return true },7437 Enabled: func() bool { return true },
7431 DefaultModel: func() string { return i.cfg.Model },7438 DefaultModel: func() string { return i.cfg.Model },
@@ -7434,6 +7441,10 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
7434 ResolveSubagent: i.cfg.ResolveSubagent,7441 ResolveSubagent: i.cfg.ResolveSubagent,
7435 OnSpawned: i.trackSwarmAgent,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 i.agent.SetTools(next)7449 i.agent.SetTools(next)
7439 }7450 }
M packages/agent/modes/slash_suggest.go +2 -1
@@ -62,7 +62,8 @@ var slashCatalog = []slashCommand{
62 {Name: "/jail", Desc: "confine tools to the current directory"},62 {Name: "/jail", Desc: "confine tools to the current directory"},
63 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},63 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
64 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},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 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},67 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
67 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},68 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
68 {Name: "/settings", Desc: "open settings"},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 return d.renderTranscript(th, width)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 if d.prompting {1010 if d.prompting {
1011 return d.renderPromptEditor(th, width, out)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 // Column header for readability.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 out = append(out, th.FG256(th.Muted, header))1071 out = append(out, th.FG256(th.Muted, header))
1072 1072
1073 for i, r := range d.rows {1073 for i, r := range d.rows {
@@ -1384,8 +1384,11 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
1384 // ✓ done write-tests-67890 1h done1384 // ✓ done write-tests-67890 1h done
1385 func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {1385 func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1386 status := statusLabel(r.Status)1386 status := statusLabel(r.Status)
1387+ if r.ProcessState != "" && r.TurnState != "" {
1388+ status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18)
1389+ }
1387 age := formatAge(r.Started)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 room := maxWidth - len([]rune(left))1392 room := maxWidth - len([]rune(left))
1390 if room < 10 {1393 if room < 10 {
1391 room = 101394 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 return i.cfg.Swarm.SendUserTurn(id, text)62 return i.cfg.Swarm.SendUserTurn(id, text)
63 }63 }
64 resumeAdapter := func(id string) error {64 resumeAdapter := func(id string) error {
65- _, err := i.cfg.Swarm.Resume(ctx, id)65+ _, err := i.cfg.Swarm.ResumeSession(ctx, id)
66 return err66 return err
67 }67 }
68 68
@@ -99,6 +99,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
99 // Anything that isn't a recognised flag terminates parsing and99 // Anything that isn't a recognised flag terminates parsing and
100 // the rest becomes the task; only leading flags are consumed.100 // the rest becomes the task; only leading flags are consumed.
101 model, provider, reasoning, subagent, task := parseSpawnFlags(rest)101 model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
102+ var profileTools []string
102 var fastModeOverride *bool103 var fastModeOverride *bool
103 if task == "" {104 if task == "" {
104 i.swarmStatus("", "/swarm new: missing task (after any spawn flags)")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 return120 return
120 }121 }
121 subagent = profile.Name122 subagent = profile.Name
123+ profileTools = append([]string(nil), profile.Tools...)
122 fastModeOverride = profile.FastMode124 fastModeOverride = profile.FastMode
123- if model == "" && provider == "" {125+ profileProvider, profileModel := profile.ModelSelection()
124- profileProvider, profileModel := profile.ModelSelection()126+ if model == "" {
125- if profileProvider != "" && profileModel != "" {127+ model = profileModel
126- provider, model = profileProvider, profileModel128+ }
127- }129+ if provider == "" {
130+ provider = profileProvider
128 }131 }
129 if reasoning == "" {132 if reasoning == "" {
130 reasoning = profile.Thinking133 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 if reasoning == "" {142 if reasoning == "" {
134 reasoning = i.cfg.Reasoning143 reasoning = i.cfg.Reasoning
135 }144 }
@@ -144,6 +153,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
144 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{153 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
145 Task: task, Model: model, Provider: provider, Reasoning: reasoning,154 Task: task, Model: model, Provider: provider, Reasoning: reasoning,
146 FastMode: fastModeOverride, Subagent: subagent,155 FastMode: fastModeOverride, Subagent: subagent,
156+ Tools: profileTools,
147 })157 })
148 if err != nil {158 if err != nil {
149 i.swarmStatus("", "spawn: "+err.Error())159 i.swarmStatus("", "spawn: "+err.Error())
@@ -158,7 +168,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
158 i.swarmStatus(status, "")168 i.swarmStatus(status, "")
159 case "kill", "stop":169 case "kill", "stop":
160 if rest == "" {170 if rest == "" {
161- i.swarmStatus("", "/swarm kill <id>: missing id")171+ i.swarmStatus("", "/subagents kill <id>: missing id")
162 return172 return
163 }173 }
164 if err := i.cfg.Swarm.Stop(rest); err != nil {174 if err := i.cfg.Swarm.Stop(rest); err != nil {
@@ -166,6 +176,16 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
166 return176 return
167 }177 }
168 i.swarmStatus("stopped "+rest, "")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 case "remove", "rm":189 case "remove", "rm":
170 if rest == "" {190 if rest == "" {
171 i.swarmStatus("", "/swarm remove <id>: missing id")191 i.swarmStatus("", "/swarm remove <id>: missing id")
@@ -194,13 +214,38 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
194 if !ok {214 if !ok {
195 i.swarmStatus("", "/swarm logs: no agent matching "+rest)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 if rest == "" {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 count := i.swarmDialog.OpenForResume(249 count := i.swarmDialog.OpenForResume(
205 i.cfg.Swarm.SnapshotAll,250 i.cfg.Swarm.SnapshotAll,
206 i.cfg.Swarm.Stop,251 i.cfg.Swarm.Stop,
@@ -212,7 +257,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
212 )257 )
213 switch count {258 switch count {
214 case 0: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 case 1:261 case 1:
217 i.swarmStatus("1 resumable agent, press R to resume", "")262 i.swarmStatus("1 resumable agent, press R to resume", "")
218 default:263 default:
@@ -220,12 +265,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
220 }265 }
221 return266 return
222 }267 }
223- a, err := i.cfg.Swarm.Resume(ctx, rest)268+ a, err := i.cfg.Swarm.ResumeSession(ctx, rest)
224 if err != nil {269 if err != nil {
225- i.swarmStatus("", "resume: "+err.Error())270+ i.swarmStatus("", "resume-session: "+err.Error())
226 return271 return
227 }272 }
228 i.swarmStatus("resumed "+a.ID, "")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 case "send", "prompt", "msg":285 case "send", "prompt", "msg":
230 // /swarm send <id> <text...> is the non-interactive286 // /swarm send <id> <text...> is the non-interactive
231 // counterpart of pressing 'p' in the dashboard. We split the287 // counterpart of pressing 'p' in the dashboard. We split the
@@ -255,7 +311,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
255 // feature. Point the user at /swarm logs in the meantime.311 // feature. Point the user at /swarm logs in the meantime.
256 i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")312 i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")
257 default: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 return s431 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 func (i *Interactive) swarmStatus(ok, errMsg string) {445 func (i *Interactive) swarmStatus(ok, errMsg string) {
379 i.mu.Lock()446 i.mu.Lock()
380 i.statusOK = ok447 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 // AutoSwarmEnabled reads the current auto-swarm flag from config.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 func AutoSwarmEnabled() bool {217 func AutoSwarmEnabled() bool {
218 cfg, err := LoadConfig()218 cfg, err := LoadConfig()
219 if err != nil {219 if err != nil {
@@ -226,10 +226,10 @@ func AutoSwarmEnabled() bool {
226 // auto-swarm is enabled, so the model knows it may delegate to226 // auto-swarm is enabled, so the model knows it may delegate to
227 // background sub-agents without the user having to mention the tool227 // background sub-agents without the user having to mention the tool
228 // by name. Kept short so it doesn't bloat the cached prompt prefix.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 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "path/filepath"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "time"9 "time"
@@ -11,22 +12,50 @@ import (
11 // Agent is one supervised task. Public fields are immutable after12 // Agent is one supervised task. Public fields are immutable after
12 // Spawn; mutable state (status, activity, transcript) lives behind13 // Spawn; mutable state (status, activity, transcript) lives behind
13 // the embedded mutex.14 // the embedded mutex.
15+type turnEndNotice struct {
16+ step int
17+ errMsg string
18+}
19+
14 type Agent struct {20 type Agent struct {
15 ID string21 ID string
16 Task string22 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 Started time.Time24 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 // Model and Provider, when non-empty, override the child45 // Model and Provider, when non-empty, override the child
21- // subprocess's model resolution. Empty means "inherit whatever46+ // subprocess's model resolution. BaseURL and InsecureTLS carry
22- // the child resolves on its own from config / env / flags" —47+ // the effective provider connection settings from the supervisor.
23- // the historical behaviour. Persisted in meta.json so Resume48+ // Empty values inherit whatever the child resolves on its own from
24- // keeps using the same model across zot restarts.49+ // config / env / flags. All four settings are persisted in meta.json
25- Model string50+ // so Resume keeps using the same provider configuration across zot
26- Provider string51+ // restarts.
27- Reasoning string52+ Model string
28- FastMode bool53+ Provider string
29- Subagent string54+ BaseURL string
55+ InsecureTLS bool
56+ Reasoning string
57+ FastMode bool
58+ Subagent string
30 59
31 // SessionID, when non-empty, scopes the agent to a particular60 // SessionID, when non-empty, scopes the agent to a particular
32 // host zot session: the dashboard only surfaces agents whose61 // host zot session: the dashboard only surfaces agents whose
@@ -52,6 +81,16 @@ type Agent struct {
52 // an Inbox at this path; the child opens a Listener.81 // an Inbox at this path; the child opens a Listener.
53 InboxPath string82 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 // EventLogPath is the durable JSONL event log for this agent.94 // EventLogPath is the durable JSONL event log for this agent.
56 // The runner appends every well-formed event from the child95 // The runner appends every well-formed event from the child
57 // (plus lifecycle events of its own) here. /swarm open in any96 // (plus lifecycle events of its own) here. /swarm open in any
@@ -75,6 +114,27 @@ type Agent struct {
75 finished time.Time114 finished time.Time
76 lastErr error115 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 // OnTurnEnd, if set, fires once per prompt-level turn_end event138 // OnTurnEnd, if set, fires once per prompt-level turn_end event
79 // emitted by the swarm daemon wrapper. Provider/tool-loop139 // emitted by the swarm daemon wrapper. Provider/tool-loop
80 // turn_end events (for example stop=tool_use) are ignored by the140 // turn_end events (for example stop=tool_use) are ignored by the
@@ -84,16 +144,25 @@ type Agent struct {
84 // daemon itself to exit — sub-agents keep running on the inbox144 // daemon itself to exit — sub-agents keep running on the inbox
85 // even after the initial task completes, so Wait() never unblocks145 // even after the initial task completes, so Wait() never unblocks
86 // for them.146 // for them.
87- OnTurnEnd func(step int, errMsg string)147+ OnTurnEnd func(step int, errMsg string)
148+ pendingTurnEnds []turnEndNotice
88 149
89 ctx context.Context150 ctx context.Context
90 cancel context.CancelFunc151 cancel context.CancelFunc
91 runner Runner152 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 // done closes when the run goroutine finalises the agent's160 // done closes when the run goroutine finalises the agent's
94 // status (done / failed / killed). Wait blocks on this so161 // status (done / failed / killed). Wait blocks on this so
95 // callers don't have to poll.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 // Inbox exposes the supervisor-side socket handle. Returns nil for168 // Inbox exposes the supervisor-side socket handle. Returns nil for
@@ -115,6 +184,124 @@ func (a *Agent) Activity() string {
115 return a.activity184 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 // Transcript returns a copy of the running transcript.305 // Transcript returns a copy of the running transcript.
119 func (a *Agent) Transcript() []string {306 func (a *Agent) Transcript() []string {
120 a.mu.Lock()307 a.mu.Lock()
@@ -135,6 +322,35 @@ func (a *Agent) Err() error {
135 // and by /swarm wait <id>.322 // and by /swarm wait <id>.
136 func (a *Agent) Wait() { <-a.done }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 // SetOnTurnEnd installs (or clears, with nil) the callback fired354 // SetOnTurnEnd installs (or clears, with nil) the callback fired
139 // from the runner when the child daemon emits a prompt-level355 // from the runner when the child daemon emits a prompt-level
140 // turn_end event with a step field. Safe to call from any goroutine: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 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {358 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
143 a.mu.Lock()359 a.mu.Lock()
144 a.OnTurnEnd = fn360 a.OnTurnEnd = fn
361+ pending := append([]turnEndNotice(nil), a.pendingTurnEnds...)
362+ a.pendingTurnEnds = nil
145 a.mu.Unlock()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 func (a *Agent) setStatus(s Status) {371 func (a *Agent) setStatus(s Status) {
149 a.mu.Lock()372 a.mu.Lock()
150 a.status = s373 a.status = s
151 a.mu.Unlock()374 a.mu.Unlock()
375+ if s == StatusRunning {
376+ a.setProcessState(ProcessAlive)
377+ }
152 }378 }
153 379
154 func (a *Agent) setActivity(msg string) {380 func (a *Agent) setActivity(msg string) {
155 a.mu.Lock()381 a.mu.Lock()
156 a.activity = strings.TrimSpace(msg)382 a.activity = strings.TrimSpace(msg)
157 a.mu.Unlock()383 a.mu.Unlock()
384+ a.markActivity(time.Now())
158 }385 }
159 386
160 func (a *Agent) appendTranscript(chunk string) {387 func (a *Agent) appendTranscript(chunk string) {
@@ -180,22 +407,68 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
180 a.transcript = append(a.transcript, linePrefix+line)407 a.transcript = append(a.transcript, linePrefix+line)
181 }408 }
182 if assistant {409 if assistant {
183- a.lastAssistant = message410+ 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 for422+ for len(a.transcript) > lineCap {
186- // long-running agents. 2000 lines is plenty for inspection;423+ a.transcript = a.transcript[1:]
187- // the durable record lives in the agent's session file.424+ a.outputTruncated = true
188- const cap = 2000425+ }
189- if len(a.transcript) > cap {426+ for len(a.transcript) > 0 {
190- a.transcript = a.transcript[len(a.transcript)-cap:]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 a.mu.Unlock()443 a.mu.Unlock()
444+ a.markActivity(time.Now())
193 }445 }
194 446
195 // newAgentID returns a short, mostly-collision-free identifier of the447 // newAgentID returns a short, mostly-collision-free identifier of the
196 // form "<slug>-<nano>". The slug is derived from the task text so448 // form "<slug>-<nano>". The slug is derived from the task text so
197 // dashboards stay readable; the nano suffix guarantees uniqueness449 // dashboards stay readable; the nano suffix guarantees uniqueness
198 // even when two agents are spawned in the same millisecond.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 func newAgentID(task string, now time.Time) string {472 func newAgentID(task string, now time.Time) string {
200 slug := taskSlug(task)473 slug := taskSlug(task)
201 return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)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 &copy
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 // stat the file size and read from their last offset on every28 // stat the file size and read from their last offset on every
29 // poll.29 // poll.
30 type Event struct {30 type Event struct {
31- Time time.Time `json:"time"`31+ Time time.Time `json:"time"`
32- Type string `json:"type"`32+ Type string `json:"type"`
33- Data map[string]any `json:"-"`33+ Version int `json:"version,omitempty"`
34- Raw map[string]any `json:"-"` // includes type+time+data for replay34+ 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 // MarshalJSON flattens Data into the top-level object so consumers41 // MarshalJSON flattens Data into the top-level object so consumers
38 // see {type, time, ...fields} rather than {type, time, data:{...}}.42 // see {type, time, ...fields} rather than {type, time, data:{...}}.
39 func (e Event) MarshalJSON() ([]byte, error) {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 for k, v := range e.Data {45 for k, v := range e.Data {
42 out[k] = v46 out[k] = v
43 }47 }
44 out["type"] = e.Type48 out["type"] = e.Type
45 out["time"] = e.Time49 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 return json.Marshal(out)66 return json.Marshal(out)
47 }67 }
48 68
@@ -56,17 +76,45 @@ func (e *Event) UnmarshalJSON(b []byte) error {
56 e.Raw = m76 e.Raw = m
57 e.Data = map[string]any{}77 e.Data = map[string]any{}
58 for k, v := range m {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 continue80 continue
61 }81 }
62 e.Data[k] = v82 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 if t, ok := m["type"].(string); ok {108 if t, ok := m["type"].(string); ok {
65 e.Type = t109 e.Type = t
66 } else {110 } else {
67 return errors.New("swarm event: missing type")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 parsed, err := time.Parse(time.RFC3339Nano, ts)118 parsed, err := time.Parse(time.RFC3339Nano, ts)
71 if err == nil {119 if err == nil {
72 e.Time = parsed120 e.Time = parsed
@@ -75,6 +123,14 @@ func (e *Event) UnmarshalJSON(b []byte) error {
75 return nil123 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 // EventLog is an append-only writer for events.jsonl. Safe for134 // EventLog is an append-only writer for events.jsonl. Safe for
79 // concurrent writers (the child only has one writer, but tests135 // concurrent writers (the child only has one writer, but tests
80 // occasionally fan out).136 // occasionally fan out).
@@ -87,13 +143,14 @@ type EventLog struct {
87 // OpenEventLog opens (or creates) the events.jsonl file at path.143 // OpenEventLog opens (or creates) the events.jsonl file at path.
88 // Parent directories are created as needed.144 // Parent directories are created as needed.
89 func OpenEventLog(path string) (*EventLog, error) {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 return nil, fmt.Errorf("event log dir: %w", err)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 if err != nil {150 if err != nil {
95 return nil, fmt.Errorf("event log open: %w", err)151 return nil, fmt.Errorf("event log open: %w", err)
96 }152 }
153+ _ = f.Chmod(0o600)
97 return &EventLog{path: path, f: f}, nil154 return &EventLog{path: path, f: f}, nil
98 }155 }
99 156
@@ -107,6 +164,9 @@ func (l *EventLog) Path() string { return l.path }
107 func (l *EventLog) Append(ev Event) error {164 func (l *EventLog) Append(ev Event) error {
108 l.mu.Lock()165 l.mu.Lock()
109 defer l.mu.Unlock()166 defer l.mu.Unlock()
167+ if l.f == nil {
168+ return errors.New("event log: closed")
169+ }
110 if ev.Time.IsZero() {170 if ev.Time.IsZero() {
111 ev.Time = time.Now()171 ev.Time = time.Now()
112 }172 }
@@ -114,8 +174,14 @@ func (l *EventLog) Append(ev Event) error {
114 if err != nil {174 if err != nil {
115 return err175 return err
116 }176 }
177+ if len(b) > maxEventLogLineBytes {
178+ return fmt.Errorf("event log: event exceeds %d bytes", maxEventLogLineBytes)
179+ }
117 b = append(b, '\n')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 return err185 return err
120 }186 }
121 187
@@ -156,19 +222,24 @@ func ReadEventLog(path string) ([]Event, error) {
156 222
157 func readEvents(r io.Reader) ([]Event, error) {223 func readEvents(r io.Reader) ([]Event, error) {
158 br := bufio.NewReader(r)224 br := bufio.NewReader(r)
159- var out []Event225+ out := make([]Event, 0, 256)
160 for {226 for {
161- line, err := br.ReadBytes('\n')227+ line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)
162- if len(line) > 0 {228+ if len(line) > 0 && !truncated {
163 var ev Event229 var ev Event
164 if jerr := json.Unmarshal(line, &ev); jerr == nil {230 if jerr := json.Unmarshal(line, &ev); jerr == nil {
165 if !isLikelyDoubleWrite(ev, out) {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 dashboard240+ // Malformed and oversized lines are skipped silently; the
170- // renders only well-formed events and the child is the241+ // dashboard renders only well-formed events and the child is
171- // only writer.242+ // the only writer.
172 }243 }
173 if err != nil {244 if err != nil {
174 if errors.Is(err, io.EOF) {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 // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate285 // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
183 // of the most recent event in `tail` — same type and identical Data286 // of the most recent event in `tail` — same type and identical Data
184 // payload, with timestamps within a small window (the supervisor and287 // payload, with timestamps within a small window (the supervisor and
@@ -296,8 +399,7 @@ func (f *EventFollower) loop() {
296 _ = fh.Close()399 _ = fh.Close()
297 continue400 continue
298 }401 }
299- evs, _ := readEvents(fh)402+ evs, newOffset := readFollowerEvents(fh, offset)
300- newOffset, _ := fh.Seek(0, io.SeekCurrent)
301 _ = fh.Close()403 _ = fh.Close()
302 offset = newOffset404 offset = newOffset
303 for _, ev := range evs {405 for _, ev := range evs {
M packages/agent/swarm/event_test.go +75 -0
@@ -1,7 +1,12 @@
1 package swarm1 package swarm
2 2
3 import (3 import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
4 "path/filepath"8 "path/filepath"
9+ "strings"
5 "testing"10 "testing"
6 "time"11 "time"
7 )12 )
@@ -48,6 +53,44 @@ func TestEventLogAppendAndRead(t *testing.T) {
48 // only write well-formed events, but a partial crash could leave53 // only write well-formed events, but a partial crash could leave
49 // a half-written line; we don't want that to prevent the dashboard54 // a half-written line; we don't want that to prevent the dashboard
50 // from showing the rest.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 func TestReadEventLogIgnoresGarbage(t *testing.T) {94 func TestReadEventLogIgnoresGarbage(t *testing.T) {
52 path := filepath.Join(t.TempDir(), "events.jsonl")95 path := filepath.Join(t.TempDir(), "events.jsonl")
53 log, err := OpenEventLog(path)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 func drain(ch <-chan Event) {280 func drain(ch <-chan Event) {
206 for {281 for {
207 select {282 select {
M packages/agent/swarm/inbox.go +107 -43
@@ -4,7 +4,6 @@ import (
4 "bufio"4 "bufio"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- "io"
8 "net"7 "net"
9 "os"8 "os"
10 "path/filepath"9 "path/filepath"
@@ -66,6 +65,20 @@ func (b *Inbox) Path() string { return b.path }
66 // ErrNotReady so the TUI can retry or report "agent not listening65 // ErrNotReady so the TUI can retry or report "agent not listening
67 // yet" rather than crashing.66 // yet" rather than crashing.
68 func (b *Inbox) SendInput(msg string) error {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 b.mu.Lock()82 b.mu.Lock()
70 defer b.mu.Unlock()83 defer b.mu.Unlock()
71 if b.conn == nil {84 if b.conn == nil {
@@ -75,7 +88,7 @@ func (b *Inbox) SendInput(msg string) error {
75 }88 }
76 b.conn = c89 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 // Drop the connection so the next call redials. The92 // Drop the connection so the next call redials. The
80 // previous error is more informative than the redial's93 // previous error is more informative than the redial's
81 // would be, so surface this one.94 // would be, so surface this one.
@@ -147,6 +160,30 @@ func isNoListenerErr(err error) bool {
147 return errors.Is(err, syscall.ECONNREFUSED)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 // Listener is the child-side end of the inbox. The swarm-agent187 // Listener is the child-side end of the inbox. The swarm-agent
151 // daemon mode constructs one with Listen, then iterates Lines188 // daemon mode constructs one with Listen, then iterates Lines
152 // to receive supervisor messages. Designed so a single goroutine189 // to receive supervisor messages. Designed so a single goroutine
@@ -158,35 +195,48 @@ type Listener struct {
158 // only expects one supervisor (the parent zot), so newer195 // only expects one supervisor (the parent zot), so newer
159 // connections preempt older ones — the previous parent196 // connections preempt older ones — the previous parent
160 // presumably crashed.197 // presumably crashed.
161- mu sync.Mutex198+ mu sync.Mutex
162- active net.Conn199+ active net.Conn
163- out chan string200+ conns map[net.Conn]struct{}
164- done chan struct{}201+ out chan string
165- wg sync.WaitGroup202+ done chan struct{}
203+ wg sync.WaitGroup
204+ closeOnce sync.Once
166 }205 }
167 206
168 // Listen creates the socket at path and starts accepting. The207 // Listen creates the socket at path and starts accepting. The
169 // caller must call Close to remove the socket file on shutdown.208 // caller must call Close to remove the socket file on shutdown.
170-// Returns the listener even if a stale socket exists from a209+// A live endpoint is preserved and rejected; only a refused probe
171-// previous run: we unlink first, mirroring how most unix daemons210+// is treated as a stale socket eligible for removal.
172-// behave.
173 func Listen(path string) (*Listener, error) {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 return nil, fmt.Errorf("inbox dir: %w", err)213 return nil, fmt.Errorf("inbox dir: %w", err)
176 }214 }
177- // Best-effort cleanup of a stale socket. If the parent215+ // Probe an existing endpoint before removing it. Unlinking an active
178- // process is still alive and using it, Listen will fail216+ // Unix socket does not stop its listener, so blindly removing the path
179- // below and the caller surfaces that as a real conflict.217+ // could let Resume start a second worker on the same session and event log.
180- _ = os.Remove(path)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 ln, err := net.Listen("unix", path)226 ln, err := net.Listen("unix", path)
182 if err != nil {227 if err != nil {
183 return nil, fmt.Errorf("inbox listen: %w", err)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 l := &Listener{234 l := &Listener{
186- path: path,235+ path: path,
187- ln: ln,236+ ln: ln,
188- out: make(chan string, 16),237+ conns: make(map[net.Conn]struct{}),
189- done: make(chan struct{}),238+ out: make(chan string, 16),
239+ done: make(chan struct{}),
190 }240 }
191 go l.acceptLoop()241 go l.acceptLoop()
192 return l, nil242 return l, nil
@@ -199,19 +249,16 @@ func (l *Listener) Lines() <-chan string { return l.out }
199 // Close stops accepting, drops the active connection, removes249 // Close stops accepting, drops the active connection, removes
200 // the socket file, and closes Lines. Idempotent.250 // the socket file, and closes Lines. Idempotent.
201 func (l *Listener) Close() error {251 func (l *Listener) Close() error {
202- select {252+ l.closeOnce.Do(func() {
203- case <-l.done:
204- return nil
205- default:
206 close(l.done)253 close(l.done)
207- }254+ l.mu.Lock()
208- l.mu.Lock()255+ for conn := range l.conns {
209- if l.active != nil {256+ _ = conn.Close()
210- _ = l.active.Close()257+ }
211- }258+ l.mu.Unlock()
212- l.mu.Unlock()259+ _ = l.ln.Close()
213- _ = l.ln.Close()260+ _ = os.Remove(l.path)
214- _ = os.Remove(l.path)261+ })
215 return nil262 return nil
216 }263 }
217 264
@@ -233,10 +280,7 @@ func (l *Listener) acceptLoop() {
233 }280 }
234 }281 }
235 l.mu.Lock()282 l.mu.Lock()
236- if l.active != nil {283+ l.conns[c] = struct{}{}
237- _ = l.active.Close()
238- }
239- l.active = c
240 l.mu.Unlock()284 l.mu.Unlock()
241 l.wg.Add(1)285 l.wg.Add(1)
242 go func() {286 go func() {
@@ -247,24 +291,44 @@ func (l *Listener) acceptLoop() {
247 }291 }
248 292
249 func (l *Listener) readLoop(c net.Conn) {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 br := bufio.NewReader(c)304 br := bufio.NewReader(c)
305+ firstLine := true
251 for {306 for {
252 line, err := br.ReadString('\n')307 line, err := br.ReadString('\n')
253 if line != "" {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 select {325 select {
255- case l.out <- trimRightNL(line):326+ case l.out <- message:
256 case <-l.done:327 case <-l.done:
257- _ = c.Close()
258 return328 return
259 }329 }
260 }330 }
261 if err != nil {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 return332 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 // by encoding/json's permissive decoder when an older meta.json is38 // by encoding/json's permissive decoder when an older meta.json is
39 // loaded; we don't need to keep them in the struct.39 // loaded; we don't need to keep them in the struct.
40 type agentMeta struct {40 type agentMeta struct {
41- ID string `json:"id"`41+ ID string `json:"id"`
42- Task string `json:"task"`42+ Task string `json:"task"`
43- Dir string `json:"dir"`43+ OriginalTask string `json:"original_task,omitempty"`
44- Started time.Time `json:"started"`44+ Dir string `json:"dir"`
45- Model string `json:"model,omitempty"`45+ RepositoryRoot string `json:"repository_root,omitempty"`
46- Provider string `json:"provider,omitempty"`46+ Started time.Time `json:"started"`
47- Reasoning string `json:"reasoning,omitempty"`47+ ParentID string `json:"parent_id,omitempty"`
48- FastMode *bool `json:"fast_mode,omitempty"`48+ BatchID string `json:"batch_id,omitempty"`
49- Subagent string `json:"subagent,omitempty"`49+ RootSessionID string `json:"root_session_id,omitempty"`
50- InboxPath string `json:"inbox_path"`50+ Model string `json:"model,omitempty"`
51- EventLogPath string `json:"event_log_path"`51+ Provider string `json:"provider,omitempty"`
52- SessionPath string `json:"session_path"`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 // SessionID, when non-empty, scopes the agent to a particular78 // SessionID, when non-empty, scopes the agent to a particular
55 // host zot session: the dashboard only shows agents whose79 // 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 // half-parsable file that fails Reload.92 // half-parsable file that fails Reload.
69 func writeAgentMeta(stateDir string, a *Agent) error {93 func writeAgentMeta(stateDir string, a *Agent) error {
70 fastMode := a.FastMode94 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 m := agentMeta{107 m := agentMeta{
72- ID: a.ID,108+ ID: a.ID,
73- Task: a.Task,109+ Task: a.Task,
74- Dir: a.Dir,110+ OriginalTask: a.OriginalTask,
75- Started: a.Started,111+ Dir: a.Dir,
76- Model: a.Model,112+ RepositoryRoot: a.RepositoryRoot,
77- Provider: a.Provider,113+ Started: a.Started,
78- Reasoning: a.Reasoning,114+ ParentID: a.ParentID,
79- FastMode: &fastMode,115+ BatchID: a.BatchID,
80- Subagent: a.Subagent,116+ RootSessionID: a.RootSessionID,
81- InboxPath: a.InboxPath,117+ Model: a.Model,
82- EventLogPath: a.EventLogPath,118+ Provider: a.Provider,
83- SessionPath: a.SessionPath,119+ BaseURL: a.BaseURL,
84- SessionID: a.SessionID,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 b, err := json.MarshalIndent(m, "", " ")146 b, err := json.MarshalIndent(m, "", " ")
87 if err != nil {147 if err != nil {
88 return fmt.Errorf("swarm meta marshal: %w", err)148 return fmt.Errorf("swarm meta marshal: %w", err)
89 }149 }
90- if err := os.MkdirAll(stateDir, 0o755); err != nil {150+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
91- return fmt.Errorf("swarm meta dir: %w", err)151+ return fmt.Errorf("subagents meta dir: %w", err)
92 }152 }
93 final := metaPath(stateDir)153 final := metaPath(stateDir)
94 tmp := final + ".tmp"154 tmp := final + ".tmp"
95- if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {155+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
96- return fmt.Errorf("swarm meta write: %w", err)156+ return fmt.Errorf("subagents meta write: %w", err)
97 }157 }
98 if err := os.Rename(tmp, final); err != nil {158 if err := os.Rename(tmp, final); err != nil {
99 _ = os.Remove(tmp)159 _ = os.Remove(tmp)
@@ -136,21 +196,100 @@ func readAgentMeta(stateDir string) (agentMeta, error) {
136 // error encountered. Malformed entries are skipped rather than196 // error encountered. Malformed entries are skipped rather than
137 // failing the whole reload — one bad meta.json shouldn't hide the197 // failing the whole reload — one bad meta.json shouldn't hide the
138 // rest of the swarm.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 func (f *Swarm) Reload() (loaded int, errs []error) {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 entries, err := os.ReadDir(agentsDir)284 entries, err := os.ReadDir(agentsDir)
142 if err != nil {285 if err != nil {
143 if errors.Is(err, os.ErrNotExist) {286 if errors.Is(err, os.ErrNotExist) {
144 return 0, nil287 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 // Sort by directory name so the load order is stable across runs.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 names := make([]string, 0, len(entries))293 names := make([]string, 0, len(entries))
155 for _, e := range entries {294 for _, e := range entries {
156 if e.IsDir() {295 if e.IsDir() {
@@ -164,14 +303,19 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
164 m, err := readAgentMeta(stateDir)303 m, err := readAgentMeta(stateDir)
165 if err != nil {304 if err != nil {
166 if errors.Is(err, os.ErrNotExist) {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 continue306 continue
171 }307 }
172 errs = append(errs, err)308 errs = append(errs, err)
173 continue309 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 f.mu.Lock()320 f.mu.Lock()
177 if _, exists := f.agents[m.ID]; exists {321 if _, exists := f.agents[m.ID]; exists {
@@ -195,43 +339,96 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
195 // The returned Agent has a closed `done` channel because Wait should339 // The returned Agent has a closed `done` channel because Wait should
196 // return instantly: there is nothing to wait for.340 // return instantly: there is nothing to wait for.
197 func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {341 func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
198- // Older meta.json files may still record a per-agent worktree342+ // Legacy meta.json files may not record a repository identity. Those
199- // path under Dir. They predate the decision to run every agent343+ // records are migrated to the current RepoRoot; current-format records
200- // in the host's repo and shouldn't continue editing that stale344+ // retain their persisted repository so a restart from another checkout
201- // checkout, which most likely no longer matches HEAD. Coerce345+ // cannot silently redirect the child.
202- // the dir back to the live RepoRoot so resume picks up where
203- // the host is now.
204 dir := m.Dir346 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 dir = f.cfg.RepoRoot350 dir = f.cfg.RepoRoot
207 }351 }
352+ if dir == "" {
353+ dir = firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot)
354+ }
208 fastMode := f.cfg.FastMode355 fastMode := f.cfg.FastMode
209 if m.FastMode != nil {356 if m.FastMode != nil {
210 fastMode = *m.FastMode357 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 a := &Agent{381 a := &Agent{
213- ID: m.ID,382+ ID: m.ID,
214- Task: m.Task,383+ Task: m.Task,
215- Dir: dir,384+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
216- Started: m.Started,385+ Dir: dir,
217- Model: m.Model,386+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
218- Provider: m.Provider,387+ Started: m.Started,
219- Reasoning: m.Reasoning,388+ ParentID: m.ParentID,
220- FastMode: fastMode,389+ BatchID: m.BatchID,
221- Subagent: m.Subagent,390+ RootSessionID: m.RootSessionID,
222- InboxPath: m.InboxPath,391+ Model: m.Model,
223- EventLogPath: m.EventLogPath,392+ Provider: m.Provider,
224- SessionPath: m.SessionPath,393+ BaseURL: m.BaseURL,
225- SessionID: m.SessionID,394+ InsecureTLS: m.InsecureTLS,
226- inbox: NewInbox(m.InboxPath),395+ Reasoning: m.Reasoning,
227- status: StatusDetached,396+ FastMode: fastMode,
228- activity: "detached",397+ Subagent: m.Subagent,
229- done: make(chan struct{}),398+ WorkspaceMode: m.WorkspaceMode,
230- }399+ WorkspacePath: m.WorkspacePath,
231- // Wait() must not block for detached agents; close the channel400+ WorkspaceBase: m.WorkspaceBase,
232- // immediately. Callers Resuming the agent will replace done with401+ WorkspaceCapture: m.WorkspaceCapture,
233- // a fresh channel before starting the new runner.402+ MaxTurns: m.MaxTurns,
234- close(a.done)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 // Recover transcript + activity hints from the event log. Best433 // Recover transcript + activity hints from the event log. Best
237 // effort: a missing or unreadable log just leaves the agent434 // effort: a missing or unreadable log just leaves the agent
@@ -241,6 +438,24 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
241 replayEventsIntoAgent(a, evs)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 return a459 return a
245 }460 }
246 461
@@ -256,6 +471,33 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
256 terminal := false471 terminal := false
257 for _, ev := range evs {472 for _, ev := range evs {
258 switch ev.Type {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 case "assistant_message", "user_message":501 case "assistant_message", "user_message":
260 var text []string502 var text []string
261 if c, ok := ev.Data["content"].([]any); ok {503 if c, ok := ev.Data["content"].([]any); ok {
@@ -337,6 +579,27 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
337 // Killed). Resuming a still-running agent returns an error so two579 // Killed). Resuming a still-running agent returns an error so two
338 // runners don't race for the same session.580 // runners don't race for the same session.
339 func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {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 existing := f.Get(id)603 existing := f.Get(id)
341 if existing == nil {604 if existing == nil {
342 return nil, fmt.Errorf("swarm: no such agent %q", id)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 if st == StatusRunning || st == StatusPending {610 if st == StatusRunning || st == StatusPending {
348 return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)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 // Rebuild from the meta record so we don't carry stale runner638 // Rebuild from the meta record so we don't carry stale runner
352 // state from a previous incarnation. The inbox is transient and639 // 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 fastMode := existing.FastMode646 fastMode := existing.FastMode
360 m := agentMeta{647 m := agentMeta{
361 ID: existing.ID, Task: existing.Task,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 Model: existing.Model, Provider: existing.Provider,652 Model: existing.Model, Provider: existing.Provider,
653+ BaseURL: existing.BaseURL, InsecureTLS: existing.InsecureTLS,
364 Reasoning: existing.Reasoning, FastMode: &fastMode,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 SessionID: existing.SessionID,660 SessionID: existing.SessionID,
367 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,661 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
368 SessionPath: existing.SessionPath,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 a := &Agent{719 a := &Agent{
372- ID: m.ID,720+ ID: m.ID,
373- Task: m.Task,721+ Task: m.Task,
374- Dir: m.Dir,722+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
375- Started: m.Started,723+ Dir: m.Dir,
376- Model: m.Model,724+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
377- Provider: m.Provider,725+ Started: m.Started,
378- Reasoning: m.Reasoning,726+ ParentID: m.ParentID,
379- FastMode: fastMode,727+ BatchID: m.BatchID,
380- Subagent: m.Subagent,728+ RootSessionID: m.RootSessionID,
381- SessionID: m.SessionID,729+ Model: m.Model,
382- InboxPath: m.InboxPath,730+ Provider: m.Provider,
383- EventLogPath: m.EventLogPath,731+ BaseURL: m.BaseURL,
384- SessionPath: m.SessionPath,732+ InsecureTLS: m.InsecureTLS,
385- Resuming: true,733+ Reasoning: m.Reasoning,
386- inbox: NewInbox(m.InboxPath),734+ FastMode: fastMode,
387- status: StatusPending,735+ Subagent: m.Subagent,
388- activity: "resuming",736+ SessionID: m.SessionID,
389- done: make(chan struct{}),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 // Carry the previous transcript forward so the dashboard doesn't765 // Carry the previous transcript forward so the dashboard doesn't
392 // flash empty between resume and the first new event.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 if len(prev) > 0 {768 if len(prev) > 0 {
395 a.appendTranscript(strings.Join(prev, "\n"))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 a.runner = f.cfg.NewRunner(a)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 f.mu.Lock()784 f.mu.Lock()
401 f.agents[a.ID] = a785 f.agents[a.ID] = a
786+ f.queue = append(f.queue, a)
402 // Keep the agent's slot in f.order; replacing in-place avoids787 // Keep the agent's slot in f.order; replacing in-place avoids
403 // reshuffling the dashboard's row ordering on resume.788 // reshuffling the dashboard's row ordering on resume.
404 found := false789 found := false
@@ -412,13 +797,12 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
412 f.order = append(f.order, a.ID)797 f.order = append(f.order, a.ID)
413 }798 }
414 f.mu.Unlock()799 f.mu.Unlock()
800+ leaseOwned = false
415 801
416- // Refresh the meta.json so any new path defaults (e.g. socket802+ // Refreshing metadata happened before queue admission, so a later
417- // path moved into /tmp because the root got renamed) get803+ // supervisor can reconstruct this resumed attempt even if the runner
418- // persisted. Best-effort; resume still works if the disk is804+ // has not reached its first event yet.
419- // read-only because everything the runner needs is in-memory.805+ f.armQueueTimeout(a)
420- _ = writeAgentMeta(f.agentStateDir(a.ID), a)806+ f.schedule()
421-
422- go f.run(a)
423 return a, nil807 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 // TestReloadIsIdempotent calls Reload twice in a row and asserts the211 // TestReloadIsIdempotent calls Reload twice in a row and asserts the
137 // second call neither duplicates rows nor errors.212 // second call neither duplicates rows nor errors.
138 func TestReloadIsIdempotent(t *testing.T) {213 func TestReloadIsIdempotent(t *testing.T) {
@@ -308,6 +383,7 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
308 if a.Status() != StatusKilled {383 if a.Status() != StatusKilled {
309 t.Fatalf("post-stop status = %q; want killed", a.Status())384 t.Fatalf("post-stop status = %q; want killed", a.Status())
310 }385 }
386+ previousAttempt := a.Snapshot().Attempt
311 387
312 // Now resume.388 // Now resume.
313 a2, err := f.Resume(context.Background(), a.ID)389 a2, err := f.Resume(context.Background(), a.ID)
@@ -326,6 +402,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
326 if a2.InboxPath != originalInbox {402 if a2.InboxPath != originalInbox {
327 t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox)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 // Two runner invocations: spawn + resume.408 // Two runner invocations: spawn + resume.
330 deadline = time.Now().Add(time.Second)409 deadline = time.Now().Add(time.Second)
331 for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {410 for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {
@@ -525,6 +604,180 @@ func TestResumeAfterReload(t *testing.T) {
525 t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript())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 // TestSpawnReqPersistsModel confirms that the per-agent model781 // TestSpawnReqPersistsModel confirms that the per-agent model
529 // override is captured at Spawn time, surfaced via Snapshot, and782 // override is captured at Spawn time, surfaced via Snapshot, and
530 // written to meta.json so a later Reload + Resume reuses it.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 // TestActiveSessionScopesSnapshotAll proves the session filter the1014 // TestActiveSessionScopesSnapshotAll proves the session filter the
731 // user asked for: a single Swarm with agents spawned under two1015 // user asked for: a single Swarm with agents spawned under two
732 // different active sessions only surfaces the agents matching the1016 // different active sessions only surfaces the agents matching the
@@ -826,10 +1110,11 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
826 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)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 g.SetActiveSession("sess-other")1115 g.SetActiveSession("sess-other")
831- if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 {1116+ if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID {
832- t.Errorf("scoped to other session, ids = %v; want []", got)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"], &timestamp); 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 &copy
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 "os/exec"11 "os/exec"
12 "path/filepath"12 "path/filepath"
13 "strings"13 "strings"
14+ "sync"
14 "time"15 "time"
15 )16 )
16 17
17-// execRunner spawns `zot --swarm-agent <inbox> --session <path>` in18+// execRunner spawns `zot --subagent-worker <inbox> --session <path>` in
18-// the host's working directory (Agent.Dir, which is always the parent19+// Agent.Dir (the shared repository root or an isolated worktree) and consumes
19-// zot's RepoRoot) and consumes its JSONL event stream on stdout.20+// its JSONL event stream on stdout.
20 //21 //
21 // Why a long-lived daemon and not `zot --print`: the supervisor and22 // Why a long-lived daemon and not `zot --print`: the supervisor and
22 // the user expect agents to keep accepting follow-up prompts. A23 // the user expect agents to keep accepting follow-up prompts. A
@@ -31,13 +32,13 @@ import (
31 //32 //
32 // The on-disk log is the durable record. The Sink updates are an33 // The on-disk log is the durable record. The Sink updates are an
33 // in-memory mirror so the dashboard doesn't have to tail the file34 // 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 would35+// for the parent's own agents. /subagents open in a separate zot would
35 // read the log directly.36 // read the log directly.
36 type execRunner struct {37 type execRunner struct {
37 agent *Agent38 agent *Agent
38 resolveCredential func(context.Context, string) (Credential, error)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 // invocation. Tests set this to a fake binary (or `go run`42 // invocation. Tests set this to a fake binary (or `go run`
42 // against a tiny stub program) so the supervisor logic can be43 // against a tiny stub program) so the supervisor logic can be
43 // tested without a real child. Production code leaves it nil.44 // tested without a real child. Production code leaves it nil.
@@ -66,10 +67,14 @@ type swarmAgentArgsOpts struct {
66 Task string67 Task string
67 Model string68 Model string
68 Provider string69 Provider string
70+ BaseURL string
71+ InsecureTLS bool
69 Reasoning string72 Reasoning string
70 FastMode bool73 FastMode bool
71 FastModeSet bool74 FastModeSet bool
72 Subagent string75 Subagent string
76+ MaxTurns int
77+ Tools []string
73 }78 }
74 79
75 // defaultChildArgs builds the argv execRunner uses when its Command80 // defaultChildArgs builds the argv execRunner uses when its Command
@@ -99,10 +104,14 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
99 Task: task,104 Task: task,
100 Model: a.Model,105 Model: a.Model,
101 Provider: a.Provider,106 Provider: a.Provider,
107+ BaseURL: a.BaseURL,
108+ InsecureTLS: a.InsecureTLS,
102 Reasoning: a.Reasoning,109 Reasoning: a.Reasoning,
103 FastMode: a.FastMode,110 FastMode: a.FastMode,
104 FastModeSet: true,111 FastModeSet: true,
105 Subagent: a.Subagent,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 func swarmAgentArgs(opts swarmAgentArgsOpts) []string {121 func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
113 args := []string{122 args := []string{
114 opts.Exe,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 "--swarm-agent", opts.InboxPath,127 "--swarm-agent", opts.InboxPath,
116 "--session", opts.SessionPath,128 "--session", opts.SessionPath,
117 "--cwd", opts.Dir,129 "--cwd", opts.Dir,
@@ -122,6 +134,12 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
122 if opts.Provider != "" {134 if opts.Provider != "" {
123 args = append(args, "--provider", opts.Provider)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 if opts.Reasoning != "" {143 if opts.Reasoning != "" {
126 args = append(args, "--reasoning", opts.Reasoning)144 args = append(args, "--reasoning", opts.Reasoning)
127 }145 }
@@ -135,10 +153,17 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
135 if opts.Subagent != "" {153 if opts.Subagent != "" {
136 args = append(args, "--subagent", opts.Subagent)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 if opts.Task != "" {162 if opts.Task != "" {
139 // First task is positional so the child treats it as the163 // First task is positional so the child treats it as the
140- // initial user turn; subsequent turns arrive via the inbox.164+ // initial user turn; the terminator keeps flag-like task text
141- args = append(args, opts.Task)165+ // from being parsed as a child CLI option.
166+ args = append(args, "--", opts.Task)
142 }167 }
143 return args168 return args
144 }169 }
@@ -191,11 +216,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
191 }216 }
192 217
193 cmd := exec.CommandContext(ctx, args[0], args[1:]...)218 cmd := exec.CommandContext(ctx, args[0], args[1:]...)
219+ configureWorkerProcess(cmd)
194 cmd.Dir = r.agent.Dir220 cmd.Dir = r.agent.Dir
195- cmd.Env = append(os.Environ(),221+ cmd.Env = append(workerEnvironment(r.agent.Provider),
196 "ZOT_SWARM_AGENT_ID="+r.agent.ID,222 "ZOT_SWARM_AGENT_ID="+r.agent.ID,
197 "ZOT_SWARM_EVENT_LOG="+logPath,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 if r.resolveCredential != nil {234 if r.resolveCredential != nil {
200 credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider)235 credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider)
201 if resolveErr != nil {236 if resolveErr != nil {
@@ -226,6 +261,32 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
226 if err := cmd.Start(); err != nil {261 if err := cmd.Start(); err != nil {
227 return err262 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 // stdout: parsed as JSONL. Every well-formed event is appended291 // stdout: parsed as JSONL. Every well-formed event is appended
231 // to the durable log AND forwarded to the in-memory sink so the292 // 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 go func() {296 go func() {
236 defer func() { done <- struct{}{} }()297 defer func() { done <- struct{}{} }()
237 dec := bufio.NewReader(stdout)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 for {306 for {
239- line, err := dec.ReadBytes('\n')307+ line, err, truncated := readBoundedLine(dec, lineLimit)
240 if len(line) > 0 {308 if len(line) > 0 {
241 trimmed := strings.TrimRight(string(line), "\r\n")309 trimmed := strings.TrimRight(string(line), "\r\n")
242- if trimmed == "" {310+ if trimmed != "" && !truncated {
243- goto next311+ if ev, ok := parseEventLine(trimmed); ok {
244- }312+ appendLog(ev)
245- if ev, ok := parseEventLine(trimmed); ok {313+ updateAgentFromEvent(r.agent, ev)
246- _ = log.Append(ev)314+ applyEventToSink(ev, sink)
247- applyEventToSink(ev, sink)315+ // Fan prompt-level task completions up to any
248- // Fan prompt-level task completions up to any316+ // subscriber on the supervised Agent. The child
249- // subscriber on the supervised Agent. The child317+ // also forwards provider/tool-loop turn_end
250- // also forwards provider/tool-loop turn_end318+ // events (for example stop=tool_use); those do
251- // events (for example stop=tool_use); those do319+ // not contain step and must not be treated as
252- // not contain step and must not be treated as320+ // swarm task completion.
253- // swarm task completion.321+ notifyPromptTurnEnd(r.agent, ev)
254- notifyPromptTurnEnd(r.agent, ev)322+ } else {
255- } else {323+ sink.Transcript(trimmed)
256- // Non-JSON output. Keep it as transcript so an324+ appendLog(NewEvent("stdout", map[string]any{"text": trimmed}))
257- // accidental fmt.Println in the child still325+ }
258- // shows up in the dashboard, and log a326+ } else if trimmed != "" {
259- // lifecycle event so the durable record stays327+ trimmed += "\n...[line truncated]"
260- // in sync.
261 sink.Transcript(trimmed)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 if err != nil {332 if err != nil {
267 return333 return
268 }334 }
@@ -276,12 +342,22 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
276 go func() {342 go func() {
277 defer func() { done <- struct{}{} }()343 defer func() { done <- struct{}{} }()
278 br := bufio.NewReader(stderr)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 for {352 for {
280- line, err := br.ReadString('\n')353+ line, err, truncated := readBoundedLine(br, lineLimit)
281- if line != "" {354+ if len(line) > 0 {
282- txt := strings.TrimRight(line, "\r\n")355+ txt := strings.TrimRight(string(line), "\r\n")
356+ if truncated {
357+ txt += "\n...[line truncated]"
358+ }
283 sink.Transcript("stderr: " + txt)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 if err != nil {362 if err != nil {
287 return363 return
@@ -298,24 +374,125 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
298 exit = ee.ExitCode()374 exit = ee.ExitCode()
299 }375 }
300 if err != nil && ctx.Err() != nil {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 return ctx.Err()381 return ctx.Err()
303 }382 }
304 if err != nil {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 return err388 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 sink.Activity("done")394 sink.Activity("done")
310 return nil395 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 // parseEventLine attempts to decode one JSONL line as an Event.478 // parseEventLine attempts to decode one JSONL line as an Event.
314 // Returns ok=false for non-JSON or JSON without a "type" field.479 // Returns ok=false for non-JSON or JSON without a "type" field.
315 func parseEventLine(line string) (Event, bool) {480 func parseEventLine(line string) (Event, bool) {
316 if len(line) == 0 || line[0] != '{' {481 if len(line) == 0 || line[0] != '{' {
317 return Event{}, false482 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 var ev Event496 var ev Event
320 if err := json.Unmarshal([]byte(line), &ev); err != nil {497 if err := json.Unmarshal([]byte(line), &ev); err != nil {
321 return Event{}, false498 return Event{}, false
@@ -329,6 +506,85 @@ func parseEventLine(line string) (Event, bool) {
329 return ev, true506 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 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the swarm588 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the swarm
333 // daemon's prompt-level completion event. Provider/tool-loop589 // daemon's prompt-level completion event. Provider/tool-loop
334 // turn_end events (such as stop=tool_use) do not include step and590 // turn_end events (such as stop=tool_use) do not include step and
@@ -342,13 +598,16 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
342 return598 return
343 }599 }
344 600
601+ errMsg, _ := ev.Data["error"].(string)
345 a.mu.Lock()602 a.mu.Lock()
346 fn := a.OnTurnEnd603 fn := a.OnTurnEnd
347- a.mu.Unlock()604+ if fn == nil {
348- if fn != nil {605+ a.pendingTurnEnds = append(a.pendingTurnEnds, turnEndNotice{step: int(step), errMsg: errMsg})
349- errMsg, _ := ev.Data["error"].(string)606+ a.mu.Unlock()
350- go fn(int(step), errMsg)607+ return
351 }608 }
609+ a.mu.Unlock()
610+ go fn(int(step), errMsg)
352 }611 }
353 612
354 // applyEventToSink translates an Event into Sink updates. Only a613 // applyEventToSink translates an Event into Sink updates. Only a
@@ -395,17 +654,17 @@ func applyEventToSink(ev Event, sink Sink) {
395 if ev.Type == "assistant_message" {654 if ev.Type == "assistant_message" {
396 sink.Activity("idle")655 sink.Activity("idle")
397 }656 }
398- case "turn_start":657+ case "turn_start", EventTurnStarted:
399 sink.Activity("thinking")658 sink.Activity("thinking")
400- case "tool_call":659+ case "tool_call", EventToolStarted:
401 if name, _ := ev.Data["name"].(string); name != "" {660 if name, _ := ev.Data["name"].(string); name != "" {
402 sink.Activity("tool: " + truncate(name, 60))661 sink.Activity("tool: " + truncate(name, 60))
403 }662 }
404- case "tool_result":663+ case "tool_result", EventToolFinished:
405 sink.Activity("idle")664 sink.Activity("idle")
406- case "turn_end":665+ case "turn_end", EventTurnResult, EventAgentIdle:
407 sink.Activity("idle")666 sink.Activity("idle")
408- case "agent_ready":667+ case "agent_ready", EventAgentReady, EventAgentHeartbeat:
409 sink.Activity("idle")668 sink.Activity("idle")
410 case "agent_stopped":669 case "agent_stopped":
411 // terminal status is decided by Swarm.run from the runner's670 // 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 exe := buildStubChild(t)42 exe := buildStubChild(t)
43+ t.Setenv("ZOT_STUB_PROTOCOL", "1")
43 44
44 root := t.TempDir()45 root := t.TempDir()
45 repo := t.TempDir()46 repo := t.TempDir()
M packages/agent/swarm/runner_test.go +69 -0
@@ -58,6 +58,38 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
58 os.Exit(0)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 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {93 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
62 t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1")94 t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1")
63 root := t.TempDir()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 // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the227 // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the
159 // agent is being adopted (no fresh task) we don't pass an empty228 // agent is being adopted (no fresh task) we don't pass an empty
160 // positional which the arg parser would treat as a real prompt.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")3+// A Swarm manages headless, long-lived zot subprocesses and keeps their
4-// that share the host's working directory. The interactive TUI4+// process/turn state, event logs, sessions, results, and optional worktree
5-// exposes the supervisor through the /swarm slash command and a5+// artifacts durable across supervisor restarts. The canonical user-facing
6-// dashboard dialog; non-TUI code can drive it directly through6+// name is "subagents"; this package and the /swarm command remain for
7-// this package.7+// compatibility with existing callers.
8 //8 //
9-// Every agent runs with cwd == the parent zot's RepoRoot — the9+// Shared workspaces preserve the historical behavior, while an explicit
10-// same files the user sees, the same files the main agent edits.10+// worktree request gives a child an isolated checkout whose patch is captured
11-// There is no git worktree, no per-agent branch, no isolation. If11+// before cleanup. The Runner abstraction lets tests inject a fake process;
12-// you want parallel edits on a separate branch, use normal git12+// production uses `zot --subagent-worker ...` with the legacy flag accepted as
13-// tooling (a real worktree, a different terminal) yourself.13+// a compatibility alias.
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.
24 package swarm14 package swarm
25 15
26 import (16 import (
@@ -32,6 +22,7 @@ import (
32 "sort"22 "sort"
33 "strings"23 "strings"
34 "sync"24 "sync"
25+ "sync/atomic"
35 "time"26 "time"
36 )27 )
37 28
@@ -49,19 +40,43 @@ const (
49 40
50 // Config configures a Swarm.41 // Config configures a Swarm.
51 type Config struct {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 // Root is the directory under which per-agent state files live.48 // Root is the directory under which per-agent state files live.
53 // Typically <ZotHome>/swarm, but tests pass a tempdir.49 // Typically <ZotHome>/swarm, but tests pass a tempdir.
54 Root string50 Root string
55 51
56- // RepoRoot is the working directory every spawned agent runs52+ // LegacyRoots are older state roots that Reload also reads. New
57- // in — the same cwd the parent zot is using. There is no53+ // agents are always written below Root; this keeps the swarm ->
58- // per-agent isolation: agents edit the host's files directly.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 RepoRoot string63 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 // FastMode is copied to every newly spawned child and persisted so69 // FastMode is copied to every newly spawned child and persisted so
62 // child subprocesses use the same OpenAI service-tier setting.70 // child subprocesses use the same OpenAI service-tier setting.
63 FastMode bool71 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 // NewRunner produces the Runner for an Agent. If nil, the default80 // NewRunner produces the Runner for an Agent. If nil, the default
66 // `zot --swarm-agent ...` exec runner is used. Tests inject a fake81 // `zot --swarm-agent ...` exec runner is used. Tests inject a fake
67 // here.82 // here.
@@ -110,9 +125,18 @@ type Sink interface {
110 type Swarm struct {125 type Swarm struct {
111 cfg Config126 cfg Config
112 127
113- mu sync.Mutex128+ mu sync.Mutex
114- agents map[string]*Agent129+ operationMu sync.Mutex
115- order []string // creation order for stable listing130+ 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 // activeSession is the host session id the dashboard is141 // activeSession is the host session id the dashboard is
118 // currently scoped to. When non-empty, SnapshotAll filters out142 // currently scoped to. When non-empty, SnapshotAll filters out
@@ -137,15 +161,37 @@ func New(cfg Config) *Swarm {
137 return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}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 return &Swarm{170 return &Swarm{
141- cfg: cfg,171+ cfg: cfg,
142- agents: map[string]*Agent{},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 // SetRepoRoot updates the shared working directory for subsequently181 // SetRepoRoot updates the shared working directory for subsequently
147 // spawned agents. Existing agents keep the directory they were started182 // spawned agents. Existing agents keep the directory they were started
148 // with; callers use this when the host session changes cwd.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 func (f *Swarm) SetRepoRoot(root string) {195 func (f *Swarm) SetRepoRoot(root string) {
150 f.mu.Lock()196 f.mu.Lock()
151 defer f.mu.Unlock()197 defer f.mu.Unlock()
@@ -176,6 +222,32 @@ func (f *Swarm) FastMode() bool {
176 return f.cfg.FastMode222 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 // SetActiveSession scopes the dashboard view (and Spawn stamping)251 // SetActiveSession scopes the dashboard view (and Spawn stamping)
180 // to a particular host zot session id. Pass empty to clear the252 // to a particular host zot session id. Pass empty to clear the
181 // scope and revert to "show every agent" (the original behaviour).253 // scope and revert to "show every agent" (the original behaviour).
@@ -211,6 +283,18 @@ func (f *Swarm) agentStateDir(id string) string {
211 return filepath.Join(f.cfg.Root, "agents", id)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 // SpawnRequest configures a Spawn. Only Task is required; the rest298 // SpawnRequest configures a Spawn. Only Task is required; the rest
215 // are optional. Model + Provider, when set, get baked into the299 // are optional. Model + Provider, when set, get baked into the
216 // child argv as --model / --provider so the agent runs against the300 // child argv as --model / --provider so the agent runs against the
@@ -220,57 +304,116 @@ func (f *Swarm) agentStateDir(id string) string {
220 // as --reasoning when set. FastMode restricts the host setting when a304 // as --reasoning when set. FastMode restricts the host setting when a
221 // selected profile declares a fastMode preference.305 // selected profile declares a fastMode preference.
222 type SpawnRequest struct {306 type SpawnRequest struct {
223- Task string307+ Task string
224- Model string // optional override; child resolves default if empty308+ Model string // optional override; child resolves default if empty
225- Provider string // optional override; usually paired with Model309+ Provider string // optional override; usually paired with Model
226- Reasoning string // optional reasoning/thinking level override310+ 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 // FastMode is an optional profile restriction. A non-nil false value315 // FastMode is an optional profile restriction. A non-nil false value
228 // disables fast mode for this child; true still cannot enable fast mode316 // disables fast mode for this child; true still cannot enable fast mode
229 // when Config.FastMode is false. Nil inherits Config.FastMode.317 // when Config.FastMode is false. Nil inherits Config.FastMode.
230 FastMode *bool318 FastMode *bool
231- Subagent string // optional named markdown profile319+ 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 its337+// Spawn creates a new Agent for the given task, allocates its on-disk
235-// on-disk state directory (events log, inbox socket path, session338+// state directory (events log, inbox socket path, session file path), and
236-// file path), and starts the Runner on a background goroutine. The339+// queues it for the manager-owned scheduler. The returned Agent is pending
237-// returned Agent is already in StatusRunning (or StatusFailed if340+// when the concurrency budget is full and transitions to running when a slot
238-// state setup failed before the goroutine started). This is the341+// opens. This is the historical signature; callers that want to override the
239-// historical signature; callers that want to override the child's342+// child's model or workspace use SpawnReq instead.
240-// model use SpawnReq instead.
241 func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {343 func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
242 return f.SpawnReq(ctx, SpawnRequest{Task: task})344 return f.SpawnReq(ctx, SpawnRequest{Task: task})
243 }345 }
244 346
245-// SpawnReq is the full-fat variant of Spawn that accepts a347+// SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest.
246-// SpawnRequest. Existing callers can keep using Spawn; new code that348+// Existing callers can keep using Spawn; new code that wants to pin the
247-// wants to pin the child's model uses this.349+// child's model, policy, or workspace 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.
252 func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {350 func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
253 task := strings.TrimSpace(req.Task)351 task := strings.TrimSpace(req.Task)
254 if task == "" {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 a368+ // Reserve a spawn budget before touching disk. This makes the total
260- // concurrent /cd cannot race a spawn or produce a mixed snapshot.369+ // budget race-free across slash commands, tools, and batch callers.
261 f.mu.Lock()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 dir := f.cfg.RepoRoot377 dir := f.cfg.RepoRoot
263 fastMode := f.cfg.FastMode378 fastMode := f.cfg.FastMode
379+ configProvider := f.cfg.Provider
380+ configBaseURL := f.cfg.BaseURL
381+ configInsecureTLS := f.cfg.InsecureTLS
264 sessionID := f.activeSession382 sessionID := f.activeSession
265 f.mu.Unlock()383 f.mu.Unlock()
266 if req.FastMode != nil {384 if req.FastMode != nil {
267 fastMode = fastMode && *req.FastMode385 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 stateDir := f.agentStateDir(id)403 stateDir := f.agentStateDir(id)
271- if err := os.MkdirAll(stateDir, 0o755); err != nil {404+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
272- return nil, fmt.Errorf("swarm state dir: %w", err)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 logPath := filepath.Join(stateDir, "events.jsonl")417 logPath := filepath.Join(stateDir, "events.jsonl")
275 sessionPath := filepath.Join(stateDir, "session.json")418 sessionPath := filepath.Join(stateDir, "session.json")
276 // Keep the transient unix socket outside the durable state root.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 // regular state files but cannot host unix socket nodes.421 // regular state files but cannot host unix socket nodes.
279 inboxPath, err := inboxSocketPath(f.cfg.Root, id)422 inboxPath, err := inboxSocketPath(f.cfg.Root, id)
280 if err != nil {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 a := &Agent{464 a := &Agent{
285- ID: id,465+ ID: id,
286- Task: task,466+ Task: task,
287- Dir: dir,467+ OriginalTask: task,
288- Started: f.cfg.Now(),468+ Dir: dir,
289- Model: strings.TrimSpace(req.Model),469+ RepositoryRoot: workspace.RepositoryRoot(),
290- Provider: strings.TrimSpace(req.Provider),470+ WorkspacePath: workspace.Dir(),
291- Reasoning: strings.TrimSpace(req.Reasoning),471+ Started: now,
292- FastMode: fastMode,472+ ParentID: strings.TrimSpace(req.ParentID),
293- Subagent: strings.TrimSpace(req.Subagent),473+ BatchID: strings.TrimSpace(req.BatchID),
294- SessionID: sessionID,474+ RootSessionID: strings.TrimSpace(req.RootSessionID),
295- InboxPath: inboxPath,475+ Model: strings.TrimSpace(req.Model),
296- EventLogPath: logPath,476+ Provider: strings.TrimSpace(req.Provider),
297- SessionPath: sessionPath,477+ BaseURL: baseURL,
298- inbox: NewInbox(inboxPath),478+ InsecureTLS: insecureTLS,
299- status: StatusPending,479+ Reasoning: strings.TrimSpace(req.Reasoning),
300- activity: "queued",480+ FastMode: fastMode,
301- done: make(chan struct{}),481+ Subagent: strings.TrimSpace(req.Subagent),
302- }482+ SessionID: sessionID,
303- a.ctx, a.cancel = context.WithCancel(ctx)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 a.runner = f.cfg.NewRunner(a)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 f.mu.Lock()526 f.mu.Lock()
307 f.agents[id] = a527 f.agents[id] = a
308 f.order = append(f.order, id)528 f.order = append(f.order, id)
529+ f.queue = append(f.queue, a)
309 f.mu.Unlock()530 f.mu.Unlock()
531+ leaseOwned = false
310 532
311- // Persist the agent's identity so a later `zot` invocation can533+ // Persisted metadata was written before queue admission, so a later
312- // reload it from disk via Swarm.Reload. Best-effort: if the disk534+ // supervisor can reconstruct work that never reached a process.
313- // is read-only we still let the runner start, the user just won't535+ f.armQueueTimeout(a)
314- // see this agent on the next launch.536+ f.schedule()
315- _ = writeAgentMeta(stateDir, a)537+ reserved = false
316-
317- go f.run(a)
318 return a, nil538 return a, nil
319 }539 }
320 540
@@ -336,22 +556,77 @@ func (f *Swarm) SendInput(id, msg string) error {
336 // SendUserTurn is sugar for the common "send the next user turn"556 // SendUserTurn is sugar for the common "send the next user turn"
337 // case. It quotes nothing and forwards verbatim; callers are557 // case. It quotes nothing and forwards verbatim; callers are
338 // expected to have already trimmed and expanded the text.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 func (f *Swarm) SendUserTurn(id, text string) error {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 func (f *Swarm) run(a *Agent) {593 func (f *Swarm) run(a *Agent) {
344- a.setStatus(StatusRunning)594+ a.mu.Lock()
345- a.setActivity("starting")595+ killedBeforeStart := a.status == StatusKilled
346- err := a.runner.Run(a.ctx, agentSink{a: a})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 a.mu.Lock()617 a.mu.Lock()
348 a.finished = f.cfg.Now()618 a.finished = f.cfg.Now()
349 switch {619 switch {
350 case a.status == StatusKilled:620 case a.status == StatusKilled:
351- // Already finalised by Stop.621+ // Stop requested a terminal state; preserve it.
352 case errors.Is(err, context.Canceled):622 case errors.Is(err, context.Canceled):
353 a.status = StatusKilled623 a.status = StatusKilled
354 a.activity = "cancelled"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 case err != nil:630 case err != nil:
356 a.status = StatusFailed631 a.status = StatusFailed
357 a.activity = "error: " + truncate(err.Error(), 120)632 a.activity = "error: " + truncate(err.Error(), 120)
@@ -360,8 +635,46 @@ func (f *Swarm) run(a *Agent) {
360 a.status = StatusDone635 a.status = StatusDone
361 a.activity = "done"636 a.activity = "done"
362 }637 }
638+ finalStatus := a.status
363 a.mu.Unlock()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 // List returns a snapshot of every agent in creation order. The680 // List returns a snapshot of every agent in creation order. The
@@ -407,42 +720,170 @@ func (f *Swarm) Get(id string) *Agent {
407 // socket about to be unlinked.720 // socket about to be unlinked.
408 //721 //
409 // Stop is a no-op for any agent that's not in a live runnable state722 // Stop is a no-op for any agent that's not in a live runnable state
410-// — Done / Failed / Killed (already finalised) and Detached (no723+// — Done / Failed / Killed (already finalised). Reloaded Detached agents
411-// in-process runner; reloaded from disk). Calling Stop on a detached724+// are probed first: if their worker survived the supervisor, Stop sends the
412-// agent must not crash: buildDetachedAgent doesn't allocate a725+// shutdown command through the durable inbox instead of pretending no
413-// context/cancel pair because there's nothing to cancel.726+// process exists.
414 func (f *Swarm) Stop(id string) error {727 func (f *Swarm) Stop(id string) error {
728+ f.operationMu.Lock()
729+ defer f.operationMu.Unlock()
415 a := f.Get(id)730 a := f.Get(id)
416 if a == nil {731 if a == nil {
417 return fmt.Errorf("no such agent %q", id)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 a.mu.Lock()741 a.mu.Lock()
420- switch a.status {742+ status := a.status
421- case StatusDone, StatusFailed, StatusKilled, StatusDetached: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 a.mu.Unlock()794 a.mu.Unlock()
795+ a.setProcessState(ProcessKilled)
796+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
797+ a.setProcessPID(0)
798+ f.persistAgent(a)
423 return nil799 return nil
424 }800 }
425 a.status = StatusKilled801 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 a.mu.Unlock()812 a.mu.Unlock()
428- // Belt-and-braces guard against the cancel-less case (detached813+ f.mu.Unlock()
429- // agents skip this branch above, but a future code path that814+ a.setTurnState(TurnCanceling, a.CurrentTurnID())
430- // builds an Agent without a runner shouldn't crash the815+
431- // supervisor).816+ if status == StatusPending {
432- if a.cancel != nil {817+ a.setProcessState(ProcessKilled)
433- a.cancel()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 if a.inbox != nil {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 return nil872 return nil
439 }873 }
440 874
441 // StopAll cancels every running agent. Used on shutdown.875 // StopAll cancels every running agent. Used on shutdown.
442 func (f *Swarm) StopAll() {876 func (f *Swarm) StopAll() {
443- for _, a := range f.List() {877+ agents := f.List()
878+ for _, a := range agents {
444 _ = f.Stop(a.ID)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 // Remove tears down the per-agent state for a terminated agent. It889 // Remove tears down the per-agent state for a terminated agent. It
@@ -451,25 +892,42 @@ func (f *Swarm) StopAll() {
451 // (reloaded from disk) remove cleanly because they have no live892 // (reloaded from disk) remove cleanly because they have no live
452 // runner racing for the same files.893 // runner racing for the same files.
453 //894 //
454-// Agents share the host's working tree, so Remove never touches895+// Shared-workspace agents never touch source files during Remove; worktree
455-// any source file — it only deletes the agent's state directory896+// agents have already captured their output and removed the isolated checkout
897+// during finalization. Remove then deletes the agent's durable state directory
456 // under <root>/agents/<id>/.898 // under <root>/agents/<id>/.
457 func (f *Swarm) Remove(id string) error {899 func (f *Swarm) Remove(id string) error {
900+ f.operationMu.Lock()
901+ defer f.operationMu.Unlock()
458 a := f.Get(id)902 a := f.Get(id)
459 if a == nil {903 if a == nil {
460 return fmt.Errorf("no such agent %q", id)904 return fmt.Errorf("no such agent %q", id)
461 }905 }
462 a.mu.Lock()906 a.mu.Lock()
463 st := a.status907 st := a.status
908+ inboxPath := a.InboxPath
464 a.mu.Unlock()909 a.mu.Unlock()
465 if st == StatusRunning || st == StatusPending {910 if st == StatusRunning || st == StatusPending {
466 return fmt.Errorf("agent %s still %s", a.ID, st)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 // Best-effort cleanup of the per-agent state directory926 // Best-effort cleanup of the per-agent state directory
469 // (meta.json, events.jsonl, session.json). Failing here would leave927 // (meta.json, events.jsonl, session.json). Failing here would leave
470 // the user with no recourse,928 // the user with no recourse,
471 // so swallow the error.929 // so swallow the error.
472- _ = os.RemoveAll(f.agentStateDir(a.ID))930+ _ = os.RemoveAll(a.stateDirectory(f.cfg.Root))
473 f.mu.Lock()931 f.mu.Lock()
474 delete(f.agents, a.ID)932 delete(f.agents, a.ID)
475 for i, k := range f.order {933 for i, k := range f.order {
@@ -485,32 +943,48 @@ func (f *Swarm) Remove(id string) error {
485 // Snapshot returns a read-only view of one agent. Safe for the TUI943 // Snapshot returns a read-only view of one agent. Safe for the TUI
486 // goroutine to call repeatedly; never blocks on the Runner.944 // goroutine to call repeatedly; never blocks on the Runner.
487 type AgentSnapshot struct {945 type AgentSnapshot struct {
488- ID string946+ ID string
489- Task string947+ Task string
490- Dir string948+ Dir string
491- Status Status949+ Status Status
492- Activity string950+ ProcessState ProcessState
493- Started time.Time951+ TurnState TurnState
494- Finished time.Time952+ CurrentTurnID string
495- Err string953+ Attempt int
496- Tail string // last few transcript lines, joined with "\n"954+ Activity string
497- Lines []string // full transcript (already capped by Agent.appendTranscript)955+ Started time.Time
498- LastAssistant string // complete final assistant message, without role formatting956+ Finished time.Time
499-957+ CreatedAt time.Time
500- // Model, Provider, and Reasoning expose the per-agent overrides set958+ UpdatedAt time.Time
501- // at Spawn time (empty when the agent inherits the child's default959+ LastActivity time.Time
502- // resolution). Subagent is the selected named markdown profile.960+ Err string
503- // The dashboard surfaces these so the user can confirm the child961+ Tail string // last few transcript lines, joined with "\n"
504- // configuration.962+ Lines []string // full transcript (already capped by Agent.appendTranscript)
505- Model string963+ LastAssistant string // complete final assistant message, without role formatting
506- Provider string964+ Result *TurnResult
507- Reasoning string965+ ResultRef string
508- FastMode bool966+ PatchRef string
509- Subagent string967+ 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 // Paths to the agent's durable state. Surface them in the985 // Paths to the agent's durable state. Surface them in the
512- // snapshot so the dashboard / /swarm open can read events.jsonl986+ // snapshot so the dashboard /subagents (or compatibility /swarm) view can
513- // or resume the session without going back through the Agent.987+ // read events.jsonl or resume the session without going back through the Agent.
514 InboxPath string988 InboxPath string
515 EventLogPath string989 EventLogPath string
516 SessionPath string990 SessionPath string
@@ -520,7 +994,6 @@ type AgentSnapshot struct {
520 // inspect at leisure.994 // inspect at leisure.
521 func (a *Agent) Snapshot() AgentSnapshot {995 func (a *Agent) Snapshot() AgentSnapshot {
522 a.mu.Lock()996 a.mu.Lock()
523- defer a.mu.Unlock()
524 tail := strings.Join(lastN(a.transcript, 6), "\n")997 tail := strings.Join(lastN(a.transcript, 6), "\n")
525 lines := make([]string, len(a.transcript))998 lines := make([]string, len(a.transcript))
526 copy(lines, a.transcript)999 copy(lines, a.transcript)
@@ -528,20 +1001,38 @@ func (a *Agent) Snapshot() AgentSnapshot {
528 if a.lastErr != nil {1001 if a.lastErr != nil {
529 errStr = a.lastErr.Error()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 return AgentSnapshot{1023 return AgentSnapshot{
532 ID: a.ID, Task: a.Task, Dir: a.Dir,1024 ID: a.ID, Task: a.Task, Dir: a.Dir,
533- Status: a.status, Activity: a.activity,1025+ Status: status, ProcessState: processState, TurnState: turnState,
534- Started: a.Started, Finished: a.finished,1026+ CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity,
1027+ Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
535 Err: errStr, Tail: tail, Lines: lines,1028 Err: errStr, Tail: tail, Lines: lines,
536- LastAssistant: a.lastAssistant,1029+ LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
537- Model: a.Model,1030+ PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,
538- Provider: a.Provider,1031+ Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL,
539- Reasoning: a.Reasoning,1032+ InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning,
540- FastMode: a.FastMode,1033+ FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode,
541- Subagent: a.Subagent,1034+ WorkspacePath: a.WorkspacePath, InboxPath: a.InboxPath,
542- InboxPath: a.InboxPath,1035+ EventLogPath: a.EventLogPath, SessionPath: a.SessionPath,
543- EventLogPath: a.EventLogPath,
544- SessionPath: a.SessionPath,
545 }1036 }
546 }1037 }
547 1038
@@ -551,11 +1042,10 @@ func (a *Agent) Snapshot() AgentSnapshot {
551 // Scoping rules:1042 // Scoping rules:
552 // - activeSession == "": no filter, every agent is returned1043 // - activeSession == "": no filter, every agent is returned
553 // (historical behaviour; used by tests and scripted callers).1044 // (historical behaviour; used by tests and scripted callers).
554-// - activeSession != "": include only agents whose SessionID1045+// - activeSession != "": include live agents whose SessionID matches
555-// matches activeSession OR is empty. The empty-id pass-through1046+// activeSession, plus unscoped legacy agents. Detached and terminal
556-// keeps pre-upgrade agents (their meta.json was written before1047+// agents from another host session remain visible so restart/recovery
557-// session_id existed) visible from any session so the user1048+// does not hide durable work.
558-// doesn't lose access after the schema bump.
559 func (f *Swarm) SnapshotAll() []AgentSnapshot {1049 func (f *Swarm) SnapshotAll() []AgentSnapshot {
560 agents := f.List()1050 agents := f.List()
561 f.mu.Lock()1051 f.mu.Lock()
@@ -565,7 +1055,13 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot {
565 out := make([]AgentSnapshot, 0, len(agents))1055 out := make([]AgentSnapshot, 0, len(agents))
566 for _, a := range agents {1056 for _, a := range agents {
567 if active != "" && a.SessionID != "" && a.SessionID != active {1057 if active != "" && a.SessionID != "" && a.SessionID != active {
568- continue1058+ // 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 out = append(out, a.Snapshot())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 "os"27 "os"
28 "sync"28 "sync"
29 "time"29 "time"
30+
31+ "github.com/patriceckhart/zot/packages/agent/swarm"
30 )32 )
31 33
32 func main() {34 func main() {
@@ -35,7 +37,8 @@ func main() {
35 sessionPath string37 sessionPath string
36 cwd string38 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 flag.StringVar(&sessionPath, "session", "", "session file path")42 flag.StringVar(&sessionPath, "session", "", "session file path")
40 flag.StringVar(&cwd, "cwd", "", "working directory")43 flag.StringVar(&cwd, "cwd", "", "working directory")
41 flag.Parse()44 flag.Parse()
@@ -81,16 +84,33 @@ func main() {
81 line, err := br.ReadString('\n')84 line, err := br.ReadString('\n')
82 if line != "" {85 if line != "" {
83 msg := trimNL(line)86 msg := trimNL(line)
84- switch {87+ if command, parseErr := swarm.ParseCommand(msg); parseErr == nil {
85- case msg == "shutdown":88+ switch command.Type {
86- emit("agent_stopped", map[string]any{"reason": "shutdown"})89+ case swarm.CommandAgentShutdown:
87- _ = c.Close()90+ emit("agent_stopped", map[string]any{"reason": "shutdown"})
88- return91+ _ = c.Close()
89- case msg == "cancel":92+ return
90- emit("turn_end", map[string]any{"stop": "cancelled"})93+ case swarm.CommandTurnCancel:
91- case len(msg) > 5 && msg[:5] == "user ":94+ emit("turn_end", map[string]any{"stop": "cancelled"})
92- runTurn(emit, msg[5:], turn)95+ case swarm.CommandTurnStart:
93- turn++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 if err != nil {116 if err != nil {
@@ -125,12 +145,18 @@ type emitter = func(string, map[string]any)
125 func newEmitter() emitter {145 func newEmitter() emitter {
126 var mu sync.Mutex146 var mu sync.Mutex
127 enc := json.NewEncoder(os.Stdout)147 enc := json.NewEncoder(os.Stdout)
148+ versioned := os.Getenv("ZOT_STUB_PROTOCOL") == "1"
128 return func(typ string, data map[string]any) {149 return func(typ string, data map[string]any) {
129 mu.Lock()150 mu.Lock()
130 defer mu.Unlock()151 defer mu.Unlock()
131 if data == nil {152 if data == nil {
132 data = map[string]any{}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 data["type"] = typ160 data["type"] = typ
135 data["time"] = time.Now().Format(time.RFC3339Nano)161 data["time"] = time.Now().Format(time.RFC3339Nano)
136 _ = enc.Encode(data)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 package agent1 package agent
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "encoding/json"6 "encoding/json"
7+ "errors"
6 "fmt"8 "fmt"
7 "io"9 "io"
8 "os"10 "os"
11+ "strconv"
9 "strings"12 "strings"
10 "sync"13 "sync"
11 "time"14 "time"
15+ "unicode/utf8"
12 16
13 "github.com/patriceckhart/zot/packages/agent/modes"17 "github.com/patriceckhart/zot/packages/agent/modes"
14 "github.com/patriceckhart/zot/packages/agent/swarm"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 em := newSwarmEmitter(os.Stdout, logMirror)114 em := newSwarmEmitter(os.Stdout, logMirror)
111-115+ em.setProtocolIdentity(os.Getenv("ZOT_SWARM_AGENT_ID"))
112- em.emit("agent_ready", map[string]any{116+ em.emit("agent.ready", map[string]any{
113 "version": version,117 "version": version,
114 "cwd": r.CWD,118 "cwd": r.CWD,
115 "model": r.Model,119 "model": r.Model,
116 })120 })
117 121
118- // turnCtx is the per-turn context. We keep a per-process122+ // Heartbeats make a live-but-detached child distinguishable from a
119- // cancel so the "cancel" inbox message can interrupt an123+ // failed turn after supervisor reconnect. The parent still treats
120- // in-flight turn without tearing down the whole daemon.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 var (148 var (
122- mu sync.Mutex149+ mu sync.Mutex
123- turnCtx context.Context = ctx150+ cancelFn context.CancelFunc
124- cancelFn context.CancelFunc151+ busyTurn bool
125- busyTurn bool152+ turnNo = initialTurnNumber(os.Getenv("ZOT_SWARM_EVENT_LOG"))
126- stopping bool153+ turnWG sync.WaitGroup
127- turnNo int154+ closing bool
128- turns sync.WaitGroup155+ shutdown = make(chan struct{})
156+ shutdownOnce sync.Once
129 )157 )
158+ maxOutputBytes, maxOutputLines := workerOutputLimits()
130 159
131 runOne := func(prompt string) {160 runOne := func(prompt string) {
132 mu.Lock()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 if busyTurn {185 if busyTurn {
134 // Drop concurrent turns rather than queuing. The186 // Drop concurrent turns rather than queuing. The
135 // supervisor protocol assumes one outstanding turn per187 // supervisor protocol assumes one outstanding turn per
@@ -142,11 +194,12 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
142 busyTurn = true194 busyTurn = true
143 turnNo++195 turnNo++
144 step := turnNo196 step := turnNo
145- turnCtx, cancelFn = context.WithCancel(ctx)197+ c, cancel := context.WithCancel(ctx)
146- c := turnCtx198+ cancelFn = cancel
147 mu.Unlock()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 sink := func(ev core.AgentEvent) {204 sink := func(ev core.AgentEvent) {
152 em.emit(ev.Type(), modes.EventToJSON(ev))205 em.emit(ev.Type(), modes.EventToJSON(ev))
@@ -156,10 +209,32 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
156 err := ag.Prompt(c, prompt, nil, sink)209 err := ag.Prompt(c, prompt, nil, sink)
157 WriteNewTranscript(ag, sess, start)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 em.emit("turn_end", map[string]any{232 em.emit("turn_end", map[string]any{
160- "step": step,233+ "step": step,
161- "error": errString(err),234+ "turn_id": turnID,
235+ "error": errString(err),
162 })236 })
237+ em.emit("agent.idle", map[string]any{"turn_id": turnID})
163 238
164 mu.Lock()239 mu.Lock()
165 busyTurn = false240 busyTurn = false
@@ -167,35 +242,46 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
167 mu.Unlock()242 mu.Unlock()
168 }243 }
169 244
170- startTurn := func(prompt string) {245+ launchTurn := func(prompt string) {
171 mu.Lock()246 mu.Lock()
172- if stopping {247+ if closing {
173 mu.Unlock()248 mu.Unlock()
174 return249 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 mu.Unlock()255 mu.Unlock()
178 go func() {256 go func() {
179- defer turns.Done()257+ defer turnWG.Done()
180 runOne(prompt)258 runOne(prompt)
181 }()259 }()
182 }260 }
183- stopAndWait := func(reason string) {261+
262+ waitForActiveTurn := func(cancel bool) {
184 mu.Lock()263 mu.Lock()
185- stopping = true264+ closing = true
186- if cancelFn != nil {265+ activeCancel := cancelFn
187- 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 mu.Unlock()276 mu.Unlock()
190- turns.Wait()277+ shutdownOnce.Do(func() { close(shutdown) })
191- em.emit("agent_stopped", map[string]any{"reason": reason})
192 }278 }
193 279
194 // Initial task: run before processing the inbox so the agent280 // Initial task: run before processing the inbox so the agent
195 // "starts working" the moment it boots, matching what users281 // "starts working" the moment it boots, matching what users
196 // expect from `/swarm new <task>`.282 // expect from `/swarm new <task>`.
197 if args.Prompt != "" {283 if args.Prompt != "" {
198- startTurn(args.Prompt)284+ launchTurn(args.Prompt)
199 }285 }
200 286
201 // Inbox loop: one supervisor message at a time. We don't spawn287 // 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 for {291 for {
206 select {292 select {
207 case <-ctx.Done():293 case <-ctx.Done():
208- stopAndWait("cancelled")294+ waitForActiveTurn(true)
295+ em.emit("agent.exited", map[string]any{"reason": "cancelled"})
209 return ctx.Err()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 case msg, ok := <-ln.Lines():305 case msg, ok := <-ln.Lines():
211 if !ok {306 if !ok {
212- stopAndWait("inbox-closed")307+ waitForActiveTurn(true)
308+ em.emit("agent.exited", map[string]any{"reason": "inbox-closed"})
213 return nil309 return nil
214 }310 }
215- switch {311+ command, parseErr := swarm.ParseCommand(msg)
216- case msg == "shutdown":312+ if parseErr != nil {
217- stopAndWait("shutdown")313+ em.emit("error", map[string]any{"message": "invalid supervisor command"})
218- return nil314+ continue
219- case msg == "cancel":315+ }
316+ switch command.Type {
317+ case swarm.CommandAgentShutdown:
318+ requestShutdown()
319+ case swarm.CommandTurnCancel:
220 mu.Lock()320 mu.Lock()
221 if cancelFn != nil {321 if cancelFn != nil {
222 cancelFn()322 cancelFn()
223 }323 }
224 mu.Unlock()324 mu.Unlock()
225- case strings.HasPrefix(msg, "user "):325+ case swarm.CommandAgentPing:
226- prompt := strings.TrimPrefix(msg, "user ")326+ em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
227- startTurn(prompt)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 default:334 default:
229- em.emit("error", map[string]any{335+ // Unknown commands are rejected rather than interpreted as
230- "message": "unknown supervisor message: " + truncateForLog(msg, 200),336+ // child-originated control requests.
231- })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 // durable log file. Concurrent goroutines call emit so we have to344 // durable log file. Concurrent goroutines call emit so we have to
239 // hold a mutex around the encoder.345 // hold a mutex around the encoder.
240 type swarmEmitter struct {346 type swarmEmitter struct {
241- mu sync.Mutex347+ mu sync.Mutex
242- w *os.File348+ w *os.File
243- mirror *swarm.EventLog349+ mirror *swarm.EventLog
350+ agentID string
351+ versioned bool
244 352
245 // orphan flips true the first time a stdout write fails (broken353 // orphan flips true the first time a stdout write fails (broken
246 // pipe — the supervisor died). Until then the mirror stays354 // pipe — the supervisor died). Until then the mirror stays
@@ -258,46 +366,190 @@ func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter {
258 return &swarmEmitter{w: w, mirror: mirror}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 func (e *swarmEmitter) emit(typ string, data map[string]any) {376 func (e *swarmEmitter) emit(typ string, data map[string]any) {
262 if data == nil {377 if data == nil {
263 data = map[string]any{}378 data = map[string]any{}
264 }379 }
265- data["type"] = typ380+ payload := make(map[string]any, len(data))
266- data["time"] = time.Now().Format(time.RFC3339Nano)381+ for key, value := range data {
382+ payload[key] = value
383+ }
384+ turnID, _ := payload["turn_id"].(string)
267 385
268 e.mu.Lock()386 e.mu.Lock()
269 defer e.mu.Unlock()387 defer e.mu.Unlock()
270 388
271- if !e.orphan {389+ wireType := typ
272- // Encode to a buffer first so we can detect the stdout write390+ if e.versioned {
273- // error — json.Encoder swallows partial writes silently, but391+ wireType = canonicalWorkerEvent(typ)
274- // a direct Write on *os.File returns broken-pipe immediately.392+ }
275- line, err := json.Marshal(data)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 if err == nil {398 if err == nil {
277- line = append(line, '\n')399+ // Flatten payload fields as a compatibility courtesy. New peers
278- if _, werr := e.w.Write(line); werr != nil {400+ // read `payload`; old peers can still inspect `step`, `content`,
279- // Supervisor's stdout pipe is gone (parent zot exited401+ // and similar fields at the top level.
280- // but we kept running). Switch to mirror-only mode so402+ var object map[string]any
281- // subsequent events still get persisted; also retro-403+ if json.Unmarshal(bytes.TrimSpace(line), &object) == nil {
282- // actively log this very event to the mirror so it404+ for key, value := range payload {
283- // doesn't get lost in the handoff.405+ if _, exists := object[key]; exists {
284- e.orphan = true406+ 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 if e.orphan && e.mirror != nil {438 if e.orphan && e.mirror != nil {
290- // Drop "type" + "time" out of data into Event fields so the439+ _ = e.mirror.Append(swarm.NewEvent(wireType, payload))
291- // mirror file matches what the supervisor would have written.440+ }
292- flat := map[string]any{}441+}
293- for k, v := range data {442+
294- if k == "type" || k == "time" {443+const compatibilityFlatValueBytes = 64 * 1024
295- continue444+
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 func errString(err error) string {555 func errString(err error) string {
@@ -307,6 +559,28 @@ func errString(err error) string {
307 return err.Error()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 func truncateForLog(s string, n int) string {584 func truncateForLog(s string, n int) string {
311 if len(s) <= n {585 if len(s) <= n {
312 return s586 return s
M packages/agent/swarm_agent_test.go +64 -0
@@ -6,11 +6,30 @@ import (
6 "io"6 "io"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "testing"10 "testing"
10 11
11 "github.com/patriceckhart/zot/packages/agent/swarm"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 // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the33 // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the
15 // "everything is doubled after reopening a swarm agent" bug.34 // "everything is doubled after reopening a swarm agent" bug.
16 //35 //
@@ -84,6 +103,51 @@ func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
84 // wire-format contract: each emitted event lands on stdout as one103 // wire-format contract: each emitted event lands on stdout as one
85 // JSON object per line with type+time at top level alongside the104 // JSON object per line with type+time at top level alongside the
86 // data fields. The supervisor's runner parses this exact shape.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 func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {151 func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
88 // Pipe so we can read what the emitter wrote.152 // Pipe so we can read what the emitter wrote.
89 r, w, err := os.Pipe()153 r, w, err := os.Pipe()
M packages/agent/tools/swarm_spawn.go +109 -41
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8+ "time"
8 9
9 "github.com/patriceckhart/zot/packages/agent/subagents"10 "github.com/patriceckhart/zot/packages/agent/subagents"
10 "github.com/patriceckhart/zot/packages/agent/swarm"11 "github.com/patriceckhart/zot/packages/agent/swarm"
@@ -22,6 +23,10 @@ import (
22 // can flip it off mid-session and the next call refuses cleanly23 // can flip it off mid-session and the next call refuses cleanly
23 // without re-registering the tool.24 // without re-registering the tool.
24 type SwarmSpawnTool struct {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 // Swarm is the supervisor used to spawn agents. Nil means30 // Swarm is the supervisor used to spawn agents. Nil means
26 // "auto-swarm not available in this mode" and the tool always31 // "auto-swarm not available in this mode" and the tool always
27 // errors.32 // errors.
@@ -51,6 +56,10 @@ type SwarmSpawnTool struct {
51 OnSpawned func(agent *swarm.Agent, task string)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 type swarmSpawnArgs struct {63 type swarmSpawnArgs struct {
55 Task string `json:"task"`64 Task string `json:"task"`
56 Agent string `json:"agent,omitempty"`65 Agent string `json:"agent,omitempty"`
@@ -58,6 +67,9 @@ type swarmSpawnArgs struct {
58 Provider string `json:"provider,omitempty"`67 Provider string `json:"provider,omitempty"`
59 Reasoning string `json:"reasoning,omitempty"`68 Reasoning string `json:"reasoning,omitempty"`
60 Thinking string `json:"thinking,omitempty"`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 const swarmSpawnSchema = `{75 const swarmSpawnSchema = `{
@@ -88,23 +100,43 @@ const swarmSpawnSchema = `{
88 "type": "string",100 "type": "string",
89 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],101 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
90 "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."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 "required": ["task"]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 func (t *SwarmSpawnTool) Description() string {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 func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }131 func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
101 132
102 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {133 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
134+ prefix := t.Name()
103 if t.Swarm == nil {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 if t.Enabled == nil || !t.Enabled() {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 var a swarmSpawnArgs141 var a swarmSpawnArgs
110 if err := json.Unmarshal(raw, &a); err != nil {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 task := strings.TrimSpace(a.Task)145 task := strings.TrimSpace(a.Task)
114 if task == "" {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 agentName := strings.TrimSpace(a.Agent)169 agentName := strings.TrimSpace(a.Agent)
@@ -120,15 +171,15 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
120 var fastModeOverride *bool171 var fastModeOverride *bool
121 if agentName != "" {172 if agentName != "" {
122 if t.ResolveSubagent == nil {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 var err error176 var err error
126 profile, err = t.ResolveSubagent(agentName)177 profile, err = t.ResolveSubagent(agentName)
127 if err != nil {178 if err != nil {
128- return protocolToolError("swarm_spawn: " + err.Error())179+ return protocolToolError(prefix + ": " + err.Error())
129 }180 }
130 if profile == nil {181 if profile == nil {
131- return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)182+ return protocolToolError(prefix + ": unknown subagent profile " + agentName)
132 }183 }
133 agentName = profile.Name184 agentName = profile.Name
134 fastModeOverride = profile.FastMode185 fastModeOverride = profile.FastMode
@@ -136,55 +187,63 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
136 187
137 model := strings.TrimSpace(a.Model)188 model := strings.TrimSpace(a.Model)
138 providerID := strings.TrimSpace(a.Provider)189 providerID := strings.TrimSpace(a.Provider)
190+ var profileTools []string
191+ if profile != nil {
192+ profileTools = append([]string(nil), profile.Tools...)
193+ }
139 if (model == "") != (providerID == "") {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 {197+ if profile != nil {
143- // A fully-qualified profile model is useful metadata for the198+ // A profile may specify a qualified model, a bare model, a provider,
144- // supervisor and also lets it inherit the right credential route.199+ // or neither. Fill only missing pieces so explicit spawn options win;
145- // Bare profile model ids remain child-resolved so the configured200+ // the parent session supplies whatever the profile leaves unspecified.
146- // provider is preserved.
147 profileProvider, profileModel := profile.ModelSelection()201 profileProvider, profileModel := profile.ModelSelection()
148- if profileProvider != "" && profileModel != "" {202+ if model == "" {
149- providerID, model = profileProvider, profileModel203+ model = profileModel
150- }
151- }
152- if model == "" && providerID == "" && profile == nil {
153- if t.DefaultModel != nil {
154- model = strings.TrimSpace(t.DefaultModel())
155 }204 }
156- if t.DefaultProvider != nil {205+ if providerID == "" {
157- providerID = strings.TrimSpace(t.DefaultProvider())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 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)216 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
162 if err != nil {217 if err != nil {
163- return protocolToolError("swarm_spawn: " + err.Error())218+ return protocolToolError(prefix + ": " + err.Error())
164 }219 }
165 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {220 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
166 reasoning, err = reasoningOverride(profile.Thinking, "")221 reasoning, err = reasoningOverride(profile.Thinking, "")
167 if err != nil {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 if reasoning == "" && t.DefaultReasoning != nil {226 if reasoning == "" && t.DefaultReasoning != nil {
172 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")227 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
173 if err != nil {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 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{233 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
179- Task: task,234+ Task: task,
180- Model: model,235+ Model: model,
181- Provider: providerID,236+ Provider: providerID,
182- Reasoning: reasoning,237+ Reasoning: reasoning,
183- FastMode: fastModeOverride,238+ FastMode: fastModeOverride,
184- Subagent: agentName,239+ Subagent: agentName,
240+ Timeout: timeout,
241+ MaxTurns: a.MaxTurns,
242+ WorkspaceMode: workspaceMode,
243+ Tools: profileTools,
185 })244 })
186 if err != nil {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 if t.OnSpawned != nil {248 if t.OnSpawned != nil {
190 t.OnSpawned(agent, task)249 t.OnSpawned(agent, task)
@@ -192,6 +251,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
192 251
193 var sb strings.Builder252 var sb strings.Builder
194 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)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 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))256 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
196 if agentName != "" {257 if agentName != "" {
197 fmt.Fprintf(&sb, "agent: %s\n", agentName)258 fmt.Fprintf(&sb, "agent: %s\n", agentName)
@@ -208,18 +269,25 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
208 if agent.FastMode {269 if agent.FastMode {
209 sb.WriteString("fast mode: enabled\n")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 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")273 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
213 return core.ToolResult{274 return core.ToolResult{
214 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},275 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
215 Details: map[string]any{276 Details: map[string]any{
216- "agent_id": agent.ID,277+ "agent_id": agent.ID,
217- "task": task,278+ "task": task,
218- "agent": agentName,279+ "agent": agentName,
219- "model": model,280+ "model": model,
220- "provider": providerID,281+ "provider": providerID,
221- "reasoning": reasoning,282+ "reasoning": reasoning,
222- "fast_mode": agent.FastMode,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 }, nil292 }, 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 func newTestSwarm(t *testing.T) *swarm.Swarm {19 func newTestSwarm(t *testing.T) *swarm.Swarm {
20 t.Helper()20 t.Helper()
21 root := t.TempDir()21 root := t.TempDir()
22- return swarm.New(swarm.Config{22+ manager := swarm.New(swarm.Config{
23 Root: filepath.Join(root, "swarm"),23 Root: filepath.Join(root, "swarm"),
24 RepoRoot: root,24 RepoRoot: root,
25 NewRunner: func(*swarm.Agent) swarm.Runner {25 NewRunner: func(*swarm.Agent) swarm.Runner {
26 return noopSwarmRunner{}26 return noopSwarmRunner{}
27 },27 },
28 })28 })
29+ t.Cleanup(manager.StopAll)
30+ return manager
29 }31 }
30 32
31 func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {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 func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {78 func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
61 lines := StatusBar(StatusBarParams{79 lines := StatusBar(StatusBarParams{
62 Theme: Dark,80 Theme: Dark,
M packages/tui/view.go +34 -10
@@ -2408,6 +2408,7 @@ type StatusBarParams struct {
2408 Provider string2408 Provider string
2409 Model string2409 Model string
2410 Reasoning string // "" means thinking off2410 Reasoning string // "" means thinking off
2411+ FastMode bool // show the provider's opt-in fast tier when enabled
2411 Busy bool2412 Busy bool
2412 BusyPrefix string // spinner + funny line when busy2413 BusyPrefix string // spinner + funny line when busy
2413 CWD string2414 CWD string
@@ -2509,8 +2510,15 @@ func StatusBar(p StatusBarParams) []string {
2509 if reasoning != "" {2510 if reasoning != "" {
2510 reasoningText = "reasoning: " + reasoning2511 reasoningText = "reasoning: " + reasoning
2511 }2512 }
2513+ fastText := ""
2514+ if p.FastMode {
2515+ fastText = "fast mode"
2516+ }
2512 statsText := strings.Join(stats, " ")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 if reasoningText != "" {2522 if reasoningText != "" {
2515 middleParts = append(middleParts, reasoningText)2523 middleParts = append(middleParts, reasoningText)
2516 }2524 }
@@ -2570,7 +2578,7 @@ func StatusBar(p StatusBarParams) []string {
2570 modelLine := pad + th.FG256(th.Muted, left)2578 modelLine := pad + th.FG256(th.Muted, left)
2571 lines := []string{busyLine}2579 lines := []string{busyLine}
2572 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {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 } else {2582 } else {
2575 var infoBuilder strings.Builder2583 var infoBuilder strings.Builder
2576 infoBuilder.WriteString(modelLine)2584 infoBuilder.WriteString(modelLine)
@@ -2592,7 +2600,7 @@ func StatusBar(p StatusBarParams) []string {
2592 // into an awkward position on small widths.2600 // into an awkward position on small widths.
2593 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {2601 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
2594 var lines []string2602 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 if cwd != "" {2604 if cwd != "" {
2597 lines = append(lines, pad+th.FG256(th.Muted, cwd))2605 lines = append(lines, pad+th.FG256(th.Muted, cwd))
2598 }2606 }
@@ -2609,9 +2617,17 @@ func StatusBar(p StatusBarParams) []string {
2609 return []string{primary, cwdRendered}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 modelLine := pad + th.FG256(th.Muted, modelText)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 lines = append(lines, modelLine)2631 lines = append(lines, modelLine)
2616 if statsText != "" {2632 if statsText != "" {
2617 lines = append(lines, pad+th.FG256(th.Muted, statsText))2633 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2619,13 +2635,21 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasonin
2619 return lines2635 return lines
2620 }2636 }
2621 2637
2622- modelReasoningPlain := pad + modelText + pad + reasoningText2638+ modelInfoPlain := pad + modelText + pad + infoText
2623- reasoningColor := reasoningStatusColor(th, reasoningText)2639+ infoColor := th.Muted
2624- if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols {2640+ if reasoningText != "" {
2625- lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+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 } else {2645 } else {
2627 lines = append(lines, modelLine)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 if statsText != "" {2654 if statsText != "" {
2631 lines = append(lines, pad+th.FG256(th.Muted, statsText))2655 lines = append(lines, pad+th.FG256(th.Muted, statsText))

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close