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
290290 | `/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. |
291291 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
292292 | `/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). |
294294 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
295295 | `/compact` | Summarize the transcript into one message to free up context. |
296296 | `/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
346346
347347 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.
348348
349-### `/swarm`
349+### `/subagents`
350350
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.
352352
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.
354354
355355 ```
356-/swarm # open the dashboard
357-/swarm new <task> # spawn an agent
358-/swarm new --agent reviewer <task> # use a named markdown profile
359-/swarm new --model gpt-5 <task> # pin the new agent to a specific model
360-/swarm new --reasoning high <task> # set the child reasoning level
361-/swarm logs <id> # jump straight into one agent's transcript
362-/swarm send <id> <text> # send a follow-up without opening the dashboard
363-/swarm resume # pick a stopped agent to bring back
364-/swarm resume <id> # bring a specific agent back
365-/swarm kill <id> # stop a running agent (its state stays)
366-/swarm remove <id> # delete the agent's session and state
367-/swarm list # alias for opening the dashboard
356+/subagents # open the dashboard
357+/subagents new <task> # spawn an agent (shared mode)
358+/subagents new --agent reviewer <task> # use a named markdown profile
359+/subagents new --reasoning high <task> # set child reasoning
360+/subagents logs <id> # open one agent's transcript
361+/subagents send <id> <text> # send a follow-up turn
362+/subagents result <id> # show the structured result reference
363+/subagents resume-session <id> # continue without replaying the task
364+/subagents restart-task <id> # intentionally run the stored task again
365+/subagents kill <id> # cancel gracefully, then force-stop
366+/subagents remove <id> # delete durable state
367+# /swarm remains an alias for /subagents
368368 ```
369369
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:
371371
372372 | Key | Action |
373373 |---|---|
@@ -386,17 +386,17 @@ Background subagents that run alongside your main session. Each one is a separat
386386
387387 **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.
388388
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.
390390
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.
392392
393393 **`/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.
394394
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`.
396396
397397 ### Named subagent profiles
398398
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.
400400
401401 ### `/settings`
402402
@@ -405,7 +405,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405405 - **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.
406406 - **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.
407407 - **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.
409409 - **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.
410410 - **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`.
411411 - **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:
3939
4040 | Field | Meaning |
4141 |---|---|
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. |
4343 | `description` | Short description shown to the main agent. |
4444 | `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. |
4545 | `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:
5050 | `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. |
5151 | `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. |
5252
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.
5454
5555 ## Selecting a profile
5656
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`:
5858
5959 ```json
6060 {
@@ -80,8 +80,29 @@ A per-spawn reasoning override is also accepted:
8080 The interactive command also supports the same selection explicitly:
8181
8282 ```text
83-/swarm new --agent reviewer Review the authentication package
84-/swarm new --agent implementer --reasoning high Implement the parser change
83+/subagents new --agent reviewer Review the authentication package
84+/subagents new --agent implementer --reasoning high Implement the parser change
8585 ```
8686
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 (
2222 ModeRPC Mode = "rpc"
2323 // ModeSwarmAgent is the long-lived, headless daemon mode used by
2424 // swarm-spawned agents. The binary opens a unix-socket inbox at
25- // the path provided by --swarm-agent, reads supervisor messages
26- // off it ("user ...", "cancel", "shutdown"), runs each user turn
27- // against a persistent session, and streams JSONL events on
28- // stdout. See packages/agent/swarm/inbox.go for the wire protocol.
29- ModeSwarmAgent Mode = "swarm-agent"
25+ // the path provided by --subagent-worker, reads supervisor messages
26+ // off it (versioned JSONL, with a legacy text reader), runs each user
27+ // turn against a persistent session, and streams JSONL events on
28+ // stdout. --swarm-agent remains an input compatibility alias.
29+ ModeSubagentWorker Mode = "subagent-worker"
30+ ModeSwarmAgent Mode = ModeSubagentWorker
3031 )
3132
3233 // Args holds parsed command-line options.
@@ -124,10 +125,13 @@ type Args struct {
124125 StartupPre string
125126
126127 // SwarmAgent is the inbox-socket path when this process is a
127- // swarm-spawned agent. Empty in every other mode. Set by
128- // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
128+ // subagent worker. Empty in every other mode. Set by either
129+ // --subagent-worker or the --swarm-agent compatibility alias.
129130 SwarmAgent string
130131
132+ // SubagentMaxTurns limits prompt-level turns in worker mode.
133+ SubagentMaxTurns int
134+
131135 // Subagent selects a named markdown profile for a swarm child.
132136 // It is intentionally an internal child flag; the parent swarm tool
133137 // passes only the profile name and the child discovers the definition
@@ -158,6 +162,12 @@ func ParseArgs(in []string) (Args, error) {
158162 for i := 0; i < len(in); i++ {
159163 arg := in[i]
160164 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)
161171 case "-h", "--help":
162172 a.Help = true
163173 case "-v", "--version":
@@ -289,13 +299,13 @@ func ParseArgs(in []string) (Args, error) {
289299 return a, err
290300 }
291301 a.CWD = v
292- case "--swarm-agent":
302+ case "--subagent-worker", "--swarm-agent":
293303 v, err := want(&i, arg)
294304 if err != nil {
295305 return a, err
296306 }
297307 a.SwarmAgent = v
298- a.Mode = ModeSwarmAgent
308+ a.Mode = ModeSubagentWorker
299309 case "--subagent":
300310 v, err := want(&i, arg)
301311 if err != nil {
@@ -323,6 +333,16 @@ func ParseArgs(in []string) (Args, error) {
323333 return a, fmt.Errorf("--max-steps must be a positive integer")
324334 }
325335 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
326346 default:
327347 if strings.HasPrefix(arg, "-") && arg != "-" {
328348 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) {
2222 }
2323 }
2424
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+
2535 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
2636 args, err := ParseArgs([]string{"--temperature", "0"})
2737 if err != nil {
M packages/agent/build.go +16 -1
@@ -737,7 +737,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
737737 append_ = append(append_, skillAddendum)
738738 }
739739 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 {
741741 homeDir, _ := os.UserHomeDir()
742742 profiles, _ := subagents.Discover(args.CWD, homeDir)
743743 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
@@ -1188,6 +1188,21 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
11881188 return false
11891189 }
11901190
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+
11911206 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
11921207 order := []string{"read", "write", "edit", "bash", "lsp"}
11931208 var out []ToolSummary
M packages/agent/cli.go +101 -14
@@ -1183,7 +1183,37 @@ func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *exte
11831183
11841184 // ---- interactive mode: opens the TUI even without credentials ----
11851185
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+
11861215 func runInteractive(ctx context.Context, args Args, version string) error {
1216+ initialCfg, _ := LoadConfig()
11871217 // Resolve WITHOUT requiring credentials.
11881218 r, err := Resolve(args, false)
11891219 if err != nil {
@@ -1256,18 +1286,40 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12561286 // in this outer scope rather than scoping it tighter.
12571287 var swarmMgr *swarm.Swarm
12581288 swarmMgr = swarm.New(swarm.Config{
1259- Root: filepath.Join(ZotHome(), "swarm"),
1260- RepoRoot: r.CWD,
1261- FastMode: r.FastMode,
1289+ Context: ctx,
1290+ Root: filepath.Join(ZotHome(), "swarm"),
1291+ RepoRoot: r.CWD,
1292+ Provider: r.Provider,
1293+ FastMode: r.FastMode,
1294+ BaseURL: r.BaseURL,
1295+ InsecureTLS: r.InsecureTLS,
1296+ Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1297+ // Keep an explicit launch key in this callback only. Swarm persists
1298+ // connection settings, but never stores credentials in Agent metadata
1299+ // or puts them in the worker's argv/environment.
12621300 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 == "" {
12641316 return swarm.Credential{Value: "ollama", Method: "apikey"}, nil
12651317 }
1266- credential, method, accountID, err := ResolveCredentialFullContext(ctx, providerID, "")
1318+ credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
12671319 if err != nil {
12681320 // Providers backed only by a local endpoint do not need a key.
12691321 // Leave stdin untouched and let the child resolve that endpoint.
1270- if !CredentialAvailable(providerID) {
1322+ if !CredentialAvailable(childProvider) {
12711323 return swarm.Credential{}, nil
12721324 }
12731325 return swarm.Credential{}, err
@@ -1278,6 +1330,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12781330 // Pull any previously-spawned agents off disk so the dashboard
12791331 // shows them as detached and the user can resume / remove them.
12801332 _, _ = 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+ }
12811340
12821341 // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
12831342 // 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 {
13001359 // toggle live-mutates the running agent's registry separately so
13011360 // flipping the flag mid-session takes effect on the next turn.
13021361 injectSwarmSpawn := func(reg core.Registry) core.Registry {
1303- if reg == nil {
1362+ if reg == nil || swarmMgr == nil || !autoSwarmToolAllowed(args) {
13041363 return reg
13051364 }
13061365 if !AutoSwarmEnabled() {
13071366 return reg
13081367 }
1309- reg["swarm_spawn"] = &tools.SwarmSpawnTool{
1310- Swarm: swarmMgr,
1311- Enabled: AutoSwarmEnabled,
1368+ canonical := &tools.SwarmSpawnTool{
1369+ ToolName: "subagent_spawn",
1370+ Swarm: swarmMgr,
1371+ Enabled: AutoSwarmEnabled,
13121372 DefaultModel: func() string {
13131373 persistMu.Lock()
13141374 defer persistMu.Unlock()
@@ -1323,6 +1383,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13231383 ResolveSubagent: resolveSubagent,
13241384 OnSpawned: onSpawnedSwarm,
13251385 }
1386+ reg[canonical.Name()] = canonical
1387+ legacy := *canonical
1388+ legacy.ToolName = "swarm_spawn"
1389+ reg[legacy.Name()] = &legacy
13261390 return reg
13271391 }
13281392 injectSwarmSpawn(r.ToolRegistry)
@@ -1407,6 +1471,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14071471 return nil, "", "", err
14081472 }
14091473 resolved.UseSandbox(sharedSandbox)
1474+ if swarmMgr != nil {
1475+ swarmMgr.SetProvider(resolved.Provider)
1476+ swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1477+ }
14101478 resolved.MergeExtensionTools(extToolAdapter)
14111479 injectSwarmSpawn(resolved.ToolRegistry)
14121480 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1426,6 +1494,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14261494 return nil, "", "", err
14271495 }
14281496 resolved.UseSandbox(sharedSandbox)
1497+ if swarmMgr != nil {
1498+ swarmMgr.SetProvider(resolved.Provider)
1499+ swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1500+ }
14291501 resolved.MergeExtensionTools(extToolAdapter)
14301502 injectSwarmSpawn(resolved.ToolRegistry)
14311503 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1453,6 +1525,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14531525 return nil, "", "", err
14541526 }
14551527 resolved.UseSandbox(sharedSandbox)
1528+ if swarmMgr != nil {
1529+ swarmMgr.SetProvider(resolved.Provider)
1530+ swarmMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1531+ }
14561532 resolved.MergeExtensionTools(extToolAdapter)
14571533 injectSwarmSpawn(resolved.ToolRegistry)
14581534 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1943,7 +2019,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19432019 }()
19442020 }
19452021
1946- initialCfg, _ := LoadConfig()
19472022 fastMode := r.FastMode
19482023 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
19492024 for idx, s := range initialCfg.QuickModelShortcuts {
@@ -1964,12 +2039,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19642039 // shows agents this session spawned (and any pre-upgrade unscoped
19652040 // agents — see SnapshotAll docs). Updated again whenever the
19662041 // user swaps sessions via loadSession below.
1967- if sess != nil {
2042+ if sess != nil && swarmMgr != nil {
19682043 swarmMgr.SetActiveSession(sess.ID)
19692044 }
19702045 // Best-effort shutdown on interactive exit: stop all running
19712046 // agents so they don't outlive their parent zot.
1972- defer swarmMgr.StopAll()
2047+ if swarmMgr != nil {
2048+ defer swarmMgr.StopAll()
2049+ }
19732050
19742051 var startupSkills []*skills.Skill
19752052 if r.SkillTool != nil {
@@ -1978,6 +2055,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19782055 homeDir, _ := os.UserHomeDir()
19792056 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
19802057 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2058+ autoSwarmToolAllowedForSession := autoSwarmToolAllowed(args)
19812059
19822060 iv = modes.NewInteractive(modes.InteractiveConfig{
19832061 Terminal: term,
@@ -1986,6 +2064,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19862064 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
19872065 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
19882066 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
2067+ AutoSwarmToolAllowed: &autoSwarmToolAllowedForSession,
19892068 FastMode: &fastMode,
19902069 LSPEnabled: initialCfg.LSPEnabled,
19912070 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
@@ -2238,9 +2317,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22382317 }
22392318 closeAgentLSP(finalAg)
22402319 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+ }
22412327 // Exit cleanly. Re-raising the signal would skip os.Exit's
22422328 // 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.
22442331 os.Exit(0)
22452332 }()
22462333
M packages/agent/config.go +22 -0
@@ -22,6 +22,25 @@ type QuickModelShortcut struct {
2222 Model string `json:"model"`
2323 }
2424
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+
2544 // Config is the persisted user configuration.
2645 type Config struct {
2746 Provider string `json:"provider"`
@@ -68,6 +87,9 @@ type Config struct {
6887 // both so no extra provider request is made. Toggle from /settings.
6988 TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`
7089
90+ // Subagents controls manager-owned child lifecycle and resource policy.
91+ Subagents SubagentsConfig `json:"subagents,omitempty"`
92+
7193 // AutoSwarmEnabled lets the main agent spawn background sub-agents
7294 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
7395 // default; nil/missing means disabled. Toggle from /settings.
M packages/agent/modes/interactive.go +16 -5
@@ -73,6 +73,10 @@ type InteractiveConfig struct {
7373 // re-reading config.json on every open.
7474 AutoSwarmEnabled *bool
7575
76+ // AutoSwarmToolAllowed is nil for normal sessions and false when the
77+ // launch-time --no-tools/--tools policy excludes delegation.
78+ AutoSwarmToolAllowed *bool
79+
7680 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
7781 // nil/missing means disabled. Unsupported providers reject attempts
7882 // to enable it and reject requests when it remains enabled.
@@ -1692,6 +1696,7 @@ func (i *Interactive) redraw() {
16921696 Provider: i.cfg.Provider,
16931697 Model: i.cfg.Model,
16941698 Reasoning: i.cfg.Reasoning,
1699+ FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
16951700 Busy: i.busy,
16961701 BusyPrefix: statusBusyPrefix,
16971702 CWD: i.cfg.CWD,
@@ -3979,7 +3984,7 @@ func (i *Interactive) openSettingsDialog() {
39793984 {
39803985 key: "auto_swarm_enabled",
39813986 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",
39833988 value: autoSwarm,
39843989 disabled: autoSwarmDisabled,
39853990 hint: autoSwarmHint,
@@ -5210,7 +5215,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
52105215 break
52115216 }
52125217 i.openSessionOpsDialog()
5213- case "/swarm":
5218+ case "/subagents", "/swarm":
52145219 i.runSwarm(ctx, parts[1:])
52155220 default:
52165221 // Last-resort fallback: try the extension manager. Built-in
@@ -7419,13 +7424,15 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
74197424 current := i.agent.Tools
74207425 next := core.Registry{}
74217426 for name, t := range current {
7422- if name == "swarm_spawn" {
7427+ if name == "swarm_spawn" || name == "subagent_spawn" {
74237428 continue
74247429 }
74257430 next[name] = t
74267431 }
7427- if active && i.cfg.Swarm != nil {
7428- next["swarm_spawn"] = &tools.SwarmSpawnTool{
7432+ toolAllowed := i.cfg.AutoSwarmToolAllowed == nil || *i.cfg.AutoSwarmToolAllowed
7433+ if active && toolAllowed && i.cfg.Swarm != nil {
7434+ canonical := &tools.SwarmSpawnTool{
7435+ ToolName: "subagent_spawn",
74297436 Swarm: i.cfg.Swarm,
74307437 Enabled: func() bool { return true },
74317438 DefaultModel: func() string { return i.cfg.Model },
@@ -7434,6 +7441,10 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
74347441 ResolveSubagent: i.cfg.ResolveSubagent,
74357442 OnSpawned: i.trackSwarmAgent,
74367443 }
7444+ next[canonical.Name()] = canonical
7445+ legacy := *canonical
7446+ legacy.ToolName = "swarm_spawn"
7447+ next[legacy.Name()] = &legacy
74377448 }
74387449 i.agent.SetTools(next)
74397450 }
M packages/agent/modes/slash_suggest.go +2 -1
@@ -62,7 +62,8 @@ var slashCatalog = []slashCommand{
6262 {Name: "/jail", Desc: "confine tools to the current directory"},
6363 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
6464 {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"},
6667 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
6768 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
6869 {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 {
10061006 return d.renderTranscript(th, width)
10071007 }
10081008
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)}
10101010 if d.prompting {
10111011 return d.renderPromptEditor(th, width, out)
10121012 }
@@ -1067,7 +1067,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
10671067 }
10681068
10691069 // 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")
10711071 out = append(out, th.FG256(th.Muted, header))
10721072
10731073 for i, r := range d.rows {
@@ -1384,8 +1384,11 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
13841384 // ✓ done write-tests-67890 1h done
13851385 func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
13861386 status := statusLabel(r.Status)
1387+ if r.ProcessState != "" && r.TurnState != "" {
1388+ status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18)
1389+ }
13871390 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)
13891392 room := maxWidth - len([]rune(left))
13901393 if room < 10 {
13911394 room = 10
M packages/agent/modes/swarm_slash.go +84 -17
@@ -62,7 +62,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
6262 return i.cfg.Swarm.SendUserTurn(id, text)
6363 }
6464 resumeAdapter := func(id string) error {
65- _, err := i.cfg.Swarm.Resume(ctx, id)
65+ _, err := i.cfg.Swarm.ResumeSession(ctx, id)
6666 return err
6767 }
6868
@@ -99,6 +99,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
9999 // Anything that isn't a recognised flag terminates parsing and
100100 // the rest becomes the task; only leading flags are consumed.
101101 model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
102+ var profileTools []string
102103 var fastModeOverride *bool
103104 if task == "" {
104105 i.swarmStatus("", "/swarm new: missing task (after any spawn flags)")
@@ -119,17 +120,25 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
119120 return
120121 }
121122 subagent = profile.Name
123+ profileTools = append([]string(nil), profile.Tools...)
122124 fastModeOverride = profile.FastMode
123- if model == "" && provider == "" {
124- profileProvider, profileModel := profile.ModelSelection()
125- if profileProvider != "" && profileModel != "" {
126- provider, model = profileProvider, profileModel
127- }
125+ profileProvider, profileModel := profile.ModelSelection()
126+ if model == "" {
127+ model = profileModel
128+ }
129+ if provider == "" {
130+ provider = profileProvider
128131 }
129132 if reasoning == "" {
130133 reasoning = profile.Thinking
131134 }
132135 }
136+ if model == "" {
137+ model = i.cfg.Model
138+ }
139+ if provider == "" {
140+ provider = i.cfg.Provider
141+ }
133142 if reasoning == "" {
134143 reasoning = i.cfg.Reasoning
135144 }
@@ -144,6 +153,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
144153 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
145154 Task: task, Model: model, Provider: provider, Reasoning: reasoning,
146155 FastMode: fastModeOverride, Subagent: subagent,
156+ Tools: profileTools,
147157 })
148158 if err != nil {
149159 i.swarmStatus("", "spawn: "+err.Error())
@@ -158,7 +168,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
158168 i.swarmStatus(status, "")
159169 case "kill", "stop":
160170 if rest == "" {
161- i.swarmStatus("", "/swarm kill <id>: missing id")
171+ i.swarmStatus("", "/subagents kill <id>: missing id")
162172 return
163173 }
164174 if err := i.cfg.Swarm.Stop(rest); err != nil {
@@ -166,6 +176,16 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
166176 return
167177 }
168178 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, "")
169189 case "remove", "rm":
170190 if rest == "" {
171191 i.swarmStatus("", "/swarm remove <id>: missing id")
@@ -194,13 +214,38 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
194214 if !ok {
195215 i.swarmStatus("", "/swarm logs: no agent matching "+rest)
196216 }
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":
198248 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.
204249 count := i.swarmDialog.OpenForResume(
205250 i.cfg.Swarm.SnapshotAll,
206251 i.cfg.Swarm.Stop,
@@ -212,7 +257,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
212257 )
213258 switch count {
214259 case 0:
215- i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)")
260+ i.swarmStatus("", "/subagents resume-session: no resumable agents")
216261 case 1:
217262 i.swarmStatus("1 resumable agent, press R to resume", "")
218263 default:
@@ -220,12 +265,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
220265 }
221266 return
222267 }
223- a, err := i.cfg.Swarm.Resume(ctx, rest)
268+ a, err := i.cfg.Swarm.ResumeSession(ctx, rest)
224269 if err != nil {
225- i.swarmStatus("", "resume: "+err.Error())
270+ i.swarmStatus("", "resume-session: "+err.Error())
226271 return
227272 }
228273 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, "")
229285 case "send", "prompt", "msg":
230286 // /swarm send <id> <text...> is the non-interactive
231287 // counterpart of pressing 'p' in the dashboard. We split the
@@ -255,7 +311,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
255311 // feature. Point the user at /swarm logs in the meantime.
256312 i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")
257313 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)")
259315 }
260316 }
261317
@@ -375,6 +431,17 @@ func firstWord(s string) string {
375431 return s
376432 }
377433
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+
378445 func (i *Interactive) swarmStatus(ok, errMsg string) {
379446 i.mu.Lock()
380447 i.statusOK = ok
M packages/agent/settings_store.go +5 -5
@@ -213,7 +213,7 @@ func (configSettingsStore) SetTheme(name string) error {
213213 }
214214
215215 // 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.
217217 func AutoSwarmEnabled() bool {
218218 cfg, err := LoadConfig()
219219 if err != nil {
@@ -226,10 +226,10 @@ func AutoSwarmEnabled() bool {
226226 // auto-swarm is enabled, so the model knows it may delegate to
227227 // background sub-agents without the user having to mention the tool
228228 // 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.
230230
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.
232232
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.
234234
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
33 import (
44 "context"
55 "fmt"
6+ "path/filepath"
67 "strings"
78 "sync"
89 "time"
@@ -11,22 +12,50 @@ import (
1112 // Agent is one supervised task. Public fields are immutable after
1213 // Spawn; mutable state (status, activity, transcript) lives behind
1314 // the embedded mutex.
15+type turnEndNotice struct {
16+ step int
17+ errMsg string
18+}
19+
1420 type Agent struct {
1521 ID string
1622 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.
1824 Started time.Time
1925
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+
2045 // Model and Provider, when non-empty, override the child
21- // subprocess's model resolution. Empty means "inherit whatever
22- // the child resolves on its own from config / env / flags" —
23- // the historical behaviour. Persisted in meta.json so Resume
24- // keeps using the same model across zot restarts.
25- Model string
26- Provider string
27- Reasoning string
28- FastMode bool
29- Subagent string
46+ // subprocess's model resolution. BaseURL and InsecureTLS carry
47+ // the effective provider connection settings from the supervisor.
48+ // Empty values inherit whatever the child resolves on its own from
49+ // config / env / flags. All four settings are persisted in meta.json
50+ // so Resume keeps using the same provider configuration across zot
51+ // restarts.
52+ Model string
53+ Provider string
54+ BaseURL string
55+ InsecureTLS bool
56+ Reasoning string
57+ FastMode bool
58+ Subagent string
3059
3160 // SessionID, when non-empty, scopes the agent to a particular
3261 // host zot session: the dashboard only surfaces agents whose
@@ -52,6 +81,16 @@ type Agent struct {
5281 // an Inbox at this path; the child opens a Listener.
5382 InboxPath string
5483
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+
5594 // EventLogPath is the durable JSONL event log for this agent.
5695 // The runner appends every well-formed event from the child
5796 // (plus lifecycle events of its own) here. /swarm open in any
@@ -75,6 +114,27 @@ type Agent struct {
75114 finished time.Time
76115 lastErr error
77116
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+
78138 // OnTurnEnd, if set, fires once per prompt-level turn_end event
79139 // emitted by the swarm daemon wrapper. Provider/tool-loop
80140 // turn_end events (for example stop=tool_use) are ignored by the
@@ -84,16 +144,25 @@ type Agent struct {
84144 // daemon itself to exit — sub-agents keep running on the inbox
85145 // even after the initial task completes, so Wait() never unblocks
86146 // for them.
87- OnTurnEnd func(step int, errMsg string)
147+ OnTurnEnd func(step int, errMsg string)
148+ pendingTurnEnds []turnEndNotice
88149
89150 ctx context.Context
90151 cancel context.CancelFunc
91152 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
92159
93160 // done closes when the run goroutine finalises the agent's
94161 // status (done / failed / killed). Wait blocks on this so
95162 // callers don't have to poll.
96- done chan struct{}
163+ done chan struct{}
164+ turnResults chan *TurnResult
165+ doneOnce sync.Once
97166 }
98167
99168 // Inbox exposes the supervisor-side socket handle. Returns nil for
@@ -115,6 +184,124 @@ func (a *Agent) Activity() string {
115184 return a.activity
116185 }
117186
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+
118305 // Transcript returns a copy of the running transcript.
119306 func (a *Agent) Transcript() []string {
120307 a.mu.Lock()
@@ -135,6 +322,35 @@ func (a *Agent) Err() error {
135322 // and by /swarm wait <id>.
136323 func (a *Agent) Wait() { <-a.done }
137324
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+
138354 // SetOnTurnEnd installs (or clears, with nil) the callback fired
139355 // from the runner when the child daemon emits a prompt-level
140356 // turn_end event with a step field. Safe to call from any goroutine:
@@ -142,19 +358,30 @@ func (a *Agent) Wait() { <-a.done }
142358 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
143359 a.mu.Lock()
144360 a.OnTurnEnd = fn
361+ pending := append([]turnEndNotice(nil), a.pendingTurnEnds...)
362+ a.pendingTurnEnds = nil
145363 a.mu.Unlock()
364+ if fn != nil {
365+ for _, notice := range pending {
366+ go fn(notice.step, notice.errMsg)
367+ }
368+ }
146369 }
147370
148371 func (a *Agent) setStatus(s Status) {
149372 a.mu.Lock()
150373 a.status = s
151374 a.mu.Unlock()
375+ if s == StatusRunning {
376+ a.setProcessState(ProcessAlive)
377+ }
152378 }
153379
154380 func (a *Agent) setActivity(msg string) {
155381 a.mu.Lock()
156382 a.activity = strings.TrimSpace(msg)
157383 a.mu.Unlock()
384+ a.markActivity(time.Now())
158385 }
159386
160387 func (a *Agent) appendTranscript(chunk string) {
@@ -180,22 +407,68 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
180407 a.transcript = append(a.transcript, linePrefix+line)
181408 }
182409 if assistant {
183- a.lastAssistant = message
410+ a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
411+ }
412+ // Bound the inline dashboard copy. The durable event log and session
413+ // remain available through the logical history reference.
414+ lineCap := a.maxOutputLines
415+ if lineCap <= 0 {
416+ lineCap = 2_000
417+ }
418+ byteCap := a.maxOutputBytes
419+ if byteCap <= 0 {
420+ byteCap = 500_000
184421 }
185- // Bound the transcript so dashboards don't hold gigabytes for
186- // long-running agents. 2000 lines is plenty for inspection;
187- // the durable record lives in the agent's session file.
188- const cap = 2000
189- if len(a.transcript) > cap {
190- a.transcript = a.transcript[len(a.transcript)-cap:]
422+ for len(a.transcript) > lineCap {
423+ a.transcript = a.transcript[1:]
424+ a.outputTruncated = true
425+ }
426+ for len(a.transcript) > 0 {
427+ bytes := 0
428+ for _, line := range a.transcript {
429+ bytes += len(line) + 1
430+ }
431+ if bytes <= byteCap {
432+ a.outputBytes = bytes
433+ a.outputLines = len(a.transcript)
434+ break
435+ }
436+ a.transcript = a.transcript[1:]
437+ a.outputTruncated = true
438+ }
439+ if len(a.transcript) == 0 {
440+ a.outputBytes = 0
441+ a.outputLines = 0
191442 }
192443 a.mu.Unlock()
444+ a.markActivity(time.Now())
193445 }
194446
195447 // newAgentID returns a short, mostly-collision-free identifier of the
196448 // form "<slug>-<nano>". The slug is derived from the task text so
197449 // dashboards stay readable; the nano suffix guarantees uniqueness
198450 // 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+
199472 func newAgentID(task string, now time.Time) string {
200473 slug := taskSlug(task)
201474 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 (
2828 // stat the file size and read from their last offset on every
2929 // poll.
3030 type Event struct {
31- Time time.Time `json:"time"`
32- Type string `json:"type"`
33- Data map[string]any `json:"-"`
34- Raw map[string]any `json:"-"` // includes type+time+data for replay
31+ Time time.Time `json:"time"`
32+ Type string `json:"type"`
33+ Version int `json:"version,omitempty"`
34+ MessageID string `json:"message_id,omitempty"`
35+ AgentID string `json:"agent_id,omitempty"`
36+ TurnID string `json:"turn_id,omitempty"`
37+ Data map[string]any `json:"-"`
38+ Raw map[string]any `json:"-"` // includes type+time+data for replay
3539 }
3640
3741 // MarshalJSON flattens Data into the top-level object so consumers
3842 // see {type, time, ...fields} rather than {type, time, data:{...}}.
3943 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)
4145 for k, v := range e.Data {
4246 out[k] = v
4347 }
4448 out["type"] = e.Type
4549 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+ }
4666 return json.Marshal(out)
4767 }
4868
@@ -56,17 +76,45 @@ func (e *Event) UnmarshalJSON(b []byte) error {
5676 e.Raw = m
5777 e.Data = map[string]any{}
5878 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" {
6080 continue
6181 }
6282 e.Data[k] = v
6383 }
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+ }
64108 if t, ok := m["type"].(string); ok {
65109 e.Type = t
66110 } else {
67111 return errors.New("swarm event: missing type")
68112 }
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 != "" {
70118 parsed, err := time.Parse(time.RFC3339Nano, ts)
71119 if err == nil {
72120 e.Time = parsed
@@ -75,6 +123,14 @@ func (e *Event) UnmarshalJSON(b []byte) error {
75123 return nil
76124 }
77125
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+
78134 // EventLog is an append-only writer for events.jsonl. Safe for
79135 // concurrent writers (the child only has one writer, but tests
80136 // occasionally fan out).
@@ -87,13 +143,14 @@ type EventLog struct {
87143 // OpenEventLog opens (or creates) the events.jsonl file at path.
88144 // Parent directories are created as needed.
89145 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 {
91147 return nil, fmt.Errorf("event log dir: %w", err)
92148 }
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)
94150 if err != nil {
95151 return nil, fmt.Errorf("event log open: %w", err)
96152 }
153+ _ = f.Chmod(0o600)
97154 return &EventLog{path: path, f: f}, nil
98155 }
99156
@@ -107,6 +164,9 @@ func (l *EventLog) Path() string { return l.path }
107164 func (l *EventLog) Append(ev Event) error {
108165 l.mu.Lock()
109166 defer l.mu.Unlock()
167+ if l.f == nil {
168+ return errors.New("event log: closed")
169+ }
110170 if ev.Time.IsZero() {
111171 ev.Time = time.Now()
112172 }
@@ -114,8 +174,14 @@ func (l *EventLog) Append(ev Event) error {
114174 if err != nil {
115175 return err
116176 }
177+ if len(b) > maxEventLogLineBytes {
178+ return fmt.Errorf("event log: event exceeds %d bytes", maxEventLogLineBytes)
179+ }
117180 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+ }
119185 return err
120186 }
121187
@@ -156,19 +222,24 @@ func ReadEventLog(path string) ([]Event, error) {
156222
157223 func readEvents(r io.Reader) ([]Event, error) {
158224 br := bufio.NewReader(r)
159- var out []Event
225+ out := make([]Event, 0, 256)
160226 for {
161- line, err := br.ReadBytes('\n')
162- if len(line) > 0 {
227+ line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)
228+ if len(line) > 0 && !truncated {
163229 var ev Event
164230 if jerr := json.Unmarshal(line, &ev); jerr == nil {
165231 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+ }
167238 }
168239 }
169- // Malformed lines are skipped silently; the dashboard
170- // renders only well-formed events and the child is the
171- // only writer.
240+ // Malformed and oversized lines are skipped silently; the
241+ // dashboard renders only well-formed events and the child is
242+ // the only writer.
172243 }
173244 if err != nil {
174245 if errors.Is(err, io.EOF) {
@@ -179,6 +250,38 @@ func readEvents(r io.Reader) ([]Event, error) {
179250 }
180251 }
181252
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+
182285 // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
183286 // of the most recent event in `tail` — same type and identical Data
184287 // payload, with timestamps within a small window (the supervisor and
@@ -296,8 +399,7 @@ func (f *EventFollower) loop() {
296399 _ = fh.Close()
297400 continue
298401 }
299- evs, _ := readEvents(fh)
300- newOffset, _ := fh.Seek(0, io.SeekCurrent)
402+ evs, newOffset := readFollowerEvents(fh, offset)
301403 _ = fh.Close()
302404 offset = newOffset
303405 for _, ev := range evs {
M packages/agent/swarm/event_test.go +75 -0
@@ -1,7 +1,12 @@
11 package swarm
22
33 import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
48 "path/filepath"
9+ "strings"
510 "testing"
611 "time"
712 )
@@ -48,6 +53,44 @@ func TestEventLogAppendAndRead(t *testing.T) {
4853 // only write well-formed events, but a partial crash could leave
4954 // a half-written line; we don't want that to prevent the dashboard
5055 // 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+
5194 func TestReadEventLogIgnoresGarbage(t *testing.T) {
5295 path := filepath.Join(t.TempDir(), "events.jsonl")
5396 log, err := OpenEventLog(path)
@@ -202,6 +245,38 @@ func TestFollowerEmitsNewEvents(t *testing.T) {
202245 }
203246 }
204247
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+
205280 func drain(ch <-chan Event) {
206281 for {
207282 select {
M packages/agent/swarm/inbox.go +107 -43
@@ -4,7 +4,6 @@ import (
44 "bufio"
55 "errors"
66 "fmt"
7- "io"
87 "net"
98 "os"
109 "path/filepath"
@@ -66,6 +65,20 @@ func (b *Inbox) Path() string { return b.path }
6665 // ErrNotReady so the TUI can retry or report "agent not listening
6766 // yet" rather than crashing.
6867 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 {
6982 b.mu.Lock()
7083 defer b.mu.Unlock()
7184 if b.conn == nil {
@@ -75,7 +88,7 @@ func (b *Inbox) SendInput(msg string) error {
7588 }
7689 b.conn = c
7790 }
78- if _, err := io.WriteString(b.conn, msg+"\n"); err != nil {
91+ if _, err := b.conn.Write(data); err != nil {
7992 // Drop the connection so the next call redials. The
8093 // previous error is more informative than the redial's
8194 // would be, so surface this one.
@@ -147,6 +160,30 @@ func isNoListenerErr(err error) bool {
147160 return errors.Is(err, syscall.ECONNREFUSED)
148161 }
149162
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+
150187 // Listener is the child-side end of the inbox. The swarm-agent
151188 // daemon mode constructs one with Listen, then iterates Lines
152189 // to receive supervisor messages. Designed so a single goroutine
@@ -158,35 +195,48 @@ type Listener struct {
158195 // only expects one supervisor (the parent zot), so newer
159196 // connections preempt older ones — the previous parent
160197 // presumably crashed.
161- mu sync.Mutex
162- active net.Conn
163- out chan string
164- done chan struct{}
165- wg sync.WaitGroup
198+ mu sync.Mutex
199+ active net.Conn
200+ conns map[net.Conn]struct{}
201+ out chan string
202+ done chan struct{}
203+ wg sync.WaitGroup
204+ closeOnce sync.Once
166205 }
167206
168207 // Listen creates the socket at path and starts accepting. The
169208 // caller must call Close to remove the socket file on shutdown.
170-// Returns the listener even if a stale socket exists from a
171-// previous run: we unlink first, mirroring how most unix daemons
172-// behave.
209+// A live endpoint is preserved and rejected; only a refused probe
210+// is treated as a stale socket eligible for removal.
173211 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 {
175213 return nil, fmt.Errorf("inbox dir: %w", err)
176214 }
177- // Best-effort cleanup of a stale socket. If the parent
178- // process is still alive and using it, Listen will fail
179- // below and the caller surfaces that as a real conflict.
180- _ = os.Remove(path)
215+ // Probe an existing endpoint before removing it. Unlinking an active
216+ // Unix socket does not stop its listener, so blindly removing the path
217+ // could let Resume start a second worker on the same session and event log.
218+ if _, statErr := os.Stat(path); statErr == nil {
219+ if inboxLive(path) {
220+ return nil, fmt.Errorf("swarm inbox already has a live listener")
221+ }
222+ // A refused probe means the socket inode is stale. Remove only that
223+ // endpoint; a live listener was handled above without unlinking it.
224+ _ = os.Remove(path)
225+ }
181226 ln, err := net.Listen("unix", path)
182227 if err != nil {
183228 return nil, fmt.Errorf("inbox listen: %w", err)
184229 }
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)
185234 l := &Listener{
186- path: path,
187- ln: ln,
188- out: make(chan string, 16),
189- done: make(chan struct{}),
235+ path: path,
236+ ln: ln,
237+ conns: make(map[net.Conn]struct{}),
238+ out: make(chan string, 16),
239+ done: make(chan struct{}),
190240 }
191241 go l.acceptLoop()
192242 return l, nil
@@ -199,19 +249,16 @@ func (l *Listener) Lines() <-chan string { return l.out }
199249 // Close stops accepting, drops the active connection, removes
200250 // the socket file, and closes Lines. Idempotent.
201251 func (l *Listener) Close() error {
202- select {
203- case <-l.done:
204- return nil
205- default:
252+ l.closeOnce.Do(func() {
206253 close(l.done)
207- }
208- l.mu.Lock()
209- if l.active != nil {
210- _ = l.active.Close()
211- }
212- l.mu.Unlock()
213- _ = l.ln.Close()
214- _ = os.Remove(l.path)
254+ l.mu.Lock()
255+ for conn := range l.conns {
256+ _ = conn.Close()
257+ }
258+ l.mu.Unlock()
259+ _ = l.ln.Close()
260+ _ = os.Remove(l.path)
261+ })
215262 return nil
216263 }
217264
@@ -233,10 +280,7 @@ func (l *Listener) acceptLoop() {
233280 }
234281 }
235282 l.mu.Lock()
236- if l.active != nil {
237- _ = l.active.Close()
238- }
239- l.active = c
283+ l.conns[c] = struct{}{}
240284 l.mu.Unlock()
241285 l.wg.Add(1)
242286 go func() {
@@ -247,24 +291,44 @@ func (l *Listener) acceptLoop() {
247291 }
248292
249293 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+
250304 br := bufio.NewReader(c)
305+ firstLine := true
251306 for {
252307 line, err := br.ReadString('\n')
253308 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+ }
254325 select {
255- case l.out <- trimRightNL(line):
326+ case l.out <- message:
256327 case <-l.done:
257- _ = c.Close()
258328 return
259329 }
260330 }
261331 if err != nil {
262- _ = c.Close()
263- l.mu.Lock()
264- if l.active == c {
265- l.active = nil
266- }
267- l.mu.Unlock()
268332 return
269333 }
270334 }
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 (
3838 // by encoding/json's permissive decoder when an older meta.json is
3939 // loaded; we don't need to keep them in the struct.
4040 type agentMeta struct {
41- ID string `json:"id"`
42- Task string `json:"task"`
43- Dir string `json:"dir"`
44- Started time.Time `json:"started"`
45- Model string `json:"model,omitempty"`
46- Provider string `json:"provider,omitempty"`
47- Reasoning string `json:"reasoning,omitempty"`
48- FastMode *bool `json:"fast_mode,omitempty"`
49- Subagent string `json:"subagent,omitempty"`
50- InboxPath string `json:"inbox_path"`
51- EventLogPath string `json:"event_log_path"`
52- SessionPath string `json:"session_path"`
41+ ID string `json:"id"`
42+ Task string `json:"task"`
43+ OriginalTask string `json:"original_task,omitempty"`
44+ Dir string `json:"dir"`
45+ RepositoryRoot string `json:"repository_root,omitempty"`
46+ Started time.Time `json:"started"`
47+ ParentID string `json:"parent_id,omitempty"`
48+ BatchID string `json:"batch_id,omitempty"`
49+ RootSessionID string `json:"root_session_id,omitempty"`
50+ Model string `json:"model,omitempty"`
51+ Provider string `json:"provider,omitempty"`
52+ BaseURL string `json:"base_url,omitempty"`
53+ InsecureTLS bool `json:"insecure_tls,omitempty"`
54+ Reasoning string `json:"reasoning,omitempty"`
55+ FastMode *bool `json:"fast_mode,omitempty"`
56+ Subagent string `json:"subagent,omitempty"`
57+ WorkspaceMode WorkspaceMode `json:"workspace_mode,omitempty"`
58+ WorkspacePath string `json:"workspace_path,omitempty"`
59+ WorkspaceBase string `json:"workspace_base,omitempty"`
60+ WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
61+ MaxTurns int `json:"max_turns,omitempty"`
62+ Timeout time.Duration `json:"timeout,omitempty"`
63+ Tools []string `json:"tools,omitempty"`
64+ ProcessState ProcessState `json:"process_state,omitempty"`
65+ TurnState TurnState `json:"turn_state,omitempty"`
66+ CurrentTurnID string `json:"current_turn_id,omitempty"`
67+ Attempt int `json:"attempt,omitempty"`
68+ ProcessPID int `json:"process_pid,omitempty"`
69+ UpdatedAt time.Time `json:"updated_at,omitempty"`
70+ LastActivity time.Time `json:"last_activity,omitempty"`
71+ ResultRef string `json:"result_ref,omitempty"`
72+ PatchRef string `json:"patch_ref,omitempty"`
73+ ChangedFiles []string `json:"changed_files,omitempty"`
74+ InboxPath string `json:"inbox_path"`
75+ EventLogPath string `json:"event_log_path"`
76+ SessionPath string `json:"session_path"`
5377
5478 // SessionID, when non-empty, scopes the agent to a particular
5579 // host zot session: the dashboard only shows agents whose
@@ -68,32 +92,68 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
6892 // half-parsable file that fails Reload.
6993 func writeAgentMeta(stateDir string, a *Agent) error {
7094 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()
71107 m := agentMeta{
72- ID: a.ID,
73- Task: a.Task,
74- Dir: a.Dir,
75- Started: a.Started,
76- Model: a.Model,
77- Provider: a.Provider,
78- Reasoning: a.Reasoning,
79- FastMode: &fastMode,
80- Subagent: a.Subagent,
81- InboxPath: a.InboxPath,
82- EventLogPath: a.EventLogPath,
83- SessionPath: a.SessionPath,
84- SessionID: a.SessionID,
108+ ID: a.ID,
109+ Task: a.Task,
110+ OriginalTask: a.OriginalTask,
111+ Dir: a.Dir,
112+ RepositoryRoot: a.RepositoryRoot,
113+ Started: a.Started,
114+ ParentID: a.ParentID,
115+ BatchID: a.BatchID,
116+ RootSessionID: a.RootSessionID,
117+ Model: a.Model,
118+ Provider: a.Provider,
119+ BaseURL: a.BaseURL,
120+ InsecureTLS: a.InsecureTLS,
121+ Reasoning: a.Reasoning,
122+ FastMode: &fastMode,
123+ Subagent: a.Subagent,
124+ WorkspaceMode: a.WorkspaceMode,
125+ WorkspacePath: a.WorkspacePath,
126+ WorkspaceBase: a.WorkspaceBase,
127+ WorkspaceCapture: a.WorkspaceCapture,
128+ MaxTurns: a.MaxTurns,
129+ Timeout: a.Timeout,
130+ Tools: append([]string(nil), a.Tools...),
131+ ProcessState: processState,
132+ TurnState: turnState,
133+ CurrentTurnID: currentTurnID,
134+ Attempt: attempt,
135+ ProcessPID: processPID,
136+ UpdatedAt: updatedAt,
137+ LastActivity: lastActivity,
138+ ResultRef: resultRef,
139+ PatchRef: patchRef,
140+ ChangedFiles: changedFiles,
141+ InboxPath: a.InboxPath,
142+ EventLogPath: a.EventLogPath,
143+ SessionPath: a.SessionPath,
144+ SessionID: a.SessionID,
85145 }
86146 b, err := json.MarshalIndent(m, "", " ")
87147 if err != nil {
88148 return fmt.Errorf("swarm meta marshal: %w", err)
89149 }
90- if err := os.MkdirAll(stateDir, 0o755); err != nil {
91- return fmt.Errorf("swarm meta dir: %w", err)
150+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
151+ return fmt.Errorf("subagents meta dir: %w", err)
92152 }
93153 final := metaPath(stateDir)
94154 tmp := final + ".tmp"
95- if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
96- return fmt.Errorf("swarm meta write: %w", err)
155+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
156+ return fmt.Errorf("subagents meta write: %w", err)
97157 }
98158 if err := os.Rename(tmp, final); err != nil {
99159 _ = os.Remove(tmp)
@@ -136,21 +196,100 @@ func readAgentMeta(stateDir string) (agentMeta, error) {
136196 // error encountered. Malformed entries are skipped rather than
137197 // failing the whole reload — one bad meta.json shouldn't hide the
138198 // 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+
139261 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")
141284 entries, err := os.ReadDir(agentsDir)
142285 if err != nil {
143286 if errors.Is(err, os.ErrNotExist) {
144287 return 0, nil
145288 }
146- return 0, []error{fmt.Errorf("swarm reload: %w", err)}
289+ return 0, []error{fmt.Errorf("subagents reload %s: %w", root, err)}
147290 }
148291
149292 // 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).
154293 names := make([]string, 0, len(entries))
155294 for _, e := range entries {
156295 if e.IsDir() {
@@ -164,14 +303,19 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
164303 m, err := readAgentMeta(stateDir)
165304 if err != nil {
166305 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.
170306 continue
171307 }
172308 errs = append(errs, err)
173309 continue
174310 }
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+ }
175319
176320 f.mu.Lock()
177321 if _, exists := f.agents[m.ID]; exists {
@@ -195,43 +339,96 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
195339 // The returned Agent has a closed `done` channel because Wait should
196340 // return instantly: there is nothing to wait for.
197341 func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
198- // Older meta.json files may still record a per-agent worktree
199- // path under Dir. They predate the decision to run every agent
200- // in the host's repo and shouldn't continue editing that stale
201- // checkout, which most likely no longer matches HEAD. Coerce
202- // the dir back to the live RepoRoot so resume picks up where
203- // the host is now.
342+ // Legacy meta.json files may not record a repository identity. Those
343+ // records are migrated to the current RepoRoot; current-format records
344+ // retain their persisted repository so a restart from another checkout
345+ // cannot silently redirect the child.
204346 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.
206350 dir = f.cfg.RepoRoot
207351 }
352+ if dir == "" {
353+ dir = firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot)
354+ }
208355 fastMode := f.cfg.FastMode
209356 if m.FastMode != nil {
210357 fastMode = *m.FastMode
211358 }
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+ }
212381 a := &Agent{
213- ID: m.ID,
214- Task: m.Task,
215- Dir: dir,
216- Started: m.Started,
217- Model: m.Model,
218- Provider: m.Provider,
219- Reasoning: m.Reasoning,
220- FastMode: fastMode,
221- Subagent: m.Subagent,
222- InboxPath: m.InboxPath,
223- EventLogPath: m.EventLogPath,
224- SessionPath: m.SessionPath,
225- SessionID: m.SessionID,
226- inbox: NewInbox(m.InboxPath),
227- status: StatusDetached,
228- activity: "detached",
229- done: make(chan struct{}),
230- }
231- // Wait() must not block for detached agents; close the channel
232- // immediately. Callers Resuming the agent will replace done with
233- // a fresh channel before starting the new runner.
234- close(a.done)
382+ ID: m.ID,
383+ Task: m.Task,
384+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
385+ Dir: dir,
386+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
387+ Started: m.Started,
388+ ParentID: m.ParentID,
389+ BatchID: m.BatchID,
390+ RootSessionID: m.RootSessionID,
391+ Model: m.Model,
392+ Provider: m.Provider,
393+ BaseURL: m.BaseURL,
394+ InsecureTLS: m.InsecureTLS,
395+ Reasoning: m.Reasoning,
396+ FastMode: fastMode,
397+ Subagent: m.Subagent,
398+ WorkspaceMode: m.WorkspaceMode,
399+ WorkspacePath: m.WorkspacePath,
400+ WorkspaceBase: m.WorkspaceBase,
401+ WorkspaceCapture: m.WorkspaceCapture,
402+ MaxTurns: m.MaxTurns,
403+ Timeout: m.Timeout,
404+ Tools: append([]string(nil), m.Tools...),
405+ Attempt: m.Attempt,
406+ ProcessPID: m.ProcessPID,
407+ InboxPath: m.InboxPath,
408+ EventLogPath: m.EventLogPath,
409+ SessionPath: m.SessionPath,
410+ SessionID: m.SessionID,
411+ inbox: NewInbox(m.InboxPath),
412+ status: StatusDetached,
413+ activity: activity,
414+ processState: processState,
415+ turnState: turnState,
416+ currentTurnID: m.CurrentTurnID,
417+ updatedAt: m.UpdatedAt,
418+ lastActivity: lastActivity,
419+ stateDir: agentStateDir,
420+ resultRef: m.ResultRef,
421+ patchRef: m.PatchRef,
422+ changedFiles: append([]string(nil), m.ChangedFiles...),
423+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
424+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
425+ done: make(chan struct{}),
426+ turnResults: make(chan *TurnResult, 16),
427+ }
428+ if a.updatedAt.IsZero() {
429+ a.updatedAt = lastActivity
430+ }
431+ a.closeDone()
235432
236433 // Recover transcript + activity hints from the event log. Best
237434 // effort: a missing or unreadable log just leaves the agent
@@ -241,6 +438,24 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
241438 replayEventsIntoAgent(a, evs)
242439 }
243440 }
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+ }
244459 return a
245460 }
246461
@@ -256,6 +471,33 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
256471 terminal := false
257472 for _, ev := range evs {
258473 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()
259501 case "assistant_message", "user_message":
260502 var text []string
261503 if c, ok := ev.Data["content"].([]any); ok {
@@ -337,6 +579,27 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
337579 // Killed). Resuming a still-running agent returns an error so two
338580 // runners don't race for the same session.
339581 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+ }
340603 existing := f.Get(id)
341604 if existing == nil {
342605 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) {
347610 if st == StatusRunning || st == StatusPending {
348611 return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)
349612 }
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()
350637
351638 // Rebuild from the meta record so we don't carry stale runner
352639 // 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) {
359646 fastMode := existing.FastMode
360647 m := agentMeta{
361648 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,
363652 Model: existing.Model, Provider: existing.Provider,
653+ BaseURL: existing.BaseURL, InsecureTLS: existing.InsecureTLS,
364654 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,
366660 SessionID: existing.SessionID,
367661 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
368662 SessionPath: existing.SessionPath,
369663 }
370664
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)
371719 a := &Agent{
372- ID: m.ID,
373- Task: m.Task,
374- Dir: m.Dir,
375- Started: m.Started,
376- Model: m.Model,
377- Provider: m.Provider,
378- Reasoning: m.Reasoning,
379- FastMode: fastMode,
380- Subagent: m.Subagent,
381- SessionID: m.SessionID,
382- InboxPath: m.InboxPath,
383- EventLogPath: m.EventLogPath,
384- SessionPath: m.SessionPath,
385- Resuming: true,
386- inbox: NewInbox(m.InboxPath),
387- status: StatusPending,
388- activity: "resuming",
389- done: make(chan struct{}),
720+ ID: m.ID,
721+ Task: m.Task,
722+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
723+ Dir: m.Dir,
724+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
725+ Started: m.Started,
726+ ParentID: m.ParentID,
727+ BatchID: m.BatchID,
728+ RootSessionID: m.RootSessionID,
729+ Model: m.Model,
730+ Provider: m.Provider,
731+ BaseURL: m.BaseURL,
732+ InsecureTLS: m.InsecureTLS,
733+ Reasoning: m.Reasoning,
734+ FastMode: fastMode,
735+ Subagent: m.Subagent,
736+ SessionID: m.SessionID,
737+ WorkspaceMode: m.WorkspaceMode,
738+ WorkspacePath: m.WorkspacePath,
739+ WorkspaceBase: m.WorkspaceBase,
740+ WorkspaceCapture: m.WorkspaceCapture,
741+ MaxTurns: maxTurns,
742+ Timeout: timeout,
743+ Attempt: m.Attempt,
744+ HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
745+ Tools: append([]string(nil), m.Tools...),
746+ InboxPath: m.InboxPath,
747+ EventLogPath: m.EventLogPath,
748+ SessionPath: m.SessionPath,
749+ Resuming: resuming,
750+ inbox: NewInbox(m.InboxPath),
751+ status: StatusPending,
752+ activity: "resuming",
753+ processState: ProcessPending,
754+ turnState: TurnQueued,
755+ updatedAt: now,
756+ lastActivity: now,
757+ currentTurnID: m.CurrentTurnID,
758+ stateDir: stateDir,
759+ lease: lease,
760+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
761+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
762+ done: make(chan struct{}),
763+ turnResults: make(chan *TurnResult, 16),
390764 }
391765 // Carry the previous transcript forward so the dashboard doesn't
392766 // flash empty between resume and the first new event.
@@ -394,11 +768,22 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
394768 if len(prev) > 0 {
395769 a.appendTranscript(strings.Join(prev, "\n"))
396770 }
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()) }
398775 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+ }
399783
400784 f.mu.Lock()
401785 f.agents[a.ID] = a
786+ f.queue = append(f.queue, a)
402787 // Keep the agent's slot in f.order; replacing in-place avoids
403788 // reshuffling the dashboard's row ordering on resume.
404789 found := false
@@ -412,13 +797,12 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
412797 f.order = append(f.order, a.ID)
413798 }
414799 f.mu.Unlock()
800+ leaseOwned = false
415801
416- // Refresh the meta.json so any new path defaults (e.g. socket
417- // path moved into /tmp because the root got renamed) get
418- // persisted. Best-effort; resume still works if the disk is
419- // read-only because everything the runner needs is in-memory.
420- _ = writeAgentMeta(f.agentStateDir(a.ID), a)
421-
422- go f.run(a)
802+ // Refreshing metadata happened before queue admission, so a later
803+ // supervisor can reconstruct this resumed attempt even if the runner
804+ // has not reached its first event yet.
805+ f.armQueueTimeout(a)
806+ f.schedule()
423807 return a, nil
424808 }
M packages/agent/swarm/persist_test.go +288 -3
@@ -133,6 +133,81 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
133133 }
134134 }
135135
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+
136211 // TestReloadIsIdempotent calls Reload twice in a row and asserts the
137212 // second call neither duplicates rows nor errors.
138213 func TestReloadIsIdempotent(t *testing.T) {
@@ -308,6 +383,7 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
308383 if a.Status() != StatusKilled {
309384 t.Fatalf("post-stop status = %q; want killed", a.Status())
310385 }
386+ previousAttempt := a.Snapshot().Attempt
311387
312388 // Now resume.
313389 a2, err := f.Resume(context.Background(), a.ID)
@@ -326,6 +402,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
326402 if a2.InboxPath != originalInbox {
327403 t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox)
328404 }
405+ if got := a2.Snapshot().Attempt; got != previousAttempt+1 {
406+ t.Errorf("resume attempt = %d; want %d", got, previousAttempt+1)
407+ }
329408 // Two runner invocations: spawn + resume.
330409 deadline = time.Now().Add(time.Second)
331410 for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {
@@ -525,6 +604,180 @@ func TestResumeAfterReload(t *testing.T) {
525604 t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript())
526605 }
527606
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+
528781 // TestSpawnReqPersistsModel confirms that the per-agent model
529782 // override is captured at Spawn time, surfaced via Snapshot, and
530783 // written to meta.json so a later Reload + Resume reuses it.
@@ -727,6 +980,37 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
727980 }
728981 }
729982
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+
7301014 // TestActiveSessionScopesSnapshotAll proves the session filter the
7311015 // user asked for: a single Swarm with agents spawned under two
7321016 // different active sessions only surfaces the agents matching the
@@ -826,10 +1110,11 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
8261110 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)
8271111 }
8281112
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.
8301115 g.SetActiveSession("sess-other")
831- if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 {
832- t.Errorf("scoped to other session, ids = %v; want []", got)
1116+ if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID {
1117+ t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID)
8331118 }
8341119 }
8351120
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 (
1111 "os/exec"
1212 "path/filepath"
1313 "strings"
14+ "sync"
1415 "time"
1516 )
1617
17-// execRunner spawns `zot --swarm-agent <inbox> --session <path>` in
18-// the host's working directory (Agent.Dir, which is always the parent
19-// zot's RepoRoot) and consumes its JSONL event stream on stdout.
18+// execRunner spawns `zot --subagent-worker <inbox> --session <path>` in
19+// Agent.Dir (the shared repository root or an isolated worktree) and consumes
20+// its JSONL event stream on stdout.
2021 //
2122 // Why a long-lived daemon and not `zot --print`: the supervisor and
2223 // the user expect agents to keep accepting follow-up prompts. A
@@ -31,13 +32,13 @@ import (
3132 //
3233 // The on-disk log is the durable record. The Sink updates are an
3334 // in-memory mirror so the dashboard doesn't have to tail the file
34-// for the parent's own agents. /swarm open in a separate zot would
35+// for the parent's own agents. /subagents open in a separate zot would
3536 // read the log directly.
3637 type execRunner struct {
3738 agent *Agent
3839 resolveCredential func(context.Context, string) (Credential, error)
3940
40- // Command overrides the default `zot --swarm-agent ...`
41+ // Command overrides the default `zot --subagent-worker ...`
4142 // invocation. Tests set this to a fake binary (or `go run`
4243 // against a tiny stub program) so the supervisor logic can be
4344 // tested without a real child. Production code leaves it nil.
@@ -66,10 +67,14 @@ type swarmAgentArgsOpts struct {
6667 Task string
6768 Model string
6869 Provider string
70+ BaseURL string
71+ InsecureTLS bool
6972 Reasoning string
7073 FastMode bool
7174 FastModeSet bool
7275 Subagent string
76+ MaxTurns int
77+ Tools []string
7378 }
7479
7580 // defaultChildArgs builds the argv execRunner uses when its Command
@@ -99,10 +104,14 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
99104 Task: task,
100105 Model: a.Model,
101106 Provider: a.Provider,
107+ BaseURL: a.BaseURL,
108+ InsecureTLS: a.InsecureTLS,
102109 Reasoning: a.Reasoning,
103110 FastMode: a.FastMode,
104111 FastModeSet: true,
105112 Subagent: a.Subagent,
113+ MaxTurns: a.MaxTurns,
114+ Tools: a.Tools,
106115 })
107116 }
108117
@@ -112,6 +121,9 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
112121 func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
113122 args := []string{
114123 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.
115127 "--swarm-agent", opts.InboxPath,
116128 "--session", opts.SessionPath,
117129 "--cwd", opts.Dir,
@@ -122,6 +134,12 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
122134 if opts.Provider != "" {
123135 args = append(args, "--provider", opts.Provider)
124136 }
137+ if opts.BaseURL != "" {
138+ args = append(args, "--base-url", opts.BaseURL)
139+ }
140+ if opts.InsecureTLS {
141+ args = append(args, "--insecure")
142+ }
125143 if opts.Reasoning != "" {
126144 args = append(args, "--reasoning", opts.Reasoning)
127145 }
@@ -135,10 +153,17 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
135153 if opts.Subagent != "" {
136154 args = append(args, "--subagent", opts.Subagent)
137155 }
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+ }
138162 if opts.Task != "" {
139163 // First task is positional so the child treats it as the
140- // initial user turn; subsequent turns arrive via the inbox.
141- args = append(args, opts.Task)
164+ // initial user turn; the terminator keeps flag-like task text
165+ // from being parsed as a child CLI option.
166+ args = append(args, "--", opts.Task)
142167 }
143168 return args
144169 }
@@ -191,11 +216,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
191216 }
192217
193218 cmd := exec.CommandContext(ctx, args[0], args[1:]...)
219+ configureWorkerProcess(cmd)
194220 cmd.Dir = r.agent.Dir
195- cmd.Env = append(os.Environ(),
221+ cmd.Env = append(workerEnvironment(r.agent.Provider),
196222 "ZOT_SWARM_AGENT_ID="+r.agent.ID,
197223 "ZOT_SWARM_EVENT_LOG="+logPath,
198224 )
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+ }
199234 if r.resolveCredential != nil {
200235 credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider)
201236 if resolveErr != nil {
@@ -226,6 +261,32 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
226261 if err := cmd.Start(); err != nil {
227262 return err
228263 }
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+ }
229290
230291 // stdout: parsed as JSONL. Every well-formed event is appended
231292 // 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 {
235296 go func() {
236297 defer func() { done <- struct{}{} }()
237298 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+ }
238306 for {
239- line, err := dec.ReadBytes('\n')
307+ line, err, truncated := readBoundedLine(dec, lineLimit)
240308 if len(line) > 0 {
241309 trimmed := strings.TrimRight(string(line), "\r\n")
242- if trimmed == "" {
243- goto next
244- }
245- if ev, ok := parseEventLine(trimmed); ok {
246- _ = log.Append(ev)
247- applyEventToSink(ev, sink)
248- // Fan prompt-level task completions up to any
249- // subscriber on the supervised Agent. The child
250- // also forwards provider/tool-loop turn_end
251- // events (for example stop=tool_use); those do
252- // not contain step and must not be treated as
253- // swarm task completion.
254- notifyPromptTurnEnd(r.agent, ev)
255- } else {
256- // Non-JSON output. Keep it as transcript so an
257- // accidental fmt.Println in the child still
258- // shows up in the dashboard, and log a
259- // lifecycle event so the durable record stays
260- // in sync.
310+ if trimmed != "" && !truncated {
311+ if ev, ok := parseEventLine(trimmed); ok {
312+ appendLog(ev)
313+ updateAgentFromEvent(r.agent, ev)
314+ applyEventToSink(ev, sink)
315+ // Fan prompt-level task completions up to any
316+ // subscriber on the supervised Agent. The child
317+ // also forwards provider/tool-loop turn_end
318+ // events (for example stop=tool_use); those do
319+ // not contain step and must not be treated as
320+ // swarm task completion.
321+ notifyPromptTurnEnd(r.agent, ev)
322+ } else {
323+ sink.Transcript(trimmed)
324+ appendLog(NewEvent("stdout", map[string]any{"text": trimmed}))
325+ }
326+ } else if trimmed != "" {
327+ trimmed += "\n...[line truncated]"
261328 sink.Transcript(trimmed)
262- _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed}))
329+ appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true}))
263330 }
264331 }
265- next:
266332 if err != nil {
267333 return
268334 }
@@ -276,12 +342,22 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
276342 go func() {
277343 defer func() { done <- struct{}{} }()
278344 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+ }
279352 for {
280- line, err := br.ReadString('\n')
281- if line != "" {
282- txt := strings.TrimRight(line, "\r\n")
353+ line, err, truncated := readBoundedLine(br, lineLimit)
354+ if len(line) > 0 {
355+ txt := strings.TrimRight(string(line), "\r\n")
356+ if truncated {
357+ txt += "\n...[line truncated]"
358+ }
283359 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}))
285361 }
286362 if err != nil {
287363 return
@@ -298,24 +374,125 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
298374 exit = ee.ExitCode()
299375 }
300376 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+ }
302381 return ctx.Err()
303382 }
304383 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+ }
306388 return err
307389 }
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+ }
309394 sink.Activity("done")
310395 return nil
311396 }
312397
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+
313478 // parseEventLine attempts to decode one JSONL line as an Event.
314479 // Returns ok=false for non-JSON or JSON without a "type" field.
315480 func parseEventLine(line string) (Event, bool) {
316481 if len(line) == 0 || line[0] != '{' {
317482 return Event{}, false
318483 }
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+ }
319496 var ev Event
320497 if err := json.Unmarshal([]byte(line), &ev); err != nil {
321498 return Event{}, false
@@ -329,6 +506,85 @@ func parseEventLine(line string) (Event, bool) {
329506 return ev, true
330507 }
331508
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+
332588 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the swarm
333589 // daemon's prompt-level completion event. Provider/tool-loop
334590 // turn_end events (such as stop=tool_use) do not include step and
@@ -342,13 +598,16 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
342598 return
343599 }
344600
601+ errMsg, _ := ev.Data["error"].(string)
345602 a.mu.Lock()
346603 fn := a.OnTurnEnd
347- a.mu.Unlock()
348- if fn != nil {
349- errMsg, _ := ev.Data["error"].(string)
350- go fn(int(step), errMsg)
604+ if fn == nil {
605+ a.pendingTurnEnds = append(a.pendingTurnEnds, turnEndNotice{step: int(step), errMsg: errMsg})
606+ a.mu.Unlock()
607+ return
351608 }
609+ a.mu.Unlock()
610+ go fn(int(step), errMsg)
352611 }
353612
354613 // applyEventToSink translates an Event into Sink updates. Only a
@@ -395,17 +654,17 @@ func applyEventToSink(ev Event, sink Sink) {
395654 if ev.Type == "assistant_message" {
396655 sink.Activity("idle")
397656 }
398- case "turn_start":
657+ case "turn_start", EventTurnStarted:
399658 sink.Activity("thinking")
400- case "tool_call":
659+ case "tool_call", EventToolStarted:
401660 if name, _ := ev.Data["name"].(string); name != "" {
402661 sink.Activity("tool: " + truncate(name, 60))
403662 }
404- case "tool_result":
663+ case "tool_result", EventToolFinished:
405664 sink.Activity("idle")
406- case "turn_end":
665+ case "turn_end", EventTurnResult, EventAgentIdle:
407666 sink.Activity("idle")
408- case "agent_ready":
667+ case "agent_ready", EventAgentReady, EventAgentHeartbeat:
409668 sink.Activity("idle")
410669 case "agent_stopped":
411670 // 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) {
4040 }
4141
4242 exe := buildStubChild(t)
43+ t.Setenv("ZOT_STUB_PROTOCOL", "1")
4344
4445 root := t.TempDir()
4546 repo := t.TempDir()
M packages/agent/swarm/runner_test.go +69 -0
@@ -58,6 +58,38 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
5858 os.Exit(0)
5959 }
6060
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+
6193 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
6294 t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1")
6395 root := t.TempDir()
@@ -155,6 +187,43 @@ func TestSwarmAgentArgs(t *testing.T) {
155187 }
156188 }
157189
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+
158227 // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the
159228 // agent is being adopted (no fresh task) we don't pass an empty
160229 // 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.
22 //
3-// A Swarm manages a set of headless zot subprocesses ("agents")
4-// that share the host's working directory. The interactive TUI
5-// exposes the supervisor through the /swarm slash command and a
6-// dashboard dialog; non-TUI code can drive it directly through
7-// this package.
3+// A Swarm manages headless, long-lived zot subprocesses and keeps their
4+// process/turn state, event logs, sessions, results, and optional worktree
5+// artifacts durable across supervisor restarts. The canonical user-facing
6+// name is "subagents"; this package and the /swarm command remain for
7+// compatibility with existing callers.
88 //
9-// Every agent runs with cwd == the parent zot's RepoRoot — the
10-// same files the user sees, the same files the main agent edits.
11-// There is no git worktree, no per-agent branch, no isolation. If
12-// you want parallel edits on a separate branch, use normal git
13-// tooling (a real worktree, a different terminal) yourself.
14-//
15-// Each Agent has:
16-// - a unique id (short slug + nanoseconds)
17-// - a Runner (the thing that actually executes the task)
18-// - a Status string + Activity string that the dashboard reads
19-//
20-// The Runner abstraction means tests can swap a fake in instead of
21-// really spawning a subprocess; the production Runner shells out to
22-// `zot --swarm-agent ...` so we reuse zot's own model resolution
23-// and tooling without re-implementing the agent loop.
9+// Shared workspaces preserve the historical behavior, while an explicit
10+// worktree request gives a child an isolated checkout whose patch is captured
11+// before cleanup. The Runner abstraction lets tests inject a fake process;
12+// production uses `zot --subagent-worker ...` with the legacy flag accepted as
13+// a compatibility alias.
2414 package swarm
2515
2616 import (
@@ -32,6 +22,7 @@ import (
3222 "sort"
3323 "strings"
3424 "sync"
25+ "sync/atomic"
3526 "time"
3627 )
3728
@@ -49,19 +40,43 @@ const (
4940
5041 // Config configures a Swarm.
5142 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+
5248 // Root is the directory under which per-agent state files live.
5349 // Typically <ZotHome>/swarm, but tests pass a tempdir.
5450 Root string
5551
56- // RepoRoot is the working directory every spawned agent runs
57- // in — the same cwd the parent zot is using. There is no
58- // per-agent isolation: agents edit the host's files directly.
52+ // LegacyRoots are older state roots that Reload also reads. New
53+ // agents are always written below Root; this keeps the swarm ->
54+ // subagents rename from making existing sessions disappear.
55+ LegacyRoots []string
56+
57+ // Policy owns concurrency, timeout, output, and path safety limits.
58+ Policy SubagentPolicy
59+
60+ // RepoRoot is the repository used by spawned agents. Shared-mode children
61+ // run here; worktree-mode children use an isolated checkout created from
62+ // this root.
5963 RepoRoot string
6064
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+
6169 // FastMode is copied to every newly spawned child and persisted so
6270 // child subprocesses use the same OpenAI service-tier setting.
6371 FastMode bool
6472
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+
6580 // NewRunner produces the Runner for an Agent. If nil, the default
6681 // `zot --swarm-agent ...` exec runner is used. Tests inject a fake
6782 // here.
@@ -110,9 +125,18 @@ type Sink interface {
110125 type Swarm struct {
111126 cfg Config
112127
113- mu sync.Mutex
114- agents map[string]*Agent
115- order []string // creation order for stable listing
128+ mu sync.Mutex
129+ operationMu sync.Mutex
130+ lifetimeCtx context.Context
131+ lifetimeCancel context.CancelFunc
132+ agents map[string]*Agent
133+ order []string // creation order for stable listing
134+ queue []*Agent
135+ active int
136+ activeByParent map[string]int
137+ activeByBatch map[string]int
138+ totalSpawned int
139+ batches map[string]*Batch
116140
117141 // activeSession is the host session id the dashboard is
118142 // currently scoped to. When non-empty, SnapshotAll filters out
@@ -137,15 +161,37 @@ func New(cfg Config) *Swarm {
137161 return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}
138162 }
139163 }
164+ cfg.Policy.normalize()
165+ lifetimeParent := cfg.Context
166+ if lifetimeParent == nil {
167+ lifetimeParent = context.Background()
168+ }
169+ lifetimeCtx, lifetimeCancel := context.WithCancel(lifetimeParent)
140170 return &Swarm{
141- cfg: cfg,
142- agents: map[string]*Agent{},
171+ cfg: cfg,
172+ lifetimeCtx: lifetimeCtx,
173+ lifetimeCancel: lifetimeCancel,
174+ agents: map[string]*Agent{},
175+ activeByParent: map[string]int{},
176+ activeByBatch: map[string]int{},
177+ batches: map[string]*Batch{},
143178 }
144179 }
145180
146181 // SetRepoRoot updates the shared working directory for subsequently
147182 // spawned agents. Existing agents keep the directory they were started
148183 // 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+
149195 func (f *Swarm) SetRepoRoot(root string) {
150196 f.mu.Lock()
151197 defer f.mu.Unlock()
@@ -176,6 +222,32 @@ func (f *Swarm) FastMode() bool {
176222 return f.cfg.FastMode
177223 }
178224
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+
179251 // SetActiveSession scopes the dashboard view (and Spawn stamping)
180252 // to a particular host zot session id. Pass empty to clear the
181253 // scope and revert to "show every agent" (the original behaviour).
@@ -211,6 +283,18 @@ func (f *Swarm) agentStateDir(id string) string {
211283 return filepath.Join(f.cfg.Root, "agents", id)
212284 }
213285
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+
214298 // SpawnRequest configures a Spawn. Only Task is required; the rest
215299 // are optional. Model + Provider, when set, get baked into the
216300 // child argv as --model / --provider so the agent runs against the
@@ -220,57 +304,116 @@ func (f *Swarm) agentStateDir(id string) string {
220304 // as --reasoning when set. FastMode restricts the host setting when a
221305 // selected profile declares a fastMode preference.
222306 type SpawnRequest struct {
223- Task string
224- Model string // optional override; child resolves default if empty
225- Provider string // optional override; usually paired with Model
226- Reasoning string // optional reasoning/thinking level override
307+ Task string
308+ Model string // optional override; child resolves default if empty
309+ Provider string // optional override; usually paired with Model
310+ BaseURL string // optional provider endpoint override
311+ // InsecureTLS enables the child to skip TLS verification for its
312+ // effective custom provider endpoint.
313+ InsecureTLS bool
314+ Reasoning string // optional reasoning/thinking level override
227315 // FastMode is an optional profile restriction. A non-nil false value
228316 // disables fast mode for this child; true still cannot enable fast mode
229317 // when Config.FastMode is false. Nil inherits Config.FastMode.
230318 FastMode *bool
231- Subagent string // optional named markdown profile
319+ Subagent string // optional named markdown profile
320+
321+ // ParentID is metadata used for per-parent scheduling limits. A
322+ // non-empty RequesterAgentID identifies an untrusted child request;
323+ // recursive spawning is rejected in v1.
324+ ParentID string
325+ BatchID string
326+ RootSessionID string
327+ RequesterAgentID string
328+
329+ Timeout time.Duration
330+ MaxTurns int
331+ Tools []string
332+ WorkspaceMode WorkspaceMode
333+ WorkspaceBase string
334+ WorkspaceCapture CaptureMode
232335 }
233336
234-// Spawn creates a new Agent for the given task, allocates its
235-// on-disk state directory (events log, inbox socket path, session
236-// file path), and starts the Runner on a background goroutine. The
237-// returned Agent is already in StatusRunning (or StatusFailed if
238-// state setup failed before the goroutine started). This is the
239-// historical signature; callers that want to override the child's
240-// model use SpawnReq instead.
337+// Spawn creates a new Agent for the given task, allocates its on-disk
338+// state directory (events log, inbox socket path, session file path), and
339+// queues it for the manager-owned scheduler. The returned Agent is pending
340+// when the concurrency budget is full and transitions to running when a slot
341+// opens. This is the historical signature; callers that want to override the
342+// child's model or workspace use SpawnReq instead.
241343 func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
242344 return f.SpawnReq(ctx, SpawnRequest{Task: task})
243345 }
244346
245-// SpawnReq is the full-fat variant of Spawn that accepts a
246-// SpawnRequest. Existing callers can keep using Spawn; new code that
247-// wants to pin the child's model uses this.
248-//
249-// Every spawned agent runs with cwd == cfg.RepoRoot — the same
250-// working directory as the host. No per-agent worktree, no branch,
251-// no isolation. The user explicitly opted out of the worktree flow.
347+// SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest.
348+// Existing callers can keep using Spawn; new code that wants to pin the
349+// child's model, policy, or workspace uses this.
252350 func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
253351 task := strings.TrimSpace(req.Task)
254352 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
256360 }
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)
258367
259- // Snapshot the shared cwd together with the active session so a
260- // concurrent /cd cannot race a spawn or produce a mixed snapshot.
368+ // Reserve a spawn budget before touching disk. This makes the total
369+ // budget race-free across slash commands, tools, and batch callers.
261370 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++
262377 dir := f.cfg.RepoRoot
263378 fastMode := f.cfg.FastMode
379+ configProvider := f.cfg.Provider
380+ configBaseURL := f.cfg.BaseURL
381+ configInsecureTLS := f.cfg.InsecureTLS
264382 sessionID := f.activeSession
265383 f.mu.Unlock()
266384 if req.FastMode != nil {
267385 fastMode = fastMode && *req.FastMode
268386 }
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+ }()
269402
270403 stateDir := f.agentStateDir(id)
271- if err := os.MkdirAll(stateDir, 0o755); err != nil {
272- return nil, fmt.Errorf("swarm state dir: %w", err)
404+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
405+ return nil, fmt.Errorf("subagents state dir: %w", err)
273406 }
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+ }()
274417 logPath := filepath.Join(stateDir, "events.jsonl")
275418 sessionPath := filepath.Join(stateDir, "session.json")
276419 // 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)
278421 // regular state files but cannot host unix socket nodes.
279422 inboxPath, err := inboxSocketPath(f.cfg.Root, id)
280423 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...)
282462 }
283463
284464 a := &Agent{
285- ID: id,
286- Task: task,
287- Dir: dir,
288- Started: f.cfg.Now(),
289- Model: strings.TrimSpace(req.Model),
290- Provider: strings.TrimSpace(req.Provider),
291- Reasoning: strings.TrimSpace(req.Reasoning),
292- FastMode: fastMode,
293- Subagent: strings.TrimSpace(req.Subagent),
294- SessionID: sessionID,
295- InboxPath: inboxPath,
296- EventLogPath: logPath,
297- SessionPath: sessionPath,
298- inbox: NewInbox(inboxPath),
299- status: StatusPending,
300- activity: "queued",
301- done: make(chan struct{}),
302- }
303- a.ctx, a.cancel = context.WithCancel(ctx)
465+ ID: id,
466+ Task: task,
467+ OriginalTask: task,
468+ Dir: dir,
469+ RepositoryRoot: workspace.RepositoryRoot(),
470+ WorkspacePath: workspace.Dir(),
471+ Started: now,
472+ ParentID: strings.TrimSpace(req.ParentID),
473+ BatchID: strings.TrimSpace(req.BatchID),
474+ RootSessionID: strings.TrimSpace(req.RootSessionID),
475+ Model: strings.TrimSpace(req.Model),
476+ Provider: strings.TrimSpace(req.Provider),
477+ BaseURL: baseURL,
478+ InsecureTLS: insecureTLS,
479+ Reasoning: strings.TrimSpace(req.Reasoning),
480+ FastMode: fastMode,
481+ Subagent: strings.TrimSpace(req.Subagent),
482+ SessionID: sessionID,
483+ WorkspaceMode: workspaceMode,
484+ WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),
485+ WorkspaceCapture: req.WorkspaceCapture,
486+ MaxTurns: maxTurns,
487+ Timeout: timeout,
488+ HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
489+ Tools: tools,
490+ InboxPath: inboxPath,
491+ EventLogPath: logPath,
492+ SessionPath: sessionPath,
493+ inbox: NewInbox(inboxPath),
494+ status: StatusPending,
495+ activity: "queued",
496+ processState: ProcessPending,
497+ turnState: TurnQueued,
498+ updatedAt: now,
499+ lastActivity: now,
500+ stateDir: stateDir,
501+ lease: lease,
502+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
503+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
504+ done: make(chan struct{}),
505+ turnResults: make(chan *TurnResult, 16),
506+ }
507+ if a.RootSessionID == "" {
508+ a.RootSessionID = sessionID
509+ }
510+ a.ctx, a.cancel = runCtx, cancel
511+ a.persistFn = f.persistAgent
512+ a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
513+ a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
304514 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+ }
305525
306526 f.mu.Lock()
307527 f.agents[id] = a
308528 f.order = append(f.order, id)
529+ f.queue = append(f.queue, a)
309530 f.mu.Unlock()
531+ leaseOwned = false
310532
311- // Persist the agent's identity so a later `zot` invocation can
312- // reload it from disk via Swarm.Reload. Best-effort: if the disk
313- // is read-only we still let the runner start, the user just won't
314- // see this agent on the next launch.
315- _ = writeAgentMeta(stateDir, a)
316-
317- go f.run(a)
533+ // Persisted metadata was written before queue admission, so a later
534+ // supervisor can reconstruct work that never reached a process.
535+ f.armQueueTimeout(a)
536+ f.schedule()
537+ reserved = false
318538 return a, nil
319539 }
320540
@@ -336,22 +556,77 @@ func (f *Swarm) SendInput(id, msg string) error {
336556 // SendUserTurn is sugar for the common "send the next user turn"
337557 // case. It quotes nothing and forwards verbatim; callers are
338558 // 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+
339576 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}))
341591 }
342592
343593 func (f *Swarm) run(a *Agent) {
344- a.setStatus(StatusRunning)
345- a.setActivity("starting")
346- err := a.runner.Run(a.ctx, agentSink{a: a})
594+ a.mu.Lock()
595+ killedBeforeStart := a.status == StatusKilled
596+ ctxErr := error(nil)
597+ if a.ctx != nil {
598+ ctxErr = a.ctx.Err()
599+ }
600+ a.mu.Unlock()
601+ var err error
602+ if killedBeforeStart || ctxErr != nil || !atomic.CompareAndSwapInt32(&a.launchState, 0, 1) {
603+ if ctxErr != nil {
604+ err = ctxErr
605+ } else {
606+ err = context.Canceled
607+ }
608+ } else {
609+ a.setActivity("starting")
610+ if a.runner == nil {
611+ err = errors.New("subagents: agent has no runner")
612+ } else {
613+ err = a.runner.Run(a.ctx, agentSink{a: a})
614+ }
615+ }
616+
347617 a.mu.Lock()
348618 a.finished = f.cfg.Now()
349619 switch {
350620 case a.status == StatusKilled:
351- // Already finalised by Stop.
621+ // Stop requested a terminal state; preserve it.
352622 case errors.Is(err, context.Canceled):
353623 a.status = StatusKilled
354624 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
355630 case err != nil:
356631 a.status = StatusFailed
357632 a.activity = "error: " + truncate(err.Error(), 120)
@@ -360,8 +635,46 @@ func (f *Swarm) run(a *Agent) {
360635 a.status = StatusDone
361636 a.activity = "done"
362637 }
638+ finalStatus := a.status
363639 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()
365678 }
366679
367680 // List returns a snapshot of every agent in creation order. The
@@ -407,42 +720,170 @@ func (f *Swarm) Get(id string) *Agent {
407720 // socket about to be unlinked.
408721 //
409722 // Stop is a no-op for any agent that's not in a live runnable state
410-// — Done / Failed / Killed (already finalised) and Detached (no
411-// in-process runner; reloaded from disk). Calling Stop on a detached
412-// agent must not crash: buildDetachedAgent doesn't allocate a
413-// context/cancel pair because there's nothing to cancel.
723+// — Done / Failed / Killed (already finalised). Reloaded Detached agents
724+// are probed first: if their worker survived the supervisor, Stop sends the
725+// shutdown command through the durable inbox instead of pretending no
726+// process exists.
414727 func (f *Swarm) Stop(id string) error {
728+ f.operationMu.Lock()
729+ defer f.operationMu.Unlock()
415730 a := f.Get(id)
416731 if a == nil {
417732 return fmt.Errorf("no such agent %q", id)
418733 }
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()
419741 a.mu.Lock()
420- switch a.status {
421- case StatusDone, StatusFailed, StatusKilled, StatusDetached:
742+ status := a.status
743+ if status == StatusDone || status == StatusFailed || status == StatusKilled {
744+ a.mu.Unlock()
745+ f.mu.Unlock()
746+ return nil
747+ }
748+ if status == StatusDetached {
749+ path := a.InboxPath
750+ inbox := a.inbox
751+ pid := a.ProcessPIDValue()
752+ protocolReady := a.protocolReady()
753+ a.mu.Unlock()
754+ f.mu.Unlock()
755+ if inbox == nil || !inboxLive(path) {
756+ return nil
757+ }
758+ var err error
759+ if protocolReady {
760+ err = inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
761+ } else {
762+ err = inbox.SendInput("shutdown")
763+ }
764+ if err != nil {
765+ return err
766+ }
767+ a.setActivity("shutdown requested")
768+ grace := f.cfg.Policy.CancelGracePeriod
769+ if grace <= 0 {
770+ grace = 10 * time.Second
771+ }
772+ deadline := time.Now().Add(grace)
773+ for inboxLive(path) {
774+ if time.Now().After(deadline) {
775+ if pid <= 0 {
776+ return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace)
777+ }
778+ if killErr := forceKillProcess(pid); killErr != nil {
779+ return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr)
780+ }
781+ forceDeadline := time.Now().Add(time.Second)
782+ for inboxLive(path) && time.Now().Before(forceDeadline) {
783+ time.Sleep(20 * time.Millisecond)
784+ }
785+ if inboxLive(path) {
786+ return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID)
787+ }
788+ }
789+ time.Sleep(20 * time.Millisecond)
790+ }
791+ a.mu.Lock()
792+ a.status = StatusKilled
793+ a.activity = "killed"
422794 a.mu.Unlock()
795+ a.setProcessState(ProcessKilled)
796+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
797+ a.setProcessPID(0)
798+ f.persistAgent(a)
423799 return nil
424800 }
425801 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+ }
427812 a.mu.Unlock()
428- // Belt-and-braces guard against the cancel-less case (detached
429- // agents skip this branch above, but a future code path that
430- // builds an Agent without a runner shouldn't crash the
431- // supervisor).
432- if a.cancel != nil {
433- a.cancel()
813+ f.mu.Unlock()
814+ a.setTurnState(TurnCanceling, a.CurrentTurnID())
815+
816+ if status == StatusPending {
817+ a.setProcessState(ProcessKilled)
818+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
819+ f.captureWorkspace(a)
820+ f.ensureResult(a, StatusKilled, context.Canceled)
821+ if a.cancel != nil {
822+ a.cancel()
823+ }
824+ f.persistAgent(a)
825+ if a.workspaceCleanup != nil {
826+ _ = a.workspaceCleanup()
827+ }
828+ _ = a.releaseLease()
829+ a.closeDone()
830+ f.schedule()
831+ return nil
434832 }
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
435838 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+ }()
437871 }
438872 return nil
439873 }
440874
441875 // StopAll cancels every running agent. Used on shutdown.
442876 func (f *Swarm) StopAll() {
443- for _, a := range f.List() {
877+ agents := f.List()
878+ for _, a := range agents {
444879 _ = f.Stop(a.ID)
445880 }
881+ for _, a := range agents {
882+ a.Wait()
883+ }
884+ if f.lifetimeCancel != nil {
885+ f.lifetimeCancel()
886+ }
446887 }
447888
448889 // Remove tears down the per-agent state for a terminated agent. It
@@ -451,25 +892,42 @@ func (f *Swarm) StopAll() {
451892 // (reloaded from disk) remove cleanly because they have no live
452893 // runner racing for the same files.
453894 //
454-// Agents share the host's working tree, so Remove never touches
455-// any source file — it only deletes the agent's state directory
895+// Shared-workspace agents never touch source files during Remove; worktree
896+// agents have already captured their output and removed the isolated checkout
897+// during finalization. Remove then deletes the agent's durable state directory
456898 // under <root>/agents/<id>/.
457899 func (f *Swarm) Remove(id string) error {
900+ f.operationMu.Lock()
901+ defer f.operationMu.Unlock()
458902 a := f.Get(id)
459903 if a == nil {
460904 return fmt.Errorf("no such agent %q", id)
461905 }
462906 a.mu.Lock()
463907 st := a.status
908+ inboxPath := a.InboxPath
464909 a.mu.Unlock()
465910 if st == StatusRunning || st == StatusPending {
466911 return fmt.Errorf("agent %s still %s", a.ID, st)
467912 }
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()
468926 // Best-effort cleanup of the per-agent state directory
469927 // (meta.json, events.jsonl, session.json). Failing here would leave
470928 // the user with no recourse,
471929 // so swallow the error.
472- _ = os.RemoveAll(f.agentStateDir(a.ID))
930+ _ = os.RemoveAll(a.stateDirectory(f.cfg.Root))
473931 f.mu.Lock()
474932 delete(f.agents, a.ID)
475933 for i, k := range f.order {
@@ -485,32 +943,48 @@ func (f *Swarm) Remove(id string) error {
485943 // Snapshot returns a read-only view of one agent. Safe for the TUI
486944 // goroutine to call repeatedly; never blocks on the Runner.
487945 type AgentSnapshot struct {
488- ID string
489- Task string
490- Dir string
491- Status Status
492- Activity string
493- Started time.Time
494- Finished time.Time
495- Err string
496- Tail string // last few transcript lines, joined with "\n"
497- Lines []string // full transcript (already capped by Agent.appendTranscript)
498- LastAssistant string // complete final assistant message, without role formatting
499-
500- // Model, Provider, and Reasoning expose the per-agent overrides set
501- // at Spawn time (empty when the agent inherits the child's default
502- // resolution). Subagent is the selected named markdown profile.
503- // The dashboard surfaces these so the user can confirm the child
504- // configuration.
505- Model string
506- Provider string
507- Reasoning string
508- FastMode bool
509- Subagent string
946+ ID string
947+ Task string
948+ Dir string
949+ Status Status
950+ ProcessState ProcessState
951+ TurnState TurnState
952+ CurrentTurnID string
953+ Attempt int
954+ Activity string
955+ Started time.Time
956+ Finished time.Time
957+ CreatedAt time.Time
958+ UpdatedAt time.Time
959+ LastActivity time.Time
960+ Err string
961+ Tail string // last few transcript lines, joined with "\n"
962+ Lines []string // full transcript (already capped by Agent.appendTranscript)
963+ LastAssistant string // complete final assistant message, without role formatting
964+ Result *TurnResult
965+ ResultRef string
966+ PatchRef string
967+ ChangedFiles []string
968+ OutputTruncated bool
969+
970+ // Model, Provider, Reasoning, and the provider connection settings
971+ // expose the per-agent configuration captured at Spawn time. Empty
972+ // values inherit the child's defaults. Subagent is the selected named
973+ // markdown profile. The dashboard surfaces these so the user can
974+ // confirm the child configuration.
975+ Model string
976+ Provider string
977+ BaseURL string
978+ InsecureTLS bool
979+ Reasoning string
980+ FastMode bool
981+ Subagent string
982+ WorkspaceMode WorkspaceMode
983+ WorkspacePath string
510984
511985 // Paths to the agent's durable state. Surface them in the
512- // snapshot so the dashboard / /swarm open can read events.jsonl
513- // or resume the session without going back through the Agent.
986+ // snapshot so the dashboard /subagents (or compatibility /swarm) view can
987+ // read events.jsonl or resume the session without going back through the Agent.
514988 InboxPath string
515989 EventLogPath string
516990 SessionPath string
@@ -520,7 +994,6 @@ type AgentSnapshot struct {
520994 // inspect at leisure.
521995 func (a *Agent) Snapshot() AgentSnapshot {
522996 a.mu.Lock()
523- defer a.mu.Unlock()
524997 tail := strings.Join(lastN(a.transcript, 6), "\n")
525998 lines := make([]string, len(a.transcript))
526999 copy(lines, a.transcript)
@@ -528,20 +1001,38 @@ func (a *Agent) Snapshot() AgentSnapshot {
5281001 if a.lastErr != nil {
5291002 errStr = a.lastErr.Error()
5301003 }
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()
5311023 return AgentSnapshot{
5321024 ID: a.ID, Task: a.Task, Dir: a.Dir,
533- Status: a.status, Activity: a.activity,
534- Started: a.Started, Finished: a.finished,
1025+ Status: status, ProcessState: processState, TurnState: turnState,
1026+ CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity,
1027+ Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
5351028 Err: errStr, Tail: tail, Lines: lines,
536- LastAssistant: a.lastAssistant,
537- Model: a.Model,
538- Provider: a.Provider,
539- Reasoning: a.Reasoning,
540- FastMode: a.FastMode,
541- Subagent: a.Subagent,
542- InboxPath: a.InboxPath,
543- EventLogPath: a.EventLogPath,
544- SessionPath: a.SessionPath,
1029+ LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
1030+ PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,
1031+ Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL,
1032+ InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning,
1033+ FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode,
1034+ WorkspacePath: a.WorkspacePath, InboxPath: a.InboxPath,
1035+ EventLogPath: a.EventLogPath, SessionPath: a.SessionPath,
5451036 }
5461037 }
5471038
@@ -551,11 +1042,10 @@ func (a *Agent) Snapshot() AgentSnapshot {
5511042 // Scoping rules:
5521043 // - activeSession == "": no filter, every agent is returned
5531044 // (historical behaviour; used by tests and scripted callers).
554-// - activeSession != "": include only agents whose SessionID
555-// matches activeSession OR is empty. The empty-id pass-through
556-// keeps pre-upgrade agents (their meta.json was written before
557-// session_id existed) visible from any session so the user
558-// doesn't lose access after the schema bump.
1045+// - activeSession != "": include live agents whose SessionID matches
1046+// activeSession, plus unscoped legacy agents. Detached and terminal
1047+// agents from another host session remain visible so restart/recovery
1048+// does not hide durable work.
5591049 func (f *Swarm) SnapshotAll() []AgentSnapshot {
5601050 agents := f.List()
5611051 f.mu.Lock()
@@ -565,7 +1055,13 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot {
5651055 out := make([]AgentSnapshot, 0, len(agents))
5661056 for _, a := range agents {
5671057 if active != "" && a.SessionID != "" && a.SessionID != active {
568- continue
1058+ // Keep detached/terminal entries visible after a normal host
1059+ // restart so durable recovery remains discoverable. Hide only
1060+ // live work belonging to another in-process host session.
1061+ status := a.Status()
1062+ if status == StatusRunning || status == StatusPending {
1063+ continue
1064+ }
5691065 }
5701066 out = append(out, a.Snapshot())
5711067 }
M packages/agent/swarm/testdata/cmd/stubchild/main.go +37 -11
@@ -27,6 +27,8 @@ import (
2727 "os"
2828 "sync"
2929 "time"
30+
31+ "github.com/patriceckhart/zot/packages/agent/swarm"
3032 )
3133
3234 func main() {
@@ -35,7 +37,8 @@ func main() {
3537 sessionPath string
3638 cwd string
3739 )
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")
3942 flag.StringVar(&sessionPath, "session", "", "session file path")
4043 flag.StringVar(&cwd, "cwd", "", "working directory")
4144 flag.Parse()
@@ -81,16 +84,33 @@ func main() {
8184 line, err := br.ReadString('\n')
8285 if line != "" {
8386 msg := trimNL(line)
84- switch {
85- case msg == "shutdown":
86- emit("agent_stopped", map[string]any{"reason": "shutdown"})
87- _ = c.Close()
88- return
89- case msg == "cancel":
90- emit("turn_end", map[string]any{"stop": "cancelled"})
91- case len(msg) > 5 && msg[:5] == "user ":
92- runTurn(emit, msg[5:], turn)
93- turn++
87+ if command, parseErr := swarm.ParseCommand(msg); parseErr == nil {
88+ switch command.Type {
89+ case swarm.CommandAgentShutdown:
90+ emit("agent_stopped", map[string]any{"reason": "shutdown"})
91+ _ = c.Close()
92+ return
93+ case swarm.CommandTurnCancel:
94+ emit("turn_end", map[string]any{"stop": "cancelled"})
95+ case swarm.CommandTurnStart:
96+ var payload swarm.TurnStartPayload
97+ if command.DecodePayload(&payload) == nil {
98+ runTurn(emit, payload.Prompt, turn)
99+ turn++
100+ }
101+ }
102+ } else {
103+ switch {
104+ case msg == "shutdown":
105+ emit("agent_stopped", map[string]any{"reason": "shutdown"})
106+ _ = c.Close()
107+ return
108+ case msg == "cancel":
109+ emit("turn_end", map[string]any{"stop": "cancelled"})
110+ case len(msg) > 5 && msg[:5] == "user ":
111+ runTurn(emit, msg[5:], turn)
112+ turn++
113+ }
94114 }
95115 }
96116 if err != nil {
@@ -125,12 +145,18 @@ type emitter = func(string, map[string]any)
125145 func newEmitter() emitter {
126146 var mu sync.Mutex
127147 enc := json.NewEncoder(os.Stdout)
148+ versioned := os.Getenv("ZOT_STUB_PROTOCOL") == "1"
128149 return func(typ string, data map[string]any) {
129150 mu.Lock()
130151 defer mu.Unlock()
131152 if data == nil {
132153 data = map[string]any{}
133154 }
155+ if versioned {
156+ envelope := swarm.NewEventEnvelope(typ, "stub-agent", "", data)
157+ _ = enc.Encode(envelope)
158+ return
159+ }
134160 data["type"] = typ
135161 data["time"] = time.Now().Format(time.RFC3339Nano)
136162 _ = 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 @@
11 package agent
22
33 import (
4+ "bytes"
45 "context"
56 "encoding/json"
7+ "errors"
68 "fmt"
79 "io"
810 "os"
11+ "strconv"
912 "strings"
1013 "sync"
1114 "time"
15+ "unicode/utf8"
1216
1317 "github.com/patriceckhart/zot/packages/agent/modes"
1418 "github.com/patriceckhart/zot/packages/agent/swarm"
@@ -108,28 +112,76 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
108112 }
109113
110114 em := newSwarmEmitter(os.Stdout, logMirror)
111-
112- em.emit("agent_ready", map[string]any{
115+ em.setProtocolIdentity(os.Getenv("ZOT_SWARM_AGENT_ID"))
116+ em.emit("agent.ready", map[string]any{
113117 "version": version,
114118 "cwd": r.CWD,
115119 "model": r.Model,
116120 })
117121
118- // turnCtx is the per-turn context. We keep a per-process
119- // cancel so the "cancel" inbox message can interrupt an
120- // in-flight turn without tearing down the whole daemon.
122+ // Heartbeats make a live-but-detached child distinguishable from a
123+ // failed turn after supervisor reconnect. The parent still treats
124+ // unknown event types as forward-compatible.
125+ heartbeatDone := make(chan struct{})
126+ defer close(heartbeatDone)
127+ go func() {
128+ interval := 10 * time.Second
129+ if raw := os.Getenv("ZOT_SWARM_HEARTBEAT_INTERVAL"); raw != "" {
130+ if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 {
131+ interval = parsed
132+ }
133+ }
134+ ticker := time.NewTicker(interval)
135+ defer ticker.Stop()
136+ for {
137+ select {
138+ case <-heartbeatDone:
139+ return
140+ case <-ticker.C:
141+ em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
142+ }
143+ }
144+ }()
145+
146+ // Keep a per-turn cancel so the "cancel" inbox message can interrupt
147+ // an in-flight turn without tearing down the whole daemon.
121148 var (
122- mu sync.Mutex
123- turnCtx context.Context = ctx
124- cancelFn context.CancelFunc
125- busyTurn bool
126- stopping bool
127- turnNo int
128- turns sync.WaitGroup
149+ mu sync.Mutex
150+ cancelFn context.CancelFunc
151+ busyTurn bool
152+ turnNo = initialTurnNumber(os.Getenv("ZOT_SWARM_EVENT_LOG"))
153+ turnWG sync.WaitGroup
154+ closing bool
155+ shutdown = make(chan struct{})
156+ shutdownOnce sync.Once
129157 )
158+ maxOutputBytes, maxOutputLines := workerOutputLimits()
130159
131160 runOne := func(prompt string) {
132161 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+ }
133185 if busyTurn {
134186 // Drop concurrent turns rather than queuing. The
135187 // supervisor protocol assumes one outstanding turn per
@@ -142,11 +194,12 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
142194 busyTurn = true
143195 turnNo++
144196 step := turnNo
145- turnCtx, cancelFn = context.WithCancel(ctx)
146- c := turnCtx
197+ c, cancel := context.WithCancel(ctx)
198+ cancelFn = cancel
147199 mu.Unlock()
148200
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})
150203
151204 sink := func(ev core.AgentEvent) {
152205 em.emit(ev.Type(), modes.EventToJSON(ev))
@@ -156,10 +209,32 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
156209 err := ag.Prompt(c, prompt, nil, sink)
157210 WriteNewTranscript(ag, sess, start)
158211
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+ }
159232 em.emit("turn_end", map[string]any{
160- "step": step,
161- "error": errString(err),
233+ "step": step,
234+ "turn_id": turnID,
235+ "error": errString(err),
162236 })
237+ em.emit("agent.idle", map[string]any{"turn_id": turnID})
163238
164239 mu.Lock()
165240 busyTurn = false
@@ -167,35 +242,46 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
167242 mu.Unlock()
168243 }
169244
170- startTurn := func(prompt string) {
245+ launchTurn := func(prompt string) {
171246 mu.Lock()
172- if stopping {
247+ if closing {
173248 mu.Unlock()
174249 return
175250 }
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)
177255 mu.Unlock()
178256 go func() {
179- defer turns.Done()
257+ defer turnWG.Done()
180258 runOne(prompt)
181259 }()
182260 }
183- stopAndWait := func(reason string) {
261+
262+ waitForActiveTurn := func(cancel bool) {
184263 mu.Lock()
185- stopping = true
186- if cancelFn != nil {
187- cancelFn()
264+ closing = true
265+ activeCancel := cancelFn
266+ mu.Unlock()
267+ if cancel && activeCancel != nil {
268+ activeCancel()
188269 }
270+ turnWG.Wait()
271+ }
272+
273+ requestShutdown := func() {
274+ mu.Lock()
275+ closing = true
189276 mu.Unlock()
190- turns.Wait()
191- em.emit("agent_stopped", map[string]any{"reason": reason})
277+ shutdownOnce.Do(func() { close(shutdown) })
192278 }
193279
194280 // Initial task: run before processing the inbox so the agent
195281 // "starts working" the moment it boots, matching what users
196282 // expect from `/swarm new <task>`.
197283 if args.Prompt != "" {
198- startTurn(args.Prompt)
284+ launchTurn(args.Prompt)
199285 }
200286
201287 // 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 {
205291 for {
206292 select {
207293 case <-ctx.Done():
208- stopAndWait("cancelled")
294+ waitForActiveTurn(true)
295+ em.emit("agent.exited", map[string]any{"reason": "cancelled"})
209296 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
210305 case msg, ok := <-ln.Lines():
211306 if !ok {
212- stopAndWait("inbox-closed")
307+ waitForActiveTurn(true)
308+ em.emit("agent.exited", map[string]any{"reason": "inbox-closed"})
213309 return nil
214310 }
215- switch {
216- case msg == "shutdown":
217- stopAndWait("shutdown")
218- return nil
219- case msg == "cancel":
311+ command, parseErr := swarm.ParseCommand(msg)
312+ if parseErr != nil {
313+ em.emit("error", map[string]any{"message": "invalid supervisor command"})
314+ continue
315+ }
316+ switch command.Type {
317+ case swarm.CommandAgentShutdown:
318+ requestShutdown()
319+ case swarm.CommandTurnCancel:
220320 mu.Lock()
221321 if cancelFn != nil {
222322 cancelFn()
223323 }
224324 mu.Unlock()
225- case strings.HasPrefix(msg, "user "):
226- prompt := strings.TrimPrefix(msg, "user ")
227- startTurn(prompt)
325+ case swarm.CommandAgentPing:
326+ em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
327+ case swarm.CommandTurnStart:
328+ var payload swarm.TurnStartPayload
329+ if err := command.DecodePayload(&payload); err != nil {
330+ em.emit("error", map[string]any{"message": "invalid turn.start payload"})
331+ continue
332+ }
333+ launchTurn(payload.Prompt)
228334 default:
229- em.emit("error", map[string]any{
230- "message": "unknown supervisor message: " + truncateForLog(msg, 200),
231- })
335+ // Unknown commands are rejected rather than interpreted as
336+ // child-originated control requests.
337+ em.emit("error", map[string]any{"message": "unknown supervisor command"})
232338 }
233339 }
234340 }
@@ -238,9 +344,11 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
238344 // durable log file. Concurrent goroutines call emit so we have to
239345 // hold a mutex around the encoder.
240346 type swarmEmitter struct {
241- mu sync.Mutex
242- w *os.File
243- mirror *swarm.EventLog
347+ mu sync.Mutex
348+ w *os.File
349+ mirror *swarm.EventLog
350+ agentID string
351+ versioned bool
244352
245353 // orphan flips true the first time a stdout write fails (broken
246354 // pipe — the supervisor died). Until then the mirror stays
@@ -258,46 +366,190 @@ func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter {
258366 return &swarmEmitter{w: w, mirror: mirror}
259367 }
260368
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+
261376 func (e *swarmEmitter) emit(typ string, data map[string]any) {
262377 if data == nil {
263378 data = map[string]any{}
264379 }
265- data["type"] = typ
266- data["time"] = time.Now().Format(time.RFC3339Nano)
380+ payload := make(map[string]any, len(data))
381+ for key, value := range data {
382+ payload[key] = value
383+ }
384+ turnID, _ := payload["turn_id"].(string)
267385
268386 e.mu.Lock()
269387 defer e.mu.Unlock()
270388
271- if !e.orphan {
272- // Encode to a buffer first so we can detect the stdout write
273- // error — json.Encoder swallows partial writes silently, but
274- // a direct Write on *os.File returns broken-pipe immediately.
275- line, err := json.Marshal(data)
389+ wireType := typ
390+ if e.versioned {
391+ wireType = canonicalWorkerEvent(typ)
392+ }
393+ var line []byte
394+ var err error
395+ if e.versioned {
396+ envelope := swarm.NewEventEnvelope(wireType, e.agentID, turnID, payload)
397+ line, err = swarm.MarshalJSONL(envelope)
276398 if err == nil {
277- line = append(line, '\n')
278- if _, werr := e.w.Write(line); werr != nil {
279- // Supervisor's stdout pipe is gone (parent zot exited
280- // but we kept running). Switch to mirror-only mode so
281- // subsequent events still get persisted; also retro-
282- // actively log this very event to the mirror so it
283- // doesn't get lost in the handoff.
284- e.orphan = true
399+ // Flatten payload fields as a compatibility courtesy. New peers
400+ // read `payload`; old peers can still inspect `step`, `content`,
401+ // and similar fields at the top level.
402+ var object map[string]any
403+ if json.Unmarshal(bytes.TrimSpace(line), &object) == nil {
404+ for key, value := range payload {
405+ if _, exists := object[key]; exists {
406+ continue
407+ }
408+ // Keep the compatibility flattening useful for old peers,
409+ // but do not duplicate large result/output fields on the
410+ // wire. New peers read the canonical payload object; a
411+ // duplicated 500 KiB output would otherwise exceed the
412+ // supervisor's bounded JSONL frame budget.
413+ if !compatibilityFlatValue(value) {
414+ continue
415+ }
416+ object[key] = value
417+ }
418+ line, err = json.Marshal(object)
419+ line = append(line, '\n')
285420 }
286421 }
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+ }
287436 }
288437
289438 if e.orphan && e.mirror != nil {
290- // Drop "type" + "time" out of data into Event fields so the
291- // mirror file matches what the supervisor would have written.
292- flat := map[string]any{}
293- for k, v := range data {
294- if k == "type" || k == "time" {
295- continue
439+ _ = e.mirror.Append(swarm.NewEvent(wireType, payload))
440+ }
441+}
442+
443+const compatibilityFlatValueBytes = 64 * 1024
444+
445+func compatibilityFlatValue(value any) bool {
446+ switch v := value.(type) {
447+ case string:
448+ return len(v) <= compatibilityFlatValueBytes
449+ case json.RawMessage:
450+ return len(v) <= compatibilityFlatValueBytes
451+ default:
452+ return true
453+ }
454+}
455+
456+func canonicalWorkerEvent(typ string) string {
457+ switch typ {
458+ case "agent_ready":
459+ return swarm.EventAgentReady
460+ case "agent_stopped":
461+ return swarm.EventAgentExited
462+ case "turn_start":
463+ return swarm.EventTurnStarted
464+ case "turn_progress":
465+ return swarm.EventTurnProgress
466+ case "tool_call":
467+ return swarm.EventToolStarted
468+ case "tool_result":
469+ return swarm.EventToolFinished
470+ case "text_delta":
471+ return swarm.EventMessageDelta
472+ default:
473+ return typ
474+ }
475+}
476+
477+func finalAssistantText(messages []provider.Message) string {
478+ var out strings.Builder
479+ for _, message := range messages {
480+ if message.Role != provider.RoleAssistant {
481+ continue
482+ }
483+ for _, block := range message.Content {
484+ if text, ok := block.(provider.TextBlock); ok {
485+ if out.Len() > 0 {
486+ out.WriteByte('\n')
487+ }
488+ out.WriteString(text.Text)
296489 }
297- flat[k] = v
298490 }
299- _ = e.mirror.Append(swarm.NewEvent(typ, flat))
300491 }
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)}
301553 }
302554
303555 func errString(err error) string {
@@ -307,6 +559,28 @@ func errString(err error) string {
307559 return err.Error()
308560 }
309561
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+
310584 func truncateForLog(s string, n int) string {
311585 if len(s) <= n {
312586 return s
M packages/agent/swarm_agent_test.go +64 -0
@@ -6,11 +6,30 @@ import (
66 "io"
77 "os"
88 "path/filepath"
9+ "strings"
910 "testing"
1011
1112 "github.com/patriceckhart/zot/packages/agent/swarm"
1213 )
1314
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+
1433 // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the
1534 // "everything is doubled after reopening a swarm agent" bug.
1635 //
@@ -84,6 +103,51 @@ func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
84103 // wire-format contract: each emitted event lands on stdout as one
85104 // JSON object per line with type+time at top level alongside the
86105 // 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+
87151 func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
88152 // Pipe so we can read what the emitter wrote.
89153 r, w, err := os.Pipe()
M packages/agent/tools/swarm_spawn.go +109 -41
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8+ "time"
89
910 "github.com/patriceckhart/zot/packages/agent/subagents"
1011 "github.com/patriceckhart/zot/packages/agent/swarm"
@@ -22,6 +23,10 @@ import (
2223 // can flip it off mid-session and the next call refuses cleanly
2324 // without re-registering the tool.
2425 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+
2530 // Swarm is the supervisor used to spawn agents. Nil means
2631 // "auto-swarm not available in this mode" and the tool always
2732 // errors.
@@ -51,6 +56,10 @@ type SwarmSpawnTool struct {
5156 OnSpawned func(agent *swarm.Agent, task string)
5257 }
5358
59+// SubagentSpawnTool is the canonical spelling; SwarmSpawnTool remains an
60+// alias for integrations that used the original API.
61+type SubagentSpawnTool = SwarmSpawnTool
62+
5463 type swarmSpawnArgs struct {
5564 Task string `json:"task"`
5665 Agent string `json:"agent,omitempty"`
@@ -58,6 +67,9 @@ type swarmSpawnArgs struct {
5867 Provider string `json:"provider,omitempty"`
5968 Reasoning string `json:"reasoning,omitempty"`
6069 Thinking string `json:"thinking,omitempty"`
70+ Isolation string `json:"isolation,omitempty"`
71+ Timeout string `json:"timeout,omitempty"`
72+ MaxTurns int `json:"max_turns,omitempty"`
6173 }
6274
6375 const swarmSpawnSchema = `{
@@ -88,23 +100,43 @@ const swarmSpawnSchema = `{
88100 "type": "string",
89101 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
90102 "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."
91117 }
92118 },
93119 "required": ["task"]
94120 }`
95121
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+}
97128 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."
99130 }
100131 func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
101132
102133 func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
134+ prefix := t.Name()
103135 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")
105137 }
106138 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.")
108140 }
109141 var a swarmSpawnArgs
110142 if err := json.Unmarshal(raw, &a); err != nil {
@@ -112,7 +144,26 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
112144 }
113145 task := strings.TrimSpace(a.Task)
114146 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")
116167 }
117168
118169 agentName := strings.TrimSpace(a.Agent)
@@ -120,15 +171,15 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
120171 var fastModeOverride *bool
121172 if agentName != "" {
122173 if t.ResolveSubagent == nil {
123- return protocolToolError("swarm_spawn: named subagent profiles are unavailable")
174+ return protocolToolError(prefix + ": named subagent profiles are unavailable")
124175 }
125176 var err error
126177 profile, err = t.ResolveSubagent(agentName)
127178 if err != nil {
128- return protocolToolError("swarm_spawn: " + err.Error())
179+ return protocolToolError(prefix + ": " + err.Error())
129180 }
130181 if profile == nil {
131- return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)
182+ return protocolToolError(prefix + ": unknown subagent profile " + agentName)
132183 }
133184 agentName = profile.Name
134185 fastModeOverride = profile.FastMode
@@ -136,55 +187,63 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
136187
137188 model := strings.TrimSpace(a.Model)
138189 providerID := strings.TrimSpace(a.Provider)
190+ var profileTools []string
191+ if profile != nil {
192+ profileTools = append([]string(nil), profile.Tools...)
193+ }
139194 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")
141196 }
142- if model == "" && providerID == "" && profile != nil {
143- // A fully-qualified profile model is useful metadata for the
144- // supervisor and also lets it inherit the right credential route.
145- // Bare profile model ids remain child-resolved so the configured
146- // provider is preserved.
197+ if profile != nil {
198+ // A profile may specify a qualified model, a bare model, a provider,
199+ // or neither. Fill only missing pieces so explicit spawn options win;
200+ // the parent session supplies whatever the profile leaves unspecified.
147201 profileProvider, profileModel := profile.ModelSelection()
148- if profileProvider != "" && profileModel != "" {
149- providerID, model = profileProvider, profileModel
150- }
151- }
152- if model == "" && providerID == "" && profile == nil {
153- if t.DefaultModel != nil {
154- model = strings.TrimSpace(t.DefaultModel())
202+ if model == "" {
203+ model = profileModel
155204 }
156- if t.DefaultProvider != nil {
157- providerID = strings.TrimSpace(t.DefaultProvider())
205+ if providerID == "" {
206+ providerID = profileProvider
158207 }
159208 }
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+ }
160215
161216 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
162217 if err != nil {
163- return protocolToolError("swarm_spawn: " + err.Error())
218+ return protocolToolError(prefix + ": " + err.Error())
164219 }
165220 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
166221 reasoning, err = reasoningOverride(profile.Thinking, "")
167222 if err != nil {
168- return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())
223+ return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
169224 }
170225 }
171226 if reasoning == "" && t.DefaultReasoning != nil {
172227 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
173228 if err != nil {
174- return protocolToolError("swarm_spawn: host " + err.Error())
229+ return protocolToolError(prefix + ": host " + err.Error())
175230 }
176231 }
177232
178233 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
179- Task: task,
180- Model: model,
181- Provider: providerID,
182- Reasoning: reasoning,
183- FastMode: fastModeOverride,
184- Subagent: agentName,
234+ Task: task,
235+ Model: model,
236+ Provider: providerID,
237+ Reasoning: reasoning,
238+ FastMode: fastModeOverride,
239+ Subagent: agentName,
240+ Timeout: timeout,
241+ MaxTurns: a.MaxTurns,
242+ WorkspaceMode: workspaceMode,
243+ Tools: profileTools,
185244 })
186245 if err != nil {
187- return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
246+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
188247 }
189248 if t.OnSpawned != nil {
190249 t.OnSpawned(agent, task)
@@ -192,6 +251,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
192251
193252 var sb strings.Builder
194253 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)
195256 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
196257 if agentName != "" {
197258 fmt.Fprintf(&sb, "agent: %s\n", agentName)
@@ -208,18 +269,25 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
208269 if agent.FastMode {
209270 sb.WriteString("fast mode: enabled\n")
210271 }
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. ")
212273 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
213274 return core.ToolResult{
214275 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
215276 Details: map[string]any{
216- "agent_id": agent.ID,
217- "task": task,
218- "agent": agentName,
219- "model": model,
220- "provider": providerID,
221- "reasoning": reasoning,
222- "fast_mode": agent.FastMode,
277+ "agent_id": agent.ID,
278+ "task": task,
279+ "agent": agentName,
280+ "model": model,
281+ "provider": providerID,
282+ "reasoning": reasoning,
283+ "fast_mode": agent.FastMode,
284+ "isolation": string(workspaceMode),
285+ "timeout": timeout.String(),
286+ "max_turns": agent.MaxTurns,
287+ "state": agent.Status(),
288+ "process_state": string(agent.ProcessState()),
289+ "turn_state": string(agent.TurnState()),
290+ "result_ref": swarm.ResultRef(agent.ID),
223291 },
224292 }, nil
225293 }
M packages/agent/tools/swarm_spawn_test.go +3 -1
@@ -19,13 +19,15 @@ func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil }
1919 func newTestSwarm(t *testing.T) *swarm.Swarm {
2020 t.Helper()
2121 root := t.TempDir()
22- return swarm.New(swarm.Config{
22+ manager := swarm.New(swarm.Config{
2323 Root: filepath.Join(root, "swarm"),
2424 RepoRoot: root,
2525 NewRunner: func(*swarm.Agent) swarm.Runner {
2626 return noopSwarmRunner{}
2727 },
2828 })
29+ t.Cleanup(manager.StopAll)
30+ return manager
2931 }
3032
3133 func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
M packages/tui/statusbar_test.go +18 -0
@@ -57,6 +57,24 @@ func TestStatusBarNoCWD(t *testing.T) {
5757 }
5858 }
5959
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+
6078 func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
6179 lines := StatusBar(StatusBarParams{
6280 Theme: Dark,
M packages/tui/view.go +34 -10
@@ -2408,6 +2408,7 @@ type StatusBarParams struct {
24082408 Provider string
24092409 Model string
24102410 Reasoning string // "" means thinking off
2411+ FastMode bool // show the provider's opt-in fast tier when enabled
24112412 Busy bool
24122413 BusyPrefix string // spinner + funny line when busy
24132414 CWD string
@@ -2509,8 +2510,15 @@ func StatusBar(p StatusBarParams) []string {
25092510 if reasoning != "" {
25102511 reasoningText = "reasoning: " + reasoning
25112512 }
2513+ fastText := ""
2514+ if p.FastMode {
2515+ fastText = "fast mode"
2516+ }
25122517 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+ }
25142522 if reasoningText != "" {
25152523 middleParts = append(middleParts, reasoningText)
25162524 }
@@ -2570,7 +2578,7 @@ func StatusBar(p StatusBarParams) []string {
25702578 modelLine := pad + th.FG256(th.Muted, left)
25712579 lines := []string{busyLine}
25722580 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)
25742582 } else {
25752583 var infoBuilder strings.Builder
25762584 infoBuilder.WriteString(modelLine)
@@ -2592,7 +2600,7 @@ func StatusBar(p StatusBarParams) []string {
25922600 // into an awkward position on small widths.
25932601 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
25942602 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)
25962604 if cwd != "" {
25972605 lines = append(lines, pad+th.FG256(th.Muted, cwd))
25982606 }
@@ -2609,9 +2617,17 @@ func StatusBar(p StatusBarParams) []string {
26092617 return []string{primary, cwdRendered}
26102618 }
26112619
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 {
26132621 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 == "" {
26152631 lines = append(lines, modelLine)
26162632 if statsText != "" {
26172633 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2619,13 +2635,21 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasonin
26192635 return lines
26202636 }
26212637
2622- modelReasoningPlain := pad + modelText + pad + reasoningText
2623- reasoningColor := reasoningStatusColor(th, reasoningText)
2624- if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols {
2625- lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+reasoningText))
2638+ modelInfoPlain := pad + modelText + pad + infoText
2639+ infoColor := th.Muted
2640+ if reasoningText != "" {
2641+ infoColor = reasoningStatusColor(th, reasoningText)
2642+ }
2643+ if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
2644+ lines = append(lines, pad+th.FG256(infoColor, modelText+pad+infoText))
26262645 } else {
26272646 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+ }
26292653 }
26302654 if statsText != "" {
26312655 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