swarm: introduce /swarm dashboard, /btw-style transcript view, and per-session scope
A /swarm subsystem for long-running parallel subagents. Each agent runs in its own subprocess against a fresh git worktree (branch swarm/<id>) with its own persistent session file and unix-socket inbox; the parent zot stays in the main session and pokes / observes them via the dashboard. Highlights: - New internal/swarm package: Agent, Spawn/Resume/Kill/Remove, event log (events.jsonl), inbox protocol (listen/dial), worktree manager, exec runner that spawns "zot --swarm-agent ...". - New internal/agent/swarm_agent.go: daemon-mode child entry point. Reuses the standard agent loop but persists turns to the supervisor- chosen session.json and streams events as JSONL on stdout. Mirror to events.jsonl is dormant while the supervisor's stdout pipe is alive so events do not get double-written. - Resume reattaches in place: reuses the same worktree, session, branch and inbox path; carries forward the prior transcript replayed from events.jsonl. Resume no longer re-fires the original Task as a fresh user turn -- that was producing "agent busy; send cancel first" races. - core.NewSessionAtPath plus an openOrCreateSession fallback so the child actually persists its session.json at the supervisor-chosen path on first spawn instead of running with sess==nil. - Dashboard in internal/agent/modes/swarm_dialog.go + swarm_slash.go: list / new / kill / remove / resume / logs / send subcommands plus an interactive picker. Transcript view is /btw-style: an always-on inline editor at the bottom, streaming auto-follow, inline busy spinner with the agent's current activity such as "thinking" or "tool: edit". /model inside the spawn editor pops the global model picker. - Per-session scope: each spawn is stamped with the host session's id and only shows in that session's /swarm dashboard. Pre-upgrade agents -- empty session_id -- remain visible everywhere as a safety net. The active scope is re-applied whenever loadSession swaps sessions. - Resolve falls back to the provider's default model when the persisted cfg.Model is no longer in the catalogue, warns on stderr, and rewrites config.json so the next launch is silent. - ReadEventLog folds back-to-back same-type identical-payload events within 250ms so events.jsonl files polluted by the old supervisor + mirror double-write read back cleanly. - DrawLog gains an idle no-op fast path: identical buffer plus identical cursor = emit nothing, so the terminal's cursor blink keeps ticking in dialogs whose underlying agent is idle. Slash UX: - New /swarm command with subcommands; the suggester picks it up. - README.md documents the full dashboard, CLI, and persistence story, and explicitly notes that /session export does NOT bundle subagents -- their worktree and unix-socket inbox cannot round-trip through a .zotsession. Tests cover: SpawnReq + Resume lifecycle, session-id scoping + persistence, default-child-args spawn vs resume contract, NewSessionAtPath at a fixed path, model fallback when the configured model is gone, swarm dialog behaviour -- auto-open editor, /model in spawn editor, transcript grows without internal scroll, busy spinner, multi-message send -- event-log dedup, swarm emitter dormant-until-orphan, and the DrawLog idle no-op + change-breaks-fast-path invariants.
- commit
- b11e6ed4e44a9e0a011000ce8fffc89807fa30fb
- author
- patriceckhart · · 2026-05-16 09:53 UTC
- parent
- 7970412
- signature
- unsigned
35 files · +8356 -13
M README.md +45 -0
| @@ -187,6 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||
| 187 | 187 | | `/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`. | | |
| 188 | 188 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 189 | 189 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 190 | + | | `/swarm` | Spawn, monitor, and chat with background subagents. Each gets its own git worktree and runs in parallel with your main session. | | |
| 190 | 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 191 | 192 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 192 | 193 | | `/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. | | |
| @@ -208,6 +209,8 @@ Shows previous sessions for the current working directory, newest first, with ti | |||
| 208 | 209 | Four ops on the current session. `/session` alone opens a picker; each is also runnable directly. | |
| 209 | 210 | ||
| 210 | 211 | - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | |
| 212 | + | ||
| 213 | + | **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, worktrees (which are real git checkouts under `$ZOT_HOME/swarm/worktrees/<id>` on branch `swarm/<id>`), unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to bundle a git worktree or revive a unix socket on another box. If you want to share a subagent's work, use normal git tooling on its branch (`git push`, `git format-patch`, etc.); if you want the conversation, copy it out of the dashboard manually. | |
| 211 | 214 | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | |
| 212 | 215 | - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. | |
| 213 | 216 | - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable. | |
| @@ -231,6 +234,48 @@ Each question fires a one-off model call against `system + main transcript + sid | |||
| 231 | 234 | ||
| 232 | 235 | 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. | |
| 233 | 236 | ||
| 237 | + | ### `/swarm` | |
| 238 | + | ||
| 239 | + | Long-running parallel subagents. Each one gets its own git worktree off your current repo (branch `swarm/<id>`), its own persistent session file, and its own background subprocess driving the model. You stay in the main session and check in on them whenever you want — the dashboard is a `/btw`-style chat per agent. | |
| 240 | + | ||
| 241 | + | Why: “go write the test harness for module X” / “investigate this stack trace” / “draft the migration” are perfect side tasks. Spawn an agent, keep working in your main session, come back to its results later — or chat with it interactively while it works. | |
| 242 | + | ||
| 243 | + | ``` | |
| 244 | + | /swarm # open the dashboard | |
| 245 | + | /swarm new <task> # spawn an agent on a fresh worktree | |
| 246 | + | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | |
| 247 | + | /swarm logs <id> # jump straight into one agent's transcript | |
| 248 | + | /swarm send <id> <text> # send a follow-up without opening the dashboard | |
| 249 | + | /swarm resume # pick a stopped agent to bring back | |
| 250 | + | /swarm resume <id> # bring a specific agent back | |
| 251 | + | /swarm kill <id> # stop a running agent (worktree stays) | |
| 252 | + | /swarm remove <id> # delete the worktree + session for an agent | |
| 253 | + | /swarm list # alias for opening the dashboard | |
| 254 | + | ``` | |
| 255 | + | ||
| 256 | + | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys: | |
| 257 | + | ||
| 258 | + | - `↑` / `↓` move the cursor between rows. | |
| 259 | + | - `enter` opens the highlighted agent's transcript view. | |
| 260 | + | - `n` spawns a new agent. Type the task, `enter` to confirm; the new agent inherits the model your main session is currently on (see `/model` and the in-editor `/model` command below). | |
| 261 | + | - `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript). | |
| 262 | + | - `R` resumes a stopped agent in place. | |
| 263 | + | - `k` kills the selected running agent (its worktree and session stay so you can resume later). | |
| 264 | + | - `r` removes the selected agent entirely (worktree + session + meta gone). | |
| 265 | + | - `esc` closes the dashboard. | |
| 266 | + | ||
| 267 | + | **Inside an agent's transcript** — a chat overlay just like `/btw`. The agent's conversation flows above an always-on inline composer; type and hit `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard. | |
| 268 | + | ||
| 269 | + | **Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The same model picker the global `/model` uses pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn. | |
| 270 | + | ||
| 271 | + | **Session scoping** — each agent is stamped with the session that spawned it and only shows up in that session's `/swarm` dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents you spawned in another session keep running and reappear when you switch back. Pre-upgrade agents (no session stamp) are visible from every session as a safety net. | |
| 272 | + | ||
| 273 | + | **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 worktree, session, branch, and inbox socket, so the conversation continues from where it left off. | |
| 274 | + | ||
| 275 | + | **Where their work lives** — worktrees go under `$ZOT_HOME/swarm/worktrees/<id>` on the branch `swarm/<id>`. Use the normal git tooling to inspect, merge, or rebase (`git worktree list`, `git log swarm/<id>`, etc.). `/swarm remove` deletes both the worktree and the swarm bookkeeping. | |
| 276 | + | ||
| 277 | + | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; swarm worktrees and per-agent state are machine-local (a real git worktree on disk and a unix-socket inbox, neither of which round-trips through a JSONL file). To share a subagent's actual work, push its `swarm/<id>` branch with the normal git tooling. To share what it said, copy it out of the transcript view manually. | |
| 278 | + | ||
| 234 | 279 | ### `/skills` | |
| 235 | 280 | ||
| 236 | 281 | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | |
M internal/agent/args.go +19 -0
| @@ -17,6 +17,13 @@ const ( | |||
| 17 | 17 | ModePrint Mode = "print" | |
| 18 | 18 | ModeJSON Mode = "json" | |
| 19 | 19 | ModeRPC Mode = "rpc" | |
| 20 | + | // ModeSwarmAgent is the long-lived, headless daemon mode used by | |
| 21 | + | // swarm-spawned agents. The binary opens a unix-socket inbox at | |
| 22 | + | // the path provided by --swarm-agent, reads supervisor messages | |
| 23 | + | // off it ("user ...", "cancel", "shutdown"), runs each user turn | |
| 24 | + | // against a persistent session, and streams JSONL events on | |
| 25 | + | // stdout. See internal/swarm/inbox.go for the wire protocol. | |
| 26 | + | ModeSwarmAgent Mode = "swarm-agent" | |
| 20 | 27 | ) | |
| 21 | 28 | ||
| 22 | 29 | // Args holds parsed command-line options. | |
| @@ -85,6 +92,11 @@ type Args struct { | |||
| 85 | 92 | Version bool | |
| 86 | 93 | ||
| 87 | 94 | Prompt string // concatenated positional args | |
| 95 | + | ||
| 96 | + | // SwarmAgent is the inbox-socket path when this process is a | |
| 97 | + | // swarm-spawned agent. Empty in every other mode. Set by | |
| 98 | + | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | |
| 99 | + | SwarmAgent string | |
| 88 | 100 | } | |
| 89 | 101 | ||
| 90 | 102 | // ParseArgs parses the process arguments (excluding argv[0]). | |
| @@ -202,6 +214,13 @@ func ParseArgs(in []string) (Args, error) { | |||
| 202 | 214 | return a, err | |
| 203 | 215 | } | |
| 204 | 216 | a.CWD = v | |
| 217 | + | case "--swarm-agent": | |
| 218 | + | v, err := want(&i, arg) | |
| 219 | + | if err != nil { | |
| 220 | + | return a, err | |
| 221 | + | } | |
| 222 | + | a.SwarmAgent = v | |
| 223 | + | a.Mode = ModeSwarmAgent | |
| 205 | 224 | case "--tools": | |
| 206 | 225 | v, err := want(&i, arg) | |
| 207 | 226 | if err != nil { | |
M internal/agent/build.go +63 -3
| @@ -118,6 +118,31 @@ func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []To | |||
| 118 | 118 | return out | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | // defaultModelForProvider returns the model id zot prefers when the | |
| 122 | + | // caller didn't pick one. Mirrors the per-provider switch used at | |
| 123 | + | // multiple points in Resolve; centralised so the unknown-model | |
| 124 | + | // recovery path and the no-model-configured path can't drift. | |
| 125 | + | // | |
| 126 | + | // Returns the empty string for "ollama", which has no built-in | |
| 127 | + | // default — the caller is expected to special-case ollama and | |
| 128 | + | // error or use whatever the user passed. | |
| 129 | + | func defaultModelForProvider(prov string) string { | |
| 130 | + | switch prov { | |
| 131 | + | case "openai": | |
| 132 | + | return "gpt-5" | |
| 133 | + | case "kimi": | |
| 134 | + | return "kimi-for-coding" | |
| 135 | + | case "deepseek": | |
| 136 | + | return "deepseek-v4-pro" | |
| 137 | + | case "google": | |
| 138 | + | return "gemini-2.5-pro" | |
| 139 | + | case "ollama": | |
| 140 | + | return "" | |
| 141 | + | default: | |
| 142 | + | return provider.DefaultModel.ID | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 121 | 146 | // Resolve merges args, config, and env into a Resolved set. | |
| 122 | 147 | // | |
| 123 | 148 | // Unlike the earlier version, Resolve NEVER returns an error for | |
| @@ -221,10 +246,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 221 | 246 | } | |
| 222 | 247 | } | |
| 223 | 248 | resolvedModel, err := provider.FindModel(provName, model) | |
| 224 | - | if err != nil && provName != "ollama" { | |
| 225 | - | return Resolved{}, err | |
| 226 | - | } | |
| 227 | 249 | if err != nil && provName == "ollama" { | |
| 250 | + | // ollama is intentionally open-catalogue: any model id the | |
| 251 | + | // local server understands is valid, even if not in the | |
| 252 | + | // baked-in catalog. | |
| 228 | 253 | resolvedModel = provider.Model{ | |
| 229 | 254 | Provider: "ollama", | |
| 230 | 255 | ID: model, | |
| @@ -234,6 +259,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 234 | 259 | BaseURL: args.BaseURL, | |
| 235 | 260 | Source: "ollama", | |
| 236 | 261 | } | |
| 262 | + | err = nil | |
| 263 | + | } | |
| 264 | + | if err != nil { | |
| 265 | + | // The model the user (or persisted config) asked for is no | |
| 266 | + | // longer in the active catalogue — they probably removed it | |
| 267 | + | // from their models.json or upgraded zot and the id changed. | |
| 268 | + | // Refusing to launch is the wrong move: it strands the user | |
| 269 | + | // with no way to even open the TUI and pick a new model. | |
| 270 | + | // Fall back to the provider's default, warn on stderr, and, | |
| 271 | + | // when the stale id came from the persisted config (not an | |
| 272 | + | // explicit --model flag), repair the config so the warning | |
| 273 | + | // doesn't repeat on every launch. | |
| 274 | + | fallback := defaultModelForProvider(provName) | |
| 275 | + | fm, ferr := provider.FindModel(provName, fallback) | |
| 276 | + | if ferr != nil { | |
| 277 | + | // Even the provider default is gone (catastrophic | |
| 278 | + | // catalogue trim). Last resort: any model on this | |
| 279 | + | // provider, then the global DefaultModel. | |
| 280 | + | if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 { | |
| 281 | + | fm = candidates[0] | |
| 282 | + | ferr = nil | |
| 283 | + | } else { | |
| 284 | + | fm = provider.DefaultModel | |
| 285 | + | ferr = nil | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | fmt.Fprintf(os.Stderr, | |
| 289 | + | "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |
| 290 | + | model, fm.ID) | |
| 291 | + | if args.Model == "" && cfg.Model == model { | |
| 292 | + | cfg.Model = fm.ID | |
| 293 | + | _ = SaveConfig(cfg) | |
| 294 | + | } | |
| 295 | + | resolvedModel = fm | |
| 296 | + | model = fm.ID | |
| 237 | 297 | } | |
| 238 | 298 | ||
| 239 | 299 | // If the model defines a base URL (e.g. local ollama) and the | |
M internal/agent/build_test.go +63 -0
| @@ -45,3 +45,66 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||
| 45 | 45 | t.Fatalf("expected no context, got %q", got) | |
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | + | ||
| 49 | + | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | |
| 50 | + | // startup failure caught by the user's screenshot: the persisted | |
| 51 | + | // config.json points at a model id that's no longer in the active | |
| 52 | + | // catalogue (because they edited models.json or zot's bundled | |
| 53 | + | // catalogue changed). Resolve must NOT error — strands the user | |
| 54 | + | // with no way to fix it from the TUI — and should repair the config | |
| 55 | + | // so the next launch is silent. | |
| 56 | + | func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) { | |
| 57 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 58 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 59 | + | // Persist a stale model id. | |
| 60 | + | stale := "gpt-5.5-pro-not-real" | |
| 61 | + | if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | r, err := Resolve(Args{}, false) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatalf("Resolve refused to launch with stale model: %v", err) | |
| 68 | + | } | |
| 69 | + | if r.Model == stale { | |
| 70 | + | t.Fatalf("Resolve kept stale model %q", r.Model) | |
| 71 | + | } | |
| 72 | + | if r.Provider != "openai" { | |
| 73 | + | t.Errorf("provider drifted: got %q; want openai", r.Provider) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Config on disk should now hold the fallback so subsequent | |
| 77 | + | // launches don't repeat the warning. | |
| 78 | + | cfg, _ := LoadConfig() | |
| 79 | + | if cfg.Model == stale { | |
| 80 | + | t.Errorf("config.json still pins the stale model %q", cfg.Model) | |
| 81 | + | } | |
| 82 | + | if cfg.Model == "" { | |
| 83 | + | t.Errorf("config.json was emptied; expected the fallback model id") | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the | |
| 88 | + | // repair-on-disk happens ONLY when the stale id came from the | |
| 89 | + | // persisted config. If the user passed --model X explicitly and X is | |
| 90 | + | // unknown, we still fall back, but we don't touch their config. | |
| 91 | + | func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { | |
| 92 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 93 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 94 | + | good := "gpt-5" | |
| 95 | + | if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | r, err := Resolve(Args{Model: "gpt-totally-fake"}, false) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatalf("Resolve errored on unknown --model: %v", err) | |
| 102 | + | } | |
| 103 | + | if r.Model == "gpt-totally-fake" { | |
| 104 | + | t.Errorf("Resolve kept the bogus --model value") | |
| 105 | + | } | |
| 106 | + | cfg, _ := LoadConfig() | |
| 107 | + | if cfg.Model != good { | |
| 108 | + | t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model) | |
| 109 | + | } | |
| 110 | + | } | |
M internal/agent/cli.go +54 -0
| @@ -4,10 +4,12 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "io" | |
| 9 | 10 | "os" | |
| 10 | 11 | "os/signal" | |
| 12 | + | "path/filepath" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "sync" | |
| 13 | 15 | "syscall" | |
| @@ -20,6 +22,7 @@ import ( | |||
| 20 | 22 | "github.com/patriceckhart/zot/internal/extproto" | |
| 21 | 23 | "github.com/patriceckhart/zot/internal/provider" | |
| 22 | 24 | "github.com/patriceckhart/zot/internal/skills" | |
| 25 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 23 | 26 | "github.com/patriceckhart/zot/internal/tui" | |
| 24 | 27 | ) | |
| 25 | 28 | ||
| @@ -218,6 +221,8 @@ func Run(rawArgs []string, version string) error { | |||
| 218 | 221 | return runJSONMode(ctx, args, version) | |
| 219 | 222 | case ModeRPC: | |
| 220 | 223 | return runRPCMode(ctx, args, version) | |
| 224 | + | case ModeSwarmAgent: | |
| 225 | + | return runSwarmAgentMode(ctx, args, version) | |
| 221 | 226 | default: | |
| 222 | 227 | return runInteractive(ctx, args, version) | |
| 223 | 228 | } | |
| @@ -534,6 +539,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 534 | 539 | ||
| 535 | 540 | var sess *core.Session | |
| 536 | 541 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 542 | + | // swarmMgr is constructed below, but loadSession (defined before | |
| 543 | + | // the construction site) needs to re-scope it whenever the user | |
| 544 | + | // swaps sessions. Forward-declare here so the closure can | |
| 545 | + | // reference it; the assignment happens at the construction line. | |
| 546 | + | var swarmMgr *swarm.Swarm | |
| 537 | 547 | // persistMu guards sess + sessBaselineMsgs against concurrent access | |
| 538 | 548 | // from the agent loop's per-message persistence hook (runs on the | |
| 539 | 549 | // agent goroutine) and the TUI's session swap / flush callbacks | |
| @@ -654,6 +664,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 654 | 664 | // the hydrated tail. | |
| 655 | 665 | sessBaselineMsgs = fullMsgCount | |
| 656 | 666 | persistMu.Unlock() | |
| 667 | + | // Re-scope the swarm dashboard to the new session so /swarm | |
| 668 | + | // only shows agents this session spawned. swarmMgr may be nil | |
| 669 | + | // here if we haven't reached the construction site yet (it | |
| 670 | + | // shouldn't be, since the interactive loop is what triggers | |
| 671 | + | // loadSession, but the nil check is cheap insurance). | |
| 672 | + | if swarmMgr != nil && newSess != nil { | |
| 673 | + | swarmMgr.SetActiveSession(newSess.ID) | |
| 674 | + | } | |
| 657 | 675 | return nil | |
| 658 | 676 | } | |
| 659 | 677 | ||
| @@ -708,6 +726,29 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 708 | 726 | }() | |
| 709 | 727 | ||
| 710 | 728 | initialCfg, _ := LoadConfig() | |
| 729 | + | ||
| 730 | + | // Build the swarm supervisor. Root lives under ZotHome/swarm so | |
| 731 | + | // worktrees survive across zot sessions and the user can hunt | |
| 732 | + | // them down with `git worktree list` if anything misbehaves. | |
| 733 | + | swarmMgr = swarm.New(swarm.Config{ | |
| 734 | + | Root: filepath.Join(ZotHome(), "swarm"), | |
| 735 | + | RepoRoot: r.CWD, | |
| 736 | + | }) | |
| 737 | + | // Pull any previously-spawned agents off disk so the user can see, | |
| 738 | + | // resume, or remove them from the dashboard. Failures here aren't | |
| 739 | + | // fatal — the supervisor still works for new agents. | |
| 740 | + | _, _ = swarmMgr.Reload() | |
| 741 | + | // Scope the dashboard to the active host session so /swarm only | |
| 742 | + | // shows agents this session spawned (and any pre-upgrade unscoped | |
| 743 | + | // agents — see SnapshotAll docs). Updated again whenever the | |
| 744 | + | // user swaps sessions via loadSession below. | |
| 745 | + | if sess != nil { | |
| 746 | + | swarmMgr.SetActiveSession(sess.ID) | |
| 747 | + | } | |
| 748 | + | // Best-effort shutdown on interactive exit: stop all running | |
| 749 | + | // agents so they don't outlive their parent zot. | |
| 750 | + | defer swarmMgr.StopAll() | |
| 751 | + | ||
| 711 | 752 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 712 | 753 | Terminal: term, | |
| 713 | 754 | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | |
| @@ -773,6 +814,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 773 | 814 | sessBaselineMsgs = len(currentAg.Messages()) | |
| 774 | 815 | }, | |
| 775 | 816 | Extensions: extMgr, | |
| 817 | + | Swarm: swarmMgr, | |
| 776 | 818 | ChangelogChan: changelogCh, | |
| 777 | 819 | OnChangelogDismiss: func() { | |
| 778 | 820 | // For dev builds (0.0.0) store the actual release version | |
| @@ -894,6 +936,18 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 894 | 936 | switch { | |
| 895 | 937 | case args.Session != "": | |
| 896 | 938 | s, msgs, err = core.OpenSession(args.Session) | |
| 939 | + | // The swarm-agent child passes a fixed --session path that | |
| 940 | + | // may not exist yet on first Spawn. Treat ENOENT as "create | |
| 941 | + | // a fresh session AT THIS PATH" so the conversation actually | |
| 942 | + | // gets persisted; without this fallback the swarm child runs | |
| 943 | + | // with sess==nil and every Resume re-starts with no memory | |
| 944 | + | // of the prior turns. Other openers (--continue / --resume / | |
| 945 | + | // the picker) never see ENOENT here because they only choose | |
| 946 | + | // paths that already exist on disk. | |
| 947 | + | if err != nil && errors.Is(err, os.ErrNotExist) { | |
| 948 | + | s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version) | |
| 949 | + | msgs = nil | |
| 950 | + | } | |
| 897 | 951 | case args.Continue: | |
| 898 | 952 | latest := core.LatestSession(ZotHome(), args.CWD) | |
| 899 | 953 | if latest != "" { | |
M internal/agent/modes/file_suggest.go +10 -2
| @@ -274,8 +274,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string { | |||
| 274 | 274 | return out | |
| 275 | 275 | } | |
| 276 | 276 | ||
| 277 | - | // fileChipRE matches [file:N:name] and [dir:N:name/] chips. | |
| 278 | - | var fileChipRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`) | |
| 277 | + | // fileChipRE matches @-picker chips inserted by modes: | |
| 278 | + | // [file:relative/path] and [dir:relative/path/]. The lower-level | |
| 279 | + | // tui.Editor has its own drag/drop chips ([file:N:basename]) which | |
| 280 | + | // Editor.SubmitValue expands before callers reach this helper; if one | |
| 281 | + | // leaks through, leave it untouched rather than guessing at a path. | |
| 282 | + | var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`) | |
| 283 | + | var editorFileChipRE = regexp.MustCompile(`^\d+:`) | |
| 279 | 284 | ||
| 280 | 285 | // expandFileChips replaces [file:name] and [dir:name/] chips with | |
| 281 | 286 | // the full path relative to cwd. | |
| @@ -286,6 +291,9 @@ func expandFileChips(text, cwd string) string { | |||
| 286 | 291 | return match | |
| 287 | 292 | } | |
| 288 | 293 | name := groups[2] | |
| 294 | + | if editorFileChipRE.MatchString(name) { | |
| 295 | + | return match | |
| 296 | + | } | |
| 289 | 297 | // Strip trailing slash from dir names. | |
| 290 | 298 | name = strings.TrimRight(name, "/") | |
| 291 | 299 | return filepath.Join(cwd, name) | |
A internal/agent/modes/file_suggest_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestExpandFileChipsAtPickerShape(t *testing.T) { | |
| 9 | + | cwd := filepath.Join(string(filepath.Separator), "repo") | |
| 10 | + | in := "read [file:README.md] and [dir:docs/]" | |
| 11 | + | want := "read " + filepath.Join(cwd, "README.md") + " and " + filepath.Join(cwd, "docs") | |
| 12 | + | if got := expandFileChips(in, cwd); got != want { | |
| 13 | + | t.Fatalf("expandFileChips() = %q, want %q", got, want) | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestExpandFileChipsLeavesEditorPlaceholderShapeAlone(t *testing.T) { | |
| 18 | + | cwd := filepath.Join(string(filepath.Separator), "repo") | |
| 19 | + | // tui.Editor.SubmitValue should expand [file:N:name] using its | |
| 20 | + | // private path map before modes see the text. If such a token leaks | |
| 21 | + | // through, modes must not guess that "1:foo.txt" is a relative path. | |
| 22 | + | in := "read [file:1:foo.txt]" | |
| 23 | + | if got := expandFileChips(in, cwd); got != in { | |
| 24 | + | t.Fatalf("editor placeholder was changed: %q", got) | |
| 25 | + | } | |
| 26 | + | } | |
M internal/agent/modes/interactive.go +69 -7
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "github.com/patriceckhart/zot/internal/extproto" | |
| 18 | 18 | "github.com/patriceckhart/zot/internal/provider" | |
| 19 | 19 | "github.com/patriceckhart/zot/internal/skills" | |
| 20 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 20 | 21 | "github.com/patriceckhart/zot/internal/tui" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| @@ -131,6 +132,13 @@ type InteractiveConfig struct { | |||
| 131 | 132 | // AFTER the built-in catalog so a built-in name always wins. | |
| 132 | 133 | Extensions *extensions.Manager | |
| 133 | 134 | ||
| 135 | + | // Swarm, if non-nil, enables the /swarm slash command and the | |
| 136 | + | // dashboard dialog. The cli constructs the Swarm once per | |
| 137 | + | // interactive run and tears it down on exit. nil disables the | |
| 138 | + | // feature entirely (used by embedders / tests that don't want | |
| 139 | + | // subprocesses). | |
| 140 | + | Swarm *swarm.Swarm | |
| 141 | + | ||
| 134 | 142 | // SkillSnapshot, if non-nil, returns the current list of | |
| 135 | 143 | // discovered SKILL.md files. Re-invoked each time /skills opens | |
| 136 | 144 | // so the picker reflects edits made during the session. | |
| @@ -280,6 +288,7 @@ type Interactive struct { | |||
| 280 | 288 | modelDialog *modelDialog | |
| 281 | 289 | rescueDialog *rescueDialog | |
| 282 | 290 | sessionDialog *sessionDialog | |
| 291 | + | swarmDialog *swarmDialog | |
| 283 | 292 | jumpDialog *jumpDialog | |
| 284 | 293 | btwDialog *btwDialog | |
| 285 | 294 | skillsDialog *skillsDialog | |
| @@ -367,6 +376,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 367 | 376 | modelDialog: newModelDialog(), | |
| 368 | 377 | rescueDialog: newRescueDialog(), | |
| 369 | 378 | sessionDialog: newSessionDialog(), | |
| 379 | + | swarmDialog: newSwarmDialog(), | |
| 370 | 380 | jumpDialog: newJumpDialog(), | |
| 371 | 381 | btwDialog: newBtwDialog(), | |
| 372 | 382 | skillsDialog: newSkillsDialog(), | |
| @@ -616,7 +626,16 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 616 | 626 | // the tick and firing it cancels the terminal's cursor | |
| 617 | 627 | // blink inside dialogs that host their own editor (btw), | |
| 618 | 628 | // because each frame re-emits hide-cursor + show-cursor. | |
| 619 | - | if i.busy || i.btwDialog.Loading() { | |
| 629 | + | // | |
| 630 | + | // The swarm dashboard is also animated: its rows reflect | |
| 631 | + | // background subprocesses whose activity / age change | |
| 632 | + | // without any user input. Without the tick redraw the | |
| 633 | + | // dashboard freezes on the snapshot taken when the user | |
| 634 | + | // last pressed a key. We exclude the dashboard when one | |
| 635 | + | // of its inline editors (spawn task or prompt composer) | |
| 636 | + | // is active so the cursor blink in those editors works | |
| 637 | + | // the same way it does inside btw. | |
| 638 | + | if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() { | |
| 620 | 639 | requestRedraw() | |
| 621 | 640 | } | |
| 622 | 641 | } | |
| @@ -877,6 +896,8 @@ func (i *Interactive) redraw() { | |||
| 877 | 896 | } | |
| 878 | 897 | i.sessionDialog.MaxRows = avail | |
| 879 | 898 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | |
| 899 | + | case i.swarmDialog.Active(): | |
| 900 | + | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | |
| 880 | 901 | case i.jumpDialog.Active(): | |
| 881 | 902 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | |
| 882 | 903 | case i.btwDialog.Active(): | |
| @@ -1024,12 +1045,22 @@ func (i *Interactive) redraw() { | |||
| 1024 | 1045 | bottom = append(bottom, "") | |
| 1025 | 1046 | } | |
| 1026 | 1047 | bottom = append(bottom, dialog...) | |
| 1027 | - | bottom = append(bottom, suggest...) | |
| 1028 | - | bottom = append(bottom, queue...) | |
| 1029 | - | bottom = append(bottom, "") | |
| 1030 | - | bottom = append(bottom, statusLines...) | |
| 1031 | - | bottom = append(bottom, "") | |
| 1032 | - | bottom = append(bottom, edLines...) | |
| 1048 | + | // The swarm dashboard owns the bottom of the screen while it's | |
| 1049 | + | // active: it has its own inline editors for spawn (`n`) and | |
| 1050 | + | // prompt (`p`), so the main input would be a confusing second | |
| 1051 | + | // caret. The suggest popup, sliding-in queue, status block, and | |
| 1052 | + | // main editor are all hidden underneath it. Keystrokes still | |
| 1053 | + | // reach handleKey — it routes them to swarmDialog.HandleKey | |
| 1054 | + | // before the editor ever sees them — so the only effect of this | |
| 1055 | + | // branch is visual. | |
| 1056 | + | if !i.swarmDialog.Active() { | |
| 1057 | + | bottom = append(bottom, suggest...) | |
| 1058 | + | bottom = append(bottom, queue...) | |
| 1059 | + | bottom = append(bottom, "") | |
| 1060 | + | bottom = append(bottom, statusLines...) | |
| 1061 | + | bottom = append(bottom, "") | |
| 1062 | + | bottom = append(bottom, edLines...) | |
| 1063 | + | } | |
| 1033 | 1064 | ||
| 1034 | 1065 | _, rows := i.cfg.Terminal.Size() | |
| 1035 | 1066 | chatRows := rows - len(bottom) | |
| @@ -1167,6 +1198,19 @@ func (i *Interactive) redraw() { | |||
| 1167 | 1198 | cursorCol = c | |
| 1168 | 1199 | } | |
| 1169 | 1200 | } | |
| 1201 | + | if i.swarmDialog.Active() { | |
| 1202 | + | if r, c := i.swarmDialog.CursorPos(cols); r >= 0 { | |
| 1203 | + | cursorRow = dialogLead + r | |
| 1204 | + | cursorCol = c | |
| 1205 | + | } else { | |
| 1206 | + | // Dashboard list / transcript view has no caret. Without | |
| 1207 | + | // this branch the default cursorRow points at the | |
| 1208 | + | // (hidden) main editor row, so the terminal would draw | |
| 1209 | + | // a stray block somewhere in the chat region. | |
| 1210 | + | cursorRow = -1 | |
| 1211 | + | cursorCol = 0 | |
| 1212 | + | } | |
| 1213 | + | } | |
| 1170 | 1214 | if i.extPanel.Active() { | |
| 1171 | 1215 | cursorRow = -1 | |
| 1172 | 1216 | cursorCol = 0 | |
| @@ -1477,6 +1521,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1477 | 1521 | } | |
| 1478 | 1522 | return false | |
| 1479 | 1523 | } | |
| 1524 | + | if i.swarmDialog.Active() { | |
| 1525 | + | if k.Kind == tui.KeyCtrlC { | |
| 1526 | + | i.swarmDialog.Close() | |
| 1527 | + | i.invalidate() | |
| 1528 | + | return false | |
| 1529 | + | } | |
| 1530 | + | _, msg, errMsg := i.swarmDialog.HandleKey(k) | |
| 1531 | + | if msg != "" || errMsg != "" { | |
| 1532 | + | i.mu.Lock() | |
| 1533 | + | i.statusOK = msg | |
| 1534 | + | i.statusErr = errMsg | |
| 1535 | + | i.mu.Unlock() | |
| 1536 | + | } | |
| 1537 | + | i.invalidate() | |
| 1538 | + | return false | |
| 1539 | + | } | |
| 1480 | 1540 | if i.logoutDialog.Active() { | |
| 1481 | 1541 | if k.Kind == tui.KeyCtrlC { | |
| 1482 | 1542 | i.logoutDialog.Close() | |
| @@ -2395,6 +2455,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 2395 | 2455 | break | |
| 2396 | 2456 | } | |
| 2397 | 2457 | i.openSessionOpsDialog() | |
| 2458 | + | case "/swarm": | |
| 2459 | + | i.runSwarm(ctx, parts[1:]) | |
| 2398 | 2460 | default: | |
| 2399 | 2461 | // Last-resort fallback: try the extension manager. Built-in | |
| 2400 | 2462 | // cases above always win; this branch only fires for slash | |
M internal/agent/modes/slash_suggest.go +1 -0
| @@ -45,6 +45,7 @@ var slashCatalog = []slashCommand{ | |||
| 45 | 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | |
| 46 | 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | |
| 47 | 47 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | |
| 48 | + | {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"}, | |
| 48 | 49 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | |
| 49 | 50 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | |
| 50 | 51 | {Name: "/clear", Desc: "clear the chat transcript"}, | |
M internal/agent/modes/slash_suggest_test.go +7 -0
| @@ -21,6 +21,13 @@ func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | func TestSlashSuggesterHasSwarm(t *testing.T) { | |
| 25 | + | s := newSlashSuggester() | |
| 26 | + | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | |
| 27 | + | t.Fatalf("/swarm missing from suggestions, got %v", got) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 24 | 31 | func commandNames(cmds []slashCommand) []string { | |
| 25 | 32 | out := make([]string, 0, len(cmds)) | |
| 26 | 33 | for _, c := range cmds { | |
A internal/agent/modes/swarm_dialog.go +1382 -0
| @@ -0,0 +1,1382 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 10 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // swarmDialog is the dashboard shown by /swarm (no argument) and | |
| 14 | + | // /swarm list. It's a read-only window onto a *swarm.Swarm; the | |
| 15 | + | // caller passes a snapshotter so the dialog never holds the swarm | |
| 16 | + | // pointer or its lock. | |
| 17 | + | // | |
| 18 | + | // Keys (list view): | |
| 19 | + | // | |
| 20 | + | // n spawn a new agent (inline task prompt) | |
| 21 | + | // p send a follow-up prompt to the selected agent | |
| 22 | + | // R resume (restart) a detached / finished agent | |
| 23 | + | // ↑/↓ move cursor | |
| 24 | + | // enter show transcript tail for the selected agent | |
| 25 | + | // k kill (Stop) the selected running agent | |
| 26 | + | // r remove a terminated agent (deletes worktree) | |
| 27 | + | // esc / q close | |
| 28 | + | // | |
| 29 | + | // Keys (transcript view): | |
| 30 | + | // | |
| 31 | + | // p send a follow-up prompt to this agent (inline editor) | |
| 32 | + | // ↑/↓/PgUp/PgDn/Home/End scroll | |
| 33 | + | // esc / q return to the list | |
| 34 | + | type swarmDialog struct { | |
| 35 | + | active bool | |
| 36 | + | snapshot func() []swarm.AgentSnapshot | |
| 37 | + | stop func(id string) error | |
| 38 | + | remove func(id string) error | |
| 39 | + | // spawn accepts an optional model + provider override (empty | |
| 40 | + | // strings mean "let the child resolve its own default"). The cli | |
| 41 | + | // adapter forwards these to swarm.Swarm.SpawnReq. | |
| 42 | + | spawn func(task, model, provider string) error | |
| 43 | + | // send delivers a follow-up user turn to a running agent's inbox. | |
| 44 | + | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | |
| 45 | + | send func(id, text string) error | |
| 46 | + | // resume restarts a detached or terminated agent on its existing | |
| 47 | + | // worktree/session. Wired by Open(); when nil the inline 'R' | |
| 48 | + | // shortcut is disabled. | |
| 49 | + | resume func(id string) error | |
| 50 | + | ||
| 51 | + | rows []swarm.AgentSnapshot | |
| 52 | + | cursor int | |
| 53 | + | viewing bool // when true, show full transcript of the selected row | |
| 54 | + | ||
| 55 | + | // transcriptSpin animates the busy hint shown above the inline | |
| 56 | + | // editor while the agent is mid-turn (status running, activity | |
| 57 | + | // anything other than idle). Same shape as the /btw spinner and | |
| 58 | + | // the main chat's busy line, so the user gets a consistent | |
| 59 | + | // "something's happening" signal. Lazily created on first | |
| 60 | + | // busy frame; reset by Open / Close. | |
| 61 | + | transcriptSpin *spinner | |
| 62 | + | ||
| 63 | + | // spawning toggles the inline "enter task" editor. While true the | |
| 64 | + | // dialog captures keys into a real tui.Editor so swarm-new has the | |
| 65 | + | // same editing, paste, drag-drop file chips, and visual style as | |
| 66 | + | // the default input and /btw input. | |
| 67 | + | spawning bool | |
| 68 | + | newTaskEd *tui.Editor | |
| 69 | + | fileSuggest *fileSuggester | |
| 70 | + | cwd string | |
| 71 | + | ||
| 72 | + | // pendingModel / pendingProvider are the model + provider every | |
| 73 | + | // freshly-spawned agent will be pinned to. They're seeded from | |
| 74 | + | // the host's currently-active model (via SetCurrentModel) at the | |
| 75 | + | // moment the user opens the dashboard, so swarm agents inherit | |
| 76 | + | // whatever the user last picked via /model. Empty means "don't | |
| 77 | + | // pin a model; let the child resolve its own default". | |
| 78 | + | pendingModel string | |
| 79 | + | pendingProvider string | |
| 80 | + | ||
| 81 | + | // pickingModel + modelPicker drive the in-spawn-editor /model | |
| 82 | + | // command: while typing a task into the spawn editor, the user | |
| 83 | + | // can submit just "/model" to pop the same picker /model uses | |
| 84 | + | // globally, choose a different model for this spawn, and resume | |
| 85 | + | // the editor with the new pending model captured. Empty when | |
| 86 | + | // the picker isn't on screen. | |
| 87 | + | pickingModel bool | |
| 88 | + | modelPicker *modelDialog | |
| 89 | + | modelPickerLoggedIn []string | |
| 90 | + | ||
| 91 | + | // spawnDraft remembers the editor buffer when we suspend it to | |
| 92 | + | // show the model picker, so the user doesn't lose half-typed | |
| 93 | + | // task text just because they wanted to switch models. Restored | |
| 94 | + | // when the picker closes (whether by select or Esc). | |
| 95 | + | spawnDraft string | |
| 96 | + | ||
| 97 | + | // prompting toggles the inline "send prompt to selected agent" | |
| 98 | + | // editor. Distinct from `spawning` so we don't accidentally | |
| 99 | + | // route a follow-up into Swarm.Spawn. The editor and its | |
| 100 | + | // file-suggest live in their own fields so opening the prompt | |
| 101 | + | // editor while a spawn editor is half-typed doesn't clobber it | |
| 102 | + | // (the dialog enforces one-at-a-time, but the separate state | |
| 103 | + | // keeps the lifecycle obvious). | |
| 104 | + | prompting bool | |
| 105 | + | promptEd *tui.Editor | |
| 106 | + | promptSuggest *fileSuggester | |
| 107 | + | // promptTargetID is the agent id captured the moment the user | |
| 108 | + | // pressed 'p'. We pin it here so the prompt routes to the same | |
| 109 | + | // agent even if the user's cursor moves (or rows reorder) while | |
| 110 | + | // they're typing. | |
| 111 | + | promptTargetID string | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func newSwarmDialog() *swarmDialog { return &swarmDialog{} } | |
| 115 | + | ||
| 116 | + | // SetCurrentModel pins the model + provider every fresh spawn will | |
| 117 | + | // inherit. The host wires this to the same Model / Provider the rest | |
| 118 | + | // of zot is currently using, so agents started from the dashboard | |
| 119 | + | // run on whatever the user last picked via /model. Pass empty | |
| 120 | + | // strings to clear the override (the child then resolves its own | |
| 121 | + | // default the same way a bare `zot` invocation does). | |
| 122 | + | func (d *swarmDialog) SetCurrentModel(model, providerID string) { | |
| 123 | + | d.pendingModel = model | |
| 124 | + | d.pendingProvider = providerID | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // SetLoggedInProviders supplies the list of providers the user has | |
| 128 | + | // active credentials for. Forwarded to the embedded modelDialog when | |
| 129 | + | // the user invokes /model inside the spawn editor so the picker can | |
| 130 | + | // filter out unauthenticated providers, matching the global /model | |
| 131 | + | // behaviour. | |
| 132 | + | func (d *swarmDialog) SetLoggedInProviders(provs []string) { | |
| 133 | + | d.modelPickerLoggedIn = provs | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Open binds the dialog to a swarm via the provided callbacks. The | |
| 137 | + | // dialog refreshes the snapshot every Render so the dashboard reads | |
| 138 | + | // live state without needing a goroutine. spawn / send are optional; | |
| 139 | + | // when nil the corresponding inline shortcut ('n' / 'p') is disabled. | |
| 140 | + | func (d *swarmDialog) Open( | |
| 141 | + | snapshot func() []swarm.AgentSnapshot, | |
| 142 | + | stop func(id string) error, | |
| 143 | + | remove func(id string) error, | |
| 144 | + | spawn func(task, model, provider string) error, | |
| 145 | + | send func(id, text string) error, | |
| 146 | + | resume func(id string) error, | |
| 147 | + | cwd string, | |
| 148 | + | ) { | |
| 149 | + | d.snapshot = snapshot | |
| 150 | + | d.stop = stop | |
| 151 | + | d.remove = remove | |
| 152 | + | d.spawn = spawn | |
| 153 | + | d.send = send | |
| 154 | + | d.resume = resume | |
| 155 | + | d.active = true | |
| 156 | + | d.cursor = 0 | |
| 157 | + | d.viewing = false | |
| 158 | + | d.spawning = false | |
| 159 | + | d.newTaskEd = nil | |
| 160 | + | d.fileSuggest = nil | |
| 161 | + | d.prompting = false | |
| 162 | + | d.promptEd = nil | |
| 163 | + | d.promptSuggest = nil | |
| 164 | + | d.promptTargetID = "" | |
| 165 | + | // pendingModel / pendingProvider are NOT cleared here: the host | |
| 166 | + | // sets them via SetCurrentModel just before opening the dialog | |
| 167 | + | // so the spawn flow inherits whatever /model selected. Resetting | |
| 168 | + | // here would undo that. | |
| 169 | + | d.pickingModel = false | |
| 170 | + | d.modelPicker = nil | |
| 171 | + | d.spawnDraft = "" | |
| 172 | + | d.transcriptSpin = nil | |
| 173 | + | d.cwd = cwd | |
| 174 | + | d.refresh() | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // CursorPos returns the row/col for the terminal cursor while an | |
| 178 | + | // inline editor (spawn or prompt) is active so the caret blinks in | |
| 179 | + | // the right spot. Returns -1, -1 when no input is being captured. | |
| 180 | + | func (d *swarmDialog) CursorPos(width int) (row, col int) { | |
| 181 | + | if !d.Active() { | |
| 182 | + | return -1, -1 | |
| 183 | + | } | |
| 184 | + | // Spawn editor (list view): rendered directly under the frame | |
| 185 | + | // header, optionally preceded by the model banner row (when a | |
| 186 | + | // model was picked) and the @ file-suggest popup, with one | |
| 187 | + | // breathing-room blank row inserted just above the editor. The | |
| 188 | + | // constants below must mirror the spawn-editor render path. | |
| 189 | + | if d.spawning && d.newTaskEd != nil { | |
| 190 | + | modelBannerRows := 0 | |
| 191 | + | if d.pendingModel != "" { | |
| 192 | + | modelBannerRows = 1 | |
| 193 | + | } | |
| 194 | + | popupRows := 0 | |
| 195 | + | if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) { | |
| 196 | + | popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width)) | |
| 197 | + | } | |
| 198 | + | _, eRow, eCol := d.newTaskEd.Render(width - 2) | |
| 199 | + | const editorBlankRow = 1 | |
| 200 | + | return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2 | |
| 201 | + | } | |
| 202 | + | // Prompt editor cursor placement. Two flavours: | |
| 203 | + | // 1. List-view modal (`p` from the dashboard list): renderPromptEditor | |
| 204 | + | // draws frame header + "send to <id>:" hint + popup + editor. | |
| 205 | + | // 2. Transcript view (always-on editor, /btw-style): the editor | |
| 206 | + | // lives at the bottom of renderTranscript's output, after the | |
| 207 | + | // metadata block, the "── transcript ──" rule, the transcript | |
| 208 | + | // body itself, optional "↑/↓ N more" rows and the @-popup. | |
| 209 | + | // | |
| 210 | + | // Both flavours funnel through here. The math must mirror the | |
| 211 | + | // corresponding render path exactly. | |
| 212 | + | if d.prompting && d.promptEd != nil { | |
| 213 | + | popupRows := 0 | |
| 214 | + | if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) { | |
| 215 | + | popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width)) | |
| 216 | + | } | |
| 217 | + | _, eRow, eCol := d.promptEd.Render(width - 2) | |
| 218 | + | ||
| 219 | + | if d.viewing { | |
| 220 | + | return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2 | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // List-view modal layout: frame header + "send to <id>:" hint | |
| 224 | + | // + popup + blank + editor. | |
| 225 | + | const headerRows = 1 | |
| 226 | + | const sendToHintRow = 1 | |
| 227 | + | const editorBlankRow = 1 | |
| 228 | + | return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2 | |
| 229 | + | } | |
| 230 | + | return -1, -1 | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // transcriptEditorCursorRow computes the visual row the caret | |
| 234 | + | // belongs on when the always-on editor sits at the bottom of the | |
| 235 | + | // transcript view. Mirrors renderTranscript's row layout so the | |
| 236 | + | // terminal cursor stays glued to the editor as the body grows. | |
| 237 | + | func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int { | |
| 238 | + | a := d.selected() | |
| 239 | + | if a == nil { | |
| 240 | + | return -1 | |
| 241 | + | } | |
| 242 | + | row := 1 // frame header | |
| 243 | + | row += 4 // task / branch / dir / status | |
| 244 | + | if a.Model != "" { | |
| 245 | + | row++ | |
| 246 | + | } | |
| 247 | + | if a.Err != "" { | |
| 248 | + | row++ | |
| 249 | + | } | |
| 250 | + | row += 3 // blank + "── transcript ──" + trailing blank | |
| 251 | + | ||
| 252 | + | // Replay transcript body sizing (no-transcript-yet path vs. | |
| 253 | + | // real body path) so the editor row count matches what render | |
| 254 | + | // actually emitted. | |
| 255 | + | raw := a.Lines | |
| 256 | + | if len(raw) == 0 && a.Tail != "" { | |
| 257 | + | raw = strings.Split(a.Tail, "\n") | |
| 258 | + | } | |
| 259 | + | if len(raw) == 0 { | |
| 260 | + | row++ // "(no transcript yet)" line | |
| 261 | + | } else { | |
| 262 | + | // Full body, no scroll window — the host's outer scrollback | |
| 263 | + | // owns overflow now. Cursor row math just counts every | |
| 264 | + | // rendered line. | |
| 265 | + | body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width) | |
| 266 | + | row += len(body) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | row += popupRows // @-file-suggest popup, if active | |
| 270 | + | if agentIsBusy(a) { | |
| 271 | + | row += 2 // blank + spinner row inserted above the editor | |
| 272 | + | } | |
| 273 | + | row++ // blank above editor (appendTranscriptEditor) | |
| 274 | + | return row + editorRowOffset | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (d *swarmDialog) Active() bool { return d != nil && d.active } | |
| 278 | + | func (d *swarmDialog) Close() { d.active = false } | |
| 279 | + | ||
| 280 | + | // NeedsTickRefresh reports whether the host's animation tick should | |
| 281 | + | // repaint the screen while this dialog is open. True when the | |
| 282 | + | // dashboard's list/transcript view is on (rows / activity / age and | |
| 283 | + | // the transcript itself are background-driven and need a periodic | |
| 284 | + | // redraw — essential for streaming agent replies to actually appear | |
| 285 | + | // on screen). False only while a modal editor (spawn, list-view | |
| 286 | + | // prompt, model picker) has the dialog to itself; those need the | |
| 287 | + | // terminal's cursor blink, which a full-frame redraw cancels. | |
| 288 | + | // | |
| 289 | + | // Note: the transcript view's always-on editor does NOT suppress | |
| 290 | + | // tick refresh. The cursor blink still works because the tick rate | |
| 291 | + | // is well below the terminal's blink rate, and we need the redraws | |
| 292 | + | // for streaming output. /btw makes the same trade-off. | |
| 293 | + | func (d *swarmDialog) NeedsTickRefresh() bool { | |
| 294 | + | if d == nil || !d.active { | |
| 295 | + | return false | |
| 296 | + | } | |
| 297 | + | if d.spawning || d.pickingModel { | |
| 298 | + | return false | |
| 299 | + | } | |
| 300 | + | // The list-view `p` modal (prompting without viewing) suppresses | |
| 301 | + | // tick like the other modal editors. The transcript-view | |
| 302 | + | // always-on editor (viewing && prompting) does not — streaming | |
| 303 | + | // agent output needs periodic redraws to render. | |
| 304 | + | if d.prompting && !d.viewing { | |
| 305 | + | return false | |
| 306 | + | } | |
| 307 | + | return true | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // OpenViewing opens the dashboard and jumps straight to the | |
| 311 | + | // transcript view for the agent with id agentID. Used by /swarm logs | |
| 312 | + | // <id>. If no such agent is found the dashboard opens normally so | |
| 313 | + | // the user can see why (and pick from the list). | |
| 314 | + | func (d *swarmDialog) OpenViewing( | |
| 315 | + | agentID string, | |
| 316 | + | snapshot func() []swarm.AgentSnapshot, | |
| 317 | + | stop func(id string) error, | |
| 318 | + | remove func(id string) error, | |
| 319 | + | spawn func(task, model, provider string) error, | |
| 320 | + | send func(id, text string) error, | |
| 321 | + | resume func(id string) error, | |
| 322 | + | cwd string, | |
| 323 | + | ) bool { | |
| 324 | + | d.Open(snapshot, stop, remove, spawn, send, resume, cwd) | |
| 325 | + | rows := d.snapshot() | |
| 326 | + | for i, r := range rows { | |
| 327 | + | if r.ID == agentID || strings.HasPrefix(r.ID, agentID) { | |
| 328 | + | d.cursor = i | |
| 329 | + | d.viewing = true | |
| 330 | + | // Auto-open the inline editor for running agents so | |
| 331 | + | // /swarm logs <id> drops the user straight into a | |
| 332 | + | // /btw-style send-ready prompt. | |
| 333 | + | if r.Status == swarm.StatusRunning && d.send != nil { | |
| 334 | + | d.openPromptEditor(tui.Theme{}, r.ID) | |
| 335 | + | } | |
| 336 | + | return true | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | return false | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // OpenForResume opens the dashboard and parks the cursor on the first | |
| 343 | + | // resumable agent (any non-running status), so the user can press R | |
| 344 | + | // to resume without typing an id. Returns the count of resumable | |
| 345 | + | // agents found — callers surface that as "3 resumable, press R" or, | |
| 346 | + | // when zero, "no agents available to resume". | |
| 347 | + | // | |
| 348 | + | // The dialog still shows the full list (live + detached + terminated) | |
| 349 | + | // so the user has context: pressing ↑/↓ they can still walk through | |
| 350 | + | // running agents, and the R key is a no-op there (Resume rejects | |
| 351 | + | // non-terminal agents with a clear error). | |
| 352 | + | func (d *swarmDialog) OpenForResume( | |
| 353 | + | snapshot func() []swarm.AgentSnapshot, | |
| 354 | + | stop func(id string) error, | |
| 355 | + | remove func(id string) error, | |
| 356 | + | spawn func(task, model, provider string) error, | |
| 357 | + | send func(id, text string) error, | |
| 358 | + | resume func(id string) error, | |
| 359 | + | cwd string, | |
| 360 | + | ) (resumable int) { | |
| 361 | + | d.Open(snapshot, stop, remove, spawn, send, resume, cwd) | |
| 362 | + | // d.rows was populated by Open's refresh(); walk it for the | |
| 363 | + | // first resumable row and count the total in one pass. | |
| 364 | + | for i, r := range d.rows { | |
| 365 | + | if isResumable(r.Status) { | |
| 366 | + | if resumable == 0 { | |
| 367 | + | d.cursor = i | |
| 368 | + | } | |
| 369 | + | resumable++ | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | return resumable | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // isResumable reports whether a Status is eligible for Resume. Mirror | |
| 376 | + | // of the precondition inside Swarm.Resume; centralised so the dialog | |
| 377 | + | // and the slash handler agree on what "resumable" means. | |
| 378 | + | func isResumable(s swarm.Status) bool { | |
| 379 | + | switch s { | |
| 380 | + | case swarm.StatusDetached, | |
| 381 | + | swarm.StatusDone, | |
| 382 | + | swarm.StatusFailed, | |
| 383 | + | swarm.StatusKilled: | |
| 384 | + | return true | |
| 385 | + | } | |
| 386 | + | return false | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // promptDisabledHint returns the status-bar message shown when the | |
| 390 | + | // user presses 'p' on an agent that can't receive input. We branch on | |
| 391 | + | // status so the hint points at the right next step: resume for the | |
| 392 | + | // states Swarm.Resume accepts; nothing useful otherwise. | |
| 393 | + | func promptDisabledHint(s swarm.Status) string { | |
| 394 | + | if isResumable(s) { | |
| 395 | + | return "prompt: agent is " + string(s) + "; press R to resume first" | |
| 396 | + | } | |
| 397 | + | return "prompt: agent is " + string(s) + " and can't receive input" | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut. | |
| 401 | + | // Kill only makes sense on running / pending agents; on detached and | |
| 402 | + | // terminal ones it's a no-op and the user usually wants 'r' (remove) | |
| 403 | + | // to clean up the worktree instead. | |
| 404 | + | func killDisabledHint(s swarm.Status) string { | |
| 405 | + | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // friendlySendErr turns a raw send error into a status-bar message | |
| 409 | + | // the user can act on. The most common failure mode — dialling an | |
| 410 | + | // agent whose previous subprocess exited — surfaces as | |
| 411 | + | // swarm.ErrNotReady; we rewrite it to mention resume instead of | |
| 412 | + | // leaking the unix-socket path. Other errors fall through verbatim | |
| 413 | + | // because they're rare and almost always indicate a real bug. | |
| 414 | + | func friendlySendErr(id string, err error) string { | |
| 415 | + | if errors.Is(err, swarm.ErrNotReady) { | |
| 416 | + | return "send: agent " + id + " isn't accepting input (press R to resume)" | |
| 417 | + | } | |
| 418 | + | return "send: " + err.Error() | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func (d *swarmDialog) refresh() { | |
| 422 | + | if d.snapshot == nil { | |
| 423 | + | d.rows = nil | |
| 424 | + | return | |
| 425 | + | } | |
| 426 | + | d.rows = d.snapshot() | |
| 427 | + | if d.cursor >= len(d.rows) { | |
| 428 | + | d.cursor = len(d.rows) - 1 | |
| 429 | + | } | |
| 430 | + | if d.cursor < 0 { | |
| 431 | + | d.cursor = 0 | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | |
| 436 | + | // statusMsg / statusErr surface in the host's status bar so the user | |
| 437 | + | // gets feedback on kill/remove without writing to the chat. | |
| 438 | + | func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |
| 439 | + | if !d.Active() { | |
| 440 | + | return false, "", "" | |
| 441 | + | } | |
| 442 | + | d.refresh() | |
| 443 | + | ||
| 444 | + | // Inline editors own key handling. They run before the other | |
| 445 | + | // modes so the user's typed letters aren't interpreted as 'k' | |
| 446 | + | // (kill), 'p' (prompt), etc. | |
| 447 | + | // | |
| 448 | + | // Transcript view is a special case: the editor is always on, | |
| 449 | + | // but we still need transcript-level keys (PageUp/PageDown/ | |
| 450 | + | // Home/End scroll, Esc close) to take precedence over the | |
| 451 | + | // editor. handleViewingKey owns that routing and delegates | |
| 452 | + | // editor input to handlePromptKey itself. | |
| 453 | + | if d.viewing { | |
| 454 | + | return d.handleViewingKey(k) | |
| 455 | + | } | |
| 456 | + | if d.prompting { | |
| 457 | + | return d.handlePromptKey(k) | |
| 458 | + | } | |
| 459 | + | if d.pickingModel { | |
| 460 | + | return d.handleModelPickerKey(k) | |
| 461 | + | } | |
| 462 | + | if d.spawning { | |
| 463 | + | return d.handleSpawnKey(k) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | switch k.Kind { | |
| 467 | + | case tui.KeyEsc: | |
| 468 | + | d.Close() | |
| 469 | + | return true, "", "" | |
| 470 | + | case tui.KeyUp: | |
| 471 | + | if d.cursor > 0 { | |
| 472 | + | d.cursor-- | |
| 473 | + | } | |
| 474 | + | case tui.KeyDown: | |
| 475 | + | if d.cursor < len(d.rows)-1 { | |
| 476 | + | d.cursor++ | |
| 477 | + | } | |
| 478 | + | case tui.KeyEnter: | |
| 479 | + | if len(d.rows) > 0 { | |
| 480 | + | d.viewing = true | |
| 481 | + | // Auto-open the inline editor (à la /btw) so the user | |
| 482 | + | // can start typing immediately. Skipped for non-running | |
| 483 | + | // agents; their inbox isn't listening, so an editor | |
| 484 | + | // would just frustrate them with bounced sends. | |
| 485 | + | if a := d.selected(); a != nil && a.Status == swarm.StatusRunning && d.send != nil { | |
| 486 | + | d.openPromptEditor(tui.Theme{}, a.ID) | |
| 487 | + | } | |
| 488 | + | } | |
| 489 | + | case tui.KeyRune: | |
| 490 | + | switch k.Rune { | |
| 491 | + | case 'q': | |
| 492 | + | d.Close() | |
| 493 | + | return true, "", "" | |
| 494 | + | case 'n': | |
| 495 | + | if d.spawn != nil { | |
| 496 | + | // Open the task editor directly. The model the new | |
| 497 | + | // agent will run against was already captured via | |
| 498 | + | // SetCurrentModel from the host's /model selection; | |
| 499 | + | // the user changes models the normal way (/model) | |
| 500 | + | // before opening /swarm. | |
| 501 | + | d.openSpawnEditor(tui.Theme{}) | |
| 502 | + | } | |
| 503 | + | case 'p': | |
| 504 | + | if a := d.selected(); a != nil && d.send != nil { | |
| 505 | + | // Only running agents have a live inbox listener; | |
| 506 | + | // dialling the unix socket on a detached / done / | |
| 507 | + | // failed / killed agent fails with a long ECONNREFUSED | |
| 508 | + | // or ENOENT trail that's not actionable. Tell the user | |
| 509 | + | // what to do instead. | |
| 510 | + | if a.Status != swarm.StatusRunning { | |
| 511 | + | return false, "", promptDisabledHint(a.Status) | |
| 512 | + | } | |
| 513 | + | d.openPromptEditor(tui.Theme{}, a.ID) | |
| 514 | + | } | |
| 515 | + | case 'R': | |
| 516 | + | // Capital R for resume; lowercase r is already taken by | |
| 517 | + | // remove. Using a different keystroke avoids a confirm | |
| 518 | + | // modal for an action that's already destructive (resume | |
| 519 | + | // is reversible, so we don't need the shift-as-guard | |
| 520 | + | // dance lowercase r has historically warranted). | |
| 521 | + | if a := d.selected(); a != nil && d.resume != nil { | |
| 522 | + | if err := d.resume(a.ID); err != nil { | |
| 523 | + | return false, "", "resume: " + err.Error() | |
| 524 | + | } | |
| 525 | + | return false, "resumed " + a.ID, "" | |
| 526 | + | } | |
| 527 | + | case 'k': | |
| 528 | + | if a := d.selected(); a != nil && d.stop != nil { | |
| 529 | + | // Only running / pending agents have something to | |
| 530 | + | // cancel. Detached and already-terminal agents would | |
| 531 | + | // be silent no-ops at the Swarm layer; surface a hint | |
| 532 | + | // so the user knows kill did nothing and what to do | |
| 533 | + | // instead (remove via 'r'). | |
| 534 | + | if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending { | |
| 535 | + | return false, "", killDisabledHint(a.Status) | |
| 536 | + | } | |
| 537 | + | if err := d.stop(a.ID); err != nil { | |
| 538 | + | return false, "", "stop: " + err.Error() | |
| 539 | + | } | |
| 540 | + | return false, "stopped " + a.ID, "" | |
| 541 | + | } | |
| 542 | + | case 'r': | |
| 543 | + | if a := d.selected(); a != nil && d.remove != nil { | |
| 544 | + | if err := d.remove(a.ID); err != nil { | |
| 545 | + | return false, "", "remove: " + err.Error() | |
| 546 | + | } | |
| 547 | + | return false, "removed " + a.ID, "" | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | return false, "", "" | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // handleViewingKey routes one keystroke while the transcript view | |
| 555 | + | // is on screen. The transcript view runs /btw-style: an inline | |
| 556 | + | // editor is always pinned at the bottom, the transcript flows in | |
| 557 | + | // full above it, and the host's outer scrollback handles overflow. | |
| 558 | + | // There is no internal scroll, so this handler is small: | |
| 559 | + | // | |
| 560 | + | // - Esc closes the transcript view (back to the dashboard list). | |
| 561 | + | // If the user was in the middle of an @-file-pick we let the | |
| 562 | + | // editor handler eat the Esc first so it just cancels the popup. | |
| 563 | + | // | |
| 564 | + | // Every other key is forwarded to handlePromptKey, which owns the | |
| 565 | + | // editor itself (typed runes, Enter to submit, Backspace, file | |
| 566 | + | // suggest, arrow keys for caret movement, etc.). Submits stay in | |
| 567 | + | // the view and clear the editor for the next message instead of | |
| 568 | + | // closing it — see handlePromptKey for the always-on behaviour. | |
| 569 | + | func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) { | |
| 570 | + | a := d.selected() | |
| 571 | + | ||
| 572 | + | // Lazy-open the editor on first key into the transcript view, so | |
| 573 | + | // re-entering an agent after switching away picks up cleanly. | |
| 574 | + | // Skipped for non-running agents (their inbox isn't listening; | |
| 575 | + | // sending would just bounce). | |
| 576 | + | if a != nil && a.Status == swarm.StatusRunning && d.send != nil && !d.prompting { | |
| 577 | + | d.openPromptEditor(tui.Theme{}, a.ID) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // Esc closes the transcript view, regardless of whether the | |
| 581 | + | // editor is open. Handled here (not in the editor handler) so | |
| 582 | + | // the user gets a consistent "esc backs out one level" feel. | |
| 583 | + | // The file-suggest popup, if active, gets to eat the Esc first | |
| 584 | + | // inside handlePromptKey — we route there and check whether the | |
| 585 | + | // editor consumed it. | |
| 586 | + | if k.Kind == tui.KeyEsc { | |
| 587 | + | if d.prompting && d.promptSuggest != nil && d.promptEd != nil && d.promptSuggest.Active(d.promptEd.Value()) { | |
| 588 | + | // Let the editor's @-suggest popup handle the Esc to | |
| 589 | + | // dismiss itself instead of collapsing the whole view. | |
| 590 | + | return d.handlePromptKey(k) | |
| 591 | + | } | |
| 592 | + | d.viewing = false | |
| 593 | + | d.closePromptEditor() | |
| 594 | + | return false, "", "" | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // Everything else — typed runes, Enter, Backspace, arrow keys | |
| 598 | + | // for caret movement, @-file picker — is editor input. If the | |
| 599 | + | // agent isn't running there's no live editor; surface a hint. | |
| 600 | + | if a == nil || a.Status != swarm.StatusRunning || d.send == nil { | |
| 601 | + | if k.Kind == tui.KeyRune && a != nil { | |
| 602 | + | return false, "", promptDisabledHint(a.Status) | |
| 603 | + | } | |
| 604 | + | return false, "", "" | |
| 605 | + | } | |
| 606 | + | return d.handlePromptKey(k) | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | // openSpawnEditor enters the inline new-agent prompt. It deliberately | |
| 610 | + | // uses tui.Editor (not a one-line string) so swarm-new supports the same | |
| 611 | + | // editing model as the normal input: multiline paste collapse, drag-drop | |
| 612 | + | // file/dir chips, cursor movement, and the same accent-bar prompt style. | |
| 613 | + | func (d *swarmDialog) openSpawnEditor(th tui.Theme) { | |
| 614 | + | d.spawning = true | |
| 615 | + | d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 616 | + | d.fileSuggest = newFileSuggester() | |
| 617 | + | d.fileSuggest.SetCWD(d.cwd) | |
| 618 | + | if d.spawnDraft != "" { | |
| 619 | + | d.newTaskEd.SetValue(d.spawnDraft) | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // openModelPicker suspends the spawn editor and shows the same | |
| 624 | + | // model dialog the global /model uses. Triggered when the user | |
| 625 | + | // submits "/model" from inside the spawn editor; on select we | |
| 626 | + | // capture the new pendingModel/Provider and reopen the spawn | |
| 627 | + | // editor with the previously-typed task buffer restored. | |
| 628 | + | func (d *swarmDialog) openModelPicker() { | |
| 629 | + | d.spawning = false | |
| 630 | + | d.newTaskEd = nil | |
| 631 | + | d.fileSuggest = nil | |
| 632 | + | d.pickingModel = true | |
| 633 | + | d.modelPicker = newModelDialog() | |
| 634 | + | d.modelPicker.Open(d.pendingModel, d.modelPickerLoggedIn) | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // handleModelPickerKey forwards one keystroke to the embedded model | |
| 638 | + | // picker and, on select or close, reopens the spawn editor so the | |
| 639 | + | // user can continue composing the task. A selection captures the new | |
| 640 | + | // pendingModel + pendingProvider; Esc leaves them unchanged (the | |
| 641 | + | // user gets to keep whatever was inherited before they invoked | |
| 642 | + | // /model). | |
| 643 | + | func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) { | |
| 644 | + | if d.modelPicker == nil { | |
| 645 | + | d.openModelPicker() | |
| 646 | + | } | |
| 647 | + | act := d.modelPicker.HandleKey(k) | |
| 648 | + | switch { | |
| 649 | + | case act.Select: | |
| 650 | + | d.pendingModel = act.Model | |
| 651 | + | d.pendingProvider = act.Provider | |
| 652 | + | d.pickingModel = false | |
| 653 | + | d.modelPicker = nil | |
| 654 | + | d.openSpawnEditor(tui.Theme{}) | |
| 655 | + | return false, "model: " + act.Model, "" | |
| 656 | + | case act.Close: | |
| 657 | + | d.pickingModel = false | |
| 658 | + | d.modelPicker = nil | |
| 659 | + | d.openSpawnEditor(tui.Theme{}) | |
| 660 | + | return false, "", "" | |
| 661 | + | } | |
| 662 | + | return false, "", "" | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | // containsSlashModelLine reports whether any line in buf has trimmed | |
| 666 | + | // content of "/model" (with optional trailing args). Used to decide | |
| 667 | + | // whether to treat a spawn-editor submit as a model-picker command | |
| 668 | + | // vs. as a task description. | |
| 669 | + | func containsSlashModelLine(buf string) bool { | |
| 670 | + | for _, ln := range strings.Split(buf, "\n") { | |
| 671 | + | t := strings.TrimSpace(ln) | |
| 672 | + | if t == "/model" || strings.HasPrefix(t, "/model ") { | |
| 673 | + | return true | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | return false | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | // stripSlashModelLine removes the line containing "/model" (with | |
| 680 | + | // optional trailing args) from the spawn-editor buffer so the | |
| 681 | + | // remainder can be restored after the picker closes. The /model line | |
| 682 | + | // is always exactly one full line in practice — the user typed | |
| 683 | + | // /model<Enter> — but we tolerate inline placement at the start of | |
| 684 | + | // a line just in case. | |
| 685 | + | func stripSlashModelLine(buf string) string { | |
| 686 | + | lines := strings.Split(buf, "\n") | |
| 687 | + | out := lines[:0] | |
| 688 | + | for _, ln := range lines { | |
| 689 | + | t := strings.TrimSpace(ln) | |
| 690 | + | if t == "/model" || strings.HasPrefix(t, "/model ") { | |
| 691 | + | continue | |
| 692 | + | } | |
| 693 | + | out = append(out, ln) | |
| 694 | + | } | |
| 695 | + | return strings.TrimRight(strings.Join(out, "\n"), "\n") | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | // openPromptEditor enters the inline "send a follow-up prompt" editor | |
| 699 | + | // for the agent with id targetID. Mirrors openSpawnEditor so the | |
| 700 | + | // editing experience (paste, @-picker, drop chips) is identical. | |
| 701 | + | func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) { | |
| 702 | + | d.prompting = true | |
| 703 | + | d.promptEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 704 | + | d.promptSuggest = newFileSuggester() | |
| 705 | + | d.promptSuggest.SetCWD(d.cwd) | |
| 706 | + | d.promptTargetID = targetID | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | // handlePromptKey owns the keystrokes while the inline prompt editor | |
| 710 | + | // is open. It mirrors handleSpawnKey but, on submit, invokes d.send | |
| 711 | + | // with the captured target id instead of d.spawn. Esc cancels and | |
| 712 | + | // returns the user to whichever view (list or transcript) was active | |
| 713 | + | // when 'p' was pressed. | |
| 714 | + | func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) { | |
| 715 | + | if d.promptEd == nil { | |
| 716 | + | d.openPromptEditor(tui.Theme{}, d.promptTargetID) | |
| 717 | + | } | |
| 718 | + | ed := d.promptEd | |
| 719 | + | fs := d.promptSuggest | |
| 720 | + | if fs != nil { | |
| 721 | + | fs.SetCWD(d.cwd) | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | // File suggestions: same keys and chip shape as the default input. | |
| 725 | + | if fs != nil && fs.Active(ed.Value()) { | |
| 726 | + | switch k.Kind { | |
| 727 | + | case tui.KeyUp: | |
| 728 | + | fs.Up() | |
| 729 | + | return false, "", "" | |
| 730 | + | case tui.KeyDown: | |
| 731 | + | fs.Down() | |
| 732 | + | return false, "", "" | |
| 733 | + | case tui.KeyRight: | |
| 734 | + | fs.Right() | |
| 735 | + | return false, "", "" | |
| 736 | + | case tui.KeyLeft: | |
| 737 | + | fs.Left() | |
| 738 | + | return false, "", "" | |
| 739 | + | case tui.KeyEnter: | |
| 740 | + | if entry, ok := fs.SelectedEntry(ed.Value()); ok { | |
| 741 | + | chip := "[file:" + entry.rel + "]" | |
| 742 | + | if entry.isDir { | |
| 743 | + | chip = "[dir:" + entry.rel + "/]" | |
| 744 | + | } | |
| 745 | + | val := ed.Value() | |
| 746 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 747 | + | val = val[:idx] | |
| 748 | + | } | |
| 749 | + | ed.SetValue(val + chip + " ") | |
| 750 | + | fs.Reset() | |
| 751 | + | } | |
| 752 | + | return false, "", "" | |
| 753 | + | case tui.KeyEsc: | |
| 754 | + | val := ed.Value() | |
| 755 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 756 | + | ed.SetValue(val[:idx]) | |
| 757 | + | } | |
| 758 | + | fs.Reset() | |
| 759 | + | return false, "", "" | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | // Esc cancels the prompt editor when the file picker isn't active. | |
| 764 | + | if k.Kind == tui.KeyEsc { | |
| 765 | + | d.closePromptEditor() | |
| 766 | + | return false, "", "" | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | if submitted := ed.HandleKey(k); submitted { | |
| 770 | + | text := strings.TrimRight(ed.SubmitValue(), "\n") | |
| 771 | + | text = expandFileChips(text, d.cwd) | |
| 772 | + | text = strings.TrimSpace(text) | |
| 773 | + | targetID := d.promptTargetID | |
| 774 | + | ||
| 775 | + | // Empty submit: nothing to send. If we're in transcript | |
| 776 | + | // view (always-on editor), just clear and stay; otherwise | |
| 777 | + | // (list-view-p flow) close the modal. | |
| 778 | + | if text == "" { | |
| 779 | + | if d.viewing { | |
| 780 | + | ed.Clear() | |
| 781 | + | return false, "", "" | |
| 782 | + | } | |
| 783 | + | d.closePromptEditor() | |
| 784 | + | return false, "", "" | |
| 785 | + | } | |
| 786 | + | if d.send == nil { | |
| 787 | + | if !d.viewing { | |
| 788 | + | d.closePromptEditor() | |
| 789 | + | } | |
| 790 | + | return false, "", "send not wired" | |
| 791 | + | } | |
| 792 | + | if targetID == "" { | |
| 793 | + | if !d.viewing { | |
| 794 | + | d.closePromptEditor() | |
| 795 | + | } | |
| 796 | + | return false, "", "send: no target agent" | |
| 797 | + | } | |
| 798 | + | if err := d.send(targetID, text); err != nil { | |
| 799 | + | if !d.viewing { | |
| 800 | + | d.closePromptEditor() | |
| 801 | + | } | |
| 802 | + | return false, "", friendlySendErr(targetID, err) | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | // In transcript view (always-on editor) keep the editor | |
| 806 | + | // mounted and clear the buffer for the next message; in the | |
| 807 | + | // list-view modal flow, close it so the user returns to the | |
| 808 | + | // list. Either way, snap the transcript to the tail so the | |
| 809 | + | // user sees their just-sent message and the agent's reply. | |
| 810 | + | if d.viewing { | |
| 811 | + | ed.Clear() | |
| 812 | + | } else { | |
| 813 | + | d.closePromptEditor() | |
| 814 | + | } | |
| 815 | + | return false, "sent to " + targetID, "" | |
| 816 | + | } | |
| 817 | + | return false, "", "" | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | // closePromptEditor resets all prompt-editor state. Called on Esc and | |
| 821 | + | // after submit so a subsequent 'p' starts from a clean slate. | |
| 822 | + | func (d *swarmDialog) closePromptEditor() { | |
| 823 | + | d.prompting = false | |
| 824 | + | d.promptEd = nil | |
| 825 | + | d.promptSuggest = nil | |
| 826 | + | d.promptTargetID = "" | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | // handleSpawnKey owns the keystrokes while the inline new-agent editor | |
| 830 | + | // is open. It mirrors the normal input's @ file-picker path and submit | |
| 831 | + | // expansion, then asks the host to spawn. | |
| 832 | + | func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) { | |
| 833 | + | if d.newTaskEd == nil { | |
| 834 | + | d.openSpawnEditor(tui.Theme{}) | |
| 835 | + | } | |
| 836 | + | ed := d.newTaskEd | |
| 837 | + | fs := d.fileSuggest | |
| 838 | + | if fs != nil { | |
| 839 | + | fs.SetCWD(d.cwd) | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | // File suggestions: same keys and chip shape as the default input. | |
| 843 | + | if fs != nil && fs.Active(ed.Value()) { | |
| 844 | + | switch k.Kind { | |
| 845 | + | case tui.KeyUp: | |
| 846 | + | fs.Up() | |
| 847 | + | return false, "", "" | |
| 848 | + | case tui.KeyDown: | |
| 849 | + | fs.Down() | |
| 850 | + | return false, "", "" | |
| 851 | + | case tui.KeyRight: | |
| 852 | + | fs.Right() | |
| 853 | + | return false, "", "" | |
| 854 | + | case tui.KeyLeft: | |
| 855 | + | fs.Left() | |
| 856 | + | return false, "", "" | |
| 857 | + | case tui.KeyEnter: | |
| 858 | + | if entry, ok := fs.SelectedEntry(ed.Value()); ok { | |
| 859 | + | chip := "[file:" + entry.rel + "]" | |
| 860 | + | if entry.isDir { | |
| 861 | + | chip = "[dir:" + entry.rel + "/]" | |
| 862 | + | } | |
| 863 | + | val := ed.Value() | |
| 864 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 865 | + | val = val[:idx] | |
| 866 | + | } | |
| 867 | + | ed.SetValue(val + chip + " ") | |
| 868 | + | fs.Reset() | |
| 869 | + | } | |
| 870 | + | return false, "", "" | |
| 871 | + | case tui.KeyEsc: | |
| 872 | + | val := ed.Value() | |
| 873 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 874 | + | ed.SetValue(val[:idx]) | |
| 875 | + | } | |
| 876 | + | fs.Reset() | |
| 877 | + | return false, "", "" | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | ||
| 881 | + | // Esc cancels the spawn prompt when the file picker isn't active. | |
| 882 | + | if k.Kind == tui.KeyEsc { | |
| 883 | + | d.spawning = false | |
| 884 | + | d.newTaskEd = nil | |
| 885 | + | d.fileSuggest = nil | |
| 886 | + | return false, "", "" | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | if submitted := ed.HandleKey(k); submitted { | |
| 890 | + | raw := strings.TrimRight(ed.SubmitValue(), "\n") | |
| 891 | + | ||
| 892 | + | // In-editor /model command: when the buffer contains a line | |
| 893 | + | // whose trimmed content is "/model" (anywhere — typically on | |
| 894 | + | // its own line, but a trailing /model after a task works | |
| 895 | + | // too), pop the same picker the global /model uses, drop the | |
| 896 | + | // /model line, and preserve the rest of the buffer so the | |
| 897 | + | // user can keep composing once they've picked. | |
| 898 | + | if containsSlashModelLine(raw) { | |
| 899 | + | d.spawnDraft = stripSlashModelLine(raw) | |
| 900 | + | d.openModelPicker() | |
| 901 | + | return false, "", "" | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | task := expandFileChips(raw, d.cwd) | |
| 905 | + | task = strings.TrimSpace(task) | |
| 906 | + | model := d.pendingModel | |
| 907 | + | provider := d.pendingProvider | |
| 908 | + | d.spawning = false | |
| 909 | + | d.newTaskEd = nil | |
| 910 | + | d.fileSuggest = nil | |
| 911 | + | d.spawnDraft = "" | |
| 912 | + | d.pendingModel = "" | |
| 913 | + | d.pendingProvider = "" | |
| 914 | + | if task == "" { | |
| 915 | + | return false, "", "" | |
| 916 | + | } | |
| 917 | + | if d.spawn == nil { | |
| 918 | + | return false, "", "spawn not wired" | |
| 919 | + | } | |
| 920 | + | if err := d.spawn(task, model, provider); err != nil { | |
| 921 | + | return false, "", "spawn: " + err.Error() | |
| 922 | + | } | |
| 923 | + | if model != "" { | |
| 924 | + | return false, "spawned (model " + model + ")", "" | |
| 925 | + | } | |
| 926 | + | return false, "spawned", "" | |
| 927 | + | } | |
| 928 | + | return false, "", "" | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | func (d *swarmDialog) selected() *swarm.AgentSnapshot { | |
| 932 | + | if d.cursor < 0 || d.cursor >= len(d.rows) { | |
| 933 | + | return nil | |
| 934 | + | } | |
| 935 | + | r := d.rows[d.cursor] | |
| 936 | + | return &r | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | // Render returns the dialog lines. Re-reads the snapshot each call so | |
| 940 | + | // the dashboard updates as agents make progress. | |
| 941 | + | func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |
| 942 | + | if !d.Active() { | |
| 943 | + | return nil | |
| 944 | + | } | |
| 945 | + | d.refresh() | |
| 946 | + | ||
| 947 | + | if d.viewing { | |
| 948 | + | return d.renderTranscript(th, width) | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | out := []string{frameHeader(th, "swarm (n new · p prompt · R resume · ↑/↓ move · enter view · k kill · r remove · esc close)", width)} | |
| 952 | + | if d.prompting { | |
| 953 | + | return d.renderPromptEditor(th, width, out) | |
| 954 | + | } | |
| 955 | + | if d.pickingModel { | |
| 956 | + | // modelDialog renders its own frame; replace ours so the | |
| 957 | + | // picker takes the dashboard cleanly. Trailing hint reminds | |
| 958 | + | // the user Esc returns to the spawn editor. | |
| 959 | + | if d.modelPicker == nil { | |
| 960 | + | d.openModelPicker() | |
| 961 | + | } | |
| 962 | + | out = d.modelPicker.Render(th, width) | |
| 963 | + | out = append(out, " "+th.FG256(th.Muted, "select model for next spawn · esc to cancel")) | |
| 964 | + | return out | |
| 965 | + | } | |
| 966 | + | if d.spawning { | |
| 967 | + | if d.newTaskEd == nil { | |
| 968 | + | d.openSpawnEditor(th) | |
| 969 | + | } | |
| 970 | + | if d.newTaskEd != nil { | |
| 971 | + | d.newTaskEd.Prompt = th.AccentBar(th.Accent) | |
| 972 | + | } | |
| 973 | + | // One-line banner reminding the user which model the | |
| 974 | + | // agent-to-be will run against. Skipped when no model was | |
| 975 | + | // picked so the layout matches the historical look. | |
| 976 | + | if d.pendingModel != "" { | |
| 977 | + | label := "model: " + d.pendingModel | |
| 978 | + | if d.pendingProvider != "" { | |
| 979 | + | label += " (" + d.pendingProvider + ")" | |
| 980 | + | } | |
| 981 | + | out = append(out, " "+th.FG256(th.Accent, label)) | |
| 982 | + | } | |
| 983 | + | if d.fileSuggest != nil { | |
| 984 | + | d.fileSuggest.SetCWD(d.cwd) | |
| 985 | + | if popup := d.fileSuggest.Render(d.newTaskEd.Value(), th, width); len(popup) > 0 { | |
| 986 | + | out = append(out, popup...) | |
| 987 | + | } | |
| 988 | + | } | |
| 989 | + | // Breathing-room blank row above the editor so the input | |
| 990 | + | // doesn't sit flush against the frame header. | |
| 991 | + | out = append(out, "") | |
| 992 | + | if d.newTaskEd != nil { | |
| 993 | + | edLines, _, _ := d.newTaskEd.Render(width - 2) | |
| 994 | + | for _, l := range edLines { | |
| 995 | + | out = append(out, " "+l) | |
| 996 | + | } | |
| 997 | + | } | |
| 998 | + | // Matching blank row below before the hint, mirroring the | |
| 999 | + | // main input's editor breathing room. | |
| 1000 | + | out = append(out, "") | |
| 1001 | + | out = append(out, " "+th.FG256(th.Muted, "enter spawn · /model pick model · @ file/dir picker · paste/drop paths become [file:] / [dir:] chips · esc cancel")) | |
| 1002 | + | out = append(out, frameRule(th, width)) | |
| 1003 | + | return out | |
| 1004 | + | } | |
| 1005 | + | if len(d.rows) == 0 { | |
| 1006 | + | out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)")) | |
| 1007 | + | out = append(out, frameRule(th, width)) | |
| 1008 | + | return out | |
| 1009 | + | } | |
| 1010 | + | ||
| 1011 | + | // Column header for readability. | |
| 1012 | + | header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY") | |
| 1013 | + | out = append(out, th.FG256(th.Muted, header)) | |
| 1014 | + | ||
| 1015 | + | for i, r := range d.rows { | |
| 1016 | + | row := formatSwarmRow(r, width-2) | |
| 1017 | + | if i == d.cursor { | |
| 1018 | + | out = append(out, th.PadHighlight(" "+row, width)) | |
| 1019 | + | } else { | |
| 1020 | + | out = append(out, " "+th.FG256(th.FG, row)) | |
| 1021 | + | } | |
| 1022 | + | } | |
| 1023 | + | out = append(out, frameRule(th, width)) | |
| 1024 | + | return out | |
| 1025 | + | } | |
| 1026 | + | ||
| 1027 | + | func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |
| 1028 | + | a := d.selected() | |
| 1029 | + | if a == nil { | |
| 1030 | + | d.viewing = false | |
| 1031 | + | return d.Render(th, width) | |
| 1032 | + | } | |
| 1033 | + | header := []string{ | |
| 1034 | + | frameHeader(th, "swarm: "+a.ID+" (type to send · esc back)", width), | |
| 1035 | + | " " + th.FG256(th.Muted, "task: "+a.Task), | |
| 1036 | + | " " + th.FG256(th.Muted, "branch: "+a.Branch), | |
| 1037 | + | " " + th.FG256(th.Muted, "dir: "+a.Dir), | |
| 1038 | + | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s · %s", a.Status, a.Activity)), | |
| 1039 | + | } | |
| 1040 | + | if a.Model != "" { | |
| 1041 | + | modelLine := "model: " + a.Model | |
| 1042 | + | if a.Provider != "" { | |
| 1043 | + | modelLine += " (" + a.Provider + ")" | |
| 1044 | + | } | |
| 1045 | + | header = append(header, " "+th.FG256(th.Muted, modelLine)) | |
| 1046 | + | } | |
| 1047 | + | if a.Err != "" { | |
| 1048 | + | header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err)) | |
| 1049 | + | } | |
| 1050 | + | header = append(header, | |
| 1051 | + | "", | |
| 1052 | + | " "+th.FG256(th.Muted, "── transcript ──"), | |
| 1053 | + | "", | |
| 1054 | + | ) | |
| 1055 | + | ||
| 1056 | + | // Body: prefer the full Lines slice when present, fall back to | |
| 1057 | + | // the Tail string for snapshots that only filled the summary. | |
| 1058 | + | raw := a.Lines | |
| 1059 | + | if len(raw) == 0 && a.Tail != "" { | |
| 1060 | + | raw = strings.Split(a.Tail, "\n") | |
| 1061 | + | } | |
| 1062 | + | if len(raw) == 0 { | |
| 1063 | + | header = append(header, " "+th.FG256(th.Muted, "(no transcript yet)")) | |
| 1064 | + | header = d.appendTranscriptEditor(header, th, width, a) | |
| 1065 | + | header = append(header, frameRule(th, width)) | |
| 1066 | + | return header | |
| 1067 | + | } | |
| 1068 | + | ||
| 1069 | + | // Re-render the flat log into proper chat blocks: user-bubble | |
| 1070 | + | // rows for echoes, markdown-rendered prose for assistant turns, | |
| 1071 | + | // tinted prefixes for stderr / errors. The result is a single | |
| 1072 | + | // pre-styled, pre-indented []string of display rows ready to | |
| 1073 | + | // hand to the host. | |
| 1074 | + | // | |
| 1075 | + | // We deliberately do NOT clip into a fixed-height viewport with | |
| 1076 | + | // internal scroll — the transcript flows in full, /btw-style, | |
| 1077 | + | // and the host's outer scrollback handles overflow naturally. | |
| 1078 | + | // That gives the user the chat UX they expect (transcript grows | |
| 1079 | + | // with the window, arrow keys belong to the editor, no "↑ N | |
| 1080 | + | // more above" cliff). | |
| 1081 | + | body := renderSwarmTranscriptBlocks(raw, th, width) | |
| 1082 | + | ||
| 1083 | + | out := header | |
| 1084 | + | out = append(out, body...) | |
| 1085 | + | ||
| 1086 | + | // /btw-style inline editor pinned to the bottom of the transcript. | |
| 1087 | + | // Rendered after the body so the editor sits flush above the | |
| 1088 | + | // frame rule. Only shown for running agents (others have no live | |
| 1089 | + | // inbox listener — we surface a hint instead). | |
| 1090 | + | out = d.appendTranscriptEditor(out, th, width, a) | |
| 1091 | + | ||
| 1092 | + | out = append(out, frameRule(th, width)) | |
| 1093 | + | return out | |
| 1094 | + | } | |
| 1095 | + | ||
| 1096 | + | // appendTranscriptEditor adds the always-on inline composer to the | |
| 1097 | + | // transcript view, mimicking the /btw side-chat layout: a breathing | |
| 1098 | + | // row, the editor itself, another breathing row, and a single muted | |
| 1099 | + | // hint. For non-running agents we render a short "can't send while | |
| 1100 | + | // X" note instead so the user knows why typing is disabled. | |
| 1101 | + | func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string { | |
| 1102 | + | if a == nil { | |
| 1103 | + | return out | |
| 1104 | + | } | |
| 1105 | + | if a.Status != swarm.StatusRunning || d.send == nil { | |
| 1106 | + | out = append(out, "") | |
| 1107 | + | out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status))) | |
| 1108 | + | return out | |
| 1109 | + | } | |
| 1110 | + | // Lazy-init the editor here too so the very first render of the | |
| 1111 | + | // transcript view (before the user has pressed any key) already | |
| 1112 | + | // shows the composer. Mirrors openPromptEditor's setup but uses | |
| 1113 | + | // the live theme so the accent bar matches. | |
| 1114 | + | if d.promptEd == nil { | |
| 1115 | + | d.openPromptEditor(th, a.ID) | |
| 1116 | + | } | |
| 1117 | + | if d.promptEd != nil { | |
| 1118 | + | d.promptEd.Prompt = th.AccentBar(th.Accent) | |
| 1119 | + | } | |
| 1120 | + | if d.promptSuggest != nil { | |
| 1121 | + | d.promptSuggest.SetCWD(d.cwd) | |
| 1122 | + | if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 { | |
| 1123 | + | out = append(out, popup...) | |
| 1124 | + | } | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | // Busy-spinner row, /btw-style. Shown whenever the agent's | |
| 1128 | + | // current activity isn't "idle" — so "thinking", "tool: foo", | |
| 1129 | + | // "starting", etc. all animate. The spinner is stopped (and the | |
| 1130 | + | // row hidden) once the agent reports idle, mirroring the main | |
| 1131 | + | // chat's busy line. | |
| 1132 | + | if agentIsBusy(a) { | |
| 1133 | + | if d.transcriptSpin == nil { | |
| 1134 | + | d.transcriptSpin = newSpinner() | |
| 1135 | + | d.transcriptSpin.Start() | |
| 1136 | + | } | |
| 1137 | + | out = append(out, "") | |
| 1138 | + | prefix := fmt.Sprintf("%s %s · %s", | |
| 1139 | + | th.FG256(th.Assistant, d.transcriptSpin.Frame()), | |
| 1140 | + | th.FG256(th.Assistant, a.Activity), | |
| 1141 | + | th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()), | |
| 1142 | + | ) | |
| 1143 | + | out = append(out, " "+prefix) | |
| 1144 | + | } else { | |
| 1145 | + | // Reset so the next busy phase starts fresh (rotating | |
| 1146 | + | // funny-line cycles back to the start, elapsed restarts). | |
| 1147 | + | d.transcriptSpin = nil | |
| 1148 | + | } | |
| 1149 | + | ||
| 1150 | + | out = append(out, "") | |
| 1151 | + | edLines, _, _ := d.promptEd.Render(width - 2) | |
| 1152 | + | for _, l := range edLines { | |
| 1153 | + | out = append(out, " "+l) | |
| 1154 | + | } | |
| 1155 | + | out = append(out, "") | |
| 1156 | + | out = append(out, " "+th.FG256(th.Muted, "enter send · @ file/dir picker · esc back")) | |
| 1157 | + | return out | |
| 1158 | + | } | |
| 1159 | + | ||
| 1160 | + | // agentIsBusy reports whether the agent is currently mid-turn and | |
| 1161 | + | // should render a spinner. Conservative: only known idle markers | |
| 1162 | + | // suppress the spinner so transient / unfamiliar activity strings | |
| 1163 | + | // still animate. Detached / done / failed / killed never animate — | |
| 1164 | + | // they aren't actively producing output. | |
| 1165 | + | func agentIsBusy(a *swarm.AgentSnapshot) bool { | |
| 1166 | + | if a == nil { | |
| 1167 | + | return false | |
| 1168 | + | } | |
| 1169 | + | if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending { | |
| 1170 | + | return false | |
| 1171 | + | } | |
| 1172 | + | switch strings.TrimSpace(a.Activity) { | |
| 1173 | + | case "", "idle": | |
| 1174 | + | return false | |
| 1175 | + | } | |
| 1176 | + | return true | |
| 1177 | + | } | |
| 1178 | + | ||
| 1179 | + | // renderSwarmTranscriptBlocks converts the agent's flat transcript | |
| 1180 | + | // log into a slice of display rows styled like the main chat: user | |
| 1181 | + | // echoes in bubble rows, assistant prose through tui.RenderMarkdown, | |
| 1182 | + | // stderr / error chatter tinted. Each output row is fully indented | |
| 1183 | + | // and ready for the caller to slice into a scroll window. | |
| 1184 | + | // | |
| 1185 | + | // The flat log mixes role markers and raw text, so we group adjacent | |
| 1186 | + | // lines of the same role into one logical "block" and render the | |
| 1187 | + | // whole block together — essential for markdown, which needs the | |
| 1188 | + | // full message body in one piece to detect fenced code spans, | |
| 1189 | + | // bullet lists, and so on. | |
| 1190 | + | func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []string { | |
| 1191 | + | type blockKind int | |
| 1192 | + | const ( | |
| 1193 | + | kindAssistant blockKind = iota | |
| 1194 | + | kindUser | |
| 1195 | + | kindStderr | |
| 1196 | + | kindError | |
| 1197 | + | ) | |
| 1198 | + | type block struct { | |
| 1199 | + | kind blockKind | |
| 1200 | + | body []string // raw lines, role prefix already stripped | |
| 1201 | + | } | |
| 1202 | + | ||
| 1203 | + | // classify maps one raw line to its role and the stripped body. | |
| 1204 | + | classify := func(s string) (blockKind, string) { | |
| 1205 | + | switch { | |
| 1206 | + | case strings.HasPrefix(s, "user: "): | |
| 1207 | + | return kindUser, strings.TrimPrefix(s, "user: ") | |
| 1208 | + | case strings.HasPrefix(s, "stderr: "): | |
| 1209 | + | return kindStderr, strings.TrimPrefix(s, "stderr: ") | |
| 1210 | + | case strings.HasPrefix(s, "error: "): | |
| 1211 | + | return kindError, strings.TrimPrefix(s, "error: ") | |
| 1212 | + | default: | |
| 1213 | + | return kindAssistant, s | |
| 1214 | + | } | |
| 1215 | + | } | |
| 1216 | + | ||
| 1217 | + | // Coalesce consecutive same-kind lines into blocks. Empty lines | |
| 1218 | + | // keep the kind of the previous block so blank lines inside an | |
| 1219 | + | // assistant message stay attached for markdown rendering (an | |
| 1220 | + | // empty line between paragraphs is significant in markdown). | |
| 1221 | + | var blocks []block | |
| 1222 | + | for _, ln := range lines { | |
| 1223 | + | if ln == "" { | |
| 1224 | + | // Empty lines stay attached to the previous block so | |
| 1225 | + | // markdown sees paragraph breaks inside an assistant | |
| 1226 | + | // turn. Standalone empties before any block are dropped | |
| 1227 | + | // (nothing meaningful to attach them to). | |
| 1228 | + | if len(blocks) > 0 { | |
| 1229 | + | blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, "") | |
| 1230 | + | } | |
| 1231 | + | continue | |
| 1232 | + | } | |
| 1233 | + | k, stripped := classify(ln) | |
| 1234 | + | if len(blocks) == 0 || blocks[len(blocks)-1].kind != k { | |
| 1235 | + | blocks = append(blocks, block{kind: k}) | |
| 1236 | + | } | |
| 1237 | + | blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, stripped) | |
| 1238 | + | } | |
| 1239 | + | ||
| 1240 | + | // Render each block. Width budget mirrors the btw dialog: the | |
| 1241 | + | // transcript text sits inside two leading frame cells, so the | |
| 1242 | + | // inner width is width-4 for markdown and width-2 for the | |
| 1243 | + | // user-bubble (the bubble already pads itself). | |
| 1244 | + | var out []string | |
| 1245 | + | innerMD := width - 4 | |
| 1246 | + | if innerMD < 20 { | |
| 1247 | + | innerMD = 20 | |
| 1248 | + | } | |
| 1249 | + | bubbleWidth := width - 2 | |
| 1250 | + | if bubbleWidth < 10 { | |
| 1251 | + | bubbleWidth = 10 | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | for i, b := range blocks { | |
| 1255 | + | if i > 0 { | |
| 1256 | + | out = append(out, "") // breathing room between turns | |
| 1257 | + | } | |
| 1258 | + | switch b.kind { | |
| 1259 | + | case kindUser: | |
| 1260 | + | text := strings.Join(b.body, "\n") | |
| 1261 | + | out = append(out, btwUserBubbleRows(th, text, bubbleWidth)...) | |
| 1262 | + | case kindAssistant: | |
| 1263 | + | text := strings.Join(b.body, "\n") | |
| 1264 | + | md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD) | |
| 1265 | + | for _, line := range strings.Split(md, "\n") { | |
| 1266 | + | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |
| 1267 | + | line = line[1:] | |
| 1268 | + | } | |
| 1269 | + | out = append(out, " "+line) | |
| 1270 | + | } | |
| 1271 | + | case kindStderr: | |
| 1272 | + | for _, line := range b.body { | |
| 1273 | + | out = append(out, " "+th.FG256(th.Muted, "stderr "+line)) | |
| 1274 | + | } | |
| 1275 | + | case kindError: | |
| 1276 | + | for _, line := range b.body { | |
| 1277 | + | out = append(out, " "+th.FG256(th.Error, "✖ "+line)) | |
| 1278 | + | } | |
| 1279 | + | } | |
| 1280 | + | } | |
| 1281 | + | return out | |
| 1282 | + | } | |
| 1283 | + | ||
| 1284 | + | // renderPromptEditor draws the inline "send a follow-up prompt" | |
| 1285 | + | // composer. Reuses the editor + file-suggest popup styling of the | |
| 1286 | + | // spawn editor so the two flows are visually consistent. `out` is | |
| 1287 | + | // the pre-built header (frame header for list view, or the agent | |
| 1288 | + | // metadata block for transcript view); we append onto it and close | |
| 1289 | + | // with the standard frame rule. | |
| 1290 | + | func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string { | |
| 1291 | + | if d.promptEd == nil { | |
| 1292 | + | d.openPromptEditor(th, d.promptTargetID) | |
| 1293 | + | } | |
| 1294 | + | if d.promptEd != nil { | |
| 1295 | + | d.promptEd.Prompt = th.AccentBar(th.Accent) | |
| 1296 | + | } | |
| 1297 | + | target := d.promptTargetID | |
| 1298 | + | if target == "" { | |
| 1299 | + | target = "<unknown>" | |
| 1300 | + | } | |
| 1301 | + | out = append(out, " "+th.FG256(th.Muted, "send to "+target+":")) | |
| 1302 | + | if d.promptSuggest != nil { | |
| 1303 | + | d.promptSuggest.SetCWD(d.cwd) | |
| 1304 | + | if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 { | |
| 1305 | + | out = append(out, popup...) | |
| 1306 | + | } | |
| 1307 | + | } | |
| 1308 | + | // Breathing-room blank row above the editor. | |
| 1309 | + | out = append(out, "") | |
| 1310 | + | if d.promptEd != nil { | |
| 1311 | + | edLines, _, _ := d.promptEd.Render(width - 2) | |
| 1312 | + | for _, l := range edLines { | |
| 1313 | + | out = append(out, " "+l) | |
| 1314 | + | } | |
| 1315 | + | } | |
| 1316 | + | // Matching blank row below before the hint. | |
| 1317 | + | out = append(out, "") | |
| 1318 | + | out = append(out, " "+th.FG256(th.Muted, "enter send · @ file/dir picker · esc cancel")) | |
| 1319 | + | out = append(out, frameRule(th, width)) | |
| 1320 | + | return out | |
| 1321 | + | } | |
| 1322 | + | ||
| 1323 | + | // formatSwarmRow is the one-line summary shown per agent. | |
| 1324 | + | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | |
| 1325 | + | status := statusLabel(r.Status) | |
| 1326 | + | age := formatAge(r.Started) | |
| 1327 | + | left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | |
| 1328 | + | room := maxWidth - len([]rune(left)) | |
| 1329 | + | if room < 10 { | |
| 1330 | + | room = 10 | |
| 1331 | + | } | |
| 1332 | + | act := strings.ReplaceAll(r.Activity, "\n", " ") | |
| 1333 | + | if act == "" { | |
| 1334 | + | act = r.Task | |
| 1335 | + | } | |
| 1336 | + | if len([]rune(act)) > room { | |
| 1337 | + | act = string([]rune(act)[:room-1]) + "…" | |
| 1338 | + | } | |
| 1339 | + | row := left + act | |
| 1340 | + | rowRunes := []rune(row) | |
| 1341 | + | if len(rowRunes) > maxWidth { | |
| 1342 | + | row = string(rowRunes[:maxWidth-1]) + "…" | |
| 1343 | + | } | |
| 1344 | + | return row | |
| 1345 | + | } | |
| 1346 | + | ||
| 1347 | + | // statusLabel returns a short, padded status badge. Kept stable across | |
| 1348 | + | // runs (no colours embedded) so tests can match it deterministically. | |
| 1349 | + | func statusLabel(s swarm.Status) string { | |
| 1350 | + | switch s { | |
| 1351 | + | case swarm.StatusPending: | |
| 1352 | + | return "● pend" | |
| 1353 | + | case swarm.StatusRunning: | |
| 1354 | + | return "● run" | |
| 1355 | + | case swarm.StatusDone: | |
| 1356 | + | return "✓ done" | |
| 1357 | + | case swarm.StatusFailed: | |
| 1358 | + | return "✗ fail" | |
| 1359 | + | case swarm.StatusKilled: | |
| 1360 | + | return "■ kill" | |
| 1361 | + | case swarm.StatusDetached: | |
| 1362 | + | return "○ detach" | |
| 1363 | + | } | |
| 1364 | + | return string(s) | |
| 1365 | + | } | |
| 1366 | + | ||
| 1367 | + | func formatAge(t time.Time) string { | |
| 1368 | + | if t.IsZero() { | |
| 1369 | + | return "-" | |
| 1370 | + | } | |
| 1371 | + | d := time.Since(t) | |
| 1372 | + | switch { | |
| 1373 | + | case d < time.Minute: | |
| 1374 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 1375 | + | case d < time.Hour: | |
| 1376 | + | return fmt.Sprintf("%dm", int(d.Minutes())) | |
| 1377 | + | case d < 24*time.Hour: | |
| 1378 | + | return fmt.Sprintf("%dh", int(d.Hours())) | |
| 1379 | + | default: | |
| 1380 | + | return fmt.Sprintf("%dd", int(d.Hours()/24)) | |
| 1381 | + | } | |
| 1382 | + | } | |
A internal/agent/modes/swarm_dialog_test.go +1279 -0
| @@ -0,0 +1,1279 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 13 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 14 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // staticSnapshots returns a deterministic snapshot slice for the | |
| 18 | + | // dialog to render. | |
| 19 | + | func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot { | |
| 20 | + | return func() []swarm.AgentSnapshot { | |
| 21 | + | out := make([]swarm.AgentSnapshot, len(rows)) | |
| 22 | + | copy(out, rows) | |
| 23 | + | return out | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestSwarmDialogEmptyState(t *testing.T) { | |
| 28 | + | d := newSwarmDialog() | |
| 29 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |
| 30 | + | lines := d.Render(tui.Theme{}, 80) | |
| 31 | + | joined := strings.Join(lines, "\n") | |
| 32 | + | if !strings.Contains(joined, "no agents") { | |
| 33 | + | t.Fatalf("empty state missing hint:\n%s", joined) | |
| 34 | + | } | |
| 35 | + | if !strings.Contains(joined, "press n") { | |
| 36 | + | t.Fatalf("empty state should advertise n shortcut:\n%s", joined) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestSwarmDialogRendersRows(t *testing.T) { | |
| 41 | + | now := time.Now() | |
| 42 | + | rows := []swarm.AgentSnapshot{ | |
| 43 | + | {ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now}, | |
| 44 | + | {ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now}, | |
| 45 | + | } | |
| 46 | + | d := newSwarmDialog() | |
| 47 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 48 | + | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 49 | + | for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { | |
| 50 | + | if !strings.Contains(out, want) { | |
| 51 | + | t.Fatalf("render missing %q:\n%s", want, out) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) { | |
| 57 | + | now := time.Now() | |
| 58 | + | rows := []swarm.AgentSnapshot{ | |
| 59 | + | {ID: "alpha-1", Status: swarm.StatusRunning, Started: now}, | |
| 60 | + | } | |
| 61 | + | stops := 0 | |
| 62 | + | removes := 0 | |
| 63 | + | d := newSwarmDialog() | |
| 64 | + | d.Open( | |
| 65 | + | staticSnapshots(rows...), | |
| 66 | + | func(id string) error { stops++; return nil }, | |
| 67 | + | func(id string) error { removes++; return nil }, | |
| 68 | + | nil, | |
| 69 | + | nil, | |
| 70 | + | nil, | |
| 71 | + | "", | |
| 72 | + | ) | |
| 73 | + | // Render once so cursor + rows are populated. | |
| 74 | + | _ = d.Render(tui.Theme{}, 80) | |
| 75 | + | ||
| 76 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'}) | |
| 77 | + | if stops != 1 { | |
| 78 | + | t.Fatalf("stop calls = %d", stops) | |
| 79 | + | } | |
| 80 | + | if !strings.Contains(msg, "alpha-1") || errMsg != "" { | |
| 81 | + | t.Fatalf("stop status (%q, %q)", msg, errMsg) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | _, msg, errMsg = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'r'}) | |
| 85 | + | if removes != 1 { | |
| 86 | + | t.Fatalf("remove calls = %d", removes) | |
| 87 | + | } | |
| 88 | + | if !strings.Contains(msg, "alpha-1") || errMsg != "" { | |
| 89 | + | t.Fatalf("remove status (%q, %q)", msg, errMsg) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestSwarmDialogEnterShowsTranscript(t *testing.T) { | |
| 94 | + | now := time.Now() | |
| 95 | + | rows := []swarm.AgentSnapshot{ | |
| 96 | + | {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now}, | |
| 97 | + | } | |
| 98 | + | d := newSwarmDialog() | |
| 99 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 100 | + | _ = d.Render(tui.Theme{}, 80) | |
| 101 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 102 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 103 | + | for _, want := range []string{"task:", "branch:", "line a", "line b"} { | |
| 104 | + | if !strings.Contains(out, want) { | |
| 105 | + | t.Fatalf("transcript view missing %q:\n%s", want, out) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | // Esc should bring us back to the list view. | |
| 109 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 110 | + | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 111 | + | if !strings.Contains(out, "STATUS") { | |
| 112 | + | t.Fatalf("did not return to list view:\n%s", out) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestSwarmDialogEscClosesList(t *testing.T) { | |
| 117 | + | d := newSwarmDialog() | |
| 118 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |
| 119 | + | closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 120 | + | if !closed { | |
| 121 | + | t.Fatal("esc did not close") | |
| 122 | + | } | |
| 123 | + | if d.Active() { | |
| 124 | + | t.Fatal("still active after esc") | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end: | |
| 129 | + | // press n, type a task, press enter, observe the spawn callback | |
| 130 | + | // fires with the right text and the prompt closes. | |
| 131 | + | func TestSwarmDialogSpawnFlow(t *testing.T) { | |
| 132 | + | var spawned string | |
| 133 | + | d := newSwarmDialog() | |
| 134 | + | d.Open( | |
| 135 | + | staticSnapshots(), | |
| 136 | + | nil, nil, | |
| 137 | + | func(task, _, _ string) error { spawned = task; return nil }, | |
| 138 | + | nil, | |
| 139 | + | nil, | |
| 140 | + | "", | |
| 141 | + | ) | |
| 142 | + | _ = d.Render(tui.Theme{}, 80) | |
| 143 | + | ||
| 144 | + | // Press n to open the spawn editor directly. The model is | |
| 145 | + | // taken from whatever the host set via SetCurrentModel; this | |
| 146 | + | // test doesn't pin one. | |
| 147 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 148 | + | if !d.spawning { | |
| 149 | + | t.Fatal("n did not enter spawning mode") | |
| 150 | + | } | |
| 151 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 152 | + | if !strings.Contains(out, "enter spawn") || !strings.Contains(out, "@ file/dir picker") { | |
| 153 | + | t.Fatalf("spawn editor hint not rendered:\n%s", out) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Type "fix x". | |
| 157 | + | for _, r := range "fix x" { | |
| 158 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 159 | + | } | |
| 160 | + | // Submit. | |
| 161 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 162 | + | if errMsg != "" { | |
| 163 | + | t.Fatalf("unexpected err: %s", errMsg) | |
| 164 | + | } | |
| 165 | + | if msg != "spawned" { | |
| 166 | + | t.Fatalf("status msg = %q", msg) | |
| 167 | + | } | |
| 168 | + | if spawned != "fix x" { | |
| 169 | + | t.Fatalf("spawn callback got %q", spawned) | |
| 170 | + | } | |
| 171 | + | if d.spawning { | |
| 172 | + | t.Fatal("prompt did not close after enter") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) { | |
| 177 | + | cwd := t.TempDir() | |
| 178 | + | if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | var spawned string | |
| 182 | + | d := newSwarmDialog() | |
| 183 | + | d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd) | |
| 184 | + | _ = d.Render(tui.Theme{}, 80) | |
| 185 | + | ||
| 186 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 187 | + | for _, r := range "summarize @READ" { | |
| 188 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 189 | + | } | |
| 190 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 191 | + | if !strings.Contains(out, "README.md") { | |
| 192 | + | t.Fatalf("@ file picker did not render match:\n%s", out) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Enter while the file picker is active selects the highlighted file, | |
| 196 | + | // inserting the compact [file:] chip instead of submitting yet. | |
| 197 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 198 | + | if d.newTaskEd == nil || !strings.Contains(d.newTaskEd.Value(), "[file:README.md]") { | |
| 199 | + | t.Fatalf("file picker did not insert chip; editor=%v", d.newTaskEd) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // A second Enter submits. The chip should expand to the absolute | |
| 203 | + | // path before the task reaches Swarm.Spawn. | |
| 204 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 205 | + | if errMsg != "" { | |
| 206 | + | t.Fatalf("unexpected err: %s", errMsg) | |
| 207 | + | } | |
| 208 | + | if msg != "spawned" { | |
| 209 | + | t.Fatalf("status msg = %q", msg) | |
| 210 | + | } | |
| 211 | + | wantPath := filepath.Join(cwd, "README.md") | |
| 212 | + | if !strings.Contains(spawned, wantPath) { | |
| 213 | + | t.Fatalf("spawned task %q missing expanded path %q", spawned, wantPath) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestSwarmDialogSpawnEscCancels(t *testing.T) { | |
| 218 | + | called := false | |
| 219 | + | d := newSwarmDialog() | |
| 220 | + | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "") | |
| 221 | + | _ = d.Render(tui.Theme{}, 80) | |
| 222 | + | ||
| 223 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 224 | + | for _, r := range "abc" { | |
| 225 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 226 | + | } | |
| 227 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 228 | + | if called { | |
| 229 | + | t.Fatal("spawn callback ran despite esc") | |
| 230 | + | } | |
| 231 | + | if d.spawning { | |
| 232 | + | t.Fatal("prompt still open after esc") | |
| 233 | + | } | |
| 234 | + | if d.Active() == false { | |
| 235 | + | t.Fatal("esc on spawn prompt should not close the whole dialog") | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) { | |
| 240 | + | d := newSwarmDialog() | |
| 241 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil | |
| 242 | + | _ = d.Render(tui.Theme{}, 80) | |
| 243 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 244 | + | if d.spawning { | |
| 245 | + | t.Fatal("n opened spawn prompt without a spawn callback wired") | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style | |
| 250 | + | // rendering: the dialog NEVER clips the transcript into a fixed | |
| 251 | + | // window with internal scroll. Every line goes into the rendered | |
| 252 | + | // output and the host's outer scrollback owns overflow. This is | |
| 253 | + | // what makes the transcript grow with the window and with the | |
| 254 | + | // content, instead of cutting off at row 16 with a "↑ N more above" | |
| 255 | + | // indicator. | |
| 256 | + | func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) { | |
| 257 | + | lines := make([]string, 60) | |
| 258 | + | for i := range lines { | |
| 259 | + | lines[i] = fmt.Sprintf("line %d", i) | |
| 260 | + | } | |
| 261 | + | rows := []swarm.AgentSnapshot{{ | |
| 262 | + | ID: "alpha-1", Task: "x", Status: swarm.StatusDone, | |
| 263 | + | Lines: lines, Started: time.Now(), | |
| 264 | + | }} | |
| 265 | + | d := newSwarmDialog() | |
| 266 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 267 | + | _ = d.Render(tui.Theme{}, 80) | |
| 268 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer | |
| 269 | + | ||
| 270 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 271 | + | // First line must be present (no internal clipping from the top). | |
| 272 | + | if !strings.Contains(out, "line 0") { | |
| 273 | + | t.Errorf("first transcript line missing — the view appears to be clipping:\n%s", out) | |
| 274 | + | } | |
| 275 | + | // Last line must be present (no internal clipping from the bottom). | |
| 276 | + | if !strings.Contains(out, fmt.Sprintf("line %d", len(lines)-1)) { | |
| 277 | + | t.Errorf("last transcript line missing — the view appears to be clipping:\n%s", out) | |
| 278 | + | } | |
| 279 | + | // The old "↑ N more above" / "↓ N more below" cliff markers | |
| 280 | + | // must NOT appear; those belonged to the old viewport model. | |
| 281 | + | if strings.Contains(out, "more above") || strings.Contains(out, "more below") { | |
| 282 | + | t.Errorf("transcript still uses internal scroll markers:\n%s", out) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Esc returns to the list view. | |
| 286 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 287 | + | if d.viewing { | |
| 288 | + | t.Fatal("esc did not exit viewer") | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs | |
| 293 | + | // <id>: OpenViewing should select the row whose id matches the | |
| 294 | + | // (possibly truncated) argument and enter the transcript view | |
| 295 | + | // directly without showing the list first. | |
| 296 | + | func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) { | |
| 297 | + | rows := []swarm.AgentSnapshot{ | |
| 298 | + | {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()}, | |
| 299 | + | {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()}, | |
| 300 | + | } | |
| 301 | + | d := newSwarmDialog() | |
| 302 | + | ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 303 | + | if !ok { | |
| 304 | + | t.Fatal("prefix match did not resolve") | |
| 305 | + | } | |
| 306 | + | if !d.viewing || d.cursor != 1 { | |
| 307 | + | t.Fatalf("viewing=%v cursor=%d", d.viewing, d.cursor) | |
| 308 | + | } | |
| 309 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 310 | + | if !strings.Contains(out, "hi from beta") { | |
| 311 | + | t.Fatalf("wrong agent shown:\n%s", out) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) { | |
| 316 | + | d := newSwarmDialog() | |
| 317 | + | ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "") | |
| 318 | + | if ok { | |
| 319 | + | t.Fatal("expected miss for unknown id") | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // TestSwarmDialogPromptFlowFromList drives the inline p-prompt: | |
| 324 | + | // press p on the selected agent, type a message, press enter, | |
| 325 | + | // and assert the send callback fired with the right id+text. | |
| 326 | + | func TestSwarmDialogPromptFlowFromList(t *testing.T) { | |
| 327 | + | rows := []swarm.AgentSnapshot{ | |
| 328 | + | {ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()}, | |
| 329 | + | } | |
| 330 | + | var gotID, gotText string | |
| 331 | + | sends := 0 | |
| 332 | + | d := newSwarmDialog() | |
| 333 | + | d.Open( | |
| 334 | + | staticSnapshots(rows...), | |
| 335 | + | nil, nil, nil, | |
| 336 | + | func(id, text string) error { sends++; gotID = id; gotText = text; return nil }, | |
| 337 | + | nil, | |
| 338 | + | "", | |
| 339 | + | ) | |
| 340 | + | _ = d.Render(tui.Theme{}, 80) | |
| 341 | + | ||
| 342 | + | // Press 'p' to open the inline prompt for alpha-1. | |
| 343 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 344 | + | if !d.prompting { | |
| 345 | + | t.Fatal("p did not enter prompting mode") | |
| 346 | + | } | |
| 347 | + | if d.promptTargetID != "alpha-1" { | |
| 348 | + | t.Fatalf("prompt target = %q; want alpha-1", d.promptTargetID) | |
| 349 | + | } | |
| 350 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 351 | + | if !strings.Contains(out, "send to alpha-1:") || !strings.Contains(out, "enter send") { | |
| 352 | + | t.Fatalf("prompt editor hint not rendered:\n%s", out) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Type "do the thing" and submit. | |
| 356 | + | for _, r := range "do the thing" { | |
| 357 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 358 | + | } | |
| 359 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 360 | + | if errMsg != "" { | |
| 361 | + | t.Fatalf("unexpected err: %s", errMsg) | |
| 362 | + | } | |
| 363 | + | if msg != "sent to alpha-1" { | |
| 364 | + | t.Fatalf("status msg = %q", msg) | |
| 365 | + | } | |
| 366 | + | if sends != 1 || gotID != "alpha-1" || gotText != "do the thing" { | |
| 367 | + | t.Fatalf("send callback got id=%q text=%q (calls=%d)", gotID, gotText, sends) | |
| 368 | + | } | |
| 369 | + | if d.prompting { | |
| 370 | + | t.Fatal("prompt did not close after enter") | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // TestSwarmDialogPromptFromTranscriptView pins the always-on editor | |
| 375 | + | // behaviour for the transcript view: entering an agent immediately | |
| 376 | + | // auto-opens the inline composer (no `p` needed), typing flows | |
| 377 | + | // straight into it, Enter sends, and the metadata stays visible. | |
| 378 | + | // The transcript view is /btw-style now — you never leave the | |
| 379 | + | // view to send a follow-up. | |
| 380 | + | func TestSwarmDialogPromptFromTranscriptView(t *testing.T) { | |
| 381 | + | rows := []swarm.AgentSnapshot{ | |
| 382 | + | {ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"}, | |
| 383 | + | } | |
| 384 | + | var gotID, gotText string | |
| 385 | + | d := newSwarmDialog() | |
| 386 | + | d.Open( | |
| 387 | + | staticSnapshots(rows...), | |
| 388 | + | nil, nil, nil, | |
| 389 | + | func(id, text string) error { gotID = id; gotText = text; return nil }, | |
| 390 | + | nil, | |
| 391 | + | "", | |
| 392 | + | ) | |
| 393 | + | // Enter the transcript view; for a running agent the editor | |
| 394 | + | // must auto-open right away. | |
| 395 | + | _ = d.Render(tui.Theme{}, 80) | |
| 396 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 397 | + | if !d.viewing { | |
| 398 | + | t.Fatal("enter did not open transcript view") | |
| 399 | + | } | |
| 400 | + | if !d.prompting { | |
| 401 | + | t.Fatal("transcript view did not auto-open inline editor for running agent") | |
| 402 | + | } | |
| 403 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 404 | + | if !strings.Contains(out, "task: do thing") { | |
| 405 | + | t.Fatalf("transcript metadata not visible:\n%s", out) | |
| 406 | + | } | |
| 407 | + | if !strings.Contains(out, "swarm: beta-2") { | |
| 408 | + | t.Fatalf("frame header missing agent id:\n%s", out) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // Typing flows straight into the editor — no `p` modal step. | |
| 412 | + | for _, r := range "keep going" { | |
| 413 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 414 | + | } | |
| 415 | + | _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 416 | + | if msg != "sent to beta-2" || gotID != "beta-2" || gotText != "keep going" { | |
| 417 | + | t.Fatalf("send didn't fire correctly: msg=%q id=%q text=%q", msg, gotID, gotText) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // Editor must stay mounted after submit (/btw-style), with an | |
| 421 | + | // empty buffer ready for the next message. | |
| 422 | + | if !d.prompting { | |
| 423 | + | t.Error("editor closed after submit; /btw-style transcript view should keep it mounted") | |
| 424 | + | } | |
| 425 | + | if !d.viewing { | |
| 426 | + | t.Error("transcript view collapsed after submit; should stay open") | |
| 427 | + | } | |
| 428 | + | if d.promptEd != nil && d.promptEd.Value() != "" { | |
| 429 | + | t.Errorf("editor buffer not cleared after submit: %q", d.promptEd.Value()) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt | |
| 434 | + | // editor aborts without invoking the send callback. | |
| 435 | + | func TestSwarmDialogPromptEscCancels(t *testing.T) { | |
| 436 | + | rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}} | |
| 437 | + | sends := 0 | |
| 438 | + | d := newSwarmDialog() | |
| 439 | + | d.Open( | |
| 440 | + | staticSnapshots(rows...), | |
| 441 | + | nil, nil, nil, | |
| 442 | + | func(id, text string) error { sends++; return nil }, | |
| 443 | + | nil, | |
| 444 | + | "", | |
| 445 | + | ) | |
| 446 | + | _ = d.Render(tui.Theme{}, 80) | |
| 447 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 448 | + | for _, r := range "oops" { | |
| 449 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 450 | + | } | |
| 451 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 452 | + | if d.prompting { | |
| 453 | + | t.Fatal("esc did not close prompt editor") | |
| 454 | + | } | |
| 455 | + | if sends != 0 { | |
| 456 | + | t.Fatalf("send fired despite esc: calls=%d", sends) | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // TestSwarmDialogPWithoutSendCallback verifies that pressing 'p' | |
| 461 | + | // when no send callback is wired is a silent no-op (mirrors the | |
| 462 | + | // existing 'n' / no-spawn-callback contract). | |
| 463 | + | func TestSwarmDialogPWithoutSendCallback(t *testing.T) { | |
| 464 | + | rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}} | |
| 465 | + | d := newSwarmDialog() | |
| 466 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 467 | + | _ = d.Render(tui.Theme{}, 80) | |
| 468 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 469 | + | if d.prompting { | |
| 470 | + | t.Fatal("p opened prompt editor without a send callback") | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // TestOpenForResumeParksCursorOnFirstResumable verifies the picker | |
| 475 | + | // entry point: with a mix of running and detached agents, the | |
| 476 | + | // dialog opens with the cursor on the first detached row (skipping | |
| 477 | + | // any leading running ones) so the user can press R immediately. | |
| 478 | + | func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) { | |
| 479 | + | rows := []swarm.AgentSnapshot{ | |
| 480 | + | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | |
| 481 | + | {ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()}, | |
| 482 | + | {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, | |
| 483 | + | {ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()}, | |
| 484 | + | } | |
| 485 | + | d := newSwarmDialog() | |
| 486 | + | count := d.OpenForResume( | |
| 487 | + | staticSnapshots(rows...), | |
| 488 | + | nil, nil, nil, nil, | |
| 489 | + | func(id string) error { return nil }, | |
| 490 | + | "", | |
| 491 | + | ) | |
| 492 | + | if count != 2 { | |
| 493 | + | t.Fatalf("resumable count = %d; want 2", count) | |
| 494 | + | } | |
| 495 | + | if d.cursor != 2 { | |
| 496 | + | t.Fatalf("cursor = %d; want 2 (first resumable row)", d.cursor) | |
| 497 | + | } | |
| 498 | + | if sel := d.selected(); sel == nil || sel.ID != "detached-1" { | |
| 499 | + | t.Fatalf("selected = %+v; want detached-1", sel) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // TestOpenForResumeWithNoResumableLeavesDialogActive ensures the | |
| 504 | + | // picker still opens the dashboard when nothing is resumable, so the | |
| 505 | + | // user sees what's there instead of getting silently nothing. The | |
| 506 | + | // slash handler is responsible for surfacing the "no resumable | |
| 507 | + | // agents" status string — the dialog itself just opens. | |
| 508 | + | func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) { | |
| 509 | + | rows := []swarm.AgentSnapshot{ | |
| 510 | + | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | |
| 511 | + | } | |
| 512 | + | d := newSwarmDialog() | |
| 513 | + | count := d.OpenForResume( | |
| 514 | + | staticSnapshots(rows...), | |
| 515 | + | nil, nil, nil, nil, | |
| 516 | + | func(id string) error { return nil }, | |
| 517 | + | "", | |
| 518 | + | ) | |
| 519 | + | if count != 0 { | |
| 520 | + | t.Fatalf("resumable count = %d; want 0", count) | |
| 521 | + | } | |
| 522 | + | if !d.Active() { | |
| 523 | + | t.Fatal("dialog should remain active so the user sees the empty state") | |
| 524 | + | } | |
| 525 | + | if d.cursor != 0 { | |
| 526 | + | t.Fatalf("cursor = %d; want 0 (no parking)", d.cursor) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | // TestOpenForResumeREnterTriggersResume drives the full picker UX: | |
| 531 | + | // /swarm resume → dialog opens parked on a resumable row → user | |
| 532 | + | // presses R → the resume callback fires for that exact id. | |
| 533 | + | func TestOpenForResumeREnterTriggersResume(t *testing.T) { | |
| 534 | + | rows := []swarm.AgentSnapshot{ | |
| 535 | + | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | |
| 536 | + | {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, | |
| 537 | + | } | |
| 538 | + | var resumed string | |
| 539 | + | d := newSwarmDialog() | |
| 540 | + | d.OpenForResume( | |
| 541 | + | staticSnapshots(rows...), | |
| 542 | + | nil, nil, nil, nil, | |
| 543 | + | func(id string) error { resumed = id; return nil }, | |
| 544 | + | "", | |
| 545 | + | ) | |
| 546 | + | _ = d.Render(tui.Theme{}, 80) | |
| 547 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'R'}) | |
| 548 | + | if errMsg != "" { | |
| 549 | + | t.Fatalf("unexpected err: %s", errMsg) | |
| 550 | + | } | |
| 551 | + | if resumed != "detached-1" { | |
| 552 | + | t.Fatalf("resume callback got %q; want detached-1", resumed) | |
| 553 | + | } | |
| 554 | + | if msg != "resumed detached-1" { | |
| 555 | + | t.Fatalf("status msg = %q", msg) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // TestNeedsTickRefresh enumerates the four dialog states so the host's | |
| 560 | + | // animation tick wakes the renderer for live updates but stays out of | |
| 561 | + | // the way of the inline editors (which need the cursor blink). | |
| 562 | + | func TestNeedsTickRefresh(t *testing.T) { | |
| 563 | + | // Closed. | |
| 564 | + | d := newSwarmDialog() | |
| 565 | + | if d.NeedsTickRefresh() { | |
| 566 | + | t.Fatal("closed dialog should not request tick refresh") | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | // List view (open, no editors): tick should refresh so rows update. | |
| 570 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |
| 571 | + | if !d.NeedsTickRefresh() { | |
| 572 | + | t.Fatal("open list view should request tick refresh") | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // Transcript view: still background-driven; tick should refresh. | |
| 576 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | |
| 577 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 578 | + | _ = d.Render(tui.Theme{}, 80) | |
| 579 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 580 | + | if !d.viewing { | |
| 581 | + | t.Fatal("setup: enter did not open transcript view") | |
| 582 | + | } | |
| 583 | + | if !d.NeedsTickRefresh() { | |
| 584 | + | t.Fatal("transcript view should request tick refresh") | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | // Spawn editor open: tick should NOT refresh (cursor blink). | |
| 588 | + | d = newSwarmDialog() | |
| 589 | + | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") | |
| 590 | + | _ = d.Render(tui.Theme{}, 80) | |
| 591 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 592 | + | if !d.spawning { | |
| 593 | + | t.Fatal("setup: n did not enter spawning mode") | |
| 594 | + | } | |
| 595 | + | if d.NeedsTickRefresh() { | |
| 596 | + | t.Fatal("spawn editor should suppress tick refresh") | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // Prompt editor open: same suppression. | |
| 600 | + | d = newSwarmDialog() | |
| 601 | + | rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | |
| 602 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") | |
| 603 | + | _ = d.Render(tui.Theme{}, 80) | |
| 604 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 605 | + | if !d.prompting { | |
| 606 | + | t.Fatal("setup: p did not enter prompting mode") | |
| 607 | + | } | |
| 608 | + | if d.NeedsTickRefresh() { | |
| 609 | + | t.Fatal("prompt editor should suppress tick refresh") | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | // TestCursorPosPromptEditor verifies the dialog reports a real | |
| 614 | + | // caret row for the prompt editor in both list-view and | |
| 615 | + | // transcript-view contexts. Without this, the terminal cursor | |
| 616 | + | // stayed at the (now-hidden) main editor row when the user | |
| 617 | + | // pressed 'p', so the visible caret looked wrong. | |
| 618 | + | func TestCursorPosPromptEditor(t *testing.T) { | |
| 619 | + | rows := []swarm.AgentSnapshot{ | |
| 620 | + | {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()}, | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // From the list view. | |
| 624 | + | d := newSwarmDialog() | |
| 625 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 626 | + | func(string, string) error { return nil }, nil, "") | |
| 627 | + | _ = d.Render(tui.Theme{}, 80) | |
| 628 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 629 | + | if !d.prompting { | |
| 630 | + | t.Fatal("setup: p did not enter prompting from list view") | |
| 631 | + | } | |
| 632 | + | row, _ := d.CursorPos(80) | |
| 633 | + | if row < 1 { | |
| 634 | + | t.Fatalf("list-view prompt CursorPos row = %d; want >= 1", row) | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // From the transcript view (deeper offset due to metadata block). | |
| 638 | + | d = newSwarmDialog() | |
| 639 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 640 | + | func(string, string) error { return nil }, nil, "") | |
| 641 | + | _ = d.Render(tui.Theme{}, 80) | |
| 642 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript | |
| 643 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 644 | + | if !d.prompting || !d.viewing { | |
| 645 | + | t.Fatalf("setup: prompting=%v viewing=%v", d.prompting, d.viewing) | |
| 646 | + | } | |
| 647 | + | rowT, _ := d.CursorPos(80) | |
| 648 | + | if rowT <= row { | |
| 649 | + | t.Fatalf("transcript-view prompt row = %d; should be deeper than list row %d", rowT, row) | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression | |
| 654 | + | // caught by the user's screenshot: pressing 'p' on a detached agent | |
| 655 | + | // dialled a dead unix socket and printed the raw path as an error. | |
| 656 | + | // We now block the prompt editor for any non-running status and | |
| 657 | + | // surface a clear status-bar hint instead. | |
| 658 | + | func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) { | |
| 659 | + | for _, st := range []swarm.Status{ | |
| 660 | + | swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | |
| 661 | + | } { | |
| 662 | + | t.Run(string(st), func(t *testing.T) { | |
| 663 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} | |
| 664 | + | sends := 0 | |
| 665 | + | d := newSwarmDialog() | |
| 666 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 667 | + | func(string, string) error { sends++; return nil }, nil, "") | |
| 668 | + | _ = d.Render(tui.Theme{}, 80) | |
| 669 | + | ||
| 670 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 671 | + | if d.prompting { | |
| 672 | + | t.Fatal("prompt editor opened on non-running agent") | |
| 673 | + | } | |
| 674 | + | if sends != 0 { | |
| 675 | + | t.Fatal("send callback fired without an open editor") | |
| 676 | + | } | |
| 677 | + | if msg != "" { | |
| 678 | + | t.Errorf("unexpected ok msg: %q", msg) | |
| 679 | + | } | |
| 680 | + | if !strings.Contains(errMsg, string(st)) { | |
| 681 | + | t.Errorf("hint = %q; should mention status %q", errMsg, st) | |
| 682 | + | } | |
| 683 | + | if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning { | |
| 684 | + | t.Errorf("hint = %q; should mention resume for resumable state", errMsg) | |
| 685 | + | } | |
| 686 | + | }) | |
| 687 | + | } | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | // TestSwarmDialogPOnRunningAgentOpensEditor is the positive | |
| 691 | + | // complement of the previous test: running agents still open the | |
| 692 | + | // editor normally. | |
| 693 | + | func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) { | |
| 694 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | |
| 695 | + | d := newSwarmDialog() | |
| 696 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 697 | + | func(string, string) error { return nil }, nil, "") | |
| 698 | + | _ = d.Render(tui.Theme{}, 80) | |
| 699 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 700 | + | if !d.prompting { | |
| 701 | + | t.Fatal("prompt editor did not open on running agent") | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | // TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the | |
| 706 | + | // user-friendly "isn't accepting input" hint, and unknown errors | |
| 707 | + | // fall through verbatim. | |
| 708 | + | func TestFriendlySendErrRewritesNotReady(t *testing.T) { | |
| 709 | + | hint := friendlySendErr("alpha-1", swarm.ErrNotReady) | |
| 710 | + | if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") { | |
| 711 | + | t.Errorf("friendlySendErr(ErrNotReady) = %q", hint) | |
| 712 | + | } | |
| 713 | + | if strings.Contains(hint, "swarm:") { | |
| 714 | + | t.Errorf("friendlySendErr leaked raw error text: %q", hint) | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | other := errors.New("some other failure") | |
| 718 | + | got := friendlySendErr("alpha-1", other) | |
| 719 | + | if got != "send: some other failure" { | |
| 720 | + | t.Errorf("friendlySendErr passthrough = %q", got) | |
| 721 | + | } | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | // TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat | |
| 725 | + | // transcript log is regrouped into user / assistant / stderr / | |
| 726 | + | // error blocks before rendering. Each output row should be styled | |
| 727 | + | // for its role rather than printed verbatim. | |
| 728 | + | func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) { | |
| 729 | + | lines := []string{ | |
| 730 | + | "user: hello", | |
| 731 | + | "hi there", | |
| 732 | + | "second assistant line", | |
| 733 | + | "stderr: warning thing", | |
| 734 | + | "error: boom", | |
| 735 | + | } | |
| 736 | + | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) | |
| 737 | + | joined := strings.Join(out, "\n") | |
| 738 | + | ||
| 739 | + | // The user echo should appear as a bubble row, which contains | |
| 740 | + | // the "▌ " left-bar glyph. | |
| 741 | + | if !strings.Contains(joined, "▌ ") { | |
| 742 | + | t.Errorf("user echo did not render as a bubble row:\n%s", joined) | |
| 743 | + | } | |
| 744 | + | // The user prefix should be stripped from the rendered output. | |
| 745 | + | if strings.Contains(joined, "user: hello") { | |
| 746 | + | t.Errorf("user prefix leaked into rendered output") | |
| 747 | + | } | |
| 748 | + | // Assistant content survives. | |
| 749 | + | if !strings.Contains(joined, "hi there") { | |
| 750 | + | t.Errorf("assistant text missing from rendered output") | |
| 751 | + | } | |
| 752 | + | // Stderr line is prefixed with stderr marker (we keep "stderr" | |
| 753 | + | // word in the rendered output for clarity). | |
| 754 | + | if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") { | |
| 755 | + | t.Errorf("stderr block missing:\n%s", joined) | |
| 756 | + | } | |
| 757 | + | // Error line gets the ✖ marker. | |
| 758 | + | if !strings.Contains(joined, "✖ boom") { | |
| 759 | + | t.Errorf("error block missing ✖ marker:\n%s", joined) | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | // TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the | |
| 764 | + | // assistant text goes through tui.RenderMarkdown: a literal **bold** | |
| 765 | + | // pair should become an ANSI bold sequence and bullet "-" should | |
| 766 | + | // become "•". | |
| 767 | + | func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) { | |
| 768 | + | lines := []string{ | |
| 769 | + | "Here is a list:", | |
| 770 | + | "- **bold** item", | |
| 771 | + | "- plain item", | |
| 772 | + | } | |
| 773 | + | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) | |
| 774 | + | joined := strings.Join(out, "\n") | |
| 775 | + | ||
| 776 | + | // ANSI bold on/off. Markdown rendering uses CSI 1 / 22. | |
| 777 | + | if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") { | |
| 778 | + | t.Errorf("expected ANSI bold sequences:\n%q", joined) | |
| 779 | + | } | |
| 780 | + | // Bullet glyph upgrade. | |
| 781 | + | if !strings.Contains(joined, "• ") { | |
| 782 | + | t.Errorf("expected • bullet:\n%s", joined) | |
| 783 | + | } | |
| 784 | + | // Literal "**bold**" markdown source must NOT leak through. | |
| 785 | + | if strings.Contains(joined, "**bold**") { | |
| 786 | + | t.Errorf("unrendered markdown leaked through:\n%s", joined) | |
| 787 | + | } | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines | |
| 791 | + | // guards the contract that consecutive assistant lines form one | |
| 792 | + | // markdown block. If we render each line in isolation, fenced code | |
| 793 | + | // (and other multi-line constructs) would never close. | |
| 794 | + | func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) { | |
| 795 | + | lines := []string{ | |
| 796 | + | "Some prose.", | |
| 797 | + | "```go", | |
| 798 | + | "package main", | |
| 799 | + | "", | |
| 800 | + | "func main() {}", | |
| 801 | + | "```", | |
| 802 | + | "Done.", | |
| 803 | + | } | |
| 804 | + | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) | |
| 805 | + | joined := strings.Join(out, "\n") | |
| 806 | + | ||
| 807 | + | // The literal fence markers should be consumed by the markdown | |
| 808 | + | // renderer; if we rendered each line on its own the ``` would | |
| 809 | + | // leak as text. | |
| 810 | + | if strings.Contains(joined, "```") { | |
| 811 | + | t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined) | |
| 812 | + | } | |
| 813 | + | // The code content should still be in the output — strip ANSI | |
| 814 | + | // before checking because syntax highlighting wraps each token. | |
| 815 | + | plain := stripANSIBytes(joined) | |
| 816 | + | if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") { | |
| 817 | + | t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain) | |
| 818 | + | } | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | // TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt- | |
| 822 | + | // disabled test: pressing 'k' on an already-terminal or detached | |
| 823 | + | // agent now surfaces a hint instead of routing to Swarm.Stop, which | |
| 824 | + | // is a no-op for those states (and used to segfault for detached | |
| 825 | + | // agents before the cancel-nil guard). | |
| 826 | + | func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) { | |
| 827 | + | for _, st := range []swarm.Status{ | |
| 828 | + | swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | |
| 829 | + | } { | |
| 830 | + | t.Run(string(st), func(t *testing.T) { | |
| 831 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} | |
| 832 | + | stops := 0 | |
| 833 | + | d := newSwarmDialog() | |
| 834 | + | d.Open(staticSnapshots(rows...), | |
| 835 | + | func(string) error { stops++; return nil }, | |
| 836 | + | nil, nil, nil, nil, "") | |
| 837 | + | _ = d.Render(tui.Theme{}, 80) | |
| 838 | + | _, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'}) | |
| 839 | + | if stops != 0 { | |
| 840 | + | t.Fatalf("stop callback fired on %s agent", st) | |
| 841 | + | } | |
| 842 | + | if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") { | |
| 843 | + | t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st) | |
| 844 | + | } | |
| 845 | + | }) | |
| 846 | + | } | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | // TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn | |
| 850 | + | // callback receives whatever model the host pinned via | |
| 851 | + | // SetCurrentModel before opening the dialog. This is how /model is | |
| 852 | + | // surfaced to swarm agents: the user picks via /model first, then | |
| 853 | + | // /swarm n, and the new agent inherits. | |
| 854 | + | func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) { | |
| 855 | + | var gotTask, gotModel, gotProv string | |
| 856 | + | d := newSwarmDialog() | |
| 857 | + | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | |
| 858 | + | d.Open(staticSnapshots(), nil, nil, | |
| 859 | + | func(task, model, prov string) error { | |
| 860 | + | gotTask, gotModel, gotProv = task, model, prov | |
| 861 | + | return nil | |
| 862 | + | }, | |
| 863 | + | nil, nil, "") | |
| 864 | + | _ = d.Render(tui.Theme{}, 80) | |
| 865 | + | ||
| 866 | + | // `n` goes straight to the spawn editor; no picker step. | |
| 867 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 868 | + | if !d.spawning { | |
| 869 | + | t.Fatal("n did not enter spawning mode") | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | // The spawn editor advertises the inherited model so the user | |
| 873 | + | // knows what they're about to spawn against. | |
| 874 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 875 | + | if !strings.Contains(out, "model: claude-sonnet-4-5") { | |
| 876 | + | t.Errorf("spawn editor missing model banner:\n%s", out) | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | for _, r := range "do thing" { | |
| 880 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 881 | + | } | |
| 882 | + | _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 883 | + | if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" { | |
| 884 | + | t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")", | |
| 885 | + | gotTask, gotModel, gotProv) | |
| 886 | + | } | |
| 887 | + | if !strings.Contains(msg, "claude-sonnet-4-5") { | |
| 888 | + | t.Errorf("status msg = %q; should mention inherited model", msg) | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | // TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in- | |
| 893 | + | // editor /model command pops the same model dialog the global | |
| 894 | + | // /model uses, that picking a model captures it as the pending | |
| 895 | + | // override, and that the spawn editor reopens with any previously- | |
| 896 | + | // typed task text intact so the user doesn't lose work. | |
| 897 | + | func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { | |
| 898 | + | var gotTask, gotModel, gotProv string | |
| 899 | + | d := newSwarmDialog() | |
| 900 | + | d.SetCurrentModel("old-model", "old-prov") | |
| 901 | + | d.Open(staticSnapshots(), nil, nil, | |
| 902 | + | func(task, model, prov string) error { | |
| 903 | + | gotTask, gotModel, gotProv = task, model, prov | |
| 904 | + | return nil | |
| 905 | + | }, | |
| 906 | + | nil, nil, "") | |
| 907 | + | _ = d.Render(tui.Theme{}, 80) | |
| 908 | + | ||
| 909 | + | // Open spawn editor and type some task text plus a /model line. | |
| 910 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 911 | + | if !d.spawning { | |
| 912 | + | t.Fatal("n did not enter spawning mode") | |
| 913 | + | } | |
| 914 | + | for _, r := range "refactor X" { | |
| 915 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 916 | + | } | |
| 917 | + | // Newline so the editor has a two-line buffer when we add /model. | |
| 918 | + | d.newTaskEd.SetValue("refactor X\n/model") | |
| 919 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 920 | + | ||
| 921 | + | if !d.pickingModel { | |
| 922 | + | t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel) | |
| 923 | + | } | |
| 924 | + | if d.spawning { | |
| 925 | + | t.Fatal("spawn editor still marked open while picker is up") | |
| 926 | + | } | |
| 927 | + | if d.spawnDraft != "refactor X" { | |
| 928 | + | t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X") | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | // Picker render must take over the dialog frame. | |
| 932 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 933 | + | if !strings.Contains(out, "select model for next spawn") { | |
| 934 | + | t.Errorf("picker render hint missing:\n%s", out) | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // Inject a fake selection into the embedded modelDialog and confirm. | |
| 938 | + | d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}} | |
| 939 | + | d.modelPicker.view = d.modelPicker.all | |
| 940 | + | d.modelPicker.cursor = 0 | |
| 941 | + | _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 942 | + | ||
| 943 | + | if d.pickingModel { | |
| 944 | + | t.Fatal("picker did not close on select") | |
| 945 | + | } | |
| 946 | + | if !d.spawning { | |
| 947 | + | t.Fatal("spawn editor did not reopen after picker select") | |
| 948 | + | } | |
| 949 | + | if d.pendingModel != "fake-1" || d.pendingProvider != "acme" { | |
| 950 | + | t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider) | |
| 951 | + | } | |
| 952 | + | if !strings.Contains(msg, "fake-1") { | |
| 953 | + | t.Errorf("status msg = %q; should mention picked model", msg) | |
| 954 | + | } | |
| 955 | + | if got := d.newTaskEd.Value(); got != "refactor X" { | |
| 956 | + | t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X") | |
| 957 | + | } | |
| 958 | + | ||
| 959 | + | // Now submit the task and confirm the spawn callback gets the | |
| 960 | + | // new model. | |
| 961 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 962 | + | if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" { | |
| 963 | + | t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv) | |
| 964 | + | } | |
| 965 | + | } | |
| 966 | + | ||
| 967 | + | // TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms | |
| 968 | + | // pressing Esc inside the picker (no selection) reopens the spawn | |
| 969 | + | // editor with the previously-typed text intact AND leaves the | |
| 970 | + | // pending model untouched, so the user can bail out cleanly. | |
| 971 | + | func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) { | |
| 972 | + | d := newSwarmDialog() | |
| 973 | + | d.SetCurrentModel("keep-me", "keep-prov") | |
| 974 | + | d.Open(staticSnapshots(), nil, nil, | |
| 975 | + | func(string, string, string) error { return nil }, | |
| 976 | + | nil, nil, "") | |
| 977 | + | _ = d.Render(tui.Theme{}, 80) | |
| 978 | + | ||
| 979 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 980 | + | d.newTaskEd.SetValue("task here\n/model") | |
| 981 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 982 | + | if !d.pickingModel { | |
| 983 | + | t.Fatal("/model did not open the picker") | |
| 984 | + | } | |
| 985 | + | ||
| 986 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 987 | + | if d.pickingModel { | |
| 988 | + | t.Fatal("esc did not close the picker") | |
| 989 | + | } | |
| 990 | + | if !d.spawning { | |
| 991 | + | t.Fatal("spawn editor did not reopen after picker cancel") | |
| 992 | + | } | |
| 993 | + | if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" { | |
| 994 | + | t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider) | |
| 995 | + | } | |
| 996 | + | if got := d.newTaskEd.Value(); got != "task here" { | |
| 997 | + | t.Errorf("reopened editor buffer = %q; want %q", got, "task here") | |
| 998 | + | } | |
| 999 | + | } | |
| 1000 | + | ||
| 1001 | + | // TestStripSlashModelLine pins the helper's behaviour: drop any | |
| 1002 | + | // line whose trimmed content is "/model" or starts with "/model ", | |
| 1003 | + | // preserve everything else verbatim. | |
| 1004 | + | func TestStripSlashModelLine(t *testing.T) { | |
| 1005 | + | cases := []struct{ in, want string }{ | |
| 1006 | + | {"/model", ""}, | |
| 1007 | + | {" /model ", ""}, | |
| 1008 | + | {"foo\n/model", "foo"}, | |
| 1009 | + | {"/model\nfoo", "foo"}, | |
| 1010 | + | {"foo\n/model\nbar", "foo\nbar"}, | |
| 1011 | + | {"/model gpt-5", ""}, | |
| 1012 | + | {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep | |
| 1013 | + | {"", ""}, | |
| 1014 | + | {"plain task", "plain task"}, | |
| 1015 | + | } | |
| 1016 | + | for _, c := range cases { | |
| 1017 | + | if got := stripSlashModelLine(c.in); got != c.want { | |
| 1018 | + | t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want) | |
| 1019 | + | } | |
| 1020 | + | } | |
| 1021 | + | } | |
| 1022 | + | ||
| 1023 | + | // TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that | |
| 1024 | + | // when the host doesn't pin a model (SetCurrentModel never called or | |
| 1025 | + | // called with empty strings), the spawn callback receives empty | |
| 1026 | + | // model/provider and the child subprocess resolves its own default. | |
| 1027 | + | func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) { | |
| 1028 | + | var gotModel, gotProv string | |
| 1029 | + | d := newSwarmDialog() | |
| 1030 | + | d.Open(staticSnapshots(), nil, nil, | |
| 1031 | + | func(_, model, prov string) error { | |
| 1032 | + | gotModel, gotProv = model, prov | |
| 1033 | + | return nil | |
| 1034 | + | }, | |
| 1035 | + | nil, nil, "") | |
| 1036 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1037 | + | ||
| 1038 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 1039 | + | for _, r := range "do thing" { | |
| 1040 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 1041 | + | } | |
| 1042 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1043 | + | if gotModel != "" || gotProv != "" { | |
| 1044 | + | t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv) | |
| 1045 | + | } | |
| 1046 | + | } | |
| 1047 | + | ||
| 1048 | + | // TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style | |
| 1049 | + | // "the transcript grows with the content" property: when the agent | |
| 1050 | + | // reply lands in the snapshot, the next render includes it in full, | |
| 1051 | + | // alongside everything that was already there. Replaces the old | |
| 1052 | + | // followTail/scroll tests that pinned a viewport mechanic we no | |
| 1053 | + | // longer have. | |
| 1054 | + | func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) { | |
| 1055 | + | cur := []swarm.AgentSnapshot{{ | |
| 1056 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |
| 1057 | + | Lines: []string{"old line 1", "old line 2"}, Started: time.Now(), | |
| 1058 | + | }} | |
| 1059 | + | snap := func() []swarm.AgentSnapshot { return cur } | |
| 1060 | + | ||
| 1061 | + | d := newSwarmDialog() | |
| 1062 | + | d.Open(snap, nil, nil, nil, | |
| 1063 | + | func(string, string) error { return nil }, | |
| 1064 | + | nil, "") | |
| 1065 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1066 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1067 | + | ||
| 1068 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1069 | + | if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") { | |
| 1070 | + | t.Fatalf("initial render missing existing lines:\n%s", out) | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | // Agent appends a fresh reply: the very next render must show | |
| 1074 | + | // both the new content AND keep the old content (no clipping). | |
| 1075 | + | cur = []swarm.AgentSnapshot{{ | |
| 1076 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |
| 1077 | + | Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"}, | |
| 1078 | + | Started: cur[0].Started, | |
| 1079 | + | }} | |
| 1080 | + | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1081 | + | for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} { | |
| 1082 | + | if !strings.Contains(out, want) { | |
| 1083 | + | t.Errorf("line %q missing after growth:\n%s", want, out) | |
| 1084 | + | } | |
| 1085 | + | } | |
| 1086 | + | } | |
| 1087 | + | ||
| 1088 | + | // TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow | |
| 1089 | + | // for the transcript view: after one Enter-submit the editor stays | |
| 1090 | + | // mounted and clears, and a second message can be composed and sent | |
| 1091 | + | // without any modal toggle. Regresses the previous design where | |
| 1092 | + | // every send closed the prompt and forced the user to press `p` | |
| 1093 | + | // again. | |
| 1094 | + | func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) { | |
| 1095 | + | rows := []swarm.AgentSnapshot{{ | |
| 1096 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), | |
| 1097 | + | Lines: []string{"warm-up"}, | |
| 1098 | + | }} | |
| 1099 | + | var sent []string | |
| 1100 | + | d := newSwarmDialog() | |
| 1101 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 1102 | + | func(_, text string) error { sent = append(sent, text); return nil }, | |
| 1103 | + | nil, "") | |
| 1104 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1105 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor | |
| 1106 | + | ||
| 1107 | + | // First message. | |
| 1108 | + | for _, r := range "first" { | |
| 1109 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 1110 | + | } | |
| 1111 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1112 | + | ||
| 1113 | + | // Second message, no `p` toggle in between. | |
| 1114 | + | for _, r := range "second" { | |
| 1115 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 1116 | + | } | |
| 1117 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1118 | + | ||
| 1119 | + | if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" { | |
| 1120 | + | t.Fatalf("sent = %v; want [first second]", sent) | |
| 1121 | + | } | |
| 1122 | + | if !d.viewing || !d.prompting { | |
| 1123 | + | t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting) | |
| 1124 | + | } | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | // TestSwarmTranscriptEscClosesView pins the Esc semantics for the | |
| 1128 | + | // /btw-style transcript view: a single Esc closes the whole view | |
| 1129 | + | // (back to the dashboard list), regardless of whether the editor | |
| 1130 | + | // is currently composing. Without this routing the editor's own | |
| 1131 | + | // Esc handler would just clear the modal and leave the user stuck | |
| 1132 | + | // inside an empty transcript view. | |
| 1133 | + | func TestSwarmTranscriptEscClosesView(t *testing.T) { | |
| 1134 | + | rows := []swarm.AgentSnapshot{{ | |
| 1135 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), | |
| 1136 | + | Lines: []string{"hi"}, | |
| 1137 | + | }} | |
| 1138 | + | d := newSwarmDialog() | |
| 1139 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 1140 | + | func(string, string) error { return nil }, | |
| 1141 | + | nil, "") | |
| 1142 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1143 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1144 | + | if !d.viewing || !d.prompting { | |
| 1145 | + | t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting) | |
| 1146 | + | } | |
| 1147 | + | ||
| 1148 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 1149 | + | if d.viewing { | |
| 1150 | + | t.Error("Esc did not close the transcript view") | |
| 1151 | + | } | |
| 1152 | + | if d.prompting { | |
| 1153 | + | t.Error("Esc did not tear down the inline editor") | |
| 1154 | + | } | |
| 1155 | + | } | |
| 1156 | + | ||
| 1157 | + | // TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net: | |
| 1158 | + | // transcripts for done / failed / killed agents must NOT auto-open | |
| 1159 | + | // an editor (the agent's inbox isn't listening, so a send would | |
| 1160 | + | // just bounce). The view shows the transcript plus a hint about | |
| 1161 | + | // resuming instead. | |
| 1162 | + | func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) { | |
| 1163 | + | rows := []swarm.AgentSnapshot{{ | |
| 1164 | + | ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(), | |
| 1165 | + | Lines: []string{"all done"}, | |
| 1166 | + | }} | |
| 1167 | + | d := newSwarmDialog() | |
| 1168 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 1169 | + | func(string, string) error { return nil }, | |
| 1170 | + | nil, "") | |
| 1171 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1172 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1173 | + | if d.prompting { | |
| 1174 | + | t.Fatal("editor auto-opened for a done agent; should only happen for running ones") | |
| 1175 | + | } | |
| 1176 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1177 | + | if !strings.Contains(out, "prompt: agent is done") { | |
| 1178 | + | t.Errorf("hint about non-running status missing:\n%s", out) | |
| 1179 | + | } | |
| 1180 | + | } | |
| 1181 | + | ||
| 1182 | + | // TestSwarmTranscriptTickRefreshWhileEditing pins the streaming | |
| 1183 | + | // fix: with the always-on editor in transcript view, the host's | |
| 1184 | + | // animation tick MUST still call us so streaming agent output | |
| 1185 | + | // renders. Without this, sending a message and watching the reply | |
| 1186 | + | // land silently failed because the prompting=true branch was | |
| 1187 | + | // suppressing redraws (like the modal editors do). | |
| 1188 | + | func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) { | |
| 1189 | + | rows := []swarm.AgentSnapshot{{ | |
| 1190 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |
| 1191 | + | Activity: "thinking", Started: time.Now(), | |
| 1192 | + | Lines: []string{"hello"}, | |
| 1193 | + | }} | |
| 1194 | + | d := newSwarmDialog() | |
| 1195 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 1196 | + | func(string, string) error { return nil }, | |
| 1197 | + | nil, "") | |
| 1198 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1199 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1200 | + | if !d.viewing || !d.prompting { | |
| 1201 | + | t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting) | |
| 1202 | + | } | |
| 1203 | + | ||
| 1204 | + | if !d.NeedsTickRefresh() { | |
| 1205 | + | t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze") | |
| 1206 | + | } | |
| 1207 | + | } | |
| 1208 | + | ||
| 1209 | + | // TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse: | |
| 1210 | + | // the list-view `p` modal (prompting without viewing) still | |
| 1211 | + | // suppresses tick like the other modal editors, so the cursor | |
| 1212 | + | // blink works cleanly while composing a one-off prompt. | |
| 1213 | + | func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) { | |
| 1214 | + | rows := []swarm.AgentSnapshot{{ | |
| 1215 | + | ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(), | |
| 1216 | + | }} | |
| 1217 | + | d := newSwarmDialog() | |
| 1218 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |
| 1219 | + | func(string, string) error { return nil }, | |
| 1220 | + | nil, "") | |
| 1221 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1222 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |
| 1223 | + | if d.viewing || !d.prompting { | |
| 1224 | + | t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting) | |
| 1225 | + | } | |
| 1226 | + | ||
| 1227 | + | if d.NeedsTickRefresh() { | |
| 1228 | + | t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave") | |
| 1229 | + | } | |
| 1230 | + | } | |
| 1231 | + | ||
| 1232 | + | // TestSwarmTranscriptBusySpinnerRenders proves the inline spinner | |
| 1233 | + | // appears above the always-on editor while the agent is busy and | |
| 1234 | + | // disappears when it goes idle. This is the /btw-style "something | |
| 1235 | + | // is happening" feedback the user was missing. | |
| 1236 | + | func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) { | |
| 1237 | + | cur := []swarm.AgentSnapshot{{ | |
| 1238 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |
| 1239 | + | Activity: "thinking", Started: time.Now(), | |
| 1240 | + | Lines: []string{"hello"}, | |
| 1241 | + | }} | |
| 1242 | + | snap := func() []swarm.AgentSnapshot { return cur } | |
| 1243 | + | d := newSwarmDialog() | |
| 1244 | + | d.Open(snap, nil, nil, nil, | |
| 1245 | + | func(string, string) error { return nil }, | |
| 1246 | + | nil, "") | |
| 1247 | + | _ = d.Render(tui.Theme{}, 80) | |
| 1248 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 1249 | + | ||
| 1250 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1251 | + | if !strings.Contains(out, "thinking") { | |
| 1252 | + | t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out) | |
| 1253 | + | } | |
| 1254 | + | if d.transcriptSpin == nil { | |
| 1255 | + | t.Error("spinner not initialised while agent is busy") | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | // Flip the activity to idle: spinner should vanish. | |
| 1259 | + | cur[0].Activity = "idle" | |
| 1260 | + | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 1261 | + | if d.transcriptSpin != nil { | |
| 1262 | + | t.Error("spinner stayed live after agent reported idle") | |
| 1263 | + | } | |
| 1264 | + | // The bare word "thinking" must not appear anymore. | |
| 1265 | + | if strings.Contains(out, "· thinking ·") { | |
| 1266 | + | t.Errorf("busy line still rendered when agent is idle:\n%s", out) | |
| 1267 | + | } | |
| 1268 | + | } | |
| 1269 | + | ||
| 1270 | + | func TestStatusLabelCoversAll(t *testing.T) { | |
| 1271 | + | for _, s := range []swarm.Status{ | |
| 1272 | + | swarm.StatusPending, swarm.StatusRunning, | |
| 1273 | + | swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | |
| 1274 | + | } { | |
| 1275 | + | if statusLabel(s) == "" { | |
| 1276 | + | t.Fatalf("no label for %s", s) | |
| 1277 | + | } | |
| 1278 | + | } | |
| 1279 | + | } | |
A internal/agent/modes/swarm_slash.go +309 -0
| @@ -0,0 +1,309 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // runSwarm dispatches /swarm subcommands. Layout: | |
| 12 | + | // | |
| 13 | + | // /swarm -> open the dashboard | |
| 14 | + | // /swarm list -> open the dashboard | |
| 15 | + | // /swarm new [--model M] [--provider P] <task...> | |
| 16 | + | // -> spawn an agent (optionally pinned to a model) | |
| 17 | + | // /swarm kill <id> -> stop a running agent | |
| 18 | + | // /swarm remove <id> -> tear down a terminated agent | |
| 19 | + | // /swarm logs <id> -> open the scrollable transcript view | |
| 20 | + | // /swarm send <id> <text...> -> send a follow-up user turn to <id> | |
| 21 | + | // /swarm resume [id] -> resume an agent (omit id to pick from a list) | |
| 22 | + | // /swarm attach <id> -> (planned) drop into the agent's TUI | |
| 23 | + | // | |
| 24 | + | // When cfg.Swarm is nil the command tells the user the feature is | |
| 25 | + | // disabled instead of pretending to work. | |
| 26 | + | func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |
| 27 | + | if i.cfg.Swarm == nil { | |
| 28 | + | i.mu.Lock() | |
| 29 | + | i.statusErr = "swarm is disabled in this build" | |
| 30 | + | i.statusOK = "" | |
| 31 | + | i.mu.Unlock() | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | sub := "" | |
| 36 | + | rest := "" | |
| 37 | + | if len(args) > 0 { | |
| 38 | + | sub = strings.ToLower(args[0]) | |
| 39 | + | // Guard the args[1:] reslice: when only the subcommand was | |
| 40 | + | // typed (e.g. bare "/swarm new"), args has length 1 and the | |
| 41 | + | // naive args[1:] is fine, but when args is empty (bare | |
| 42 | + | // "/swarm") the reslice is [1:0] and panics. The len>0 branch | |
| 43 | + | // here keeps both cases safe. | |
| 44 | + | if len(args) > 1 { | |
| 45 | + | rest = strings.TrimSpace(strings.Join(args[1:], " ")) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // spawnAdapter, sendAdapter, resumeAdapter wrap the Swarm methods | |
| 50 | + | // in the signatures the dialog expects. Defined once here so the | |
| 51 | + | // three Open()-shaped entry points (list, logs/view-jump, resume) | |
| 52 | + | // feed the dialog identical callbacks. | |
| 53 | + | spawnAdapter := func(task, model, provider string) error { | |
| 54 | + | _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 55 | + | Task: task, Model: model, Provider: provider, | |
| 56 | + | }) | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | sendAdapter := func(id, text string) error { | |
| 60 | + | return i.cfg.Swarm.SendUserTurn(id, text) | |
| 61 | + | } | |
| 62 | + | resumeAdapter := func(id string) error { | |
| 63 | + | _, err := i.cfg.Swarm.Resume(ctx, id) | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Pin every fresh spawn to whatever the host's /model selection | |
| 68 | + | // is right now. This is captured at /swarm time — if the user | |
| 69 | + | // wants a different model for the next swarm agent, they pick it | |
| 70 | + | // via /model first (globally), or, while inside the spawn | |
| 71 | + | // editor, by typing /model on its own line to pop the picker. | |
| 72 | + | i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |
| 73 | + | if i.cfg.LoggedInProviders != nil { | |
| 74 | + | i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | switch sub { | |
| 78 | + | case "", "list", "ls", "ps": | |
| 79 | + | i.swarmDialog.Open( | |
| 80 | + | i.cfg.Swarm.SnapshotAll, | |
| 81 | + | i.cfg.Swarm.Stop, | |
| 82 | + | i.cfg.Swarm.Remove, | |
| 83 | + | spawnAdapter, | |
| 84 | + | sendAdapter, | |
| 85 | + | resumeAdapter, | |
| 86 | + | i.cfg.CWD, | |
| 87 | + | ) | |
| 88 | + | case "new", "spawn": | |
| 89 | + | if rest == "" { | |
| 90 | + | i.swarmStatus("", "/swarm new <task>: missing task") | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | // Permit `--model X --provider Y` flags before the task so | |
| 94 | + | // scripts can pin a model without going through the dialog. | |
| 95 | + | // Anything that isn't a recognised flag terminates parsing | |
| 96 | + | // and the rest becomes the task; this keeps `/swarm new | |
| 97 | + | // --model foo do a thing` and `/swarm new do --model thing` | |
| 98 | + | // (where --model is part of the task) unambiguous — only | |
| 99 | + | // leading flags are consumed. | |
| 100 | + | model, provider, task := parseSpawnFlags(rest) | |
| 101 | + | if task == "" { | |
| 102 | + | i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)") | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 106 | + | Task: task, Model: model, Provider: provider, | |
| 107 | + | }) | |
| 108 | + | if err != nil { | |
| 109 | + | i.swarmStatus("", "spawn: "+err.Error()) | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | if model != "" { | |
| 113 | + | i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "") | |
| 114 | + | } else { | |
| 115 | + | i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "") | |
| 116 | + | } | |
| 117 | + | case "kill", "stop": | |
| 118 | + | if rest == "" { | |
| 119 | + | i.swarmStatus("", "/swarm kill <id>: missing id") | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | if err := i.cfg.Swarm.Stop(rest); err != nil { | |
| 123 | + | i.swarmStatus("", "kill: "+err.Error()) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | i.swarmStatus("stopped "+rest, "") | |
| 127 | + | case "remove", "rm": | |
| 128 | + | if rest == "" { | |
| 129 | + | i.swarmStatus("", "/swarm remove <id>: missing id") | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if err := i.cfg.Swarm.Remove(rest); err != nil { | |
| 133 | + | i.swarmStatus("", "remove: "+err.Error()) | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | i.swarmStatus("removed "+rest, "") | |
| 137 | + | case "logs", "log", "view": | |
| 138 | + | if rest == "" { | |
| 139 | + | i.swarmStatus("", "/swarm logs <id>: missing id") | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | ok := i.swarmDialog.OpenViewing( | |
| 143 | + | rest, | |
| 144 | + | i.cfg.Swarm.SnapshotAll, | |
| 145 | + | i.cfg.Swarm.Stop, | |
| 146 | + | i.cfg.Swarm.Remove, | |
| 147 | + | spawnAdapter, | |
| 148 | + | sendAdapter, | |
| 149 | + | resumeAdapter, | |
| 150 | + | i.cfg.CWD, | |
| 151 | + | ) | |
| 152 | + | if !ok { | |
| 153 | + | i.swarmStatus("", "/swarm logs: no agent matching "+rest) | |
| 154 | + | } | |
| 155 | + | case "resume", "reattach", "reopen": | |
| 156 | + | if rest == "" { | |
| 157 | + | // No id given: open the dashboard with the cursor | |
| 158 | + | // pre-positioned on the first resumable agent, and | |
| 159 | + | // tell the user how many there are so they know what | |
| 160 | + | // to expect. Pressing R confirms; ↑/↓ to pick a | |
| 161 | + | // different row first. | |
| 162 | + | count := i.swarmDialog.OpenForResume( | |
| 163 | + | i.cfg.Swarm.SnapshotAll, | |
| 164 | + | i.cfg.Swarm.Stop, | |
| 165 | + | i.cfg.Swarm.Remove, | |
| 166 | + | spawnAdapter, | |
| 167 | + | sendAdapter, | |
| 168 | + | resumeAdapter, | |
| 169 | + | i.cfg.CWD, | |
| 170 | + | ) | |
| 171 | + | switch count { | |
| 172 | + | case 0: | |
| 173 | + | i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)") | |
| 174 | + | case 1: | |
| 175 | + | i.swarmStatus("1 resumable agent · press R to resume", "") | |
| 176 | + | default: | |
| 177 | + | i.swarmStatus(fmt.Sprintf("%d resumable agents · ↑/↓ to pick · R to resume", count), "") | |
| 178 | + | } | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | a, err := i.cfg.Swarm.Resume(ctx, rest) | |
| 182 | + | if err != nil { | |
| 183 | + | i.swarmStatus("", "resume: "+err.Error()) | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | i.swarmStatus("resumed "+a.ID, "") | |
| 187 | + | case "send", "prompt", "msg": | |
| 188 | + | // /swarm send <id> <text...> is the non-interactive | |
| 189 | + | // counterpart of pressing 'p' in the dashboard. We split the | |
| 190 | + | // joined `rest` ourselves rather than reusing the dispatcher's | |
| 191 | + | // already-fielded args[] because the text may contain spaces | |
| 192 | + | // the user expects to be preserved verbatim. | |
| 193 | + | id, text := splitIDAndRest(rest) | |
| 194 | + | if id == "" { | |
| 195 | + | i.swarmStatus("", "/swarm send <id> <text>: missing id") | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | if text == "" { | |
| 199 | + | i.swarmStatus("", "/swarm send <id> <text>: missing text") | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil { | |
| 203 | + | i.swarmStatus("", friendlySendErr(id, err)) | |
| 204 | + | return | |
| 205 | + | } | |
| 206 | + | i.swarmStatus("sent to "+id, "") | |
| 207 | + | case "attach": | |
| 208 | + | // PTY-reparenting is a significant chunk of work I haven't | |
| 209 | + | // landed yet (see the design sketch). Recognise the name so | |
| 210 | + | // /swarm attach doesn't fall through to the generic "unknown | |
| 211 | + | // subcommand" path — that error message is misleading because | |
| 212 | + | // it makes attach sound like a typo instead of a planned | |
| 213 | + | // feature. Point the user at /swarm logs in the meantime. | |
| 214 | + | i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.") | |
| 215 | + | default: | |
| 216 | + | i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)") | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // parseSpawnFlags consumes any leading `--model X` / `--provider Y` | |
| 221 | + | // flags from s and returns them along with the remaining task body. | |
| 222 | + | // We deliberately only honour LEADING flags so a task like "check | |
| 223 | + | // --model lookup" doesn't accidentally swallow part of its prose as | |
| 224 | + | // the model name. | |
| 225 | + | // | |
| 226 | + | // Recognised forms: | |
| 227 | + | // | |
| 228 | + | // --model X two-token form | |
| 229 | + | // --model=X single-token form | |
| 230 | + | // --provider X two-token form | |
| 231 | + | // --provider=X single-token form | |
| 232 | + | func parseSpawnFlags(s string) (model, provider, task string) { | |
| 233 | + | fields := strings.Fields(s) | |
| 234 | + | i := 0 | |
| 235 | + | for i < len(fields) { | |
| 236 | + | f := fields[i] | |
| 237 | + | switch { | |
| 238 | + | case f == "--model": | |
| 239 | + | // Consume the flag even when no value follows so a | |
| 240 | + | // dangling "--model" doesn't leak into the task. The | |
| 241 | + | // caller surfaces "missing task" instead. | |
| 242 | + | if i+1 < len(fields) { | |
| 243 | + | model = fields[i+1] | |
| 244 | + | i += 2 | |
| 245 | + | } else { | |
| 246 | + | i++ | |
| 247 | + | } | |
| 248 | + | continue | |
| 249 | + | case strings.HasPrefix(f, "--model="): | |
| 250 | + | model = strings.TrimPrefix(f, "--model=") | |
| 251 | + | i++ | |
| 252 | + | continue | |
| 253 | + | case f == "--provider": | |
| 254 | + | if i+1 < len(fields) { | |
| 255 | + | provider = fields[i+1] | |
| 256 | + | i += 2 | |
| 257 | + | } else { | |
| 258 | + | i++ | |
| 259 | + | } | |
| 260 | + | continue | |
| 261 | + | case strings.HasPrefix(f, "--provider="): | |
| 262 | + | provider = strings.TrimPrefix(f, "--provider=") | |
| 263 | + | i++ | |
| 264 | + | continue | |
| 265 | + | } | |
| 266 | + | break | |
| 267 | + | } | |
| 268 | + | task = strings.TrimSpace(strings.Join(fields[i:], " ")) | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // splitIDAndRest splits "<id> <text...>" into (id, text). The text | |
| 273 | + | // half preserves all whitespace after the first token so the agent | |
| 274 | + | // receives the user's prompt verbatim (modulo a single trim of the | |
| 275 | + | // boundary space). Returns ("", "") when s is empty so the caller | |
| 276 | + | // can surface a missing-id error. | |
| 277 | + | func splitIDAndRest(s string) (id, text string) { | |
| 278 | + | s = strings.TrimLeft(s, " \t") | |
| 279 | + | if s == "" { | |
| 280 | + | return "", "" | |
| 281 | + | } | |
| 282 | + | cut := strings.IndexAny(s, " \t") | |
| 283 | + | if cut < 0 { | |
| 284 | + | return s, "" | |
| 285 | + | } | |
| 286 | + | return s[:cut], strings.TrimLeft(s[cut+1:], " \t") | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // firstWord returns the first whitespace-separated token of s, or | |
| 290 | + | // "<id>" when s is empty. Used to keep the "/swarm attach" hint | |
| 291 | + | // readable even when the user typed no argument. | |
| 292 | + | func firstWord(s string) string { | |
| 293 | + | s = strings.TrimSpace(s) | |
| 294 | + | if s == "" { | |
| 295 | + | return "<id>" | |
| 296 | + | } | |
| 297 | + | if i := strings.IndexAny(s, " \t"); i >= 0 { | |
| 298 | + | return s[:i] | |
| 299 | + | } | |
| 300 | + | return s | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func (i *Interactive) swarmStatus(ok, errMsg string) { | |
| 304 | + | i.mu.Lock() | |
| 305 | + | i.statusOK = ok | |
| 306 | + | i.statusErr = errMsg | |
| 307 | + | i.mu.Unlock() | |
| 308 | + | i.invalidate() | |
| 309 | + | } | |
A internal/agent/modes/swarm_slash_test.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // newInteractiveForSwarmTest builds the minimal Interactive scaffolding | |
| 13 | + | // runSwarm needs. It does NOT call NewInteractive (which would pull in | |
| 14 | + | // the whole TUI); the runSwarm method only touches cfg.Swarm, the | |
| 15 | + | // status mutex, and the swarm dialog, so we hand-build those. | |
| 16 | + | func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { | |
| 17 | + | t.Helper() | |
| 18 | + | root := t.TempDir() | |
| 19 | + | f := swarm.New(swarm.Config{ | |
| 20 | + | Root: root, | |
| 21 | + | RepoRoot: root, | |
| 22 | + | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |
| 23 | + | NewRunner: func(a *swarm.Agent) swarm.Runner { | |
| 24 | + | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |
| 25 | + | <-ctx.Done() | |
| 26 | + | return ctx.Err() | |
| 27 | + | }) | |
| 28 | + | }, | |
| 29 | + | }) | |
| 30 | + | iv := &Interactive{ | |
| 31 | + | swarmDialog: newSwarmDialog(), | |
| 32 | + | dirty: make(chan struct{}, 1), | |
| 33 | + | } | |
| 34 | + | iv.cfg.Swarm = f | |
| 35 | + | return iv, f | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range | |
| 39 | + | // panic that hit when /swarm was typed with no subcommand: runSwarm | |
| 40 | + | // did args[1:] without checking len(args), which panics as [1:0]. | |
| 41 | + | func TestRunSwarmBareDoesNotPanic(t *testing.T) { | |
| 42 | + | iv, _ := newInteractiveForSwarmTest(t) | |
| 43 | + | defer iv.cfg.Swarm.StopAll() | |
| 44 | + | ||
| 45 | + | // Bare /swarm: parts[1:] from the dispatcher is an empty slice. | |
| 46 | + | iv.runSwarm(context.Background(), nil) | |
| 47 | + | ||
| 48 | + | if !iv.swarmDialog.Active() { | |
| 49 | + | t.Fatal("bare /swarm should open the dashboard") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) { | |
| 54 | + | iv, _ := newInteractiveForSwarmTest(t) | |
| 55 | + | defer iv.cfg.Swarm.StopAll() | |
| 56 | + | ||
| 57 | + | // Each row is the slice that the dispatcher hands to runSwarm — | |
| 58 | + | // i.e. parts[1:] where parts was strings.Fields of the slash | |
| 59 | + | // command. Mixing zero-arg and arg'd forms exercises both | |
| 60 | + | // branches of the reslice guard. | |
| 61 | + | cases := [][]string{ | |
| 62 | + | {"list"}, | |
| 63 | + | {"new"}, | |
| 64 | + | {"new", "fix", "the", "thing"}, | |
| 65 | + | {"kill"}, | |
| 66 | + | {"kill", "no-such-id"}, | |
| 67 | + | {"remove"}, | |
| 68 | + | {"remove", "no-such-id"}, | |
| 69 | + | {"send"}, | |
| 70 | + | {"send", "no-such-id"}, | |
| 71 | + | {"send", "no-such-id", "hello", "world"}, | |
| 72 | + | {"bogus"}, | |
| 73 | + | } | |
| 74 | + | for _, args := range cases { | |
| 75 | + | func() { | |
| 76 | + | defer func() { | |
| 77 | + | if r := recover(); r != nil { | |
| 78 | + | t.Fatalf("runSwarm(%v) panicked: %v", args, r) | |
| 79 | + | } | |
| 80 | + | }() | |
| 81 | + | iv.runSwarm(context.Background(), args) | |
| 82 | + | }() | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestRunSwarmNewSpawnsAgent(t *testing.T) { | |
| 87 | + | iv, f := newInteractiveForSwarmTest(t) | |
| 88 | + | defer f.StopAll() | |
| 89 | + | ||
| 90 | + | iv.runSwarm(context.Background(), []string{"new", "do", "stuff"}) | |
| 91 | + | agents := f.List() | |
| 92 | + | if len(agents) != 1 { | |
| 93 | + | t.Fatalf("want 1 agent; got %d", len(agents)) | |
| 94 | + | } | |
| 95 | + | if agents[0].Task != "do stuff" { | |
| 96 | + | t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a | |
| 101 | + | // fake Runner whose only job is to forward inbox lines to a channel, | |
| 102 | + | // then asserts the /swarm send <id> <text...> path routes through | |
| 103 | + | // Swarm.SendUserTurn and lands at the agent verbatim. | |
| 104 | + | func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |
| 105 | + | root := t.TempDir() | |
| 106 | + | recv := make(chan string, 4) | |
| 107 | + | f := swarm.New(swarm.Config{ | |
| 108 | + | Root: root, | |
| 109 | + | RepoRoot: root, | |
| 110 | + | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |
| 111 | + | NewRunner: func(a *swarm.Agent) swarm.Runner { | |
| 112 | + | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |
| 113 | + | // Stand up a real Listener on the agent's inbox path so | |
| 114 | + | // SendUserTurn (which dials a unix socket) actually has | |
| 115 | + | // something to talk to. The runner-test stubs do the | |
| 116 | + | // same; this is the minimum to exercise the wire. | |
| 117 | + | ln, err := swarm.Listen(a.InboxPath) | |
| 118 | + | if err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | defer ln.Close() | |
| 122 | + | for { | |
| 123 | + | select { | |
| 124 | + | case <-ctx.Done(): | |
| 125 | + | return ctx.Err() | |
| 126 | + | case line, ok := <-ln.Lines(): | |
| 127 | + | if !ok { | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | recv <- line | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | }, | |
| 135 | + | }) | |
| 136 | + | defer f.StopAll() | |
| 137 | + | iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} | |
| 138 | + | iv.cfg.Swarm = f | |
| 139 | + | ||
| 140 | + | a, err := f.Spawn(context.Background(), "do thing") | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatalf("spawn: %v", err) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Run /swarm send <id> <text...>. The dispatcher would have | |
| 146 | + | // already strings.Fields-ed the input; mirror that here. | |
| 147 | + | iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"}) | |
| 148 | + | ||
| 149 | + | select { | |
| 150 | + | case msg := <-recv: | |
| 151 | + | want := "user please continue" | |
| 152 | + | if msg != want { | |
| 153 | + | t.Fatalf("agent received %q; want %q", msg, want) | |
| 154 | + | } | |
| 155 | + | case <-time.After(2 * time.Second): | |
| 156 | + | t.Fatal("timed out waiting for agent to receive the prompt") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | if iv.statusErr != "" { | |
| 160 | + | t.Fatalf("status err set: %q", iv.statusErr) | |
| 161 | + | } | |
| 162 | + | if iv.statusOK == "" || iv.statusOK[:7] != "sent to" { | |
| 163 | + | t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestParseSpawnFlags(t *testing.T) { | |
| 168 | + | cases := []struct { | |
| 169 | + | in string | |
| 170 | + | wantModel, wantProv string | |
| 171 | + | wantTask string | |
| 172 | + | }{ | |
| 173 | + | {"do x", "", "", "do x"}, | |
| 174 | + | {"--model claude do x", "claude", "", "do x"}, | |
| 175 | + | {"--model=claude do x", "claude", "", "do x"}, | |
| 176 | + | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"}, | |
| 177 | + | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"}, | |
| 178 | + | // Only LEADING flags are consumed. | |
| 179 | + | {"do --model x", "", "", "do --model x"}, | |
| 180 | + | // Missing value: --model with no follow-up token leaves model empty | |
| 181 | + | // and the next field starts the task. | |
| 182 | + | {"--model", "", "", ""}, | |
| 183 | + | } | |
| 184 | + | for _, c := range cases { | |
| 185 | + | m, p, task := parseSpawnFlags(c.in) | |
| 186 | + | if m != c.wantModel || p != c.wantProv || task != c.wantTask { | |
| 187 | + | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)", | |
| 188 | + | c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestSplitIDAndRest(t *testing.T) { | |
| 194 | + | cases := []struct { | |
| 195 | + | in string | |
| 196 | + | wantID string | |
| 197 | + | wantText string | |
| 198 | + | }{ | |
| 199 | + | {"", "", ""}, | |
| 200 | + | {" ", "", ""}, | |
| 201 | + | {"alpha", "alpha", ""}, | |
| 202 | + | {"alpha hello world", "alpha", "hello world"}, | |
| 203 | + | {" alpha hello world ", "alpha", "hello world "}, | |
| 204 | + | {"alpha\thi", "alpha", "hi"}, | |
| 205 | + | } | |
| 206 | + | for _, c := range cases { | |
| 207 | + | gotID, gotText := splitIDAndRest(c.in) | |
| 208 | + | if gotID != c.wantID || gotText != c.wantText { | |
| 209 | + | t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) { | |
| 215 | + | iv := &Interactive{ | |
| 216 | + | swarmDialog: newSwarmDialog(), | |
| 217 | + | dirty: make(chan struct{}, 1), | |
| 218 | + | } | |
| 219 | + | // cfg.Swarm stays nil. The command should set a status err and | |
| 220 | + | // otherwise be inert. | |
| 221 | + | iv.runSwarm(context.Background(), nil) | |
| 222 | + | if iv.swarmDialog.Active() { | |
| 223 | + | t.Fatal("dialog opened despite no swarm") | |
| 224 | + | } | |
| 225 | + | if iv.statusErr == "" { | |
| 226 | + | t.Fatal("expected a status error when swarm is nil") | |
| 227 | + | } | |
| 228 | + | } | |
A internal/agent/swarm_agent.go +262 -0
| @@ -0,0 +1,262 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/internal/agent/modes" | |
| 13 | + | "github.com/patriceckhart/zot/internal/core" | |
| 14 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 15 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // runSwarmAgentMode is the daemon-mode entry point used by every | |
| 19 | + | // swarm-spawned zot subprocess. It's intentionally close in shape to | |
| 20 | + | // runJSONMode but with two key differences: | |
| 21 | + | // | |
| 22 | + | // - Lifetime: the process stays alive across many user turns. The | |
| 23 | + | // initial positional task (if any) is the first turn; subsequent | |
| 24 | + | // turns arrive through the inbox unix socket at args.SwarmAgent. | |
| 25 | + | // | |
| 26 | + | // - Output: every emitted JSON line is also mirrored verbatim into | |
| 27 | + | // events.jsonl (see ZOT_SWARM_EVENT_LOG) so a separate zot | |
| 28 | + | // process can /swarm open this agent and replay its full history | |
| 29 | + | // even after the parent that spawned us is long gone. | |
| 30 | + | // | |
| 31 | + | // The runner in internal/swarm/runner.go is the only caller in | |
| 32 | + | // production; tests use the stubchild binary under | |
| 33 | + | // internal/swarm/testdata/cmd/stubchild instead of the real model | |
| 34 | + | // loop. | |
| 35 | + | func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |
| 36 | + | if args.SwarmAgent == "" { | |
| 37 | + | return fmt.Errorf("--swarm-agent requires a socket path") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | r, err := Resolve(args, true) | |
| 41 | + | if err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |
| 45 | + | defer stopExt() | |
| 46 | + | ||
| 47 | + | ag := r.NewAgent() | |
| 48 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 49 | + | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 50 | + | defer sess.Close() | |
| 51 | + | ||
| 52 | + | // Open the inbox listener BEFORE emitting agent_ready so the | |
| 53 | + | // supervisor can dial through on the very first send. The | |
| 54 | + | // swarm supervisor's Inbox.SendInput retries dialing for a | |
| 55 | + | // short window, but emitting ready first and then listening | |
| 56 | + | // would still race in tight loops. | |
| 57 | + | ln, err := swarm.Listen(args.SwarmAgent) | |
| 58 | + | if err != nil { | |
| 59 | + | return fmt.Errorf("swarm-agent listen: %w", err) | |
| 60 | + | } | |
| 61 | + | defer ln.Close() | |
| 62 | + | ||
| 63 | + | // Event log is owned by the supervisor's runner via stdout, but | |
| 64 | + | // the daemon also writes a redundant copy here when the runner's | |
| 65 | + | // pipe is closed (e.g. parent zot exited but the agent is still | |
| 66 | + | // running headless). The env var is set by the runner; if it's | |
| 67 | + | // empty we silently skip the second mirror. | |
| 68 | + | var logMirror *swarm.EventLog | |
| 69 | + | if path := os.Getenv("ZOT_SWARM_EVENT_LOG"); path != "" { | |
| 70 | + | logMirror, _ = swarm.OpenEventLog(path) | |
| 71 | + | } | |
| 72 | + | if logMirror != nil { | |
| 73 | + | defer logMirror.Close() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | em := newSwarmEmitter(os.Stdout, logMirror) | |
| 77 | + | ||
| 78 | + | em.emit("agent_ready", map[string]any{ | |
| 79 | + | "version": version, | |
| 80 | + | "cwd": r.CWD, | |
| 81 | + | "model": r.Model, | |
| 82 | + | }) | |
| 83 | + | ||
| 84 | + | // turnCtx is the per-turn context. We keep a per-process | |
| 85 | + | // cancel so the "cancel" inbox message can interrupt an | |
| 86 | + | // in-flight turn without tearing down the whole daemon. | |
| 87 | + | var ( | |
| 88 | + | mu sync.Mutex | |
| 89 | + | turnCtx context.Context = ctx | |
| 90 | + | cancelFn context.CancelFunc | |
| 91 | + | busyTurn bool | |
| 92 | + | turnNo int | |
| 93 | + | shutdown = make(chan struct{}) | |
| 94 | + | ) | |
| 95 | + | ||
| 96 | + | runOne := func(prompt string) { | |
| 97 | + | mu.Lock() | |
| 98 | + | if busyTurn { | |
| 99 | + | // Drop concurrent turns rather than queuing. The | |
| 100 | + | // supervisor protocol assumes one outstanding turn per | |
| 101 | + | // agent; if a user really wants to interrupt and start | |
| 102 | + | // another, they should send "cancel" first. | |
| 103 | + | mu.Unlock() | |
| 104 | + | em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"}) | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | busyTurn = true | |
| 108 | + | turnNo++ | |
| 109 | + | step := turnNo | |
| 110 | + | turnCtx, cancelFn = context.WithCancel(ctx) | |
| 111 | + | c := turnCtx | |
| 112 | + | mu.Unlock() | |
| 113 | + | ||
| 114 | + | em.emit("turn_start", map[string]any{"step": step}) | |
| 115 | + | ||
| 116 | + | sink := func(ev core.AgentEvent) { | |
| 117 | + | em.emit(ev.Type(), modes.EventToJSON(ev)) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | start := len(ag.Messages()) | |
| 121 | + | err := ag.Prompt(c, prompt, nil, sink) | |
| 122 | + | WriteNewTranscript(ag, sess, start) | |
| 123 | + | ||
| 124 | + | em.emit("turn_end", map[string]any{ | |
| 125 | + | "step": step, | |
| 126 | + | "error": errString(err), | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | mu.Lock() | |
| 130 | + | busyTurn = false | |
| 131 | + | cancelFn = nil | |
| 132 | + | mu.Unlock() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Initial task: run before processing the inbox so the agent | |
| 136 | + | // "starts working" the moment it boots, matching what users | |
| 137 | + | // expect from `/swarm new <task>`. | |
| 138 | + | if args.Prompt != "" { | |
| 139 | + | go runOne(args.Prompt) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Inbox loop: one supervisor message at a time. We don't spawn | |
| 143 | + | // a goroutine per turn because runOne already serialises them | |
| 144 | + | // via the busyTurn flag; doing the dispatch on the main | |
| 145 | + | // goroutine keeps the daemon's lifecycle easy to follow. | |
| 146 | + | for { | |
| 147 | + | select { | |
| 148 | + | case <-ctx.Done(): | |
| 149 | + | em.emit("agent_stopped", map[string]any{"reason": "cancelled"}) | |
| 150 | + | return ctx.Err() | |
| 151 | + | case <-shutdown: | |
| 152 | + | em.emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |
| 153 | + | return nil | |
| 154 | + | case msg, ok := <-ln.Lines(): | |
| 155 | + | if !ok { | |
| 156 | + | em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"}) | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | switch { | |
| 160 | + | case msg == "shutdown": | |
| 161 | + | close(shutdown) | |
| 162 | + | case msg == "cancel": | |
| 163 | + | mu.Lock() | |
| 164 | + | if cancelFn != nil { | |
| 165 | + | cancelFn() | |
| 166 | + | } | |
| 167 | + | mu.Unlock() | |
| 168 | + | case strings.HasPrefix(msg, "user "): | |
| 169 | + | prompt := strings.TrimPrefix(msg, "user ") | |
| 170 | + | go runOne(prompt) | |
| 171 | + | default: | |
| 172 | + | em.emit("error", map[string]any{ | |
| 173 | + | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | |
| 174 | + | }) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // swarmEmitter serialises events to stdout and (optionally) to a | |
| 181 | + | // durable log file. Concurrent goroutines call emit so we have to | |
| 182 | + | // hold a mutex around the encoder. | |
| 183 | + | type swarmEmitter struct { | |
| 184 | + | mu sync.Mutex | |
| 185 | + | w *os.File | |
| 186 | + | mirror *swarm.EventLog | |
| 187 | + | ||
| 188 | + | // orphan flips true the first time a stdout write fails (broken | |
| 189 | + | // pipe — the supervisor died). Until then the mirror stays | |
| 190 | + | // dormant: the supervisor is the canonical writer to events.jsonl | |
| 191 | + | // (it parses our stdout and Append()s each event itself). Writing | |
| 192 | + | // from both sides used to land every event in the log twice, | |
| 193 | + | // which showed up as a fully-duplicated transcript the next time | |
| 194 | + | // the agent was reloaded — the exact "why is everything doubled" | |
| 195 | + | // bug. Once orphaned the mirror takes over so the events still | |
| 196 | + | // land on disk for the next reload. | |
| 197 | + | orphan bool | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter { | |
| 201 | + | return &swarmEmitter{w: w, mirror: mirror} | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (e *swarmEmitter) emit(typ string, data map[string]any) { | |
| 205 | + | if data == nil { | |
| 206 | + | data = map[string]any{} | |
| 207 | + | } | |
| 208 | + | data["type"] = typ | |
| 209 | + | data["time"] = time.Now().Format(time.RFC3339Nano) | |
| 210 | + | ||
| 211 | + | e.mu.Lock() | |
| 212 | + | defer e.mu.Unlock() | |
| 213 | + | ||
| 214 | + | if !e.orphan { | |
| 215 | + | // Encode to a buffer first so we can detect the stdout write | |
| 216 | + | // error — json.Encoder swallows partial writes silently, but | |
| 217 | + | // a direct Write on *os.File returns broken-pipe immediately. | |
| 218 | + | line, err := json.Marshal(data) | |
| 219 | + | if err == nil { | |
| 220 | + | line = append(line, '\n') | |
| 221 | + | if _, werr := e.w.Write(line); werr != nil { | |
| 222 | + | // Supervisor's stdout pipe is gone (parent zot exited | |
| 223 | + | // but we kept running). Switch to mirror-only mode so | |
| 224 | + | // subsequent events still get persisted; also retro- | |
| 225 | + | // actively log this very event to the mirror so it | |
| 226 | + | // doesn't get lost in the handoff. | |
| 227 | + | e.orphan = true | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | if e.orphan && e.mirror != nil { | |
| 233 | + | // Drop "type" + "time" out of data into Event fields so the | |
| 234 | + | // mirror file matches what the supervisor would have written. | |
| 235 | + | flat := map[string]any{} | |
| 236 | + | for k, v := range data { | |
| 237 | + | if k == "type" || k == "time" { | |
| 238 | + | continue | |
| 239 | + | } | |
| 240 | + | flat[k] = v | |
| 241 | + | } | |
| 242 | + | _ = e.mirror.Append(swarm.NewEvent(typ, flat)) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func errString(err error) string { | |
| 247 | + | if err == nil { | |
| 248 | + | return "" | |
| 249 | + | } | |
| 250 | + | return err.Error() | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func truncateForLog(s string, n int) string { | |
| 254 | + | if len(s) <= n { | |
| 255 | + | return s | |
| 256 | + | } | |
| 257 | + | return s[:n-1] + "…" | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // _ keeps the provider import used; provider types may surface | |
| 261 | + | // through ag.OnEvent / modes.EventToJSON in future iterations. | |
| 262 | + | var _ provider.Content = provider.TextBlock{} | |
A internal/agent/swarm_agent_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the | |
| 15 | + | // "everything is doubled after reopening a swarm agent" bug. | |
| 16 | + | // | |
| 17 | + | // Symptom: events.jsonl held two copies of every event because the | |
| 18 | + | // child mirrored each event to disk AND the supervisor parsed the | |
| 19 | + | // child's stdout and appended each event to disk too. On next zot | |
| 20 | + | // launch the replay produced two transcript lines per real one. | |
| 21 | + | // | |
| 22 | + | // Fix invariant: while stdout writes succeed (i.e. the supervisor is | |
| 23 | + | // alive on the other end of the pipe), the child's mirror writes | |
| 24 | + | // NOTHING. Only when a stdout write fails (broken pipe → orphan) | |
| 25 | + | // does the mirror take over so events still get persisted. | |
| 26 | + | func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |
| 27 | + | // Real *os.File for the emitter's stdout-equivalent so the | |
| 28 | + | // emitter's write() path (which expects *os.File) actually runs. | |
| 29 | + | stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo") | |
| 30 | + | stdoutFile, err := os.Create(stdoutPath) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatalf("create stdout file: %v", err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Mirror writes go to a separate events.jsonl that we can read | |
| 36 | + | // at the end to assert how many events the mirror emitted. | |
| 37 | + | mirrorPath := filepath.Join(t.TempDir(), "events.jsonl") | |
| 38 | + | mirror, err := swarm.OpenEventLog(mirrorPath) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatalf("open mirror: %v", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | em := newSwarmEmitter(stdoutFile, mirror) | |
| 44 | + | ||
| 45 | + | // Healthy stdout: emit three events. Mirror must stay empty. | |
| 46 | + | em.emit("turn_start", map[string]any{"step": 1}) | |
| 47 | + | em.emit("assistant_message", map[string]any{"text": "hi"}) | |
| 48 | + | em.emit("turn_end", map[string]any{"step": 1}) | |
| 49 | + | ||
| 50 | + | got, err := swarm.ReadEventLog(mirrorPath) | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatalf("read mirror: %v", err) | |
| 53 | + | } | |
| 54 | + | if len(got) != 0 { | |
| 55 | + | t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v", | |
| 56 | + | len(got), got) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Simulate supervisor death: close stdout so the next Write | |
| 60 | + | // returns EBADF / broken pipe. The emitter must flip into | |
| 61 | + | // orphan mode and start writing through the mirror. | |
| 62 | + | if err := stdoutFile.Close(); err != nil { | |
| 63 | + | t.Fatalf("close stdout: %v", err) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | em.emit("assistant_message", map[string]any{"text": "after orphan"}) | |
| 67 | + | em.emit("turn_end", map[string]any{"step": 2}) | |
| 68 | + | ||
| 69 | + | got, err = swarm.ReadEventLog(mirrorPath) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatalf("read mirror post-orphan: %v", err) | |
| 72 | + | } | |
| 73 | + | if len(got) < 2 { | |
| 74 | + | t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got)) | |
| 75 | + | } | |
| 76 | + | if got[len(got)-1].Type != "turn_end" { | |
| 77 | + | t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // TestSwarmEmitterStdoutShapeMatchesSupervisorParser pins the | |
| 82 | + | // wire-format contract: each emitted event lands on stdout as one | |
| 83 | + | // JSON object per line with type+time at top level alongside the | |
| 84 | + | // data fields. The supervisor's runner parses this exact shape. | |
| 85 | + | func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |
| 86 | + | // Pipe so we can read what the emitter wrote. | |
| 87 | + | r, w, err := os.Pipe() | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatalf("pipe: %v", err) | |
| 90 | + | } | |
| 91 | + | defer r.Close() | |
| 92 | + | ||
| 93 | + | em := newSwarmEmitter(w, nil) | |
| 94 | + | em.emit("turn_start", map[string]any{"step": 1}) | |
| 95 | + | _ = w.Close() | |
| 96 | + | ||
| 97 | + | body, err := io.ReadAll(r) | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatalf("read pipe: %v", err) | |
| 100 | + | } | |
| 101 | + | // One trailing newline => one event line. | |
| 102 | + | lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n")) | |
| 103 | + | if len(lines) != 1 { | |
| 104 | + | t.Fatalf("expected 1 event line, got %d: %q", len(lines), body) | |
| 105 | + | } | |
| 106 | + | var flat map[string]any | |
| 107 | + | if err := json.Unmarshal(lines[0], &flat); err != nil { | |
| 108 | + | t.Fatalf("not valid json: %v\n%s", err, lines[0]) | |
| 109 | + | } | |
| 110 | + | if flat["type"] != "turn_start" { | |
| 111 | + | t.Errorf("type field missing or wrong: %v", flat["type"]) | |
| 112 | + | } | |
| 113 | + | if _, ok := flat["time"].(string); !ok { | |
| 114 | + | t.Errorf("time field missing: %v", flat["time"]) | |
| 115 | + | } | |
| 116 | + | if flat["step"] != float64(1) { | |
| 117 | + | t.Errorf("step field missing or wrong: %v", flat["step"]) | |
| 118 | + | } | |
| 119 | + | } | |
M internal/core/core_test.go +48 -0
| @@ -46,6 +46,54 @@ func TestSessionRoundTrip(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's | |
| 50 | + | // session-persistence fallback works: NewSessionAtPath creates the | |
| 51 | + | // file (and parent dirs) at the exact path the caller chose, | |
| 52 | + | // independent of SessionsDir(root, cwd). Without this helper, the | |
| 53 | + | // swarm child's --session <path> with a non-existent path would | |
| 54 | + | // fall through to NewSession and write the session under a | |
| 55 | + | // different (autogenerated) name, leaving the supervisor-chosen | |
| 56 | + | // path empty. | |
| 57 | + | func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) { | |
| 58 | + | dir := t.TempDir() | |
| 59 | + | want := dir + "/nested/sub/session.json" | |
| 60 | + | ||
| 61 | + | s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test") | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatalf("NewSessionAtPath: %v", err) | |
| 64 | + | } | |
| 65 | + | if s.Path != want { | |
| 66 | + | t.Errorf("Path = %q; want %q", s.Path, want) | |
| 67 | + | } | |
| 68 | + | if err := s.AppendMessage(provider.Message{ | |
| 69 | + | Role: provider.RoleUser, | |
| 70 | + | Content: []provider.Content{provider.TextBlock{Text: "hi"}}, | |
| 71 | + | Time: time.Now().UTC(), | |
| 72 | + | }); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | if err := s.Close(); err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Reopen at the same path and the message must still be there. | |
| 80 | + | reopened, msgs, err := OpenSession(want) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatalf("OpenSession at fixed path: %v", err) | |
| 83 | + | } | |
| 84 | + | t.Cleanup(func() { _ = reopened.Close() }) | |
| 85 | + | if len(msgs) != 1 { | |
| 86 | + | t.Fatalf("reopen got %d msgs; want 1", len(msgs)) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // A second call to NewSessionAtPath at the same path must fail | |
| 90 | + | // (O_EXCL): callers should use OpenSession to reattach to an | |
| 91 | + | // existing file. | |
| 92 | + | if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil { | |
| 93 | + | t.Error("NewSessionAtPath on existing path returned nil; want error") | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 49 | 97 | func TestCostAdd(t *testing.T) { | |
| 50 | 98 | var c CostTracker | |
| 51 | 99 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01}) | |
M internal/core/session.go +22 -1
| @@ -85,7 +85,8 @@ func SessionsDir(root, cwd string) string { | |||
| 85 | 85 | return filepath.Join(root, "sessions", short) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | // NewSession creates and opens a new session file. | |
| 88 | + | // NewSession creates and opens a new session file under | |
| 89 | + | // SessionsDir(root, cwd) with an autogenerated, time-stamped name. | |
| 89 | 90 | func NewSession(root, cwd, providerName, model, version string) (*Session, error) { | |
| 90 | 91 | dir := SessionsDir(root, cwd) | |
| 91 | 92 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| @@ -94,6 +95,26 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error | |||
| 94 | 95 | id := uuid.NewString() | |
| 95 | 96 | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8]) | |
| 96 | 97 | p := filepath.Join(dir, name) | |
| 98 | + | return newSessionAt(p, cwd, providerName, model, version) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // NewSessionAtPath creates a session at an explicit file path. Used | |
| 102 | + | // by callers (notably the swarm-agent child) that need the session | |
| 103 | + | // file to live at a path chosen by their parent rather than under | |
| 104 | + | // SessionsDir. Returns an error if the file already exists — use | |
| 105 | + | // OpenSession for that case. | |
| 106 | + | func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) { | |
| 107 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 108 | + | return nil, err | |
| 109 | + | } | |
| 110 | + | return newSessionAt(path, cwd, providerName, model, version) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // newSessionAt is the shared implementation. Both NewSession and | |
| 114 | + | // NewSessionAtPath funnel through here so the meta-line layout, | |
| 115 | + | // freshFile bookkeeping, and id format stay identical. | |
| 116 | + | func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) { | |
| 117 | + | id := uuid.NewString() | |
| 97 | 118 | f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | |
| 98 | 119 | if err != nil { | |
| 99 | 120 | return nil, err | |
A internal/swarm/agent.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Agent is one supervised task. Public fields are immutable after | |
| 12 | + | // Spawn; mutable state (status, activity, transcript) lives behind | |
| 13 | + | // the embedded mutex. | |
| 14 | + | type Agent struct { | |
| 15 | + | ID string | |
| 16 | + | Task string | |
| 17 | + | Branch string | |
| 18 | + | Dir string | |
| 19 | + | Started time.Time | |
| 20 | + | ||
| 21 | + | // Model and Provider, when non-empty, override the child | |
| 22 | + | // subprocess's model resolution. Empty means "inherit whatever | |
| 23 | + | // the child resolves on its own from config / env / flags" — | |
| 24 | + | // the historical behaviour. Persisted in meta.json so Resume | |
| 25 | + | // keeps using the same model across zot restarts. | |
| 26 | + | Model string | |
| 27 | + | Provider string | |
| 28 | + | ||
| 29 | + | // SessionID, when non-empty, scopes the agent to a particular | |
| 30 | + | // host zot session: the dashboard only surfaces agents whose | |
| 31 | + | // SessionID matches the active session. Empty means "unscoped" | |
| 32 | + | // (legacy meta files from before the field existed, or agents | |
| 33 | + | // spawned without a session context such as in tests). Set at | |
| 34 | + | // Spawn time from Swarm.activeSession and persisted in | |
| 35 | + | // meta.json so the scope survives restarts. | |
| 36 | + | SessionID string | |
| 37 | + | ||
| 38 | + | // Resuming is true when this Agent struct was built by Resume | |
| 39 | + | // rather than Spawn. The runner consults it to decide whether | |
| 40 | + | // to pass the original Task as a positional argv to the child: | |
| 41 | + | // on first Spawn we want the child to run the task immediately; | |
| 42 | + | // on Resume the task was already run last time and replaying it | |
| 43 | + | // would produce a second turn that collides ("agent busy; send | |
| 44 | + | // 'cancel' first") with whatever the user types next. Not | |
| 45 | + | // persisted — every Resume sets it explicitly. | |
| 46 | + | Resuming bool | |
| 47 | + | ||
| 48 | + | // InboxPath is the unix socket the child agent listens on for | |
| 49 | + | // follow-up prompts and control messages. The supervisor opens | |
| 50 | + | // an Inbox at this path; the child opens a Listener. | |
| 51 | + | InboxPath string | |
| 52 | + | ||
| 53 | + | // EventLogPath is the durable JSONL event log for this agent. | |
| 54 | + | // The runner appends every well-formed event from the child | |
| 55 | + | // (plus lifecycle events of its own) here. /swarm open in any | |
| 56 | + | // zot process reads from this file to replay the full history. | |
| 57 | + | EventLogPath string | |
| 58 | + | ||
| 59 | + | // SessionPath is the child's persistent session file. Surfaced | |
| 60 | + | // so the dashboard / /swarm open can resume the agent later. | |
| 61 | + | SessionPath string | |
| 62 | + | ||
| 63 | + | // inbox is the supervisor-side socket handle. Populated by | |
| 64 | + | // Swarm.Spawn so SendInput can route messages without each | |
| 65 | + | // caller redialing. | |
| 66 | + | inbox *Inbox | |
| 67 | + | ||
| 68 | + | mu sync.Mutex | |
| 69 | + | status Status | |
| 70 | + | activity string | |
| 71 | + | transcript []string | |
| 72 | + | finished time.Time | |
| 73 | + | lastErr error | |
| 74 | + | ||
| 75 | + | ctx context.Context | |
| 76 | + | cancel context.CancelFunc | |
| 77 | + | runner Runner | |
| 78 | + | ||
| 79 | + | // done closes when the run goroutine finalises the agent's | |
| 80 | + | // status (done / failed / killed). Wait blocks on this so | |
| 81 | + | // callers don't have to poll. | |
| 82 | + | done chan struct{} | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Inbox exposes the supervisor-side socket handle. Returns nil for | |
| 86 | + | // agents without inboxes (e.g. tests using a custom Runner that | |
| 87 | + | // doesn't speak the daemon protocol). | |
| 88 | + | func (a *Agent) Inbox() *Inbox { return a.inbox } | |
| 89 | + | ||
| 90 | + | // Status returns the current high-level status. Cheap; safe from any goroutine. | |
| 91 | + | func (a *Agent) Status() Status { | |
| 92 | + | a.mu.Lock() | |
| 93 | + | defer a.mu.Unlock() | |
| 94 | + | return a.status | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Activity returns the current one-line activity string. | |
| 98 | + | func (a *Agent) Activity() string { | |
| 99 | + | a.mu.Lock() | |
| 100 | + | defer a.mu.Unlock() | |
| 101 | + | return a.activity | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Transcript returns a copy of the running transcript. | |
| 105 | + | func (a *Agent) Transcript() []string { | |
| 106 | + | a.mu.Lock() | |
| 107 | + | defer a.mu.Unlock() | |
| 108 | + | out := make([]string, len(a.transcript)) | |
| 109 | + | copy(out, a.transcript) | |
| 110 | + | return out | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Err returns the runner's terminal error, if any. | |
| 114 | + | func (a *Agent) Err() error { | |
| 115 | + | a.mu.Lock() | |
| 116 | + | defer a.mu.Unlock() | |
| 117 | + | return a.lastErr | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Wait blocks until the agent reaches a terminal state. Used by tests | |
| 121 | + | // and by /swarm wait <id>. | |
| 122 | + | func (a *Agent) Wait() { <-a.done } | |
| 123 | + | ||
| 124 | + | func (a *Agent) setStatus(s Status) { | |
| 125 | + | a.mu.Lock() | |
| 126 | + | a.status = s | |
| 127 | + | a.mu.Unlock() | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (a *Agent) setActivity(msg string) { | |
| 131 | + | a.mu.Lock() | |
| 132 | + | a.activity = strings.TrimSpace(msg) | |
| 133 | + | a.mu.Unlock() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (a *Agent) appendTranscript(chunk string) { | |
| 137 | + | chunk = strings.TrimRight(chunk, "\n") | |
| 138 | + | if chunk == "" { | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | a.mu.Lock() | |
| 142 | + | for _, line := range strings.Split(chunk, "\n") { | |
| 143 | + | a.transcript = append(a.transcript, line) | |
| 144 | + | } | |
| 145 | + | // Bound the transcript so dashboards don't hold gigabytes for | |
| 146 | + | // long-running agents. 2000 lines is plenty for inspection; | |
| 147 | + | // the durable record lives in the agent's session file. | |
| 148 | + | const cap = 2000 | |
| 149 | + | if len(a.transcript) > cap { | |
| 150 | + | a.transcript = a.transcript[len(a.transcript)-cap:] | |
| 151 | + | } | |
| 152 | + | a.mu.Unlock() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // newAgentID returns a short, mostly-collision-free identifier of the | |
| 156 | + | // form "<slug>-<nano>". The slug is derived from the task text so | |
| 157 | + | // dashboards stay readable; the nano suffix guarantees uniqueness | |
| 158 | + | // even when two agents are spawned in the same millisecond. | |
| 159 | + | func newAgentID(task string, now time.Time) string { | |
| 160 | + | slug := taskSlug(task) | |
| 161 | + | return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func taskSlug(task string) string { | |
| 165 | + | task = strings.ToLower(task) | |
| 166 | + | var b strings.Builder | |
| 167 | + | dash := false | |
| 168 | + | for _, r := range task { | |
| 169 | + | switch { | |
| 170 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |
| 171 | + | b.WriteRune(r) | |
| 172 | + | dash = false | |
| 173 | + | default: | |
| 174 | + | if !dash && b.Len() > 0 { | |
| 175 | + | b.WriteByte('-') | |
| 176 | + | dash = true | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | if b.Len() >= 24 { | |
| 180 | + | break | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | out := strings.Trim(b.String(), "-") | |
| 184 | + | if out == "" { | |
| 185 | + | out = "agent" | |
| 186 | + | } | |
| 187 | + | return out | |
| 188 | + | } | |
A internal/swarm/event.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Event is one structured datum in an agent's durable event log. | |
| 16 | + | // | |
| 17 | + | // The shape is intentionally a thin map+timestamp wrapper rather | |
| 18 | + | // than a strict struct so the swarm package doesn't have to track | |
| 19 | + | // every field added to core.AgentEvent. The child writes | |
| 20 | + | // `modes.EventToJSON(ev)` plus a few swarm-internal types | |
| 21 | + | // (lifecycle, user_input echo, error). The supervisor doesn't | |
| 22 | + | // interpret most fields; it just keeps them for replay and lifts | |
| 23 | + | // a few well-known ones into AgentSnapshot (Activity, Status, | |
| 24 | + | // Tail) for the dashboard. | |
| 25 | + | // | |
| 26 | + | // The on-disk file (events.jsonl) is append-only, one event per | |
| 27 | + | // line, newline-terminated JSON. Reading is forward-only; readers | |
| 28 | + | // stat the file size and read from their last offset on every | |
| 29 | + | // poll. | |
| 30 | + | type Event struct { | |
| 31 | + | Time time.Time `json:"time"` | |
| 32 | + | Type string `json:"type"` | |
| 33 | + | Data map[string]any `json:"-"` | |
| 34 | + | Raw map[string]any `json:"-"` // includes type+time+data for replay | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // MarshalJSON flattens Data into the top-level object so consumers | |
| 38 | + | // see {type, time, ...fields} rather than {type, time, data:{...}}. | |
| 39 | + | func (e Event) MarshalJSON() ([]byte, error) { | |
| 40 | + | out := make(map[string]any, len(e.Data)+2) | |
| 41 | + | for k, v := range e.Data { | |
| 42 | + | out[k] = v | |
| 43 | + | } | |
| 44 | + | out["type"] = e.Type | |
| 45 | + | out["time"] = e.Time | |
| 46 | + | return json.Marshal(out) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // UnmarshalJSON accepts a flat object with at least type+time. | |
| 50 | + | // All other fields land in both Data and Raw. | |
| 51 | + | func (e *Event) UnmarshalJSON(b []byte) error { | |
| 52 | + | m := map[string]any{} | |
| 53 | + | if err := json.Unmarshal(b, &m); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | e.Raw = m | |
| 57 | + | e.Data = map[string]any{} | |
| 58 | + | for k, v := range m { | |
| 59 | + | if k == "type" || k == "time" { | |
| 60 | + | continue | |
| 61 | + | } | |
| 62 | + | e.Data[k] = v | |
| 63 | + | } | |
| 64 | + | if t, ok := m["type"].(string); ok { | |
| 65 | + | e.Type = t | |
| 66 | + | } else { | |
| 67 | + | return errors.New("swarm event: missing type") | |
| 68 | + | } | |
| 69 | + | if ts, ok := m["time"].(string); ok { | |
| 70 | + | parsed, err := time.Parse(time.RFC3339Nano, ts) | |
| 71 | + | if err == nil { | |
| 72 | + | e.Time = parsed | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // EventLog is an append-only writer for events.jsonl. Safe for | |
| 79 | + | // concurrent writers (the child only has one writer, but tests | |
| 80 | + | // occasionally fan out). | |
| 81 | + | type EventLog struct { | |
| 82 | + | path string | |
| 83 | + | mu sync.Mutex | |
| 84 | + | f *os.File | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // OpenEventLog opens (or creates) the events.jsonl file at path. | |
| 88 | + | // Parent directories are created as needed. | |
| 89 | + | func OpenEventLog(path string) (*EventLog, error) { | |
| 90 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 91 | + | return nil, fmt.Errorf("event log dir: %w", err) | |
| 92 | + | } | |
| 93 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |
| 94 | + | if err != nil { | |
| 95 | + | return nil, fmt.Errorf("event log open: %w", err) | |
| 96 | + | } | |
| 97 | + | return &EventLog{path: path, f: f}, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Path returns the absolute log path. | |
| 101 | + | func (l *EventLog) Path() string { return l.path } | |
| 102 | + | ||
| 103 | + | // Append writes one event. The encoding is `<json>\n`. Concurrent | |
| 104 | + | // callers are serialised; small enough events never need partial | |
| 105 | + | // writes since unix guarantees atomicity for writes ≤ PIPE_BUF on | |
| 106 | + | // regular files, and we hold a per-process mutex on top. | |
| 107 | + | func (l *EventLog) Append(ev Event) error { | |
| 108 | + | l.mu.Lock() | |
| 109 | + | defer l.mu.Unlock() | |
| 110 | + | if ev.Time.IsZero() { | |
| 111 | + | ev.Time = time.Now() | |
| 112 | + | } | |
| 113 | + | b, err := json.Marshal(ev) | |
| 114 | + | if err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | b = append(b, '\n') | |
| 118 | + | _, err = l.f.Write(b) | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Close flushes and closes the underlying file. | |
| 123 | + | func (l *EventLog) Close() error { | |
| 124 | + | l.mu.Lock() | |
| 125 | + | defer l.mu.Unlock() | |
| 126 | + | if l.f == nil { | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | err := l.f.Close() | |
| 130 | + | l.f = nil | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // NewEvent is a convenience constructor that fills the timestamp. | |
| 135 | + | func NewEvent(typ string, data map[string]any) Event { | |
| 136 | + | if data == nil { | |
| 137 | + | data = map[string]any{} | |
| 138 | + | } | |
| 139 | + | return Event{Time: time.Now(), Type: typ, Data: data} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // ReadEventLog parses every event currently in the file. Used by | |
| 143 | + | // the dashboard on first open and by tests; live tailing uses | |
| 144 | + | // FollowEventLog below. | |
| 145 | + | func ReadEventLog(path string) ([]Event, error) { | |
| 146 | + | f, err := os.Open(path) | |
| 147 | + | if err != nil { | |
| 148 | + | if os.IsNotExist(err) { | |
| 149 | + | return nil, nil | |
| 150 | + | } | |
| 151 | + | return nil, err | |
| 152 | + | } | |
| 153 | + | defer f.Close() | |
| 154 | + | return readEvents(f) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func readEvents(r io.Reader) ([]Event, error) { | |
| 158 | + | br := bufio.NewReader(r) | |
| 159 | + | var out []Event | |
| 160 | + | for { | |
| 161 | + | line, err := br.ReadBytes('\n') | |
| 162 | + | if len(line) > 0 { | |
| 163 | + | var ev Event | |
| 164 | + | if jerr := json.Unmarshal(line, &ev); jerr == nil { | |
| 165 | + | if !isLikelyDoubleWrite(ev, out) { | |
| 166 | + | out = append(out, ev) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | // Malformed lines are skipped silently; the dashboard | |
| 170 | + | // renders only well-formed events and the child is the | |
| 171 | + | // only writer. | |
| 172 | + | } | |
| 173 | + | if err != nil { | |
| 174 | + | if errors.Is(err, io.EOF) { | |
| 175 | + | return out, nil | |
| 176 | + | } | |
| 177 | + | return out, err | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | |
| 183 | + | // of the most recent event in `tail` — same type and identical Data | |
| 184 | + | // payload, with timestamps within a small window (the supervisor and | |
| 185 | + | // the child's mirror used to BOTH write each event to disk, landing | |
| 186 | + | // the same content twice in quick succession in events.jsonl). The | |
| 187 | + | // historical behaviour was fixed at write time (the child's mirror | |
| 188 | + | // is now dormant unless the supervisor is gone), but on-disk files | |
| 189 | + | // from before the fix are still polluted, so we dedupe defensively | |
| 190 | + | // at read time too. | |
| 191 | + | // | |
| 192 | + | // We deliberately bound by time (250ms) so two genuinely identical | |
| 193 | + | // adjacent events that happen seconds apart — e.g. an agent that | |
| 194 | + | // runs the same tool twice in a row — still both render. | |
| 195 | + | func isLikelyDoubleWrite(ev Event, tail []Event) bool { | |
| 196 | + | if len(tail) == 0 { | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | prev := tail[len(tail)-1] | |
| 200 | + | if prev.Type != ev.Type { | |
| 201 | + | return false | |
| 202 | + | } | |
| 203 | + | if !ev.Time.IsZero() && !prev.Time.IsZero() { | |
| 204 | + | dt := ev.Time.Sub(prev.Time) | |
| 205 | + | if dt < 0 { | |
| 206 | + | dt = -dt | |
| 207 | + | } | |
| 208 | + | if dt > 250*time.Millisecond { | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | return sameEventData(prev.Data, ev.Data) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // sameEventData deep-compares two event payloads. Cheap because the | |
| 216 | + | // payloads are small map[string]any trees built from JSON, and only | |
| 217 | + | // called for adjacent same-type pairs. | |
| 218 | + | func sameEventData(a, b map[string]any) bool { | |
| 219 | + | if len(a) != len(b) { | |
| 220 | + | return false | |
| 221 | + | } | |
| 222 | + | ab, _ := json.Marshal(a) | |
| 223 | + | bb, _ := json.Marshal(b) | |
| 224 | + | return string(ab) == string(bb) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // EventFollower polls an events.jsonl file and emits new events as | |
| 228 | + | // they're appended. The supervisor uses one per agent so dashboard | |
| 229 | + | // snapshots stay current without reparsing the whole file on every | |
| 230 | + | // frame. Close stops the goroutine; the channel returned by Events | |
| 231 | + | // is closed when Close is called or the file is removed. | |
| 232 | + | type EventFollower struct { | |
| 233 | + | path string | |
| 234 | + | interval time.Duration | |
| 235 | + | out chan Event | |
| 236 | + | done chan struct{} | |
| 237 | + | once sync.Once | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // FollowEventLog starts polling path every interval. interval ≤ 0 | |
| 241 | + | // defaults to 50ms which is invisible to the user and cheap on | |
| 242 | + | // disk (one stat + one short read per agent per tick). | |
| 243 | + | func FollowEventLog(path string, interval time.Duration) *EventFollower { | |
| 244 | + | if interval <= 0 { | |
| 245 | + | interval = 50 * time.Millisecond | |
| 246 | + | } | |
| 247 | + | f := &EventFollower{ | |
| 248 | + | path: path, | |
| 249 | + | interval: interval, | |
| 250 | + | out: make(chan Event, 256), | |
| 251 | + | done: make(chan struct{}), | |
| 252 | + | } | |
| 253 | + | go f.loop() | |
| 254 | + | return f | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // Events returns the receive end of the event stream. | |
| 258 | + | func (f *EventFollower) Events() <-chan Event { return f.out } | |
| 259 | + | ||
| 260 | + | // Close stops polling and closes the events channel. | |
| 261 | + | func (f *EventFollower) Close() { | |
| 262 | + | f.once.Do(func() { | |
| 263 | + | close(f.done) | |
| 264 | + | }) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (f *EventFollower) loop() { | |
| 268 | + | defer close(f.out) | |
| 269 | + | var offset int64 | |
| 270 | + | tick := time.NewTicker(f.interval) | |
| 271 | + | defer tick.Stop() | |
| 272 | + | for { | |
| 273 | + | select { | |
| 274 | + | case <-f.done: | |
| 275 | + | return | |
| 276 | + | case <-tick.C: | |
| 277 | + | } | |
| 278 | + | fi, err := os.Stat(f.path) | |
| 279 | + | if err != nil { | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | if fi.Size() <= offset { | |
| 283 | + | continue | |
| 284 | + | } | |
| 285 | + | fh, err := os.Open(f.path) | |
| 286 | + | if err != nil { | |
| 287 | + | continue | |
| 288 | + | } | |
| 289 | + | if _, err := fh.Seek(offset, io.SeekStart); err != nil { | |
| 290 | + | _ = fh.Close() | |
| 291 | + | continue | |
| 292 | + | } | |
| 293 | + | evs, _ := readEvents(fh) | |
| 294 | + | newOffset, _ := fh.Seek(0, io.SeekCurrent) | |
| 295 | + | _ = fh.Close() | |
| 296 | + | offset = newOffset | |
| 297 | + | for _, ev := range evs { | |
| 298 | + | select { | |
| 299 | + | case f.out <- ev: | |
| 300 | + | case <-f.done: | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
A internal/swarm/event_test.go +227 -0
| @@ -0,0 +1,227 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestEventLogAppendAndRead is the simplest possible invariant: | |
| 10 | + | // what we write goes back through ReadEventLog exactly, including | |
| 11 | + | // the flat field shape and the timestamp. | |
| 12 | + | func TestEventLogAppendAndRead(t *testing.T) { | |
| 13 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 14 | + | log, err := OpenEventLog(path) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatalf("open: %v", err) | |
| 17 | + | } | |
| 18 | + | want := []Event{ | |
| 19 | + | NewEvent("turn_start", map[string]any{"step": 0}), | |
| 20 | + | NewEvent("assistant_message", map[string]any{ | |
| 21 | + | "content": []any{map[string]any{"type": "text", "text": "hi"}}, | |
| 22 | + | }), | |
| 23 | + | NewEvent("turn_end", map[string]any{"stop": "end"}), | |
| 24 | + | } | |
| 25 | + | for _, ev := range want { | |
| 26 | + | if err := log.Append(ev); err != nil { | |
| 27 | + | t.Fatalf("append: %v", err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | _ = log.Close() | |
| 31 | + | ||
| 32 | + | got, err := ReadEventLog(path) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatalf("read: %v", err) | |
| 35 | + | } | |
| 36 | + | if len(got) != len(want) { | |
| 37 | + | t.Fatalf("got %d events, want %d", len(got), len(want)) | |
| 38 | + | } | |
| 39 | + | for i := range want { | |
| 40 | + | if got[i].Type != want[i].Type { | |
| 41 | + | t.Errorf("event %d type = %q; want %q", i, got[i].Type, want[i].Type) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // TestReadEventLogIgnoresGarbage documents that one malformed line | |
| 47 | + | // doesn't break the whole replay. The runner's responsibility is to | |
| 48 | + | // only write well-formed events, but a partial crash could leave | |
| 49 | + | // a half-written line; we don't want that to prevent the dashboard | |
| 50 | + | // from showing the rest. | |
| 51 | + | func TestReadEventLogIgnoresGarbage(t *testing.T) { | |
| 52 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 53 | + | log, err := OpenEventLog(path) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatalf("open: %v", err) | |
| 56 | + | } | |
| 57 | + | if err := log.Append(NewEvent("first", nil)); err != nil { | |
| 58 | + | t.Fatalf("append: %v", err) | |
| 59 | + | } | |
| 60 | + | // Sneak a garbage line in by writing directly. | |
| 61 | + | if _, err := log.f.WriteString("not json at all\n"); err != nil { | |
| 62 | + | t.Fatalf("raw write: %v", err) | |
| 63 | + | } | |
| 64 | + | if err := log.Append(NewEvent("second", nil)); err != nil { | |
| 65 | + | t.Fatalf("append: %v", err) | |
| 66 | + | } | |
| 67 | + | _ = log.Close() | |
| 68 | + | ||
| 69 | + | got, err := ReadEventLog(path) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatalf("read: %v", err) | |
| 72 | + | } | |
| 73 | + | if len(got) != 2 || got[0].Type != "first" || got[1].Type != "second" { | |
| 74 | + | t.Fatalf("garbage tripped parser: %+v", got) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // TestReadEventLogDeduplicatesDoubleWrites pins the read-time fix | |
| 79 | + | // for already-polluted events.jsonl files. The historical bug: the | |
| 80 | + | // supervisor parsed child stdout and called log.Append(ev), AND the | |
| 81 | + | // child's mirror appended the same event. Two writes per event | |
| 82 | + | // landed in the file in quick succession with near-identical | |
| 83 | + | // timestamps, and the next zot launch replayed everything twice — | |
| 84 | + | // the user saw every transcript line duplicated. | |
| 85 | + | // | |
| 86 | + | // The write-time fix makes the child's mirror dormant when the | |
| 87 | + | // supervisor is alive, but old files are still polluted. ReadEventLog | |
| 88 | + | // now folds back-to-back same-type identical-payload events whose | |
| 89 | + | // timestamps are within ~250ms of each other. | |
| 90 | + | func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) { | |
| 91 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 92 | + | log, err := OpenEventLog(path) | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatalf("open: %v", err) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Two writes per logical event, mimicking the supervisor + | |
| 98 | + | // mirror double-write pattern. Timestamps differ by a few ms | |
| 99 | + | // like they would in the wild. | |
| 100 | + | now := time.Now() | |
| 101 | + | writePair := func(typ string, data map[string]any) { | |
| 102 | + | e1 := NewEvent(typ, data) | |
| 103 | + | e1.Time = now | |
| 104 | + | if err := log.Append(e1); err != nil { | |
| 105 | + | t.Fatalf("append: %v", err) | |
| 106 | + | } | |
| 107 | + | e2 := NewEvent(typ, data) | |
| 108 | + | e2.Time = now.Add(2 * time.Millisecond) | |
| 109 | + | if err := log.Append(e2); err != nil { | |
| 110 | + | t.Fatalf("append: %v", err) | |
| 111 | + | } | |
| 112 | + | now = now.Add(500 * time.Millisecond) // next event well outside the dedup window | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | writePair("assistant_message", map[string]any{ | |
| 116 | + | "content": []any{map[string]any{"type": "text", "text": "hi"}}, | |
| 117 | + | }) | |
| 118 | + | writePair("turn_end", map[string]any{"step": float64(1)}) | |
| 119 | + | _ = log.Close() | |
| 120 | + | ||
| 121 | + | got, err := ReadEventLog(path) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatalf("read: %v", err) | |
| 124 | + | } | |
| 125 | + | if len(got) != 2 { | |
| 126 | + | t.Fatalf("expected double-writes to collapse to 2 events; got %d", len(got)) | |
| 127 | + | } | |
| 128 | + | if got[0].Type != "assistant_message" || got[1].Type != "turn_end" { | |
| 129 | + | t.Errorf("unexpected types after dedup: %q, %q", got[0].Type, got[1].Type) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // TestReadEventLogKeepsLegitimateAdjacentEvents pins the inverse: | |
| 134 | + | // two events of the same type with identical payloads but separated | |
| 135 | + | // by more than the dedup window (250ms) must both survive. The | |
| 136 | + | // agent legitimately can emit identical adjacent events (think: a | |
| 137 | + | // retry running the same tool seconds apart). | |
| 138 | + | func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { | |
| 139 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 140 | + | log, err := OpenEventLog(path) | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatalf("open: %v", err) | |
| 143 | + | } | |
| 144 | + | now := time.Now() | |
| 145 | + | for i := 0; i < 3; i++ { | |
| 146 | + | e := NewEvent("tool_call", map[string]any{"name": "read"}) | |
| 147 | + | e.Time = now.Add(time.Duration(i) * time.Second) | |
| 148 | + | if err := log.Append(e); err != nil { | |
| 149 | + | t.Fatalf("append: %v", err) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | _ = log.Close() | |
| 153 | + | ||
| 154 | + | got, err := ReadEventLog(path) | |
| 155 | + | if err != nil { | |
| 156 | + | t.Fatalf("read: %v", err) | |
| 157 | + | } | |
| 158 | + | if len(got) != 3 { | |
| 159 | + | t.Errorf("dedup collapsed legitimately separated events; got %d, want 3", len(got)) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // TestFollowerEmitsNewEvents follows a live file and ensures events | |
| 164 | + | // appended after the follower starts make it to the channel. | |
| 165 | + | func TestFollowerEmitsNewEvents(t *testing.T) { | |
| 166 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 167 | + | // Pre-seed one event so the file exists. The follower's | |
| 168 | + | // contract is "emit events appended AFTER it starts", so this | |
| 169 | + | // first event is not expected on the channel. | |
| 170 | + | log, err := OpenEventLog(path) | |
| 171 | + | if err != nil { | |
| 172 | + | t.Fatalf("open: %v", err) | |
| 173 | + | } | |
| 174 | + | if err := log.Append(NewEvent("preexisting", nil)); err != nil { | |
| 175 | + | t.Fatalf("append: %v", err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | fol := FollowEventLog(path, 10*time.Millisecond) | |
| 179 | + | defer fol.Close() | |
| 180 | + | ||
| 181 | + | // Give the follower one tick to read the pre-existing event so | |
| 182 | + | // its offset advances past the first line. After that, any new | |
| 183 | + | // append should show up. | |
| 184 | + | time.Sleep(40 * time.Millisecond) | |
| 185 | + | drain(fol.Events()) | |
| 186 | + | ||
| 187 | + | for i := 0; i < 3; i++ { | |
| 188 | + | if err := log.Append(NewEvent("live", map[string]any{"i": i})); err != nil { | |
| 189 | + | t.Fatalf("append %d: %v", i, err) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | got := waitFor(t, fol.Events(), 3, time.Second) | |
| 194 | + | if len(got) != 3 { | |
| 195 | + | t.Fatalf("got %d live events, want 3", len(got)) | |
| 196 | + | } | |
| 197 | + | for i, ev := range got { | |
| 198 | + | if ev.Type != "live" { | |
| 199 | + | t.Errorf("event %d type = %q; want live", i, ev.Type) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func drain(ch <-chan Event) { | |
| 205 | + | for { | |
| 206 | + | select { | |
| 207 | + | case <-ch: | |
| 208 | + | default: | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func waitFor(t *testing.T, ch <-chan Event, n int, timeout time.Duration) []Event { | |
| 215 | + | t.Helper() | |
| 216 | + | var got []Event | |
| 217 | + | deadline := time.After(timeout) | |
| 218 | + | for len(got) < n { | |
| 219 | + | select { | |
| 220 | + | case ev := <-ch: | |
| 221 | + | got = append(got, ev) | |
| 222 | + | case <-deadline: | |
| 223 | + | return got | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | return got | |
| 227 | + | } | |
A internal/swarm/inbox.go +270 -0
| @@ -0,0 +1,270 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sync" | |
| 12 | + | "syscall" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Inbox is the supervisor-side handle on a per-agent unix socket | |
| 17 | + | // that the parent zot uses to send follow-up prompts (and other | |
| 18 | + | // control lines) to a running swarm agent. | |
| 19 | + | // | |
| 20 | + | // Protocol on the wire: one UTF-8 message per line, newline- | |
| 21 | + | // terminated. Three message kinds: | |
| 22 | + | // | |
| 23 | + | // user <text>... — append <text> as the next user turn | |
| 24 | + | // cancel — cancel the agent's in-flight turn | |
| 25 | + | // shutdown — graceful exit; child will write a final | |
| 26 | + | // EvTurnEnd-equivalent JSON event and quit | |
| 27 | + | // | |
| 28 | + | // The child listens on the same socket via Listener (below) and | |
| 29 | + | // translates the messages into core.Agent.Prompt calls. The | |
| 30 | + | // parent never reads from the socket — it's a one-way channel | |
| 31 | + | // driven by the supervisor. | |
| 32 | + | // | |
| 33 | + | // Why a socket and not a FIFO: FIFOs are POSIX-only and have | |
| 34 | + | // awkward blocking-open semantics. A unix socket gives us: | |
| 35 | + | // | |
| 36 | + | // - the ability to fail fast if no child is listening yet, | |
| 37 | + | // so SendInput returns a useful error to the TUI; | |
| 38 | + | // - clean shutdown semantics when the child closes the | |
| 39 | + | // listener; | |
| 40 | + | // - works under tests with a temp dir; and | |
| 41 | + | // - portable enough; Windows support is a follow-up that can | |
| 42 | + | // swap in a named pipe behind this same interface. | |
| 43 | + | type Inbox struct { | |
| 44 | + | path string | |
| 45 | + | ||
| 46 | + | mu sync.Mutex | |
| 47 | + | conn net.Conn // lazily dialed; one persistent connection per agent | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // NewInbox returns a handle that will dial the socket at path on | |
| 51 | + | // the first SendInput call. The socket file is expected to be | |
| 52 | + | // created by the child (Listener.Open below). This split lets the | |
| 53 | + | // parent build the Inbox before the child has booted; sends that | |
| 54 | + | // happen before the child is ready get a clear "not yet" error | |
| 55 | + | // the caller can retry or surface. | |
| 56 | + | func NewInbox(path string) *Inbox { return &Inbox{path: path} } | |
| 57 | + | ||
| 58 | + | // Path returns the absolute socket path. Used by the runner to | |
| 59 | + | // pass the path to the child as a flag. | |
| 60 | + | func (b *Inbox) Path() string { return b.path } | |
| 61 | + | ||
| 62 | + | // SendInput writes msg to the inbox as one line. msg must already | |
| 63 | + | // include the leading kind ("user ", "cancel", "shutdown") — the | |
| 64 | + | // caller chooses the shape so we don't proliferate methods. The | |
| 65 | + | // socket is reused across calls; transient dial failures yield | |
| 66 | + | // ErrNotReady so the TUI can retry or report "agent not listening | |
| 67 | + | // yet" rather than crashing. | |
| 68 | + | func (b *Inbox) SendInput(msg string) error { | |
| 69 | + | b.mu.Lock() | |
| 70 | + | defer b.mu.Unlock() | |
| 71 | + | if b.conn == nil { | |
| 72 | + | c, err := dialUnix(b.path, 200*time.Millisecond) | |
| 73 | + | if err != nil { | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | b.conn = c | |
| 77 | + | } | |
| 78 | + | if _, err := io.WriteString(b.conn, msg+"\n"); err != nil { | |
| 79 | + | // Drop the connection so the next call redials. The | |
| 80 | + | // previous error is more informative than the redial's | |
| 81 | + | // would be, so surface this one. | |
| 82 | + | _ = b.conn.Close() | |
| 83 | + | b.conn = nil | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Close drops any persistent connection. Safe to call repeatedly. | |
| 90 | + | // Does not unlink the socket file — that's the child's job | |
| 91 | + | // (Listener.Close). | |
| 92 | + | func (b *Inbox) Close() error { | |
| 93 | + | b.mu.Lock() | |
| 94 | + | defer b.mu.Unlock() | |
| 95 | + | if b.conn != nil { | |
| 96 | + | err := b.conn.Close() | |
| 97 | + | b.conn = nil | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | return nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // ErrNotReady is returned by SendInput when the child hasn't yet | |
| 104 | + | // opened its listener. Callers can retry after a short backoff; | |
| 105 | + | // the TUI surfaces it as "agent <id> not listening yet". | |
| 106 | + | var ErrNotReady = errors.New("swarm: agent inbox not ready") | |
| 107 | + | ||
| 108 | + | // dialUnix wraps net.DialTimeout with a small retry window so the | |
| 109 | + | // happy path "child just started, supervisor sends right away" | |
| 110 | + | // works without forcing the caller to poll. | |
| 111 | + | // | |
| 112 | + | // Errors collapse onto ErrNotReady whenever the failure means "no | |
| 113 | + | // one is listening": either the socket file doesn't exist (child | |
| 114 | + | // hasn't booted) or it does exist but the dial was refused (the | |
| 115 | + | // previous child exited and left a stale inode). Both are | |
| 116 | + | // recoverable by Swarm.Resume, and both should surface as the same | |
| 117 | + | // user-facing hint rather than as a path-leaking error string. | |
| 118 | + | func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | |
| 119 | + | deadline := time.Now().Add(timeout) | |
| 120 | + | var lastErr error | |
| 121 | + | for { | |
| 122 | + | c, err := net.DialTimeout("unix", path, 50*time.Millisecond) | |
| 123 | + | if err == nil { | |
| 124 | + | return c, nil | |
| 125 | + | } | |
| 126 | + | lastErr = err | |
| 127 | + | if time.Now().After(deadline) { | |
| 128 | + | break | |
| 129 | + | } | |
| 130 | + | time.Sleep(20 * time.Millisecond) | |
| 131 | + | } | |
| 132 | + | if _, statErr := os.Stat(path); os.IsNotExist(statErr) { | |
| 133 | + | return nil, ErrNotReady | |
| 134 | + | } | |
| 135 | + | if isNoListenerErr(lastErr) { | |
| 136 | + | return nil, ErrNotReady | |
| 137 | + | } | |
| 138 | + | return nil, fmt.Errorf("swarm: dial %s: %w", path, lastErr) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // isNoListenerErr reports whether err means "the socket file exists | |
| 142 | + | // but no process is listening on it" — i.e. ECONNREFUSED on unix | |
| 143 | + | // domain sockets. We pattern-match on errno via errors.Is rather | |
| 144 | + | // than the error string so platform variants (linux vs darwin) all | |
| 145 | + | // fold to the same case. | |
| 146 | + | func isNoListenerErr(err error) bool { | |
| 147 | + | return errors.Is(err, syscall.ECONNREFUSED) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Listener is the child-side end of the inbox. The swarm-agent | |
| 151 | + | // daemon mode constructs one with Listen, then iterates Lines | |
| 152 | + | // to receive supervisor messages. Designed so a single goroutine | |
| 153 | + | // in the child can drive prompting without juggling raw net code. | |
| 154 | + | type Listener struct { | |
| 155 | + | path string | |
| 156 | + | ln net.Listener | |
| 157 | + | // active is the most recent accepted connection. The protocol | |
| 158 | + | // only expects one supervisor (the parent zot), so newer | |
| 159 | + | // connections preempt older ones — the previous parent | |
| 160 | + | // presumably crashed. | |
| 161 | + | mu sync.Mutex | |
| 162 | + | active net.Conn | |
| 163 | + | out chan string | |
| 164 | + | done chan struct{} | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Listen creates the socket at path and starts accepting. The | |
| 168 | + | // caller must call Close to remove the socket file on shutdown. | |
| 169 | + | // Returns the listener even if a stale socket exists from a | |
| 170 | + | // previous run: we unlink first, mirroring how most unix daemons | |
| 171 | + | // behave. | |
| 172 | + | func Listen(path string) (*Listener, error) { | |
| 173 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 174 | + | return nil, fmt.Errorf("inbox dir: %w", err) | |
| 175 | + | } | |
| 176 | + | // Best-effort cleanup of a stale socket. If the parent | |
| 177 | + | // process is still alive and using it, Listen will fail | |
| 178 | + | // below and the caller surfaces that as a real conflict. | |
| 179 | + | _ = os.Remove(path) | |
| 180 | + | ln, err := net.Listen("unix", path) | |
| 181 | + | if err != nil { | |
| 182 | + | return nil, fmt.Errorf("inbox listen: %w", err) | |
| 183 | + | } | |
| 184 | + | l := &Listener{ | |
| 185 | + | path: path, | |
| 186 | + | ln: ln, | |
| 187 | + | out: make(chan string, 16), | |
| 188 | + | done: make(chan struct{}), | |
| 189 | + | } | |
| 190 | + | go l.acceptLoop() | |
| 191 | + | return l, nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Lines returns a channel of newline-stripped messages received | |
| 195 | + | // from the supervisor. The channel closes when Close is called. | |
| 196 | + | func (l *Listener) Lines() <-chan string { return l.out } | |
| 197 | + | ||
| 198 | + | // Close stops accepting, drops the active connection, removes | |
| 199 | + | // the socket file, and closes Lines. Idempotent. | |
| 200 | + | func (l *Listener) Close() error { | |
| 201 | + | select { | |
| 202 | + | case <-l.done: | |
| 203 | + | return nil | |
| 204 | + | default: | |
| 205 | + | close(l.done) | |
| 206 | + | } | |
| 207 | + | l.mu.Lock() | |
| 208 | + | if l.active != nil { | |
| 209 | + | _ = l.active.Close() | |
| 210 | + | } | |
| 211 | + | l.mu.Unlock() | |
| 212 | + | _ = l.ln.Close() | |
| 213 | + | _ = os.Remove(l.path) | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (l *Listener) acceptLoop() { | |
| 218 | + | defer close(l.out) | |
| 219 | + | for { | |
| 220 | + | c, err := l.ln.Accept() | |
| 221 | + | if err != nil { | |
| 222 | + | select { | |
| 223 | + | case <-l.done: | |
| 224 | + | return | |
| 225 | + | default: | |
| 226 | + | // Accept can fail transiently (signal); back off briefly. | |
| 227 | + | time.Sleep(20 * time.Millisecond) | |
| 228 | + | continue | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | l.mu.Lock() | |
| 232 | + | if l.active != nil { | |
| 233 | + | _ = l.active.Close() | |
| 234 | + | } | |
| 235 | + | l.active = c | |
| 236 | + | l.mu.Unlock() | |
| 237 | + | go l.readLoop(c) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (l *Listener) readLoop(c net.Conn) { | |
| 242 | + | br := bufio.NewReader(c) | |
| 243 | + | for { | |
| 244 | + | line, err := br.ReadString('\n') | |
| 245 | + | if line != "" { | |
| 246 | + | select { | |
| 247 | + | case l.out <- trimRightNL(line): | |
| 248 | + | case <-l.done: | |
| 249 | + | _ = c.Close() | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | if err != nil { | |
| 254 | + | _ = c.Close() | |
| 255 | + | l.mu.Lock() | |
| 256 | + | if l.active == c { | |
| 257 | + | l.active = nil | |
| 258 | + | } | |
| 259 | + | l.mu.Unlock() | |
| 260 | + | return | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func trimRightNL(s string) string { | |
| 266 | + | for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { | |
| 267 | + | s = s[:len(s)-1] | |
| 268 | + | } | |
| 269 | + | return s | |
| 270 | + | } | |
A internal/swarm/inbox_test.go +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sync" | |
| 9 | + | "syscall" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // shortSocketDir returns a tempdir short enough that we can append | |
| 15 | + | // "in.sock" and still fit under the unix-socket path cap. The Go | |
| 16 | + | // runner's t.TempDir() lives under a long /var/folders/... path on | |
| 17 | + | // macOS which blows the 104-byte limit by itself, so the inbox tests | |
| 18 | + | // can't use it. | |
| 19 | + | func shortSocketDir(t *testing.T) string { | |
| 20 | + | t.Helper() | |
| 21 | + | dir, err := os.MkdirTemp("/tmp", "zot-in-") | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatalf("mkdir: %v", err) | |
| 24 | + | } | |
| 25 | + | t.Cleanup(func() { os.RemoveAll(dir) }) | |
| 26 | + | return dir | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // TestInboxRoundTrip is the happy path: child listens, supervisor | |
| 30 | + | // dials and sends three lines, child receives them in order, and | |
| 31 | + | // closing both sides is clean. Locks in the line-framed protocol. | |
| 32 | + | func TestInboxRoundTrip(t *testing.T) { | |
| 33 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |
| 34 | + | ln, err := Listen(path) | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatalf("Listen: %v", err) | |
| 37 | + | } | |
| 38 | + | defer ln.Close() | |
| 39 | + | ||
| 40 | + | got := collectFor(t, ln.Lines(), 3, time.Second) | |
| 41 | + | inbox := NewInbox(path) | |
| 42 | + | defer inbox.Close() | |
| 43 | + | ||
| 44 | + | want := []string{"user hello", "user world", "cancel"} | |
| 45 | + | for _, msg := range want { | |
| 46 | + | if err := inbox.SendInput(msg); err != nil { | |
| 47 | + | t.Fatalf("SendInput(%q): %v", msg, err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | gotLines := <-got | |
| 52 | + | if len(gotLines) != len(want) { | |
| 53 | + | t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines) | |
| 54 | + | } | |
| 55 | + | for i, w := range want { | |
| 56 | + | if gotLines[i] != w { | |
| 57 | + | t.Errorf("line %d = %q; want %q", i, gotLines[i], w) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // TestInboxNotReady regression-tests the explicit "child hasn't | |
| 63 | + | // opened the socket yet" case. The TUI needs an error it can show | |
| 64 | + | // as "agent not listening yet" rather than a generic dial failure. | |
| 65 | + | func TestInboxNotReady(t *testing.T) { | |
| 66 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |
| 67 | + | inbox := NewInbox(path) | |
| 68 | + | defer inbox.Close() | |
| 69 | + | err := inbox.SendInput("user hi") | |
| 70 | + | if err == nil { | |
| 71 | + | t.Fatal("SendInput on missing socket: want error") | |
| 72 | + | } | |
| 73 | + | if !errors.Is(err, ErrNotReady) { | |
| 74 | + | t.Fatalf("SendInput err = %v; want ErrNotReady", err) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | |
| 79 | + | // that the user's screenshot regression depends on: any dial error | |
| 80 | + | // whose root cause is "the kernel refused the connect because nobody | |
| 81 | + | // is listening" must fold into ErrNotReady at the SendInput layer, | |
| 82 | + | // so the TUI shows "agent not accepting input (press R to resume)" | |
| 83 | + | // instead of leaking the unix-socket path. We pattern-match via | |
| 84 | + | // errors.Is on syscall.ECONNREFUSED rather than string-matching the | |
| 85 | + | // error text so platform variants all hit the same branch. | |
| 86 | + | func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) { | |
| 87 | + | if !isNoListenerErr(syscall.ECONNREFUSED) { | |
| 88 | + | t.Error("isNoListenerErr(ECONNREFUSED) = false; want true") | |
| 89 | + | } | |
| 90 | + | // Wrapped via fmt.Errorf — common form of the dial error. | |
| 91 | + | if !isNoListenerErr(fmt.Errorf("dial unix /tmp/x: %w", syscall.ECONNREFUSED)) { | |
| 92 | + | t.Error("isNoListenerErr(wrapped) = false; want true") | |
| 93 | + | } | |
| 94 | + | if isNoListenerErr(syscall.ECONNRESET) { | |
| 95 | + | t.Error("isNoListenerErr(ECONNRESET) = true; want false (unrelated errno)") | |
| 96 | + | } | |
| 97 | + | if isNoListenerErr(nil) { | |
| 98 | + | t.Error("isNoListenerErr(nil) = true; want false") | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // TestInboxNewSupervisorPreemptsOld documents that only one | |
| 103 | + | // supervisor connection at a time is honoured. The swarm design | |
| 104 | + | // assumes a single parent zot owns each agent; if a second | |
| 105 | + | // parent dials, the listener boots the first one. The first | |
| 106 | + | // supervisor's already-delivered messages still land in the | |
| 107 | + | // channel — what we're guarding against is silent dual ownership, | |
| 108 | + | // not message loss. | |
| 109 | + | func TestInboxNewSupervisorPreemptsOld(t *testing.T) { | |
| 110 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |
| 111 | + | ln, err := Listen(path) | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatalf("Listen: %v", err) | |
| 114 | + | } | |
| 115 | + | defer ln.Close() | |
| 116 | + | ||
| 117 | + | first := NewInbox(path) | |
| 118 | + | if err := first.SendInput("user one"); err != nil { | |
| 119 | + | t.Fatalf("first send: %v", err) | |
| 120 | + | } | |
| 121 | + | // Wait for the listener to absorb "user one" before dialing the | |
| 122 | + | // second supervisor; otherwise we race against the connection | |
| 123 | + | // preemption and "user one" can be dropped (the first conn is | |
| 124 | + | // closed by Accept before its read loop processes the byte). | |
| 125 | + | waitN(t, ln.Lines(), 1, time.Second) | |
| 126 | + | ||
| 127 | + | second := NewInbox(path) | |
| 128 | + | defer second.Close() | |
| 129 | + | if err := second.SendInput("user two"); err != nil { | |
| 130 | + | t.Fatalf("second send: %v", err) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | secondLines := waitN(t, ln.Lines(), 1, time.Second) | |
| 134 | + | if len(secondLines) != 1 || secondLines[0] != "user two" { | |
| 135 | + | t.Fatalf("second supervisor not received: %v", secondLines) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // The original supervisor's connection was preempted by the | |
| 139 | + | // listener accepting the second dial. The next SendInput on the | |
| 140 | + | // first handle sees a broken pipe and drops the cached connection; | |
| 141 | + | // the call after that redials successfully. Both behaviours | |
| 142 | + | // matter: the broken-pipe error must surface (so a real | |
| 143 | + | // supervisor knows it lost ownership) AND the next call must | |
| 144 | + | // recover so a flaky reconnect doesn't permanently wedge the | |
| 145 | + | // inbox. | |
| 146 | + | if err := first.SendInput("user three"); err == nil { | |
| 147 | + | t.Fatal("expected broken-pipe error on stale connection") | |
| 148 | + | } | |
| 149 | + | if err := first.SendInput("user four"); err != nil { | |
| 150 | + | t.Fatalf("first redial after eviction: %v", err) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func waitN(t *testing.T, ch <-chan string, n int, timeout time.Duration) []string { | |
| 155 | + | t.Helper() | |
| 156 | + | var got []string | |
| 157 | + | deadline := time.After(timeout) | |
| 158 | + | for len(got) < n { | |
| 159 | + | select { | |
| 160 | + | case msg := <-ch: | |
| 161 | + | got = append(got, msg) | |
| 162 | + | case <-deadline: | |
| 163 | + | return got | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | return got | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // collectFor consumes up to n items from ch within timeout and | |
| 170 | + | // pushes the slice onto the returned channel. Used by the tests | |
| 171 | + | // above so they don't race against the listener's accept goroutine. | |
| 172 | + | func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-chan []string { | |
| 173 | + | t.Helper() | |
| 174 | + | out := make(chan []string, 1) | |
| 175 | + | go func() { | |
| 176 | + | var got []string | |
| 177 | + | deadline := time.After(timeout) | |
| 178 | + | for len(got) < n { | |
| 179 | + | select { | |
| 180 | + | case msg, ok := <-ch: | |
| 181 | + | if !ok { | |
| 182 | + | out <- got | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | got = append(got, msg) | |
| 186 | + | case <-deadline: | |
| 187 | + | out <- got | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | out <- got | |
| 192 | + | }() | |
| 193 | + | return out | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // TestInboxConcurrentSends serialises the SendInput call so two | |
| 197 | + | // goroutines writing at once still produce two whole lines, never | |
| 198 | + | // a tearing of bytes between them. | |
| 199 | + | func TestInboxConcurrentSends(t *testing.T) { | |
| 200 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |
| 201 | + | ln, err := Listen(path) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatalf("Listen: %v", err) | |
| 204 | + | } | |
| 205 | + | defer ln.Close() | |
| 206 | + | inbox := NewInbox(path) | |
| 207 | + | defer inbox.Close() | |
| 208 | + | ||
| 209 | + | var wg sync.WaitGroup | |
| 210 | + | const n = 50 | |
| 211 | + | wg.Add(n) | |
| 212 | + | for i := 0; i < n; i++ { | |
| 213 | + | go func(i int) { | |
| 214 | + | defer wg.Done() | |
| 215 | + | if err := inbox.SendInput("user msg"); err != nil { | |
| 216 | + | t.Errorf("send %d: %v", i, err) | |
| 217 | + | } | |
| 218 | + | }(i) | |
| 219 | + | } | |
| 220 | + | wg.Wait() | |
| 221 | + | ||
| 222 | + | collected := collectFor(t, ln.Lines(), n, 2*time.Second) | |
| 223 | + | got := <-collected | |
| 224 | + | if len(got) != n { | |
| 225 | + | t.Fatalf("got %d lines, want %d", len(got), n) | |
| 226 | + | } | |
| 227 | + | for i, g := range got { | |
| 228 | + | if g != "user msg" { | |
| 229 | + | t.Fatalf("line %d garbled: %q", i, g) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
A internal/swarm/persist.go +394 -0
| @@ -0,0 +1,394 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | // On-disk persistence for swarm agents. | |
| 4 | + | // | |
| 5 | + | // Every Spawn writes a meta.json next to the agent's events.jsonl and | |
| 6 | + | // session.json. The file captures the immutable identity bits (id, | |
| 7 | + | // task, branch, dir) plus the paths the runner needs to resume the | |
| 8 | + | // agent later. On a fresh zot launch, Swarm.Reload() walks | |
| 9 | + | // <root>/agents/*/meta.json and re-registers every agent it finds in | |
| 10 | + | // StatusDetached so the user can see, view, resume, or remove them | |
| 11 | + | // from the dashboard. | |
| 12 | + | // | |
| 13 | + | // We don't try to keep meta.json in sync with mutable state (status, | |
| 14 | + | // activity, transcript). Those live in the events log (durable) and | |
| 15 | + | // in-memory Agent fields (rebuilt by Reload from the log tail). | |
| 16 | + | // Keeping meta.json immutable means we never have to worry about | |
| 17 | + | // concurrent writers stomping on each other and the file matters | |
| 18 | + | // only on the spawn/reload boundary. | |
| 19 | + | ||
| 20 | + | import ( | |
| 21 | + | "context" | |
| 22 | + | "encoding/json" | |
| 23 | + | "errors" | |
| 24 | + | "fmt" | |
| 25 | + | "os" | |
| 26 | + | "path/filepath" | |
| 27 | + | "sort" | |
| 28 | + | "strings" | |
| 29 | + | "time" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // agentMeta is the durable identity record for one agent. Only fields | |
| 33 | + | // the supervisor needs to rebuild an Agent after a restart live here. | |
| 34 | + | // Adding a field is backwards-compatible (older meta.json files just | |
| 35 | + | // leave it zero); removing or renaming one is not. | |
| 36 | + | type agentMeta struct { | |
| 37 | + | ID string `json:"id"` | |
| 38 | + | Task string `json:"task"` | |
| 39 | + | Branch string `json:"branch"` | |
| 40 | + | Dir string `json:"dir"` | |
| 41 | + | Started time.Time `json:"started"` | |
| 42 | + | Model string `json:"model,omitempty"` | |
| 43 | + | Provider string `json:"provider,omitempty"` | |
| 44 | + | InboxPath string `json:"inbox_path"` | |
| 45 | + | EventLogPath string `json:"event_log_path"` | |
| 46 | + | SessionPath string `json:"session_path"` | |
| 47 | + | ||
| 48 | + | // SessionID, when non-empty, scopes the agent to a particular | |
| 49 | + | // host zot session: the dashboard only shows agents whose | |
| 50 | + | // SessionID matches the active session. Older meta.json files | |
| 51 | + | // (and agents spawned outside of any session, e.g. by tests or | |
| 52 | + | // scripted callers that didn't call SetActiveSession) have an | |
| 53 | + | // empty SessionID and are visible from every session as a | |
| 54 | + | // backward-compat fallback. Added in 2026 — a fresh field on a | |
| 55 | + | // json struct is backwards-compatible by design. | |
| 56 | + | SessionID string `json:"session_id,omitempty"` | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | |
| 60 | + | ||
| 61 | + | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | |
| 62 | + | // write is atomic (tmp + rename) so a crash mid-write can't leave a | |
| 63 | + | // half-parsable file that fails Reload. | |
| 64 | + | func writeAgentMeta(stateDir string, a *Agent) error { | |
| 65 | + | m := agentMeta{ | |
| 66 | + | ID: a.ID, | |
| 67 | + | Task: a.Task, | |
| 68 | + | Branch: a.Branch, | |
| 69 | + | Dir: a.Dir, | |
| 70 | + | Started: a.Started, | |
| 71 | + | Model: a.Model, | |
| 72 | + | Provider: a.Provider, | |
| 73 | + | InboxPath: a.InboxPath, | |
| 74 | + | EventLogPath: a.EventLogPath, | |
| 75 | + | SessionPath: a.SessionPath, | |
| 76 | + | SessionID: a.SessionID, | |
| 77 | + | } | |
| 78 | + | b, err := json.MarshalIndent(m, "", " ") | |
| 79 | + | if err != nil { | |
| 80 | + | return fmt.Errorf("swarm meta marshal: %w", err) | |
| 81 | + | } | |
| 82 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| 83 | + | return fmt.Errorf("swarm meta dir: %w", err) | |
| 84 | + | } | |
| 85 | + | final := metaPath(stateDir) | |
| 86 | + | tmp := final + ".tmp" | |
| 87 | + | if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { | |
| 88 | + | return fmt.Errorf("swarm meta write: %w", err) | |
| 89 | + | } | |
| 90 | + | if err := os.Rename(tmp, final); err != nil { | |
| 91 | + | _ = os.Remove(tmp) | |
| 92 | + | return fmt.Errorf("swarm meta rename: %w", err) | |
| 93 | + | } | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped) | |
| 98 | + | // when the file is missing so callers can distinguish "no such agent" | |
| 99 | + | // from "corrupt metadata". | |
| 100 | + | func readAgentMeta(stateDir string) (agentMeta, error) { | |
| 101 | + | var m agentMeta | |
| 102 | + | b, err := os.ReadFile(metaPath(stateDir)) | |
| 103 | + | if err != nil { | |
| 104 | + | return m, err | |
| 105 | + | } | |
| 106 | + | if err := json.Unmarshal(b, &m); err != nil { | |
| 107 | + | return m, fmt.Errorf("swarm meta parse %s: %w", stateDir, err) | |
| 108 | + | } | |
| 109 | + | if m.ID == "" { | |
| 110 | + | return m, fmt.Errorf("swarm meta %s: missing id", stateDir) | |
| 111 | + | } | |
| 112 | + | return m, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Reload scans <root>/agents/*/meta.json and re-registers every | |
| 116 | + | // previously-spawned agent as a StatusDetached entry. Agents already | |
| 117 | + | // present in memory are left alone (Reload is idempotent and safe to | |
| 118 | + | // call after Spawn, though in practice the cli invokes it exactly | |
| 119 | + | // once just after New()). | |
| 120 | + | // | |
| 121 | + | // The reloaded agents have no live Runner; the user can: | |
| 122 | + | // - view their transcript (the dashboard reads from EventLogPath), | |
| 123 | + | // - resume them via Swarm.Resume (starts a fresh subprocess on the | |
| 124 | + | // same worktree / session / inbox path), | |
| 125 | + | // - remove them (worktree + meta + events log gone). | |
| 126 | + | // | |
| 127 | + | // Reload returns the number of agents loaded plus any per-directory | |
| 128 | + | // error encountered. Malformed entries are skipped rather than | |
| 129 | + | // failing the whole reload — one bad meta.json shouldn't hide the | |
| 130 | + | // rest of the swarm. | |
| 131 | + | func (f *Swarm) Reload() (loaded int, errs []error) { | |
| 132 | + | agentsDir := filepath.Join(f.cfg.Root, "agents") | |
| 133 | + | entries, err := os.ReadDir(agentsDir) | |
| 134 | + | if err != nil { | |
| 135 | + | if errors.Is(err, os.ErrNotExist) { | |
| 136 | + | return 0, nil | |
| 137 | + | } | |
| 138 | + | return 0, []error{fmt.Errorf("swarm reload: %w", err)} | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Sort by directory name so the load order is stable across runs. | |
| 142 | + | // agentStateDir uses the id verbatim so name-sort == id-sort, | |
| 143 | + | // which mirrors the creation order well enough for the dashboard | |
| 144 | + | // (we also sort by Started in SnapshotAll, but having a stable | |
| 145 | + | // f.order helps tests). | |
| 146 | + | names := make([]string, 0, len(entries)) | |
| 147 | + | for _, e := range entries { | |
| 148 | + | if e.IsDir() { | |
| 149 | + | names = append(names, e.Name()) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | sort.Strings(names) | |
| 153 | + | ||
| 154 | + | for _, name := range names { | |
| 155 | + | stateDir := filepath.Join(agentsDir, name) | |
| 156 | + | m, err := readAgentMeta(stateDir) | |
| 157 | + | if err != nil { | |
| 158 | + | if errors.Is(err, os.ErrNotExist) { | |
| 159 | + | // Bare directory with no meta.json — probably a | |
| 160 | + | // leftover from a Spawn that failed before | |
| 161 | + | // writeAgentMeta. Ignore silently. | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | errs = append(errs, err) | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | f.mu.Lock() | |
| 169 | + | if _, exists := f.agents[m.ID]; exists { | |
| 170 | + | f.mu.Unlock() | |
| 171 | + | continue | |
| 172 | + | } | |
| 173 | + | a := f.buildDetachedAgent(m) | |
| 174 | + | f.agents[m.ID] = a | |
| 175 | + | f.order = append(f.order, m.ID) | |
| 176 | + | f.mu.Unlock() | |
| 177 | + | loaded++ | |
| 178 | + | } | |
| 179 | + | return loaded, errs | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // buildDetachedAgent constructs an Agent from a meta.json with no | |
| 183 | + | // running Runner. The agent's transcript is populated from the tail | |
| 184 | + | // of its event log so the dashboard immediately shows recent output; | |
| 185 | + | // activity is inferred from the last lifecycle event. | |
| 186 | + | // | |
| 187 | + | // The returned Agent has a closed `done` channel because Wait should | |
| 188 | + | // return instantly: there is nothing to wait for. | |
| 189 | + | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |
| 190 | + | a := &Agent{ | |
| 191 | + | ID: m.ID, | |
| 192 | + | Task: m.Task, | |
| 193 | + | Branch: m.Branch, | |
| 194 | + | Dir: m.Dir, | |
| 195 | + | Started: m.Started, | |
| 196 | + | Model: m.Model, | |
| 197 | + | Provider: m.Provider, | |
| 198 | + | InboxPath: m.InboxPath, | |
| 199 | + | EventLogPath: m.EventLogPath, | |
| 200 | + | SessionPath: m.SessionPath, | |
| 201 | + | SessionID: m.SessionID, | |
| 202 | + | inbox: NewInbox(m.InboxPath), | |
| 203 | + | status: StatusDetached, | |
| 204 | + | activity: "detached", | |
| 205 | + | done: make(chan struct{}), | |
| 206 | + | } | |
| 207 | + | // Wait() must not block for detached agents; close the channel | |
| 208 | + | // immediately. Callers Resuming the agent will replace done with | |
| 209 | + | // a fresh channel before starting the new runner. | |
| 210 | + | close(a.done) | |
| 211 | + | ||
| 212 | + | // Recover transcript + activity hints from the event log. Best | |
| 213 | + | // effort: a missing or unreadable log just leaves the agent | |
| 214 | + | // detached with an empty transcript. | |
| 215 | + | if a.EventLogPath != "" { | |
| 216 | + | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |
| 217 | + | replayEventsIntoAgent(a, evs) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | return a | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // replayEventsIntoAgent re-derives an agent's transcript and last | |
| 224 | + | // known status hint from its event log. Mirrors applyEventToSink in | |
| 225 | + | // runner.go but writes directly to the Agent fields (no Sink because | |
| 226 | + | // the agent isn't being driven by a runner yet). | |
| 227 | + | // | |
| 228 | + | // Status precedence: explicit lifecycle events (agent_stopped) win | |
| 229 | + | // over inferred ones (assistant_message → idle). If the log contains | |
| 230 | + | // no terminator we keep status=StatusDetached so the user can resume. | |
| 231 | + | func replayEventsIntoAgent(a *Agent, evs []Event) { | |
| 232 | + | terminal := false | |
| 233 | + | for _, ev := range evs { | |
| 234 | + | switch ev.Type { | |
| 235 | + | case "assistant_message": | |
| 236 | + | if c, ok := ev.Data["content"].([]any); ok { | |
| 237 | + | for _, blk := range c { | |
| 238 | + | m, _ := blk.(map[string]any) | |
| 239 | + | if t, _ := m["type"].(string); t == "text" { | |
| 240 | + | if txt, _ := m["text"].(string); txt != "" { | |
| 241 | + | a.appendTranscript(txt) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | case "user_message": | |
| 247 | + | if c, ok := ev.Data["content"].([]any); ok { | |
| 248 | + | for _, blk := range c { | |
| 249 | + | m, _ := blk.(map[string]any) | |
| 250 | + | if t, _ := m["type"].(string); t == "text" { | |
| 251 | + | if txt, _ := m["text"].(string); txt != "" { | |
| 252 | + | a.appendTranscript("user: " + txt) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | case "stdout": | |
| 258 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 259 | + | a.appendTranscript(txt) | |
| 260 | + | } | |
| 261 | + | case "stderr": | |
| 262 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 263 | + | a.appendTranscript("stderr: " + txt) | |
| 264 | + | } | |
| 265 | + | case "error": | |
| 266 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |
| 267 | + | a.appendTranscript("error: " + msg) | |
| 268 | + | } | |
| 269 | + | case "agent_stopped": | |
| 270 | + | terminal = true | |
| 271 | + | reason, _ := ev.Data["reason"].(string) | |
| 272 | + | a.mu.Lock() | |
| 273 | + | switch reason { | |
| 274 | + | case "cancelled": | |
| 275 | + | a.status = StatusKilled | |
| 276 | + | a.activity = "cancelled (offline)" | |
| 277 | + | case "shutdown": | |
| 278 | + | a.status = StatusDone | |
| 279 | + | a.activity = "shutdown (offline)" | |
| 280 | + | case "exit": | |
| 281 | + | if code, ok := ev.Data["code"].(float64); ok && code != 0 { | |
| 282 | + | a.status = StatusFailed | |
| 283 | + | a.activity = fmt.Sprintf("exit %d (offline)", int(code)) | |
| 284 | + | } else { | |
| 285 | + | a.status = StatusDone | |
| 286 | + | a.activity = "done (offline)" | |
| 287 | + | } | |
| 288 | + | default: | |
| 289 | + | a.status = StatusDone | |
| 290 | + | a.activity = "stopped (offline)" | |
| 291 | + | } | |
| 292 | + | a.mu.Unlock() | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | if !terminal { | |
| 296 | + | // Non-terminal log means the previous parent died mid-run. | |
| 297 | + | // Leave status=StatusDetached but record a hint so the | |
| 298 | + | // dashboard shows something useful. | |
| 299 | + | a.mu.Lock() | |
| 300 | + | if a.activity == "detached" && len(a.transcript) > 0 { | |
| 301 | + | a.activity = "detached (resume to continue)" | |
| 302 | + | } | |
| 303 | + | a.mu.Unlock() | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Resume re-attaches a Runner to a previously-spawned agent. The | |
| 308 | + | // existing worktree, session file, branch, and inbox path are kept; | |
| 309 | + | // only the in-memory Agent and its runner are replaced. Use this to | |
| 310 | + | // continue a swarm session across zot restarts: | |
| 311 | + | // | |
| 312 | + | // swarmMgr.Reload() | |
| 313 | + | // a, err := swarmMgr.Resume(ctx, "alpha-12345") | |
| 314 | + | // swarmMgr.SendUserTurn(a.ID, "where were we?") | |
| 315 | + | // | |
| 316 | + | // The agent must be in a non-running state (Detached, Done, Failed, | |
| 317 | + | // Killed). Resuming a still-running agent returns an error so two | |
| 318 | + | // runners don't race for the same session. | |
| 319 | + | func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |
| 320 | + | existing := f.Get(id) | |
| 321 | + | if existing == nil { | |
| 322 | + | return nil, fmt.Errorf("swarm: no such agent %q", id) | |
| 323 | + | } | |
| 324 | + | existing.mu.Lock() | |
| 325 | + | st := existing.status | |
| 326 | + | existing.mu.Unlock() | |
| 327 | + | if st == StatusRunning || st == StatusPending { | |
| 328 | + | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Rebuild from the meta record so we don't carry stale runner | |
| 332 | + | // state from a previous incarnation. We re-read meta.json rather | |
| 333 | + | // than reusing the live struct's fields so callers that mutated | |
| 334 | + | // (e.g. tests that hand-built an Agent) don't accidentally route | |
| 335 | + | // the new runner at the wrong paths. | |
| 336 | + | m := agentMeta{ | |
| 337 | + | ID: existing.ID, Task: existing.Task, Branch: existing.Branch, | |
| 338 | + | Dir: existing.Dir, Started: existing.Started, | |
| 339 | + | Model: existing.Model, Provider: existing.Provider, | |
| 340 | + | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | |
| 341 | + | SessionPath: existing.SessionPath, | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | a := &Agent{ | |
| 345 | + | ID: m.ID, | |
| 346 | + | Task: m.Task, | |
| 347 | + | Branch: m.Branch, | |
| 348 | + | Dir: m.Dir, | |
| 349 | + | Started: m.Started, | |
| 350 | + | Model: m.Model, | |
| 351 | + | Provider: m.Provider, | |
| 352 | + | InboxPath: m.InboxPath, | |
| 353 | + | EventLogPath: m.EventLogPath, | |
| 354 | + | SessionPath: m.SessionPath, | |
| 355 | + | Resuming: true, | |
| 356 | + | inbox: NewInbox(m.InboxPath), | |
| 357 | + | status: StatusPending, | |
| 358 | + | activity: "resuming", | |
| 359 | + | done: make(chan struct{}), | |
| 360 | + | } | |
| 361 | + | // Carry the previous transcript forward so the dashboard doesn't | |
| 362 | + | // flash empty between resume and the first new event. | |
| 363 | + | prev := existing.Transcript() | |
| 364 | + | if len(prev) > 0 { | |
| 365 | + | a.appendTranscript(strings.Join(prev, "\n")) | |
| 366 | + | } | |
| 367 | + | a.ctx, a.cancel = context.WithCancel(ctx) | |
| 368 | + | a.runner = f.cfg.NewRunner(a) | |
| 369 | + | ||
| 370 | + | f.mu.Lock() | |
| 371 | + | f.agents[a.ID] = a | |
| 372 | + | // Keep the agent's slot in f.order; replacing in-place avoids | |
| 373 | + | // reshuffling the dashboard's row ordering on resume. | |
| 374 | + | found := false | |
| 375 | + | for _, k := range f.order { | |
| 376 | + | if k == a.ID { | |
| 377 | + | found = true | |
| 378 | + | break | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | if !found { | |
| 382 | + | f.order = append(f.order, a.ID) | |
| 383 | + | } | |
| 384 | + | f.mu.Unlock() | |
| 385 | + | ||
| 386 | + | // Refresh the meta.json so any new path defaults (e.g. socket | |
| 387 | + | // path moved into /tmp because the root got renamed) get | |
| 388 | + | // persisted. Best-effort; resume still works if the disk is | |
| 389 | + | // read-only because everything the runner needs is in-memory. | |
| 390 | + | _ = writeAgentMeta(f.agentStateDir(a.ID), a) | |
| 391 | + | ||
| 392 | + | go f.run(a) | |
| 393 | + | return a, nil | |
| 394 | + | } | |
A internal/swarm/persist_test.go +838 -0
| @@ -0,0 +1,838 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // TestSpawnWritesMetaJSON asserts the durability contract: every | |
| 16 | + | // successful Spawn leaves a meta.json on disk with the agent's | |
| 17 | + | // identity bits. Without this, Reload on the next zot launch can't | |
| 18 | + | // find the agent and the user loses access to the worktree. | |
| 19 | + | func TestSpawnWritesMetaJSON(t *testing.T) { | |
| 20 | + | root := t.TempDir() | |
| 21 | + | f := New(Config{ | |
| 22 | + | Root: root, | |
| 23 | + | RepoRoot: root, | |
| 24 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 25 | + | NewRunner: func(a *Agent) Runner { | |
| 26 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 27 | + | <-ctx.Done() | |
| 28 | + | return ctx.Err() | |
| 29 | + | }) | |
| 30 | + | }, | |
| 31 | + | }) | |
| 32 | + | defer f.StopAll() | |
| 33 | + | ||
| 34 | + | a, err := f.Spawn(context.Background(), "investigate widget") | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatalf("spawn: %v", err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Read meta.json straight off disk. | |
| 40 | + | metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json")) | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatalf("read meta.json: %v", err) | |
| 43 | + | } | |
| 44 | + | var got agentMeta | |
| 45 | + | if err := json.Unmarshal(metaBytes, &got); err != nil { | |
| 46 | + | t.Fatalf("parse meta.json: %v", err) | |
| 47 | + | } | |
| 48 | + | if got.ID != a.ID { | |
| 49 | + | t.Errorf("meta.ID = %q; want %q", got.ID, a.ID) | |
| 50 | + | } | |
| 51 | + | if got.Task != "investigate widget" { | |
| 52 | + | t.Errorf("meta.Task = %q", got.Task) | |
| 53 | + | } | |
| 54 | + | if got.Branch != a.Branch || got.Dir != a.Dir { | |
| 55 | + | t.Errorf("meta paths drifted: %+v vs agent %+v", got, a) | |
| 56 | + | } | |
| 57 | + | if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" { | |
| 58 | + | t.Errorf("meta paths empty: %+v", got) | |
| 59 | + | } | |
| 60 | + | if got.Started.IsZero() { | |
| 61 | + | t.Error("meta.Started is zero") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning | |
| 66 | + | // in one Swarm, throwing it away, then opening a fresh Swarm against | |
| 67 | + | // the same root and calling Reload. The user-visible state — id, | |
| 68 | + | // task, branch, dir — must come back, and status must be Detached so | |
| 69 | + | // the dashboard can offer resume. | |
| 70 | + | func TestReloadRebuildsDetachedAgents(t *testing.T) { | |
| 71 | + | root := t.TempDir() | |
| 72 | + | ||
| 73 | + | // First incarnation: spawn two agents, then drop the supervisor. | |
| 74 | + | first := New(Config{ | |
| 75 | + | Root: root, | |
| 76 | + | RepoRoot: root, | |
| 77 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 78 | + | NewRunner: func(a *Agent) Runner { | |
| 79 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 80 | + | <-ctx.Done() | |
| 81 | + | return ctx.Err() | |
| 82 | + | }) | |
| 83 | + | }, | |
| 84 | + | }) | |
| 85 | + | a1, err := first.Spawn(context.Background(), "alpha task") | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatalf("spawn a1: %v", err) | |
| 88 | + | } | |
| 89 | + | a2, err := first.Spawn(context.Background(), "beta task") | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatalf("spawn a2: %v", err) | |
| 92 | + | } | |
| 93 | + | first.StopAll() | |
| 94 | + | // Wait briefly for the runner goroutines to settle so their | |
| 95 | + | // stop event reaches the log before we move on. Reload itself | |
| 96 | + | // doesn't need this; it makes the assertions below deterministic. | |
| 97 | + | a1.Wait() | |
| 98 | + | a2.Wait() | |
| 99 | + | ||
| 100 | + | // Second incarnation against the same root. | |
| 101 | + | second := New(Config{ | |
| 102 | + | Root: root, | |
| 103 | + | RepoRoot: root, | |
| 104 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 105 | + | }) | |
| 106 | + | loaded, errs := second.Reload() | |
| 107 | + | if len(errs) > 0 { | |
| 108 | + | t.Fatalf("reload errs: %v", errs) | |
| 109 | + | } | |
| 110 | + | if loaded != 2 { | |
| 111 | + | t.Fatalf("loaded = %d; want 2", loaded) | |
| 112 | + | } | |
| 113 | + | snap := second.SnapshotAll() | |
| 114 | + | if len(snap) != 2 { | |
| 115 | + | t.Fatalf("snapshot len = %d; want 2", len(snap)) | |
| 116 | + | } | |
| 117 | + | ids := map[string]AgentSnapshot{} | |
| 118 | + | for _, s := range snap { | |
| 119 | + | ids[s.ID] = s | |
| 120 | + | } | |
| 121 | + | if _, ok := ids[a1.ID]; !ok { | |
| 122 | + | t.Errorf("reloaded set missing %s", a1.ID) | |
| 123 | + | } | |
| 124 | + | if _, ok := ids[a2.ID]; !ok { | |
| 125 | + | t.Errorf("reloaded set missing %s", a2.ID) | |
| 126 | + | } | |
| 127 | + | for _, s := range snap { | |
| 128 | + | if s.Status != StatusDetached { | |
| 129 | + | t.Errorf("agent %s status = %q; want detached", s.ID, s.Status) | |
| 130 | + | } | |
| 131 | + | if s.Task == "" { | |
| 132 | + | t.Errorf("agent %s lost its task", s.ID) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // TestReloadIsIdempotent calls Reload twice in a row and asserts the | |
| 138 | + | // second call neither duplicates rows nor errors. | |
| 139 | + | func TestReloadIsIdempotent(t *testing.T) { | |
| 140 | + | root := t.TempDir() | |
| 141 | + | first := New(Config{ | |
| 142 | + | Root: root, RepoRoot: root, | |
| 143 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 144 | + | NewRunner: func(a *Agent) Runner { | |
| 145 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 146 | + | }, | |
| 147 | + | }) | |
| 148 | + | if _, err := first.Spawn(context.Background(), "only one"); err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | first.StopAll() | |
| 152 | + | ||
| 153 | + | second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 154 | + | loaded1, _ := second.Reload() | |
| 155 | + | loaded2, errs := second.Reload() | |
| 156 | + | if len(errs) > 0 { | |
| 157 | + | t.Fatalf("errs on second reload: %v", errs) | |
| 158 | + | } | |
| 159 | + | if loaded1 != 1 || loaded2 != 0 { | |
| 160 | + | t.Fatalf("loaded counts = (%d, %d); want (1, 0)", loaded1, loaded2) | |
| 161 | + | } | |
| 162 | + | if got := len(second.SnapshotAll()); got != 1 { | |
| 163 | + | t.Fatalf("snapshot len = %d; want 1", got) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl | |
| 168 | + | // next to a meta.json and checks that the transcript surfaces in the | |
| 169 | + | // reloaded agent. This is the user-facing payoff of Reload: opening | |
| 170 | + | // /swarm logs <id> after a restart shows what the agent said before. | |
| 171 | + | func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |
| 172 | + | root := t.TempDir() | |
| 173 | + | id := "alpha-9" | |
| 174 | + | stateDir := filepath.Join(root, "agents", id) | |
| 175 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | // meta.json | |
| 179 | + | m := agentMeta{ | |
| 180 | + | ID: id, Task: "do thing", Branch: "swarm/alpha-9", | |
| 181 | + | Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour), | |
| 182 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |
| 183 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| 184 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |
| 185 | + | } | |
| 186 | + | mb, _ := json.MarshalIndent(m, "", " ") | |
| 187 | + | if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644); err != nil { | |
| 188 | + | t.Fatal(err) | |
| 189 | + | } | |
| 190 | + | // Event log with one assistant turn and a graceful stop. | |
| 191 | + | log, err := OpenEventLog(m.EventLogPath) | |
| 192 | + | if err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | _ = log.Append(NewEvent("assistant_message", map[string]any{ | |
| 196 | + | "content": []any{map[string]any{"type": "text", "text": "hello from the past"}}, | |
| 197 | + | })) | |
| 198 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"})) | |
| 199 | + | _ = log.Close() | |
| 200 | + | ||
| 201 | + | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 202 | + | loaded, errs := f.Reload() | |
| 203 | + | if len(errs) > 0 || loaded != 1 { | |
| 204 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 205 | + | } | |
| 206 | + | a := f.Get(id) | |
| 207 | + | if a == nil { | |
| 208 | + | t.Fatal("reloaded agent not found") | |
| 209 | + | } | |
| 210 | + | // Status should reflect the lifecycle terminator we wrote. | |
| 211 | + | if a.Status() != StatusDone { | |
| 212 | + | t.Errorf("status = %q; want done (offline)", a.Status()) | |
| 213 | + | } | |
| 214 | + | got := a.Transcript() | |
| 215 | + | found := false | |
| 216 | + | for _, line := range got { | |
| 217 | + | if line == "hello from the past" { | |
| 218 | + | found = true | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | if !found { | |
| 222 | + | t.Errorf("transcript missing replayed line: %v", got) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | |
| 227 | + | // doesn't blow up the whole reload. Directories with no meta.json | |
| 228 | + | // at all are silently ignored (Spawn that failed mid-way leaves | |
| 229 | + | // these); corrupt meta files are reported as errors but don't stop | |
| 230 | + | // the rest of the load. | |
| 231 | + | func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) { | |
| 232 | + | root := t.TempDir() | |
| 233 | + | agentsDir := filepath.Join(root, "agents") | |
| 234 | + | // Bare directory, no meta.json. | |
| 235 | + | if err := os.MkdirAll(filepath.Join(agentsDir, "leftover"), 0o755); err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | // Directory with garbage meta.json. | |
| 239 | + | if err := os.MkdirAll(filepath.Join(agentsDir, "corrupt"), 0o755); err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | if err := os.WriteFile(filepath.Join(agentsDir, "corrupt", "meta.json"), []byte("not json"), 0o644); err != nil { | |
| 243 | + | t.Fatal(err) | |
| 244 | + | } | |
| 245 | + | // One good agent. | |
| 246 | + | good := "good-1" | |
| 247 | + | stateDir := filepath.Join(agentsDir, good) | |
| 248 | + | _ = os.MkdirAll(stateDir, 0o755) | |
| 249 | + | m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()} | |
| 250 | + | mb, _ := json.MarshalIndent(m, "", " ") | |
| 251 | + | _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644) | |
| 252 | + | ||
| 253 | + | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 254 | + | loaded, errs := f.Reload() | |
| 255 | + | if loaded != 1 { | |
| 256 | + | t.Errorf("loaded = %d; want 1", loaded) | |
| 257 | + | } | |
| 258 | + | if len(errs) != 1 { | |
| 259 | + | t.Errorf("errs len = %d; want 1 (the corrupt entry): %v", len(errs), errs) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // TestResumeRestartsRunnerOnSameSession is the headline test. We: | |
| 264 | + | // | |
| 265 | + | // 1. Spawn an agent with a counting runner (records each Run call). | |
| 266 | + | // 2. Stop it. | |
| 267 | + | // 3. Resume it. | |
| 268 | + | // 4. Assert Run was called twice — once for spawn, once for resume — | |
| 269 | + | // against the same SessionPath / InboxPath / Dir. | |
| 270 | + | func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |
| 271 | + | root := t.TempDir() | |
| 272 | + | var ( | |
| 273 | + | mu sync.Mutex | |
| 274 | + | runs []*Agent | |
| 275 | + | runsDone int32 | |
| 276 | + | ) | |
| 277 | + | f := New(Config{ | |
| 278 | + | Root: root, RepoRoot: root, | |
| 279 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 280 | + | NewRunner: func(a *Agent) Runner { | |
| 281 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 282 | + | mu.Lock() | |
| 283 | + | runs = append(runs, a) | |
| 284 | + | mu.Unlock() | |
| 285 | + | sink.Activity("ran") | |
| 286 | + | atomic.AddInt32(&runsDone, 1) | |
| 287 | + | <-ctx.Done() | |
| 288 | + | return ctx.Err() | |
| 289 | + | }) | |
| 290 | + | }, | |
| 291 | + | }) | |
| 292 | + | defer f.StopAll() | |
| 293 | + | ||
| 294 | + | a, err := f.Spawn(context.Background(), "do thing") | |
| 295 | + | if err != nil { | |
| 296 | + | t.Fatalf("spawn: %v", err) | |
| 297 | + | } | |
| 298 | + | originalSession := a.SessionPath | |
| 299 | + | originalDir := a.Dir | |
| 300 | + | originalInbox := a.InboxPath | |
| 301 | + | ||
| 302 | + | // Wait for the runner to record its presence, then stop the agent. | |
| 303 | + | deadline := time.Now().Add(time.Second) | |
| 304 | + | for atomic.LoadInt32(&runsDone) < 1 && time.Now().Before(deadline) { | |
| 305 | + | time.Sleep(2 * time.Millisecond) | |
| 306 | + | } | |
| 307 | + | if err := f.Stop(a.ID); err != nil { | |
| 308 | + | t.Fatalf("stop: %v", err) | |
| 309 | + | } | |
| 310 | + | a.Wait() | |
| 311 | + | if a.Status() != StatusKilled { | |
| 312 | + | t.Fatalf("post-stop status = %q; want killed", a.Status()) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // Now resume. | |
| 316 | + | a2, err := f.Resume(context.Background(), a.ID) | |
| 317 | + | if err != nil { | |
| 318 | + | t.Fatalf("resume: %v", err) | |
| 319 | + | } | |
| 320 | + | if a2.ID != a.ID { | |
| 321 | + | t.Errorf("resume returned different id: %s vs %s", a2.ID, a.ID) | |
| 322 | + | } | |
| 323 | + | if a2.SessionPath != originalSession { | |
| 324 | + | t.Errorf("resume changed session path: %s vs %s", a2.SessionPath, originalSession) | |
| 325 | + | } | |
| 326 | + | if a2.Dir != originalDir { | |
| 327 | + | t.Errorf("resume changed dir: %s vs %s", a2.Dir, originalDir) | |
| 328 | + | } | |
| 329 | + | if a2.InboxPath != originalInbox { | |
| 330 | + | t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox) | |
| 331 | + | } | |
| 332 | + | // Two runner invocations: spawn + resume. | |
| 333 | + | deadline = time.Now().Add(time.Second) | |
| 334 | + | for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) { | |
| 335 | + | time.Sleep(2 * time.Millisecond) | |
| 336 | + | } | |
| 337 | + | mu.Lock() | |
| 338 | + | defer mu.Unlock() | |
| 339 | + | if len(runs) != 2 { | |
| 340 | + | t.Fatalf("runner invocations = %d; want 2", len(runs)) | |
| 341 | + | } | |
| 342 | + | if runs[1].ID != a.ID { | |
| 343 | + | t.Errorf("second run had wrong id: %s", runs[1].ID) | |
| 344 | + | } | |
| 345 | + | if got := len(f.SnapshotAll()); got != 1 { | |
| 346 | + | t.Errorf("snapshot len = %d; want 1 (in-place replace)", got) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // TestResumeSetsResumingFlag pins the contract execRunner relies on: | |
| 351 | + | // after Resume, the new Agent has Resuming == true; after a fresh | |
| 352 | + | // Spawn it's false. The runner branches on this to decide whether | |
| 353 | + | // to pass the original Task as a positional argv to the child, so | |
| 354 | + | // getting it wrong silently re-runs the initial task on every | |
| 355 | + | // resume and surfaces "agent busy; send 'cancel' first" between | |
| 356 | + | // assistant messages. | |
| 357 | + | func TestResumeSetsResumingFlag(t *testing.T) { | |
| 358 | + | root := t.TempDir() | |
| 359 | + | f := New(Config{ | |
| 360 | + | Root: root, RepoRoot: root, | |
| 361 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 362 | + | NewRunner: func(a *Agent) Runner { | |
| 363 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 364 | + | }, | |
| 365 | + | }) | |
| 366 | + | defer f.StopAll() | |
| 367 | + | ||
| 368 | + | a, err := f.Spawn(context.Background(), "x") | |
| 369 | + | if err != nil { | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | if a.Resuming { | |
| 373 | + | t.Fatal("fresh Spawn produced Resuming==true; want false") | |
| 374 | + | } | |
| 375 | + | if err := f.Stop(a.ID); err != nil { | |
| 376 | + | t.Fatal(err) | |
| 377 | + | } | |
| 378 | + | a.Wait() | |
| 379 | + | ||
| 380 | + | a2, err := f.Resume(context.Background(), a.ID) | |
| 381 | + | if err != nil { | |
| 382 | + | t.Fatalf("resume: %v", err) | |
| 383 | + | } | |
| 384 | + | if !a2.Resuming { | |
| 385 | + | t.Error("Resume produced Resuming==false; want true so the runner skips the duplicate initial-task argv") | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // TestResumeRejectsRunningAgent prevents the user from double-running | |
| 390 | + | // an agent: two runners on the same session.json would race. | |
| 391 | + | func TestResumeRejectsRunningAgent(t *testing.T) { | |
| 392 | + | root := t.TempDir() | |
| 393 | + | f := New(Config{ | |
| 394 | + | Root: root, RepoRoot: root, | |
| 395 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 396 | + | NewRunner: func(a *Agent) Runner { | |
| 397 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 398 | + | }, | |
| 399 | + | }) | |
| 400 | + | defer f.StopAll() | |
| 401 | + | a, err := f.Spawn(context.Background(), "x") | |
| 402 | + | if err != nil { | |
| 403 | + | t.Fatal(err) | |
| 404 | + | } | |
| 405 | + | // Wait for the runner to actually start. | |
| 406 | + | deadline := time.Now().Add(time.Second) | |
| 407 | + | for a.Status() != StatusRunning && time.Now().Before(deadline) { | |
| 408 | + | time.Sleep(2 * time.Millisecond) | |
| 409 | + | } | |
| 410 | + | if _, err := f.Resume(context.Background(), a.ID); err == nil { | |
| 411 | + | t.Fatal("resume on running agent did not error") | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // TestResumeAfterReload exercises the full lifecycle the user cares | |
| 416 | + | // about: spawn in process A, exit A, start process B, Reload, Resume. | |
| 417 | + | // The resumed agent must keep its id and start producing new output | |
| 418 | + | // against the same on-disk state. | |
| 419 | + | // | |
| 420 | + | // Note on transcript persistence: in production the runner is | |
| 421 | + | // execRunner, which writes every event to events.jsonl, and Reload's | |
| 422 | + | // replayEventsIntoAgent rebuilds the transcript from there. The fake | |
| 423 | + | // RunnerFunc in this test calls sink.Transcript directly (which only | |
| 424 | + | // touches in-memory state), so the "first run" line is *expected* to | |
| 425 | + | // be lost across the restart — the in-memory Agent went away. We | |
| 426 | + | // have TestReloadReplaysTranscriptFromEventLog covering the | |
| 427 | + | // log-replay path with a curated events.jsonl. | |
| 428 | + | func TestResumeAfterReload(t *testing.T) { | |
| 429 | + | root := t.TempDir() | |
| 430 | + | // Process A | |
| 431 | + | a := New(Config{ | |
| 432 | + | Root: root, RepoRoot: root, | |
| 433 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 434 | + | NewRunner: func(ag *Agent) Runner { | |
| 435 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 436 | + | sink.Transcript("first run for " + ag.ID) | |
| 437 | + | <-ctx.Done() | |
| 438 | + | return ctx.Err() | |
| 439 | + | }) | |
| 440 | + | }, | |
| 441 | + | }) | |
| 442 | + | ag, err := a.Spawn(context.Background(), "x") | |
| 443 | + | if err != nil { | |
| 444 | + | t.Fatal(err) | |
| 445 | + | } | |
| 446 | + | // Let the runner record its transcript line before we tear it down. | |
| 447 | + | deadline := time.Now().Add(time.Second) | |
| 448 | + | for len(ag.Transcript()) == 0 && time.Now().Before(deadline) { | |
| 449 | + | time.Sleep(2 * time.Millisecond) | |
| 450 | + | } | |
| 451 | + | id := ag.ID | |
| 452 | + | a.StopAll() | |
| 453 | + | ag.Wait() | |
| 454 | + | ||
| 455 | + | // Process B: a new Swarm against the same root. | |
| 456 | + | resumed := make(chan struct{}, 1) | |
| 457 | + | b := New(Config{ | |
| 458 | + | Root: root, RepoRoot: root, | |
| 459 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 460 | + | NewRunner: func(ag *Agent) Runner { | |
| 461 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 462 | + | sink.Transcript("second run for " + ag.ID) | |
| 463 | + | select { | |
| 464 | + | case resumed <- struct{}{}: | |
| 465 | + | default: | |
| 466 | + | } | |
| 467 | + | <-ctx.Done() | |
| 468 | + | return ctx.Err() | |
| 469 | + | }) | |
| 470 | + | }, | |
| 471 | + | }) | |
| 472 | + | if loaded, errs := b.Reload(); loaded != 1 || len(errs) > 0 { | |
| 473 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 474 | + | } | |
| 475 | + | a2, err := b.Resume(context.Background(), id) | |
| 476 | + | if err != nil { | |
| 477 | + | t.Fatalf("resume: %v", err) | |
| 478 | + | } | |
| 479 | + | defer b.StopAll() | |
| 480 | + | ||
| 481 | + | select { | |
| 482 | + | case <-resumed: | |
| 483 | + | case <-time.After(time.Second): | |
| 484 | + | t.Fatal("resume runner did not start") | |
| 485 | + | } | |
| 486 | + | if a2.ID != id { | |
| 487 | + | t.Errorf("resume id = %s; want %s", a2.ID, id) | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // Wait for the new transcript line to land. | |
| 491 | + | deadline = time.Now().Add(time.Second) | |
| 492 | + | for time.Now().Before(deadline) { | |
| 493 | + | for _, l := range a2.Transcript() { | |
| 494 | + | if l == "second run for "+id { | |
| 495 | + | return | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | time.Sleep(5 * time.Millisecond) | |
| 499 | + | } | |
| 500 | + | t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript()) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // TestSpawnReqPersistsModel confirms that the per-agent model | |
| 504 | + | // override is captured at Spawn time, surfaced via Snapshot, and | |
| 505 | + | // written to meta.json so a later Reload + Resume reuses it. | |
| 506 | + | func TestSpawnReqPersistsModel(t *testing.T) { | |
| 507 | + | root := t.TempDir() | |
| 508 | + | f := New(Config{ | |
| 509 | + | Root: root, RepoRoot: root, | |
| 510 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 511 | + | NewRunner: func(a *Agent) Runner { | |
| 512 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 513 | + | }, | |
| 514 | + | }) | |
| 515 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | |
| 516 | + | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | |
| 517 | + | }) | |
| 518 | + | if err != nil { | |
| 519 | + | t.Fatalf("spawn: %v", err) | |
| 520 | + | } | |
| 521 | + | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" { | |
| 522 | + | t.Fatalf("agent fields = (%q,%q); want (claude-sonnet-4-5, anthropic)", a.Model, a.Provider) | |
| 523 | + | } | |
| 524 | + | snap := a.Snapshot() | |
| 525 | + | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" { | |
| 526 | + | t.Fatalf("snapshot = (%q,%q); model fields not surfaced", snap.Model, snap.Provider) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | // Stop so we can read meta.json without racing the run loop. | |
| 530 | + | if err := f.Stop(a.ID); err != nil { | |
| 531 | + | t.Fatal(err) | |
| 532 | + | } | |
| 533 | + | a.Wait() | |
| 534 | + | ||
| 535 | + | metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json")) | |
| 536 | + | if err != nil { | |
| 537 | + | t.Fatalf("read meta.json: %v", err) | |
| 538 | + | } | |
| 539 | + | var got agentMeta | |
| 540 | + | if err := json.Unmarshal(metaBytes, &got); err != nil { | |
| 541 | + | t.Fatal(err) | |
| 542 | + | } | |
| 543 | + | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" { | |
| 544 | + | t.Errorf("meta = (%q,%q); want model + provider persisted", got.Model, got.Provider) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // Reload in a fresh Swarm and confirm the detached agent still | |
| 548 | + | // carries the model/provider so Resume can route the child | |
| 549 | + | // subprocess back to the same model. | |
| 550 | + | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 551 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | |
| 552 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 553 | + | } | |
| 554 | + | re := g.Get(a.ID) | |
| 555 | + | if re == nil { | |
| 556 | + | t.Fatal("reloaded agent missing") | |
| 557 | + | } | |
| 558 | + | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" { | |
| 559 | + | t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider) | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that | |
| 564 | + | // connects the supervisor's per-agent model override to the child's | |
| 565 | + | // --model / --provider flag set. Adding a new path to argv without | |
| 566 | + | // updating this assertion is the failure mode this catches. | |
| 567 | + | func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) { | |
| 568 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |
| 569 | + | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | |
| 570 | + | Task: "do x", Model: "gpt-5", Provider: "openai", | |
| 571 | + | }) | |
| 572 | + | want := []string{"--model", "gpt-5", "--provider", "openai"} | |
| 573 | + | for i := 0; i+1 < len(want); i += 2 { | |
| 574 | + | flag, value := want[i], want[i+1] | |
| 575 | + | found := false | |
| 576 | + | for j := 0; j+1 < len(args); j++ { | |
| 577 | + | if args[j] == flag && args[j+1] == value { | |
| 578 | + | found = true | |
| 579 | + | break | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | if !found { | |
| 583 | + | t.Errorf("argv missing %s %s: %v", flag, value, args) | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | // And the task still ends up positional last. | |
| 587 | + | if args[len(args)-1] != "do x" { | |
| 588 | + | t.Errorf("task should be last positional; argv=%v", args) | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the | |
| 593 | + | // segfault that hit when the user pressed 'k' on a reloaded | |
| 594 | + | // (detached) agent: Swarm.Stop unconditionally called a.cancel(), | |
| 595 | + | // but buildDetachedAgent never assigns a cancel func because there's | |
| 596 | + | // no in-process runner to cancel. The fix is to short-circuit Stop | |
| 597 | + | // for StatusDetached and — belt-and-braces — nil-check a.cancel. | |
| 598 | + | func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |
| 599 | + | root := t.TempDir() | |
| 600 | + | // Build a detached agent the same way Reload does: drop a | |
| 601 | + | // meta.json on disk, then ask a fresh Swarm to pick it up. | |
| 602 | + | id := "alpha-1" | |
| 603 | + | stateDir := filepath.Join(root, "agents", id) | |
| 604 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| 605 | + | t.Fatal(err) | |
| 606 | + | } | |
| 607 | + | m := agentMeta{ | |
| 608 | + | ID: id, Task: "t", Branch: "swarm/" + id, | |
| 609 | + | Dir: filepath.Join(root, "wt", id), | |
| 610 | + | Started: time.Now().Add(-time.Hour), | |
| 611 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |
| 612 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| 613 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |
| 614 | + | } | |
| 615 | + | mb, _ := json.MarshalIndent(m, "", " ") | |
| 616 | + | if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644); err != nil { | |
| 617 | + | t.Fatal(err) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 621 | + | if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 { | |
| 622 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 623 | + | } | |
| 624 | + | a := f.Get(id) | |
| 625 | + | if a == nil { | |
| 626 | + | t.Fatal("detached agent missing from swarm") | |
| 627 | + | } | |
| 628 | + | if a.Status() != StatusDetached { | |
| 629 | + | t.Fatalf("setup: agent status = %q; want detached", a.Status()) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // The real assertion: Stop returns cleanly without panicking. | |
| 633 | + | defer func() { | |
| 634 | + | if r := recover(); r != nil { | |
| 635 | + | t.Fatalf("Stop on detached agent panicked: %v", r) | |
| 636 | + | } | |
| 637 | + | }() | |
| 638 | + | if err := f.Stop(id); err != nil { | |
| 639 | + | t.Fatalf("Stop on detached agent: %v", err) | |
| 640 | + | } | |
| 641 | + | // Status must remain Detached (Stop is a no-op). | |
| 642 | + | if got := a.Status(); got != StatusDetached { | |
| 643 | + | t.Errorf("after Stop status = %q; want detached (no-op)", got) | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | // StopAll, which the cli defers on exit, must also be safe. | |
| 647 | + | f.StopAll() | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | // TestRemoveAlsoCleansStateDir ensures Remove deletes the on-disk | |
| 651 | + | // state directory in addition to the worktree, so a removed agent | |
| 652 | + | // doesn't reappear on the next Reload. | |
| 653 | + | func TestRemoveAlsoCleansStateDir(t *testing.T) { | |
| 654 | + | root := t.TempDir() | |
| 655 | + | f := New(Config{ | |
| 656 | + | Root: root, RepoRoot: root, | |
| 657 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 658 | + | NewRunner: func(a *Agent) Runner { | |
| 659 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 660 | + | }, | |
| 661 | + | }) | |
| 662 | + | a, err := f.Spawn(context.Background(), "x") | |
| 663 | + | if err != nil { | |
| 664 | + | t.Fatal(err) | |
| 665 | + | } | |
| 666 | + | stateDir := filepath.Join(root, "agents", a.ID) | |
| 667 | + | if _, err := os.Stat(filepath.Join(stateDir, "meta.json")); err != nil { | |
| 668 | + | t.Fatalf("meta.json missing pre-remove: %v", err) | |
| 669 | + | } | |
| 670 | + | if err := f.Stop(a.ID); err != nil { | |
| 671 | + | t.Fatal(err) | |
| 672 | + | } | |
| 673 | + | a.Wait() | |
| 674 | + | if err := f.Remove(a.ID); err != nil { | |
| 675 | + | t.Fatal(err) | |
| 676 | + | } | |
| 677 | + | if _, err := os.Stat(stateDir); !errors.Is(err, os.ErrNotExist) { | |
| 678 | + | t.Fatalf("state dir should be gone after remove; got %v", err) | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | // A fresh Swarm + Reload should find nothing. | |
| 682 | + | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 683 | + | if loaded, _ := g.Reload(); loaded != 0 { | |
| 684 | + | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | // TestActiveSessionScopesSnapshotAll proves the session filter the | |
| 689 | + | // user asked for: a single Swarm with agents spawned under two | |
| 690 | + | // different active sessions only surfaces the agents matching the | |
| 691 | + | // currently-active session via SnapshotAll. Switching the active | |
| 692 | + | // session re-narrows the view without touching agent state. | |
| 693 | + | func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |
| 694 | + | root := t.TempDir() | |
| 695 | + | f := New(Config{ | |
| 696 | + | Root: root, RepoRoot: root, | |
| 697 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 698 | + | NewRunner: func(a *Agent) Runner { | |
| 699 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 700 | + | }, | |
| 701 | + | }) | |
| 702 | + | ||
| 703 | + | // Spawn one agent under session A and one under session B. | |
| 704 | + | f.SetActiveSession("sess-A") | |
| 705 | + | aA, err := f.Spawn(context.Background(), "task A") | |
| 706 | + | if err != nil { | |
| 707 | + | t.Fatal(err) | |
| 708 | + | } | |
| 709 | + | f.SetActiveSession("sess-B") | |
| 710 | + | aB, err := f.Spawn(context.Background(), "task B") | |
| 711 | + | if err != nil { | |
| 712 | + | t.Fatal(err) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | // Both agents must carry the session id they were spawned under, | |
| 716 | + | // regardless of any later SetActiveSession. | |
| 717 | + | if aA.SessionID != "sess-A" { | |
| 718 | + | t.Errorf("aA.SessionID = %q; want sess-A", aA.SessionID) | |
| 719 | + | } | |
| 720 | + | if aB.SessionID != "sess-B" { | |
| 721 | + | t.Errorf("aB.SessionID = %q; want sess-B", aB.SessionID) | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | // Active session B → only aB visible. | |
| 725 | + | only := snapshotIDs(f.SnapshotAll()) | |
| 726 | + | if len(only) != 1 || only[0] != aB.ID { | |
| 727 | + | t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID) | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | // Switch back to A → only aA visible. | |
| 731 | + | f.SetActiveSession("sess-A") | |
| 732 | + | only = snapshotIDs(f.SnapshotAll()) | |
| 733 | + | if len(only) != 1 || only[0] != aA.ID { | |
| 734 | + | t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID) | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | // Clear the scope → both visible. | |
| 738 | + | f.SetActiveSession("") | |
| 739 | + | all := snapshotIDs(f.SnapshotAll()) | |
| 740 | + | if len(all) != 2 { | |
| 741 | + | t.Errorf("unscoped snapshot ids = %v; want both agents", all) | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | // Cleanup. | |
| 745 | + | _ = f.Stop(aA.ID) | |
| 746 | + | _ = f.Stop(aB.ID) | |
| 747 | + | aA.Wait() | |
| 748 | + | aB.Wait() | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | // TestSessionIDPersistsAcrossReload confirms the SessionID field | |
| 752 | + | // survives a Swarm restart: an agent spawned under session A in one | |
| 753 | + | // Swarm instance is still SessionID="sess-A" after a fresh New + | |
| 754 | + | // Reload reads it back from meta.json. Without persistence, the | |
| 755 | + | // scope filter would forget which session owned each agent after | |
| 756 | + | // a zot restart and the dashboard would leak everything again. | |
| 757 | + | func TestSessionIDPersistsAcrossReload(t *testing.T) { | |
| 758 | + | root := t.TempDir() | |
| 759 | + | mkSwarm := func() *Swarm { | |
| 760 | + | return New(Config{ | |
| 761 | + | Root: root, RepoRoot: root, | |
| 762 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 763 | + | NewRunner: func(a *Agent) Runner { | |
| 764 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 765 | + | }, | |
| 766 | + | }) | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | f := mkSwarm() | |
| 770 | + | f.SetActiveSession("sess-keep") | |
| 771 | + | a, err := f.Spawn(context.Background(), "persist me") | |
| 772 | + | if err != nil { | |
| 773 | + | t.Fatal(err) | |
| 774 | + | } | |
| 775 | + | _ = f.Stop(a.ID) | |
| 776 | + | a.Wait() | |
| 777 | + | ||
| 778 | + | // Fresh Swarm, same root, reload from disk. | |
| 779 | + | g := mkSwarm() | |
| 780 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | |
| 781 | + | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | |
| 782 | + | } | |
| 783 | + | g.SetActiveSession("sess-keep") | |
| 784 | + | got := snapshotIDs(g.SnapshotAll()) | |
| 785 | + | if len(got) != 1 || got[0] != a.ID { | |
| 786 | + | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | // Scope to a different session: agent must be hidden. | |
| 790 | + | g.SetActiveSession("sess-other") | |
| 791 | + | if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 { | |
| 792 | + | t.Errorf("scoped to other session, ids = %v; want []", got) | |
| 793 | + | } | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | // TestEmptySessionIDIsVisibleFromAnyScope pins the backward-compat | |
| 797 | + | // rule: agents whose meta.json predates the SessionID field (or who | |
| 798 | + | // were spawned without an active session, e.g. via a test rig or | |
| 799 | + | // scripted caller) carry SessionID == "" and remain visible from | |
| 800 | + | // every scope. Otherwise the schema bump would orphan every | |
| 801 | + | // pre-existing agent the moment a user upgraded zot. | |
| 802 | + | func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |
| 803 | + | root := t.TempDir() | |
| 804 | + | f := New(Config{ | |
| 805 | + | Root: root, RepoRoot: root, | |
| 806 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 807 | + | NewRunner: func(a *Agent) Runner { | |
| 808 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 809 | + | }, | |
| 810 | + | }) | |
| 811 | + | // No SetActiveSession call → agent spawned with empty SessionID. | |
| 812 | + | a, err := f.Spawn(context.Background(), "legacy") | |
| 813 | + | if err != nil { | |
| 814 | + | t.Fatal(err) | |
| 815 | + | } | |
| 816 | + | if a.SessionID != "" { | |
| 817 | + | t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID) | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | for _, scope := range []string{"", "any-session", "some-other"} { | |
| 821 | + | f.SetActiveSession(scope) | |
| 822 | + | got := snapshotIDs(f.SnapshotAll()) | |
| 823 | + | if len(got) != 1 || got[0] != a.ID { | |
| 824 | + | t.Errorf("scope=%q: ids = %v; want legacy agent visible", scope, got) | |
| 825 | + | } | |
| 826 | + | } | |
| 827 | + | ||
| 828 | + | _ = f.Stop(a.ID) | |
| 829 | + | a.Wait() | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | func snapshotIDs(ss []AgentSnapshot) []string { | |
| 833 | + | out := make([]string, len(ss)) | |
| 834 | + | for i, s := range ss { | |
| 835 | + | out[i] = s.ID | |
| 836 | + | } | |
| 837 | + | return out | |
| 838 | + | } | |
A internal/swarm/runner.go +339 -0
| @@ -0,0 +1,339 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | |
| 17 | + | // the agent's worktree and consumes its JSONL event stream on stdout. | |
| 18 | + | // | |
| 19 | + | // Why a long-lived daemon and not `zot --print`: the supervisor and | |
| 20 | + | // the user expect agents to keep accepting follow-up prompts. A | |
| 21 | + | // one-shot subprocess can't do that; this design gives each swarm | |
| 22 | + | // agent a persistent session file plus an inbox socket the parent | |
| 23 | + | // writes to, mirroring Claude Code's "Agents view" model. | |
| 24 | + | // | |
| 25 | + | // Events flow: | |
| 26 | + | // | |
| 27 | + | // child stdout --> decoder --> EventLog (events.jsonl) | |
| 28 | + | // --> Sink (Activity/Transcript) | |
| 29 | + | // | |
| 30 | + | // The on-disk log is the durable record. The Sink updates are an | |
| 31 | + | // in-memory mirror so the dashboard doesn't have to tail the file | |
| 32 | + | // for the parent's own agents. /swarm open in a separate zot would | |
| 33 | + | // read the log directly. | |
| 34 | + | type execRunner struct { | |
| 35 | + | agent *Agent | |
| 36 | + | ||
| 37 | + | // Command overrides the default `zot --swarm-agent ...` | |
| 38 | + | // invocation. Tests set this to a fake binary (or `go run` | |
| 39 | + | // against a tiny stub program) so the supervisor logic can be | |
| 40 | + | // tested without a real child. Production code leaves it nil. | |
| 41 | + | Command []string | |
| 42 | + | ||
| 43 | + | // SessionPath is the agent's session file. When empty the | |
| 44 | + | // runner derives it as <Dir>/.zot/session.json so each agent | |
| 45 | + | // owns its own session inside its worktree. | |
| 46 | + | SessionPath string | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs | |
| 50 | + | // so future per-agent overrides (e.g. tools, reasoning) can be added | |
| 51 | + | // without churning the signature. The fields map 1:1 onto child CLI | |
| 52 | + | // flags; empty values omit the flag entirely and let the child | |
| 53 | + | // resolve a default the same way a normal `zot` invocation does. | |
| 54 | + | type swarmAgentArgsOpts struct { | |
| 55 | + | Exe string | |
| 56 | + | Dir string | |
| 57 | + | SessionPath string | |
| 58 | + | InboxPath string | |
| 59 | + | Task string | |
| 60 | + | Model string | |
| 61 | + | Provider string | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // defaultChildArgs builds the argv execRunner uses when its Command | |
| 65 | + | // override is empty. Centralised so the spawn-vs-resume decision | |
| 66 | + | // (whether to pass the original Task as a positional) lives in one | |
| 67 | + | // place that tests can hit directly without going through Run's | |
| 68 | + | // side effects. | |
| 69 | + | // | |
| 70 | + | // On Spawn (Resuming==false) we pass the task so the child's first | |
| 71 | + | // turn runs immediately. On Resume (Resuming==true) we omit it: the | |
| 72 | + | // child reopens the existing session file, loads the prior | |
| 73 | + | // conversation, and just waits on the inbox for the next prompt. | |
| 74 | + | // Re-firing the task on every resume produces a duplicate turn that | |
| 75 | + | // collides with whatever the user types next, surfacing the | |
| 76 | + | // "agent busy; send 'cancel' first" error between two assistant | |
| 77 | + | // messages — which is exactly the bug this helper fixes. | |
| 78 | + | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | |
| 79 | + | task := a.Task | |
| 80 | + | if a.Resuming { | |
| 81 | + | task = "" | |
| 82 | + | } | |
| 83 | + | return swarmAgentArgs(swarmAgentArgsOpts{ | |
| 84 | + | Exe: exe, | |
| 85 | + | Dir: a.Dir, | |
| 86 | + | SessionPath: sessionPath, | |
| 87 | + | InboxPath: inboxPath, | |
| 88 | + | Task: task, | |
| 89 | + | Model: a.Model, | |
| 90 | + | Provider: a.Provider, | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // swarmAgentArgs builds the argv used when execRunner.Command is | |
| 95 | + | // empty. Pulled out so tests can lock in the flag set without | |
| 96 | + | // actually spawning a subprocess. | |
| 97 | + | func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |
| 98 | + | args := []string{ | |
| 99 | + | opts.Exe, | |
| 100 | + | "--swarm-agent", opts.InboxPath, | |
| 101 | + | "--session", opts.SessionPath, | |
| 102 | + | "--cwd", opts.Dir, | |
| 103 | + | } | |
| 104 | + | if opts.Model != "" { | |
| 105 | + | args = append(args, "--model", opts.Model) | |
| 106 | + | } | |
| 107 | + | if opts.Provider != "" { | |
| 108 | + | args = append(args, "--provider", opts.Provider) | |
| 109 | + | } | |
| 110 | + | if opts.Task != "" { | |
| 111 | + | // First task is positional so the child treats it as the | |
| 112 | + | // initial user turn; subsequent turns arrive via the inbox. | |
| 113 | + | args = append(args, opts.Task) | |
| 114 | + | } | |
| 115 | + | return args | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |
| 119 | + | sessionPath := r.SessionPath | |
| 120 | + | if sessionPath == "" { | |
| 121 | + | sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json") | |
| 122 | + | } | |
| 123 | + | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | |
| 124 | + | return fmt.Errorf("session dir: %w", err) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | inboxPath := r.agent.InboxPath | |
| 128 | + | logPath := r.agent.EventLogPath | |
| 129 | + | if logPath == "" { | |
| 130 | + | return fmt.Errorf("swarm: agent missing event log path") | |
| 131 | + | } | |
| 132 | + | log, err := OpenEventLog(logPath) | |
| 133 | + | if err != nil { | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | defer log.Close() | |
| 137 | + | ||
| 138 | + | args := r.Command | |
| 139 | + | if len(args) == 0 { | |
| 140 | + | exe, err := os.Executable() | |
| 141 | + | if err != nil { | |
| 142 | + | return fmt.Errorf("locate self: %w", err) | |
| 143 | + | } | |
| 144 | + | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | cmd := exec.CommandContext(ctx, args[0], args[1:]...) | |
| 148 | + | cmd.Dir = r.agent.Dir | |
| 149 | + | cmd.Env = append(os.Environ(), | |
| 150 | + | "ZOT_SWARM_AGENT_ID="+r.agent.ID, | |
| 151 | + | "ZOT_SWARM_EVENT_LOG="+logPath, | |
| 152 | + | ) | |
| 153 | + | ||
| 154 | + | stdout, err := cmd.StdoutPipe() | |
| 155 | + | if err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | stderr, err := cmd.StderrPipe() | |
| 159 | + | if err != nil { | |
| 160 | + | return err | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // "spawning" is briefly shown until the first event arrives; | |
| 164 | + | // the child's "spawned" lifecycle event then overwrites it. | |
| 165 | + | sink.Activity("starting") | |
| 166 | + | if err := cmd.Start(); err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // stdout: parsed as JSONL. Every well-formed event is appended | |
| 171 | + | // to the durable log AND forwarded to the in-memory sink so the | |
| 172 | + | // dashboard updates without having to tail the file. Malformed | |
| 173 | + | // lines are surfaced as plain transcript so they don't vanish. | |
| 174 | + | done := make(chan struct{}, 2) | |
| 175 | + | go func() { | |
| 176 | + | defer func() { done <- struct{}{} }() | |
| 177 | + | dec := bufio.NewReader(stdout) | |
| 178 | + | for { | |
| 179 | + | line, err := dec.ReadBytes('\n') | |
| 180 | + | if len(line) > 0 { | |
| 181 | + | trimmed := strings.TrimRight(string(line), "\r\n") | |
| 182 | + | if trimmed == "" { | |
| 183 | + | goto next | |
| 184 | + | } | |
| 185 | + | if ev, ok := parseEventLine(trimmed); ok { | |
| 186 | + | _ = log.Append(ev) | |
| 187 | + | applyEventToSink(ev, sink) | |
| 188 | + | } else { | |
| 189 | + | // Non-JSON output. Keep it as transcript so an | |
| 190 | + | // accidental fmt.Println in the child still | |
| 191 | + | // shows up in the dashboard, and log a | |
| 192 | + | // lifecycle event so the durable record stays | |
| 193 | + | // in sync. | |
| 194 | + | sink.Transcript(trimmed) | |
| 195 | + | _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed})) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | next: | |
| 199 | + | if err != nil { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | }() | |
| 204 | + | ||
| 205 | + | // stderr: lifecycle/error chatter from the child. Every line | |
| 206 | + | // is mirrored as a stderr event in the durable log AND surfaced | |
| 207 | + | // in the transcript so users can diagnose a failing agent | |
| 208 | + | // without leaving the dashboard. | |
| 209 | + | go func() { | |
| 210 | + | defer func() { done <- struct{}{} }() | |
| 211 | + | br := bufio.NewReader(stderr) | |
| 212 | + | for { | |
| 213 | + | line, err := br.ReadString('\n') | |
| 214 | + | if line != "" { | |
| 215 | + | txt := strings.TrimRight(line, "\r\n") | |
| 216 | + | sink.Transcript("stderr: " + txt) | |
| 217 | + | _ = log.Append(NewEvent("stderr", map[string]any{"text": txt})) | |
| 218 | + | } | |
| 219 | + | if err != nil { | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | }() | |
| 224 | + | ||
| 225 | + | <-done | |
| 226 | + | <-done | |
| 227 | + | ||
| 228 | + | err = cmd.Wait() | |
| 229 | + | exit := 0 | |
| 230 | + | if ee, ok := err.(*exec.ExitError); ok { | |
| 231 | + | exit = ee.ExitCode() | |
| 232 | + | } | |
| 233 | + | if err != nil && ctx.Err() != nil { | |
| 234 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})) | |
| 235 | + | return ctx.Err() | |
| 236 | + | } | |
| 237 | + | if err != nil { | |
| 238 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |
| 239 | + | return err | |
| 240 | + | } | |
| 241 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |
| 242 | + | sink.Activity("done") | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // parseEventLine attempts to decode one JSONL line as an Event. | |
| 247 | + | // Returns ok=false for non-JSON or JSON without a "type" field. | |
| 248 | + | func parseEventLine(line string) (Event, bool) { | |
| 249 | + | if len(line) == 0 || line[0] != '{' { | |
| 250 | + | return Event{}, false | |
| 251 | + | } | |
| 252 | + | var ev Event | |
| 253 | + | if err := json.Unmarshal([]byte(line), &ev); err != nil { | |
| 254 | + | return Event{}, false | |
| 255 | + | } | |
| 256 | + | if ev.Type == "" { | |
| 257 | + | return Event{}, false | |
| 258 | + | } | |
| 259 | + | if ev.Time.IsZero() { | |
| 260 | + | ev.Time = time.Now() | |
| 261 | + | } | |
| 262 | + | return ev, true | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // applyEventToSink translates an Event into Sink updates. Only a | |
| 266 | + | // few event types are interpreted; the rest still land in the | |
| 267 | + | // durable log via the caller. | |
| 268 | + | func applyEventToSink(ev Event, sink Sink) { | |
| 269 | + | switch ev.Type { | |
| 270 | + | case "assistant_message": | |
| 271 | + | if c, ok := ev.Data["content"].([]any); ok { | |
| 272 | + | for _, blk := range c { | |
| 273 | + | m, _ := blk.(map[string]any) | |
| 274 | + | if t, _ := m["type"].(string); t == "text" { | |
| 275 | + | if txt, _ := m["text"].(string); txt != "" { | |
| 276 | + | sink.Transcript(txt) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | sink.Activity("idle") | |
| 282 | + | case "user_message": | |
| 283 | + | if c, ok := ev.Data["content"].([]any); ok { | |
| 284 | + | for _, blk := range c { | |
| 285 | + | m, _ := blk.(map[string]any) | |
| 286 | + | if t, _ := m["type"].(string); t == "text" { | |
| 287 | + | if txt, _ := m["text"].(string); txt != "" { | |
| 288 | + | sink.Transcript("user: " + txt) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | case "turn_start": | |
| 294 | + | sink.Activity("thinking") | |
| 295 | + | case "tool_call": | |
| 296 | + | if name, _ := ev.Data["name"].(string); name != "" { | |
| 297 | + | sink.Activity("tool: " + truncate(name, 60)) | |
| 298 | + | } | |
| 299 | + | case "tool_result": | |
| 300 | + | sink.Activity("idle") | |
| 301 | + | case "turn_end": | |
| 302 | + | sink.Activity("idle") | |
| 303 | + | case "agent_ready": | |
| 304 | + | sink.Activity("idle") | |
| 305 | + | case "agent_stopped": | |
| 306 | + | // terminal status is decided by Swarm.run from the runner's | |
| 307 | + | // return value, not from this event. Don't overwrite the | |
| 308 | + | // activity here. | |
| 309 | + | case "error": | |
| 310 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |
| 311 | + | sink.Transcript("error: " + msg) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // RunnerFunc adapts a plain function into a Runner. Useful for tests | |
| 317 | + | // and for callers who don't need their own type. | |
| 318 | + | type RunnerFunc func(ctx context.Context, sink Sink) error | |
| 319 | + | ||
| 320 | + | func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) } | |
| 321 | + | ||
| 322 | + | // streamLines kept around for any caller still using it directly. | |
| 323 | + | // | |
| 324 | + | // Deprecated: the runner now parses JSONL from stdout via | |
| 325 | + | // parseEventLine; this helper is unused inside the package but | |
| 326 | + | // remains exported via internal use by tests in the runner_test | |
| 327 | + | // suite that pre-date the daemon switch. | |
| 328 | + | func streamLines(r io.Reader, fn func(string)) { | |
| 329 | + | br := bufio.NewReader(r) | |
| 330 | + | for { | |
| 331 | + | line, err := br.ReadString('\n') | |
| 332 | + | if line != "" { | |
| 333 | + | fn(strings.TrimRight(line, "\r\n")) | |
| 334 | + | } | |
| 335 | + | if err != nil { | |
| 336 | + | return | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | } | |
A internal/swarm/runner_e2e_test.go +194 -0
| @@ -0,0 +1,194 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "runtime" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // TestRunnerEndToEndWithStubChild is the integration test for the | |
| 16 | + | // new daemon-mode runner. It compiles the stubchild binary in | |
| 17 | + | // testdata/cmd/stubchild, points an execRunner at it, and drives | |
| 18 | + | // a Swarm through one Spawn + one SendUserTurn + Stop cycle. | |
| 19 | + | // | |
| 20 | + | // What this proves: | |
| 21 | + | // | |
| 22 | + | // - The default argv shape (swarmAgentArgs) is one the child can | |
| 23 | + | // actually parse — locks the shape against silent breakage. | |
| 24 | + | // - The stdout JSONL parser ingests events and writes them to the | |
| 25 | + | // durable log. | |
| 26 | + | // - applyEventToSink turns events into Activity / Transcript | |
| 27 | + | // updates the dashboard reads. | |
| 28 | + | // - The supervisor inbox dials the child's socket and a follow-up | |
| 29 | + | // line round-trips back as a user_message event. | |
| 30 | + | // - Stop closes the inbox AND cancels the child's context so the | |
| 31 | + | // stub exits cleanly. | |
| 32 | + | // | |
| 33 | + | // The test is skipped on platforms without unix sockets (Windows). | |
| 34 | + | func TestRunnerEndToEndWithStubChild(t *testing.T) { | |
| 35 | + | if runtime.GOOS == "windows" { | |
| 36 | + | t.Skip("unix sockets not supported") | |
| 37 | + | } | |
| 38 | + | if testing.Short() { | |
| 39 | + | t.Skip("skip end-to-end runner test in -short mode") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | exe := buildStubChild(t) | |
| 43 | + | ||
| 44 | + | root := t.TempDir() | |
| 45 | + | repo := t.TempDir() | |
| 46 | + | f := New(Config{ | |
| 47 | + | Root: root, | |
| 48 | + | RepoRoot: repo, | |
| 49 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 50 | + | NewRunner: func(a *Agent) Runner { | |
| 51 | + | return &execRunner{ | |
| 52 | + | agent: a, | |
| 53 | + | Command: swarmAgentArgs(swarmAgentArgsOpts{ | |
| 54 | + | Exe: exe, | |
| 55 | + | Dir: a.Dir, | |
| 56 | + | SessionPath: a.SessionPath, | |
| 57 | + | InboxPath: a.InboxPath, | |
| 58 | + | Task: a.Task, | |
| 59 | + | Model: a.Model, | |
| 60 | + | Provider: a.Provider, | |
| 61 | + | }), | |
| 62 | + | } | |
| 63 | + | }, | |
| 64 | + | }) | |
| 65 | + | defer f.StopAll() | |
| 66 | + | ||
| 67 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 68 | + | defer cancel() | |
| 69 | + | ||
| 70 | + | a, err := f.Spawn(ctx, "first task") | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("Spawn: %v", err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Wait until the durable log has at least one assistant_message | |
| 76 | + | // from the initial task. That confirms stdout→log→follower. | |
| 77 | + | waitFor := func(want string) { | |
| 78 | + | t.Helper() | |
| 79 | + | deadline := time.Now().Add(5 * time.Second) | |
| 80 | + | for time.Now().Before(deadline) { | |
| 81 | + | evs, _ := ReadEventLog(a.EventLogPath) | |
| 82 | + | for _, ev := range evs { | |
| 83 | + | if strings.Contains(eventText(ev), want) { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | time.Sleep(20 * time.Millisecond) | |
| 88 | + | } | |
| 89 | + | evs, _ := ReadEventLog(a.EventLogPath) | |
| 90 | + | t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s", | |
| 91 | + | want, len(evs), formatEvents(evs), dumpEventsVerbose(evs)) | |
| 92 | + | } | |
| 93 | + | waitFor("echo: first task") | |
| 94 | + | ||
| 95 | + | // Send a follow-up over the inbox. The stub echoes the text into | |
| 96 | + | // an assistant_message we can poll for in the log. | |
| 97 | + | if err := retrySend(f, a.ID, "user follow up", time.Second); err != nil { | |
| 98 | + | t.Fatalf("SendInput: %v", err) | |
| 99 | + | } | |
| 100 | + | waitFor("echo: follow up") | |
| 101 | + | ||
| 102 | + | // Shut the agent down gracefully via the inbox. | |
| 103 | + | if err := f.SendInput(a.ID, "shutdown"); err != nil && !errors.Is(err, ErrNotReady) { | |
| 104 | + | t.Fatalf("shutdown send: %v", err) | |
| 105 | + | } | |
| 106 | + | a.Wait() | |
| 107 | + | if got := a.Status(); got != StatusDone && got != StatusKilled { | |
| 108 | + | t.Fatalf("final status = %s; want done/killed", got) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // retrySend exists because the inbox dial races against the child | |
| 113 | + | // opening the socket. Production callers handle ErrNotReady with a | |
| 114 | + | // status message; tests retry within a small window. | |
| 115 | + | func retrySend(f *Swarm, id, msg string, timeout time.Duration) error { | |
| 116 | + | deadline := time.Now().Add(timeout) | |
| 117 | + | var lastErr error | |
| 118 | + | for time.Now().Before(deadline) { | |
| 119 | + | err := f.SendInput(id, msg) | |
| 120 | + | if err == nil { | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | lastErr = err | |
| 124 | + | if !errors.Is(err, ErrNotReady) { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | time.Sleep(30 * time.Millisecond) | |
| 128 | + | } | |
| 129 | + | return lastErr | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func eventText(ev Event) string { | |
| 133 | + | if ev.Type != "assistant_message" && ev.Type != "user_message" { | |
| 134 | + | return "" | |
| 135 | + | } | |
| 136 | + | content, _ := ev.Data["content"].([]any) | |
| 137 | + | var sb strings.Builder | |
| 138 | + | for _, c := range content { | |
| 139 | + | m, _ := c.(map[string]any) | |
| 140 | + | if t, _ := m["type"].(string); t == "text" { | |
| 141 | + | if txt, _ := m["text"].(string); txt != "" { | |
| 142 | + | sb.WriteString(txt) | |
| 143 | + | sb.WriteByte('\n') | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | return sb.String() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func dumpEventsVerbose(evs []Event) string { | |
| 151 | + | var sb strings.Builder | |
| 152 | + | for _, ev := range evs { | |
| 153 | + | sb.WriteString(ev.Type) | |
| 154 | + | sb.WriteString("\t") | |
| 155 | + | for k, v := range ev.Data { | |
| 156 | + | sb.WriteString(k) | |
| 157 | + | sb.WriteString("=") | |
| 158 | + | switch vv := v.(type) { | |
| 159 | + | case string: | |
| 160 | + | sb.WriteString(vv) | |
| 161 | + | default: | |
| 162 | + | sb.WriteString("<...>") | |
| 163 | + | } | |
| 164 | + | sb.WriteString(" ") | |
| 165 | + | } | |
| 166 | + | sb.WriteString("\n") | |
| 167 | + | } | |
| 168 | + | return sb.String() | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func formatEvents(evs []Event) string { | |
| 172 | + | var sb strings.Builder | |
| 173 | + | for _, ev := range evs { | |
| 174 | + | sb.WriteString(ev.Type) | |
| 175 | + | sb.WriteString(" ") | |
| 176 | + | sb.WriteString(ev.Time.Format(time.RFC3339Nano)) | |
| 177 | + | sb.WriteString("\n") | |
| 178 | + | } | |
| 179 | + | return sb.String() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func buildStubChild(t *testing.T) string { | |
| 183 | + | t.Helper() | |
| 184 | + | out := filepath.Join(t.TempDir(), "stubchild") | |
| 185 | + | cmd := exec.Command("go", "build", "-o", out, "./testdata/cmd/stubchild") | |
| 186 | + | // Pass through the test runner's env so `go build` can find | |
| 187 | + | // HOME, PATH, GOCACHE, etc. CGO is disabled to keep the build | |
| 188 | + | // hermetic across machines. | |
| 189 | + | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | |
| 190 | + | if b, err := cmd.CombinedOutput(); err != nil { | |
| 191 | + | t.Fatalf("build stubchild: %v\n%s", err, b) | |
| 192 | + | } | |
| 193 | + | return out | |
| 194 | + | } | |
A internal/swarm/runner_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // TestSwarmAgentArgs locks in the exact flag set the subprocess | |
| 9 | + | // runner uses to start a swarm agent in daemon mode. Past | |
| 10 | + | // regressions in this area: | |
| 11 | + | // | |
| 12 | + | // - "--no-sess" instead of "--no-session" (old print-mode runner): | |
| 13 | + | // every spawned agent died with "unknown flag" before it could | |
| 14 | + | // talk to the model. | |
| 15 | + | // | |
| 16 | + | // - Forgetting --cwd: the child resolved tools against the parent | |
| 17 | + | // zot's working directory, defeating the whole point of the | |
| 18 | + | // worktree isolation. | |
| 19 | + | // | |
| 20 | + | // - Forgetting --session: a daemon-mode agent without a session | |
| 21 | + | // file would lose context between follow-up turns, making | |
| 22 | + | // "send another message" mostly useless. | |
| 23 | + | // | |
| 24 | + | // The test asserts the load-bearing pieces are present in plausible | |
| 25 | + | // positions. If a flag is renamed, update both the runner and this | |
| 26 | + | // test so we notice immediately. | |
| 27 | + | func TestSwarmAgentArgs(t *testing.T) { | |
| 28 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |
| 29 | + | Exe: "/path/to/zot", | |
| 30 | + | Dir: "/tmp/worktree", | |
| 31 | + | SessionPath: "/tmp/state/session.json", | |
| 32 | + | InboxPath: "/tmp/state/in.sock", | |
| 33 | + | Task: "do the thing", | |
| 34 | + | }) | |
| 35 | + | if len(args) < 7 { | |
| 36 | + | t.Fatalf("argv unexpectedly short: %v", args) | |
| 37 | + | } | |
| 38 | + | if args[0] != "/path/to/zot" { | |
| 39 | + | t.Fatalf("argv[0] = %q; want the binary path", args[0]) | |
| 40 | + | } | |
| 41 | + | // The task must come last so anything that looks flag-like in | |
| 42 | + | // the task body doesn't get interpreted as a flag. | |
| 43 | + | if args[len(args)-1] != "do the thing" { | |
| 44 | + | t.Fatalf("task should be last positional; got %v", args) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | mustHave := map[string]string{ | |
| 48 | + | "--swarm-agent": "/tmp/state/in.sock", | |
| 49 | + | "--session": "/tmp/state/session.json", | |
| 50 | + | "--cwd": "/tmp/worktree", | |
| 51 | + | } | |
| 52 | + | for flag, value := range mustHave { | |
| 53 | + | i := indexOf(args, flag) | |
| 54 | + | if i < 0 { | |
| 55 | + | t.Errorf("argv missing %q: %v", flag, args) | |
| 56 | + | continue | |
| 57 | + | } | |
| 58 | + | if i+1 >= len(args) || args[i+1] != value { | |
| 59 | + | t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Reject prior bad flags explicitly so a future revert is caught. | |
| 64 | + | joined := strings.Join(args, " ") | |
| 65 | + | for _, bad := range []string{"--print", "--no-sess ", "--no-session"} { | |
| 66 | + | if strings.Contains(joined, bad) { | |
| 67 | + | t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the | |
| 73 | + | // agent is being adopted (no fresh task) we don't pass an empty | |
| 74 | + | // positional which the arg parser would treat as a real prompt. | |
| 75 | + | func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) { | |
| 76 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |
| 77 | + | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | |
| 78 | + | }) | |
| 79 | + | for _, a := range args { | |
| 80 | + | if a == "" { | |
| 81 | + | t.Fatalf("argv contains an empty positional: %v", args) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | // last arg should be a real flag value, not a stray positional | |
| 85 | + | if a := args[len(args)-1]; strings.HasPrefix(a, "--") { | |
| 86 | + | t.Fatalf("argv ends on a flag with no value: %v", args) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a | |
| 91 | + | // fresh (non-resuming) Agent produces argv that ends with the | |
| 92 | + | // original task as a positional, so the child runs it as the | |
| 93 | + | // initial user turn. | |
| 94 | + | func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) { | |
| 95 | + | a := &Agent{Dir: "/wt", Task: "do thing"} | |
| 96 | + | args := defaultChildArgs("/zot", a, "/s.json", "/in.sock") | |
| 97 | + | if got := args[len(args)-1]; got != "do thing" { | |
| 98 | + | t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // TestDefaultChildArgsResumeOmitsTask is the regression for the | |
| 103 | + | // "agent busy; send 'cancel' first" error: when an Agent is being | |
| 104 | + | // resumed (Resuming==true), the child argv MUST NOT include the | |
| 105 | + | // original Task as a positional. Otherwise the child fires the task | |
| 106 | + | // as a fresh user turn on every resume, racing with whatever the | |
| 107 | + | // user types next via the inbox. | |
| 108 | + | func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |
| 109 | + | a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true} | |
| 110 | + | args := defaultChildArgs("/zot", a, "/s.json", "/in.sock") | |
| 111 | + | for _, v := range args { | |
| 112 | + | if v == "do thing" { | |
| 113 | + | t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | // And no trailing positional at all: the last arg should be a | |
| 117 | + | // flag value, not a stray empty string. | |
| 118 | + | if got := args[len(args)-1]; got == "" { | |
| 119 | + | t.Fatalf("resume argv ends with an empty positional: %v", args) | |
| 120 | + | } | |
| 121 | + | if strings.HasPrefix(args[len(args)-1], "--") { | |
| 122 | + | t.Fatalf("resume argv ends on a bare flag (no value): %v", args) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func indexOf(xs []string, want string) int { | |
| 127 | + | for i, x := range xs { | |
| 128 | + | if x == want { | |
| 129 | + | return i | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return -1 | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func safeAt(xs []string, i int) string { | |
| 136 | + | if i < 0 || i >= len(xs) { | |
| 137 | + | return "" | |
| 138 | + | } | |
| 139 | + | return xs[i] | |
| 140 | + | } | |
A internal/swarm/socketpath.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha1" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "runtime" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // maxUnixSocketPath is the conservative platform-portable path limit | |
| 13 | + | // for unix sockets. macOS allows 104, linux 108 (including the NUL | |
| 14 | + | // terminator). We pick 100 so the path itself plus a small filename | |
| 15 | + | // tail stays under both caps with a safety margin. | |
| 16 | + | const maxUnixSocketPath = 100 | |
| 17 | + | ||
| 18 | + | // inboxSocketPath returns a per-agent unix-socket path that's short | |
| 19 | + | // enough to actually work (see maxUnixSocketPath) and unique per | |
| 20 | + | // swarm root so two zot instances on the same machine don't collide. | |
| 21 | + | // | |
| 22 | + | // Strategy: | |
| 23 | + | // | |
| 24 | + | // 1. Try <root>/agents/<id>/in.sock. This is the obvious place and | |
| 25 | + | // puts everything next to the durable state; on most setups it | |
| 26 | + | // fits. | |
| 27 | + | // 2. If that's too long, fall back to <tmp>/zot-swarm-<roothash>/<id>.sock. | |
| 28 | + | // We hash root rather than embedding it so the tmp directory name | |
| 29 | + | // stays short. SHA-1's first 8 hex chars is plenty: collisions | |
| 30 | + | // only matter within a single user's tmp dir and we already | |
| 31 | + | // create a dedicated subdir. | |
| 32 | + | // 3. If even /tmp is somehow too long (chroots, containers), give | |
| 33 | + | // up with a clear error so the caller surfaces it instead of | |
| 34 | + | // leaving the user wondering why follow-ups don't work. | |
| 35 | + | func inboxSocketPath(root, agentID string) (string, error) { | |
| 36 | + | primary := filepath.Join(root, "agents", agentID, "in.sock") | |
| 37 | + | if len(primary) <= maxUnixSocketPath { | |
| 38 | + | return primary, nil | |
| 39 | + | } | |
| 40 | + | tmp := os.TempDir() | |
| 41 | + | dir := filepath.Join(tmp, "zot-swarm-"+rootTag(root)) | |
| 42 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 43 | + | return "", fmt.Errorf("socket tmp dir: %w", err) | |
| 44 | + | } | |
| 45 | + | candidate := filepath.Join(dir, agentID+".sock") | |
| 46 | + | if len(candidate) <= maxUnixSocketPath { | |
| 47 | + | return candidate, nil | |
| 48 | + | } | |
| 49 | + | // Last-resort: use just the short hash of the id so even very long | |
| 50 | + | // task slugs fit. We surface the original id in the meta.json / | |
| 51 | + | // events log; the socket path is purely transport. | |
| 52 | + | short := shortHash(agentID) | |
| 53 | + | candidate = filepath.Join(dir, short+".sock") | |
| 54 | + | if len(candidate) <= maxUnixSocketPath { | |
| 55 | + | return candidate, nil | |
| 56 | + | } | |
| 57 | + | return "", fmt.Errorf("unix socket path too long even after shortening (%s, %d > %d, GOOS=%s)", | |
| 58 | + | candidate, len(candidate), maxUnixSocketPath, runtime.GOOS) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // rootTag returns a stable 8-hex-char tag for the swarm root. Used | |
| 62 | + | // in the tmp-dir name so two parallel zot instances with different | |
| 63 | + | // roots don't share sockets. | |
| 64 | + | func rootTag(root string) string { return shortHash(root) } | |
| 65 | + | ||
| 66 | + | func shortHash(s string) string { | |
| 67 | + | sum := sha1.Sum([]byte(s)) | |
| 68 | + | return hex.EncodeToString(sum[:4]) | |
| 69 | + | } | |
A internal/swarm/swarm.go +527 -0
| @@ -0,0 +1,527 @@ | |||
| 1 | + | // Package swarm implements zot's multi-agent supervisor. | |
| 2 | + | // | |
| 3 | + | // A Swarm manages a set of headless zot subprocesses ("agents") | |
| 4 | + | // working in their own git worktrees. The interactive TUI exposes | |
| 5 | + | // the supervisor through the /swarm slash command and a dashboard | |
| 6 | + | // dialog; non-TUI code can drive it directly through this package. | |
| 7 | + | // | |
| 8 | + | // Each Agent has: | |
| 9 | + | // - a unique id (short slug + nanoseconds) | |
| 10 | + | // - a branch name on a fresh git worktree under <root>/swarm/<id> | |
| 11 | + | // - a Runner (the thing that actually executes the task) | |
| 12 | + | // - a Status string + Activity string that the dashboard reads | |
| 13 | + | // | |
| 14 | + | // The Runner abstraction means tests can swap a fake in instead of | |
| 15 | + | // really spawning a subprocess; the production Runner shells out to | |
| 16 | + | // `zot --print` so we reuse zot's own model resolution and tooling | |
| 17 | + | // without re-implementing the agent loop. | |
| 18 | + | package swarm | |
| 19 | + | ||
| 20 | + | import ( | |
| 21 | + | "context" | |
| 22 | + | "errors" | |
| 23 | + | "fmt" | |
| 24 | + | "os" | |
| 25 | + | "path/filepath" | |
| 26 | + | "sort" | |
| 27 | + | "strings" | |
| 28 | + | "sync" | |
| 29 | + | "time" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Status is the high-level lifecycle state of an Agent. | |
| 33 | + | type Status string | |
| 34 | + | ||
| 35 | + | const ( | |
| 36 | + | StatusPending Status = "pending" // created, not started yet | |
| 37 | + | StatusRunning Status = "running" // Runner.Run is in flight | |
| 38 | + | StatusDone Status = "done" // Runner.Run returned nil | |
| 39 | + | StatusFailed Status = "failed" // Runner.Run returned an error | |
| 40 | + | StatusKilled Status = "killed" // Stop() called before completion | |
| 41 | + | StatusDetached Status = "detached" // reloaded from disk; no live runner | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // Config configures a Swarm. | |
| 45 | + | type Config struct { | |
| 46 | + | // Root is the directory under which worktrees + state files live. | |
| 47 | + | // Typically <ZotHome>/swarm, but tests pass a tempdir. | |
| 48 | + | Root string | |
| 49 | + | ||
| 50 | + | // RepoRoot is the path to the user's git repo (CWD of the parent | |
| 51 | + | // zot). New worktrees branch off this repo. | |
| 52 | + | RepoRoot string | |
| 53 | + | ||
| 54 | + | // Worktree creates the per-agent working directory. If nil, the | |
| 55 | + | // default git-worktree implementation is used. | |
| 56 | + | Worktree WorktreeManager | |
| 57 | + | ||
| 58 | + | // NewRunner produces the Runner for an Agent. If nil, the default | |
| 59 | + | // `zot --print` exec runner is used. Tests inject a fake here. | |
| 60 | + | NewRunner func(a *Agent) Runner | |
| 61 | + | ||
| 62 | + | // Now is a clock seam for tests; defaults to time.Now. | |
| 63 | + | Now func() time.Time | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // WorktreeManager creates and removes per-agent working directories. | |
| 67 | + | type WorktreeManager interface { | |
| 68 | + | Create(id, branch, base string) (dir string, err error) | |
| 69 | + | Remove(id, dir string) error | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Runner executes one agent task. Run blocks until the task finishes, | |
| 73 | + | // is cancelled via ctx, or hits an unrecoverable error. | |
| 74 | + | // | |
| 75 | + | // Run should report progress by writing short human-readable strings | |
| 76 | + | // to the activity channel and final transcript text to transcript. | |
| 77 | + | // Both channels are non-blocking sinks owned by the Swarm; if the | |
| 78 | + | // dashboard isn't reading, sends are dropped. | |
| 79 | + | type Runner interface { | |
| 80 | + | Run(ctx context.Context, sink Sink) error | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Sink is how a Runner reports activity and transcript back to the | |
| 84 | + | // supervisor. All methods are safe to call from any goroutine and | |
| 85 | + | // never block. | |
| 86 | + | type Sink interface { | |
| 87 | + | // Activity sets the one-line "what is this agent doing right now" | |
| 88 | + | // string shown in the dashboard. | |
| 89 | + | Activity(msg string) | |
| 90 | + | // Transcript appends a chunk of agent output (typically a final | |
| 91 | + | // assistant message) to the agent's running transcript. | |
| 92 | + | Transcript(chunk string) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Swarm supervises a set of Agents. | |
| 96 | + | type Swarm struct { | |
| 97 | + | cfg Config | |
| 98 | + | ||
| 99 | + | mu sync.Mutex | |
| 100 | + | agents map[string]*Agent | |
| 101 | + | order []string // creation order for stable listing | |
| 102 | + | ||
| 103 | + | // activeSession is the host session id the dashboard is | |
| 104 | + | // currently scoped to. When non-empty, SnapshotAll filters out | |
| 105 | + | // agents whose SessionID doesn't match (legacy / unscoped | |
| 106 | + | // agents with SessionID == "" are always shown). Spawn stamps | |
| 107 | + | // new agents with this value so they appear only in the session | |
| 108 | + | // that created them. When empty (the default), the historical | |
| 109 | + | // "show everything" behaviour is preserved — important for | |
| 110 | + | // tests and any scripted use of the Swarm that doesn't bother | |
| 111 | + | // with sessions. | |
| 112 | + | activeSession string | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // New constructs a Swarm from cfg. Missing config fields are filled | |
| 116 | + | // with defaults. | |
| 117 | + | func New(cfg Config) *Swarm { | |
| 118 | + | if cfg.Now == nil { | |
| 119 | + | cfg.Now = time.Now | |
| 120 | + | } | |
| 121 | + | if cfg.Worktree == nil { | |
| 122 | + | cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot} | |
| 123 | + | } | |
| 124 | + | if cfg.NewRunner == nil { | |
| 125 | + | cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} } | |
| 126 | + | } | |
| 127 | + | return &Swarm{ | |
| 128 | + | cfg: cfg, | |
| 129 | + | agents: map[string]*Agent{}, | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |
| 134 | + | // to a particular host zot session id. Pass empty to clear the | |
| 135 | + | // scope and revert to "show every agent" (the original behaviour). | |
| 136 | + | // | |
| 137 | + | // Existing in-memory agents keep their SessionID; only the filter | |
| 138 | + | // applied at snapshot time changes. So swapping the active session | |
| 139 | + | // with /sessions instantly re-narrows the dashboard without | |
| 140 | + | // touching any agent state. | |
| 141 | + | func (f *Swarm) SetActiveSession(id string) { | |
| 142 | + | f.mu.Lock() | |
| 143 | + | defer f.mu.Unlock() | |
| 144 | + | f.activeSession = id | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // ActiveSession returns the current scope, mostly for tests and | |
| 148 | + | // diagnostics. Empty means "no scope; show everything". | |
| 149 | + | func (f *Swarm) ActiveSession() string { | |
| 150 | + | f.mu.Lock() | |
| 151 | + | defer f.mu.Unlock() | |
| 152 | + | return f.activeSession | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // agentStateDir is the per-agent state directory laid out as: | |
| 156 | + | // | |
| 157 | + | // <root>/agents/<id>/ | |
| 158 | + | // events.jsonl durable event log (runner-owned) | |
| 159 | + | // in.sock unix socket inbox (child-owned) | |
| 160 | + | // session.json persistent agent session (child-owned) | |
| 161 | + | // meta.json static metadata (id, task, branch, dir) | |
| 162 | + | func (f *Swarm) agentStateDir(id string) string { | |
| 163 | + | return filepath.Join(f.cfg.Root, "agents", id) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // SpawnRequest configures a Spawn. Only Task is required; the rest | |
| 167 | + | // are optional. Model + Provider, when set, get baked into the | |
| 168 | + | // child argv as --model / --provider so the agent runs against the | |
| 169 | + | // chosen model regardless of the parent's current selection. | |
| 170 | + | type SpawnRequest struct { | |
| 171 | + | Task string | |
| 172 | + | Model string // optional override; child resolves default if empty | |
| 173 | + | Provider string // optional override; usually paired with Model | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Spawn creates a new Agent for the given task, sets up its worktree, | |
| 177 | + | // allocates the on-disk state directory (events log, inbox socket | |
| 178 | + | // path, session file path), and starts the Runner on a background | |
| 179 | + | // goroutine. The returned Agent is already in StatusRunning (or | |
| 180 | + | // StatusFailed if worktree setup failed before the goroutine | |
| 181 | + | // started). This is the historical signature; callers that want to | |
| 182 | + | // override the child's model use SpawnReq instead. | |
| 183 | + | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |
| 184 | + | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // SpawnReq is the full-fat variant of Spawn that accepts a | |
| 188 | + | // SpawnRequest. Existing callers can keep using Spawn; new code that | |
| 189 | + | // wants to pin the child's model uses this. | |
| 190 | + | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | |
| 191 | + | task := strings.TrimSpace(req.Task) | |
| 192 | + | if task == "" { | |
| 193 | + | return nil, errors.New("swarm: empty task") | |
| 194 | + | } | |
| 195 | + | id := newAgentID(task, f.cfg.Now()) | |
| 196 | + | branch := "swarm/" + id | |
| 197 | + | dir, err := f.cfg.Worktree.Create(id, branch, f.cfg.RepoRoot) | |
| 198 | + | if err != nil { | |
| 199 | + | return nil, fmt.Errorf("worktree create: %w", err) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | stateDir := f.agentStateDir(id) | |
| 203 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| 204 | + | return nil, fmt.Errorf("swarm state dir: %w", err) | |
| 205 | + | } | |
| 206 | + | logPath := filepath.Join(stateDir, "events.jsonl") | |
| 207 | + | sessionPath := filepath.Join(stateDir, "session.json") | |
| 208 | + | // Unix sockets have a hard 104-byte path limit on darwin and 108 | |
| 209 | + | // on linux. Long ZOT_HOME paths plus an agent slug blow that cap | |
| 210 | + | // quickly. Pick the shortest path that still keeps sockets | |
| 211 | + | // per-swarm-root so two zot instances on the same machine don't | |
| 212 | + | // collide. inboxSocketPath falls back from $TMPDIR to /tmp if | |
| 213 | + | // neither is short enough. | |
| 214 | + | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | |
| 215 | + | if err != nil { | |
| 216 | + | return nil, fmt.Errorf("swarm inbox path: %w", err) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Snapshot activeSession under the lock; we use it twice (struct | |
| 220 | + | // init below + persistence). Reading it without the lock would | |
| 221 | + | // race a concurrent SetActiveSession call. | |
| 222 | + | f.mu.Lock() | |
| 223 | + | sessionID := f.activeSession | |
| 224 | + | f.mu.Unlock() | |
| 225 | + | ||
| 226 | + | a := &Agent{ | |
| 227 | + | ID: id, | |
| 228 | + | Task: task, | |
| 229 | + | Branch: branch, | |
| 230 | + | Dir: dir, | |
| 231 | + | Started: f.cfg.Now(), | |
| 232 | + | Model: strings.TrimSpace(req.Model), | |
| 233 | + | Provider: strings.TrimSpace(req.Provider), | |
| 234 | + | SessionID: sessionID, | |
| 235 | + | InboxPath: inboxPath, | |
| 236 | + | EventLogPath: logPath, | |
| 237 | + | SessionPath: sessionPath, | |
| 238 | + | inbox: NewInbox(inboxPath), | |
| 239 | + | status: StatusPending, | |
| 240 | + | activity: "queued", | |
| 241 | + | done: make(chan struct{}), | |
| 242 | + | } | |
| 243 | + | a.ctx, a.cancel = context.WithCancel(ctx) | |
| 244 | + | a.runner = f.cfg.NewRunner(a) | |
| 245 | + | ||
| 246 | + | f.mu.Lock() | |
| 247 | + | f.agents[id] = a | |
| 248 | + | f.order = append(f.order, id) | |
| 249 | + | f.mu.Unlock() | |
| 250 | + | ||
| 251 | + | // Persist the agent's identity so a later `zot` invocation can | |
| 252 | + | // reload it from disk via Swarm.Reload. Best-effort: if the disk | |
| 253 | + | // is read-only we still let the runner start, the user just won't | |
| 254 | + | // see this agent on the next launch. | |
| 255 | + | _ = writeAgentMeta(stateDir, a) | |
| 256 | + | ||
| 257 | + | go f.run(a) | |
| 258 | + | return a, nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // SendInput delivers msg to the agent's inbox. msg should already | |
| 262 | + | // include the leading kind ("user " + text, "cancel", "shutdown"). | |
| 263 | + | // Returns an error wrapping ErrNotReady if the child hasn't opened | |
| 264 | + | // its listener yet; the caller can retry or surface the wait. | |
| 265 | + | func (f *Swarm) SendInput(id, msg string) error { | |
| 266 | + | a := f.Get(id) | |
| 267 | + | if a == nil { | |
| 268 | + | return fmt.Errorf("no such agent %q", id) | |
| 269 | + | } | |
| 270 | + | if a.inbox == nil { | |
| 271 | + | return fmt.Errorf("agent %s has no inbox", a.ID) | |
| 272 | + | } | |
| 273 | + | return a.inbox.SendInput(msg) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // SendUserTurn is sugar for the common "send the next user turn" | |
| 277 | + | // case. It quotes nothing and forwards verbatim; callers are | |
| 278 | + | // expected to have already trimmed and expanded the text. | |
| 279 | + | func (f *Swarm) SendUserTurn(id, text string) error { | |
| 280 | + | return f.SendInput(id, "user "+text) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (f *Swarm) run(a *Agent) { | |
| 284 | + | a.setStatus(StatusRunning) | |
| 285 | + | a.setActivity("starting") | |
| 286 | + | err := a.runner.Run(a.ctx, agentSink{a: a}) | |
| 287 | + | a.mu.Lock() | |
| 288 | + | a.finished = f.cfg.Now() | |
| 289 | + | switch { | |
| 290 | + | case a.status == StatusKilled: | |
| 291 | + | // Already finalised by Stop. | |
| 292 | + | case errors.Is(err, context.Canceled): | |
| 293 | + | a.status = StatusKilled | |
| 294 | + | a.activity = "cancelled" | |
| 295 | + | case err != nil: | |
| 296 | + | a.status = StatusFailed | |
| 297 | + | a.activity = "error: " + truncate(err.Error(), 120) | |
| 298 | + | a.lastErr = err | |
| 299 | + | default: | |
| 300 | + | a.status = StatusDone | |
| 301 | + | a.activity = "done" | |
| 302 | + | } | |
| 303 | + | a.mu.Unlock() | |
| 304 | + | close(a.done) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // List returns a snapshot of every agent in creation order. The | |
| 308 | + | // returned slice is a copy; callers may iterate without holding the | |
| 309 | + | // swarm lock. Agent fields are read under their own mutex during | |
| 310 | + | // formatting in Snapshot. | |
| 311 | + | func (f *Swarm) List() []*Agent { | |
| 312 | + | f.mu.Lock() | |
| 313 | + | defer f.mu.Unlock() | |
| 314 | + | out := make([]*Agent, 0, len(f.order)) | |
| 315 | + | for _, id := range f.order { | |
| 316 | + | out = append(out, f.agents[id]) | |
| 317 | + | } | |
| 318 | + | return out | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // Get returns the agent with the given (possibly truncated) id, or | |
| 322 | + | // nil. Matching is prefix-based so the user can type the first few | |
| 323 | + | // characters of a long id. | |
| 324 | + | func (f *Swarm) Get(id string) *Agent { | |
| 325 | + | f.mu.Lock() | |
| 326 | + | defer f.mu.Unlock() | |
| 327 | + | if a, ok := f.agents[id]; ok { | |
| 328 | + | return a | |
| 329 | + | } | |
| 330 | + | // Prefix match. | |
| 331 | + | var hits []*Agent | |
| 332 | + | for k, a := range f.agents { | |
| 333 | + | if strings.HasPrefix(k, id) { | |
| 334 | + | hits = append(hits, a) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | if len(hits) == 1 { | |
| 338 | + | return hits[0] | |
| 339 | + | } | |
| 340 | + | return nil | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // Stop cancels the agent's context. The Runner should observe the | |
| 344 | + | // cancellation and return promptly; the goroutine then finalises the | |
| 345 | + | // agent in StatusKilled. Also closes the supervisor-side inbox handle | |
| 346 | + | // so any pending SendInput retries fail fast instead of dialing a | |
| 347 | + | // socket about to be unlinked. | |
| 348 | + | // | |
| 349 | + | // Stop is a no-op for any agent that's not in a live runnable state | |
| 350 | + | // — Done / Failed / Killed (already finalised) and Detached (no | |
| 351 | + | // in-process runner; reloaded from disk). Calling Stop on a detached | |
| 352 | + | // agent must not crash: buildDetachedAgent doesn't allocate a | |
| 353 | + | // context/cancel pair because there's nothing to cancel. | |
| 354 | + | func (f *Swarm) Stop(id string) error { | |
| 355 | + | a := f.Get(id) | |
| 356 | + | if a == nil { | |
| 357 | + | return fmt.Errorf("no such agent %q", id) | |
| 358 | + | } | |
| 359 | + | a.mu.Lock() | |
| 360 | + | switch a.status { | |
| 361 | + | case StatusDone, StatusFailed, StatusKilled, StatusDetached: | |
| 362 | + | a.mu.Unlock() | |
| 363 | + | return nil | |
| 364 | + | } | |
| 365 | + | a.status = StatusKilled | |
| 366 | + | a.activity = "stopped" | |
| 367 | + | a.mu.Unlock() | |
| 368 | + | // Belt-and-braces guard against the cancel-less case (detached | |
| 369 | + | // agents skip this branch above, but a future code path that | |
| 370 | + | // builds an Agent without a runner shouldn't crash the | |
| 371 | + | // supervisor). | |
| 372 | + | if a.cancel != nil { | |
| 373 | + | a.cancel() | |
| 374 | + | } | |
| 375 | + | if a.inbox != nil { | |
| 376 | + | _ = a.inbox.Close() | |
| 377 | + | } | |
| 378 | + | return nil | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // StopAll cancels every running agent. Used on shutdown. | |
| 382 | + | func (f *Swarm) StopAll() { | |
| 383 | + | for _, a := range f.List() { | |
| 384 | + | _ = f.Stop(a.ID) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // Remove tears down the worktree for a terminated agent. It is an | |
| 389 | + | // error to remove an agent that's still running; call Stop first and | |
| 390 | + | // wait for the status to settle. Detached agents (reloaded from | |
| 391 | + | // disk) remove cleanly because they have no live runner racing for | |
| 392 | + | // the same files. | |
| 393 | + | func (f *Swarm) Remove(id string) error { | |
| 394 | + | a := f.Get(id) | |
| 395 | + | if a == nil { | |
| 396 | + | return fmt.Errorf("no such agent %q", id) | |
| 397 | + | } | |
| 398 | + | a.mu.Lock() | |
| 399 | + | st := a.status | |
| 400 | + | dir := a.Dir | |
| 401 | + | a.mu.Unlock() | |
| 402 | + | if st == StatusRunning || st == StatusPending { | |
| 403 | + | return fmt.Errorf("agent %s still %s", a.ID, st) | |
| 404 | + | } | |
| 405 | + | if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil { | |
| 406 | + | return err | |
| 407 | + | } | |
| 408 | + | // Best-effort cleanup of the per-agent state directory (meta.json, | |
| 409 | + | // events.jsonl, session.json, in.sock if it's local). Worktree | |
| 410 | + | // removal already succeeded; failing here would leave the user | |
| 411 | + | // with no recourse, so swallow the error. | |
| 412 | + | _ = os.RemoveAll(f.agentStateDir(a.ID)) | |
| 413 | + | f.mu.Lock() | |
| 414 | + | delete(f.agents, a.ID) | |
| 415 | + | for i, k := range f.order { | |
| 416 | + | if k == a.ID { | |
| 417 | + | f.order = append(f.order[:i], f.order[i+1:]...) | |
| 418 | + | break | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | f.mu.Unlock() | |
| 422 | + | return nil | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // Snapshot returns a read-only view of one agent. Safe for the TUI | |
| 426 | + | // goroutine to call repeatedly; never blocks on the Runner. | |
| 427 | + | type AgentSnapshot struct { | |
| 428 | + | ID string | |
| 429 | + | Task string | |
| 430 | + | Branch string | |
| 431 | + | Dir string | |
| 432 | + | Status Status | |
| 433 | + | Activity string | |
| 434 | + | Started time.Time | |
| 435 | + | Finished time.Time | |
| 436 | + | Err string | |
| 437 | + | Tail string // last few transcript lines, joined with "\n" | |
| 438 | + | Lines []string // full transcript (already capped by Agent.appendTranscript) | |
| 439 | + | ||
| 440 | + | // Model and Provider expose the per-agent overrides set at | |
| 441 | + | // Spawn time (empty when the agent inherits the child's default | |
| 442 | + | // resolution). The dashboard surfaces these so the user can | |
| 443 | + | // confirm which model an agent is running against. | |
| 444 | + | Model string | |
| 445 | + | Provider string | |
| 446 | + | ||
| 447 | + | // Paths to the agent's durable state. Surface them in the | |
| 448 | + | // snapshot so the dashboard / /swarm open can read events.jsonl | |
| 449 | + | // or resume the session without going back through the Agent. | |
| 450 | + | InboxPath string | |
| 451 | + | EventLogPath string | |
| 452 | + | SessionPath string | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // Snapshot copies the live agent state into a value the caller can | |
| 456 | + | // inspect at leisure. | |
| 457 | + | func (a *Agent) Snapshot() AgentSnapshot { | |
| 458 | + | a.mu.Lock() | |
| 459 | + | defer a.mu.Unlock() | |
| 460 | + | tail := strings.Join(lastN(a.transcript, 6), "\n") | |
| 461 | + | lines := make([]string, len(a.transcript)) | |
| 462 | + | copy(lines, a.transcript) | |
| 463 | + | errStr := "" | |
| 464 | + | if a.lastErr != nil { | |
| 465 | + | errStr = a.lastErr.Error() | |
| 466 | + | } | |
| 467 | + | return AgentSnapshot{ | |
| 468 | + | ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir, | |
| 469 | + | Status: a.status, Activity: a.activity, | |
| 470 | + | Started: a.Started, Finished: a.finished, | |
| 471 | + | Err: errStr, Tail: tail, Lines: lines, | |
| 472 | + | Model: a.Model, | |
| 473 | + | Provider: a.Provider, | |
| 474 | + | InboxPath: a.InboxPath, | |
| 475 | + | EventLogPath: a.EventLogPath, | |
| 476 | + | SessionPath: a.SessionPath, | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // SnapshotAll returns snapshots of every agent in creation order, | |
| 481 | + | // scoped to the active session when one is set. | |
| 482 | + | // | |
| 483 | + | // Scoping rules: | |
| 484 | + | // - activeSession == "": no filter, every agent is returned | |
| 485 | + | // (historical behaviour; used by tests and scripted callers). | |
| 486 | + | // - activeSession != "": include only agents whose SessionID | |
| 487 | + | // matches activeSession OR is empty. The empty-id pass-through | |
| 488 | + | // keeps pre-upgrade agents (their meta.json was written before | |
| 489 | + | // session_id existed) visible from any session so the user | |
| 490 | + | // doesn't lose access after the schema bump. | |
| 491 | + | func (f *Swarm) SnapshotAll() []AgentSnapshot { | |
| 492 | + | agents := f.List() | |
| 493 | + | f.mu.Lock() | |
| 494 | + | active := f.activeSession | |
| 495 | + | f.mu.Unlock() | |
| 496 | + | ||
| 497 | + | out := make([]AgentSnapshot, 0, len(agents)) | |
| 498 | + | for _, a := range agents { | |
| 499 | + | if active != "" && a.SessionID != "" && a.SessionID != active { | |
| 500 | + | continue | |
| 501 | + | } | |
| 502 | + | out = append(out, a.Snapshot()) | |
| 503 | + | } | |
| 504 | + | // Sort by start time for a stable, deterministic listing. | |
| 505 | + | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |
| 506 | + | return out | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // agentSink is the Sink the Swarm hands to each Runner. | |
| 510 | + | type agentSink struct{ a *Agent } | |
| 511 | + | ||
| 512 | + | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | |
| 513 | + | func (s agentSink) Transcript(chunk string) { s.a.appendTranscript(chunk) } | |
| 514 | + | ||
| 515 | + | func truncate(s string, n int) string { | |
| 516 | + | if len(s) <= n { | |
| 517 | + | return s | |
| 518 | + | } | |
| 519 | + | return s[:n-1] + "…" | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func lastN(lines []string, n int) []string { | |
| 523 | + | if len(lines) <= n { | |
| 524 | + | return lines | |
| 525 | + | } | |
| 526 | + | return lines[len(lines)-n:] | |
| 527 | + | } | |
A internal/swarm/swarm_test.go +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory | |
| 14 | + | // worktree and a Runner factory the test controls. Returns the swarm | |
| 15 | + | // plus a slice of runners keyed by spawn order so tests can assert | |
| 16 | + | // they were actually invoked. | |
| 17 | + | func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | |
| 18 | + | t.Helper() | |
| 19 | + | root := t.TempDir() | |
| 20 | + | return New(Config{ | |
| 21 | + | Root: root, | |
| 22 | + | RepoRoot: root, | |
| 23 | + | Worktree: MemWorktree(filepath.Join(root, "worktrees")), | |
| 24 | + | NewRunner: mk, | |
| 25 | + | }) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestSpawnRunsAndCompletes(t *testing.T) { | |
| 29 | + | ran := make(chan string, 1) | |
| 30 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 31 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 32 | + | ran <- a.Task | |
| 33 | + | sink.Activity("hello") | |
| 34 | + | sink.Transcript("line one") | |
| 35 | + | sink.Transcript("line two") | |
| 36 | + | return nil | |
| 37 | + | }) | |
| 38 | + | }) | |
| 39 | + | a, err := f.Spawn(context.Background(), "do a thing") | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | select { | |
| 44 | + | case got := <-ran: | |
| 45 | + | if got != "do a thing" { | |
| 46 | + | t.Fatalf("runner got task %q; want %q", got, "do a thing") | |
| 47 | + | } | |
| 48 | + | case <-time.After(time.Second): | |
| 49 | + | t.Fatal("runner did not start") | |
| 50 | + | } | |
| 51 | + | a.Wait() | |
| 52 | + | if a.Status() != StatusDone { | |
| 53 | + | t.Fatalf("status %s; want done", a.Status()) | |
| 54 | + | } | |
| 55 | + | if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" { | |
| 56 | + | t.Fatalf("transcript = %q", got) | |
| 57 | + | } | |
| 58 | + | if !strings.Contains(a.ID, "do-a-thing") { | |
| 59 | + | t.Fatalf("id %q missing slug", a.ID) | |
| 60 | + | } | |
| 61 | + | if a.Branch != "swarm/"+a.ID { | |
| 62 | + | t.Fatalf("branch = %q", a.Branch) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestSpawnEmptyTaskFails(t *testing.T) { | |
| 67 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 68 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| 69 | + | }) | |
| 70 | + | if _, err := f.Spawn(context.Background(), " "); err == nil { | |
| 71 | + | t.Fatal("expected error on empty task") | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestRunnerErrorMarksFailed(t *testing.T) { | |
| 76 | + | wantErr := errors.New("boom") | |
| 77 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 78 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr }) | |
| 79 | + | }) | |
| 80 | + | a, _ := f.Spawn(context.Background(), "explode") | |
| 81 | + | a.Wait() | |
| 82 | + | if a.Status() != StatusFailed { | |
| 83 | + | t.Fatalf("status %s; want failed", a.Status()) | |
| 84 | + | } | |
| 85 | + | if !errors.Is(a.Err(), wantErr) { | |
| 86 | + | t.Fatalf("err = %v", a.Err()) | |
| 87 | + | } | |
| 88 | + | if !strings.Contains(a.Activity(), "boom") { | |
| 89 | + | t.Fatalf("activity = %q; want it to mention the error", a.Activity()) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestStopCancelsRunningAgent(t *testing.T) { | |
| 94 | + | started := make(chan struct{}) | |
| 95 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 96 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 97 | + | close(started) | |
| 98 | + | <-ctx.Done() | |
| 99 | + | return ctx.Err() | |
| 100 | + | }) | |
| 101 | + | }) | |
| 102 | + | a, _ := f.Spawn(context.Background(), "long") | |
| 103 | + | <-started | |
| 104 | + | if err := f.Stop(a.ID); err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | a.Wait() | |
| 108 | + | if a.Status() != StatusKilled { | |
| 109 | + | t.Fatalf("status = %s; want killed", a.Status()) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestStopAfterDoneIsNoop(t *testing.T) { | |
| 114 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 115 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| 116 | + | }) | |
| 117 | + | a, _ := f.Spawn(context.Background(), "quick") | |
| 118 | + | a.Wait() | |
| 119 | + | if err := f.Stop(a.ID); err != nil { | |
| 120 | + | t.Fatalf("stop after done: %v", err) | |
| 121 | + | } | |
| 122 | + | if a.Status() != StatusDone { | |
| 123 | + | t.Fatalf("status flipped to %s", a.Status()) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestGetPrefixMatch(t *testing.T) { | |
| 128 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 129 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| 130 | + | }) | |
| 131 | + | a, _ := f.Spawn(context.Background(), "alpha task") | |
| 132 | + | a.Wait() | |
| 133 | + | // Full id works. | |
| 134 | + | if got := f.Get(a.ID); got != a { | |
| 135 | + | t.Fatal("get by full id failed") | |
| 136 | + | } | |
| 137 | + | // Slug prefix works as long as it's unique. | |
| 138 | + | if got := f.Get("alpha"); got != a { | |
| 139 | + | t.Fatal("get by prefix failed") | |
| 140 | + | } | |
| 141 | + | // Bogus id returns nil. | |
| 142 | + | if got := f.Get("zzz-nope"); got != nil { | |
| 143 | + | t.Fatalf("expected nil; got %#v", got) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestRemoveRequiresTerminalState(t *testing.T) { | |
| 148 | + | hold := make(chan struct{}) | |
| 149 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 150 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 151 | + | <-hold | |
| 152 | + | return nil | |
| 153 | + | }) | |
| 154 | + | }) | |
| 155 | + | a, _ := f.Spawn(context.Background(), "still going") | |
| 156 | + | // Wait for run goroutine to flip to running. | |
| 157 | + | for i := 0; i < 100 && a.Status() != StatusRunning; i++ { | |
| 158 | + | time.Sleep(time.Millisecond) | |
| 159 | + | } | |
| 160 | + | if err := f.Remove(a.ID); err == nil { | |
| 161 | + | t.Fatal("remove of running agent should fail") | |
| 162 | + | } | |
| 163 | + | close(hold) | |
| 164 | + | a.Wait() | |
| 165 | + | if err := f.Remove(a.ID); err != nil { | |
| 166 | + | t.Fatalf("remove after done: %v", err) | |
| 167 | + | } | |
| 168 | + | if got := f.Get(a.ID); got != nil { | |
| 169 | + | t.Fatal("agent still present after remove") | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestSnapshotIsStableAcrossAccess(t *testing.T) { | |
| 174 | + | var wg sync.WaitGroup | |
| 175 | + | wg.Add(1) | |
| 176 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 177 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 178 | + | defer wg.Done() | |
| 179 | + | for i := 0; i < 200; i++ { | |
| 180 | + | sink.Transcript("chunk") | |
| 181 | + | sink.Activity("step") | |
| 182 | + | } | |
| 183 | + | return nil | |
| 184 | + | }) | |
| 185 | + | }) | |
| 186 | + | a, _ := f.Spawn(context.Background(), "race") | |
| 187 | + | // Hammer Snapshot while the runner is writing; the -race detector | |
| 188 | + | // is the real assertion here. | |
| 189 | + | stop := make(chan struct{}) | |
| 190 | + | go func() { | |
| 191 | + | for { | |
| 192 | + | select { | |
| 193 | + | case <-stop: | |
| 194 | + | return | |
| 195 | + | default: | |
| 196 | + | _ = a.Snapshot() | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | }() | |
| 200 | + | wg.Wait() | |
| 201 | + | a.Wait() | |
| 202 | + | close(stop) | |
| 203 | + | if a.Status() != StatusDone { | |
| 204 | + | t.Fatalf("status = %s", a.Status()) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestTaskSlug(t *testing.T) { | |
| 209 | + | cases := map[string]string{ | |
| 210 | + | "fix the login form": "fix-the-login-form", | |
| 211 | + | " weird --- spaces!! ": "weird-spaces", | |
| 212 | + | "": "agent", | |
| 213 | + | "a-very-long-task-name-that-overflows": "a-very-long-task-name-th", | |
| 214 | + | } | |
| 215 | + | for in, want := range cases { | |
| 216 | + | if got := taskSlug(in); got != want { | |
| 217 | + | t.Errorf("taskSlug(%q) = %q; want %q", in, got, want) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestSnapshotAllSorted(t *testing.T) { | |
| 223 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 224 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| 225 | + | }) | |
| 226 | + | a1, _ := f.Spawn(context.Background(), "first") | |
| 227 | + | // Force second spawn into a later nanosecond bucket. | |
| 228 | + | time.Sleep(2 * time.Millisecond) | |
| 229 | + | a2, _ := f.Spawn(context.Background(), "second") | |
| 230 | + | a1.Wait() | |
| 231 | + | a2.Wait() | |
| 232 | + | snaps := f.SnapshotAll() | |
| 233 | + | if len(snaps) != 2 { | |
| 234 | + | t.Fatalf("want 2 snapshots; got %d", len(snaps)) | |
| 235 | + | } | |
| 236 | + | if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) { | |
| 237 | + | t.Fatal("snapshots not in spawn order") | |
| 238 | + | } | |
| 239 | + | } | |
A internal/swarm/testdata/cmd/stubchild/main.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | // stubchild is a minimal swarm-agent stand-in used by the runner | |
| 2 | + | // end-to-end test. It speaks the daemon protocol the real zot | |
| 3 | + | // binary will implement next: | |
| 4 | + | // | |
| 5 | + | // - parses --swarm-agent <path>, --session <path>, --cwd <path>, | |
| 6 | + | // and an optional positional task, | |
| 7 | + | // - opens a unix-socket listener at the inbox path so the | |
| 8 | + | // supervisor's Inbox can dial through, | |
| 9 | + | // - emits well-formed JSONL events on stdout that the supervisor | |
| 10 | + | // mirrors into the durable event log, | |
| 11 | + | // - reads one line per supervisor message and echoes it back as | |
| 12 | + | // a "user_message" event followed by a fake "assistant_message" | |
| 13 | + | // so the runner sees the dialogue happen. | |
| 14 | + | // | |
| 15 | + | // The runner test compiles this binary into a tempdir, points | |
| 16 | + | // swarm's execRunner at it via Command, and asserts the events | |
| 17 | + | // flow through correctly without needing the full zot model | |
| 18 | + | // machinery. | |
| 19 | + | package main | |
| 20 | + | ||
| 21 | + | import ( | |
| 22 | + | "bufio" | |
| 23 | + | "encoding/json" | |
| 24 | + | "flag" | |
| 25 | + | "fmt" | |
| 26 | + | "net" | |
| 27 | + | "os" | |
| 28 | + | "sync" | |
| 29 | + | "time" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | func main() { | |
| 33 | + | var ( | |
| 34 | + | inboxPath string | |
| 35 | + | sessionPath string | |
| 36 | + | cwd string | |
| 37 | + | ) | |
| 38 | + | flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path") | |
| 39 | + | flag.StringVar(&sessionPath, "session", "", "session file path") | |
| 40 | + | flag.StringVar(&cwd, "cwd", "", "working directory") | |
| 41 | + | flag.Parse() | |
| 42 | + | ||
| 43 | + | if inboxPath == "" { | |
| 44 | + | fmt.Fprintln(os.Stderr, "stubchild: --swarm-agent required") | |
| 45 | + | os.Exit(2) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | emit := newEmitter() | |
| 49 | + | ||
| 50 | + | // Open the inbox listener BEFORE doing any work so the supervisor | |
| 51 | + | // can dial through as soon as it sees the agent_ready event. If we | |
| 52 | + | // processed the initial task first, the parent's SendInput call | |
| 53 | + | // would race the stub's net.Listen and trip ErrNotReady. | |
| 54 | + | ln, err := net.Listen("unix", inboxPath) | |
| 55 | + | if err != nil { | |
| 56 | + | emit("error", map[string]any{"message": err.Error()}) | |
| 57 | + | os.Exit(1) | |
| 58 | + | } | |
| 59 | + | defer os.Remove(inboxPath) | |
| 60 | + | ||
| 61 | + | emit("agent_ready", map[string]any{ | |
| 62 | + | "inbox": inboxPath, | |
| 63 | + | "session": sessionPath, | |
| 64 | + | "cwd": cwd, | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | // Initial task lives in the positional. Process it as the first | |
| 68 | + | // user turn so the supervisor's "initial task" path is exercised. | |
| 69 | + | if task := flag.Arg(0); task != "" { | |
| 70 | + | runTurn(emit, task, 0) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | turn := 1 | |
| 74 | + | for { | |
| 75 | + | c, err := ln.Accept() | |
| 76 | + | if err != nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | br := bufio.NewReader(c) | |
| 80 | + | for { | |
| 81 | + | line, err := br.ReadString('\n') | |
| 82 | + | if line != "" { | |
| 83 | + | 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++ | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if err != nil { | |
| 97 | + | _ = c.Close() | |
| 98 | + | break | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // runTurn fakes one model round-trip: turn_start, an echoed | |
| 105 | + | // user_message, an assistant_message that echoes back, and a | |
| 106 | + | // turn_end. Enough event variety that applyEventToSink has | |
| 107 | + | // something to interpret. | |
| 108 | + | func runTurn(emit emitter, text string, step int) { | |
| 109 | + | emit("turn_start", map[string]any{"step": step}) | |
| 110 | + | emit("user_message", map[string]any{ | |
| 111 | + | "content": []any{ | |
| 112 | + | map[string]any{"type": "text", "text": text}, | |
| 113 | + | }, | |
| 114 | + | }) | |
| 115 | + | emit("assistant_message", map[string]any{ | |
| 116 | + | "content": []any{ | |
| 117 | + | map[string]any{"type": "text", "text": "echo: " + text}, | |
| 118 | + | }, | |
| 119 | + | }) | |
| 120 | + | emit("turn_end", map[string]any{"stop": "end"}) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | type emitter = func(string, map[string]any) | |
| 124 | + | ||
| 125 | + | func newEmitter() emitter { | |
| 126 | + | var mu sync.Mutex | |
| 127 | + | enc := json.NewEncoder(os.Stdout) | |
| 128 | + | return func(typ string, data map[string]any) { | |
| 129 | + | mu.Lock() | |
| 130 | + | defer mu.Unlock() | |
| 131 | + | if data == nil { | |
| 132 | + | data = map[string]any{} | |
| 133 | + | } | |
| 134 | + | data["type"] = typ | |
| 135 | + | data["time"] = time.Now().Format(time.RFC3339Nano) | |
| 136 | + | _ = enc.Encode(data) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func trimNL(s string) string { | |
| 141 | + | for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { | |
| 142 | + | s = s[:len(s)-1] | |
| 143 | + | } | |
| 144 | + | return s | |
| 145 | + | } | |
A internal/swarm/worktree.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // gitWorktree creates per-agent working directories via `git worktree | |
| 13 | + | // add`. If the repo path isn't a git repository it falls back to a | |
| 14 | + | // plain mkdir under root/<id>, which is still useful for tests and | |
| 15 | + | // for running agents in non-git directories. | |
| 16 | + | type gitWorktree struct { | |
| 17 | + | root string // <swarm-root>/worktrees | |
| 18 | + | repo string // user CWD; git operations resolve from here | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Create makes a new worktree on branch off of HEAD. The branch is | |
| 22 | + | // created if it doesn't already exist. Returns the absolute path. | |
| 23 | + | func (g *gitWorktree) Create(id, branch, base string) (string, error) { | |
| 24 | + | dir := filepath.Join(g.root, id) | |
| 25 | + | if err := os.MkdirAll(g.root, 0o755); err != nil { | |
| 26 | + | return "", err | |
| 27 | + | } | |
| 28 | + | if !isGitRepo(g.repo) { | |
| 29 | + | // Non-git fallback: just a fresh directory. The agent works | |
| 30 | + | // here; nothing is staged for merge. | |
| 31 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 32 | + | return "", err | |
| 33 | + | } | |
| 34 | + | return dir, nil | |
| 35 | + | } | |
| 36 | + | cmd := exec.Command("git", "worktree", "add", "-b", branch, dir) | |
| 37 | + | cmd.Dir = g.repo | |
| 38 | + | out, err := cmd.CombinedOutput() | |
| 39 | + | if err != nil { | |
| 40 | + | // If the branch already exists (e.g. a leftover from a | |
| 41 | + | // previous run), retry without -b so the user doesn't need | |
| 42 | + | // to clean up by hand. | |
| 43 | + | if strings.Contains(string(out), "already exists") { | |
| 44 | + | cmd = exec.Command("git", "worktree", "add", dir, branch) | |
| 45 | + | cmd.Dir = g.repo | |
| 46 | + | out, err = cmd.CombinedOutput() | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | if err != nil { | |
| 50 | + | return "", fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err) | |
| 51 | + | } | |
| 52 | + | return dir, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Remove deletes the worktree. Uses `git worktree remove --force` so | |
| 56 | + | // dirty trees don't block cleanup; the user is the one running this | |
| 57 | + | // command explicitly. | |
| 58 | + | func (g *gitWorktree) Remove(id, dir string) error { | |
| 59 | + | if dir == "" { | |
| 60 | + | return errors.New("empty worktree dir") | |
| 61 | + | } | |
| 62 | + | if !isGitRepo(g.repo) { | |
| 63 | + | return os.RemoveAll(dir) | |
| 64 | + | } | |
| 65 | + | cmd := exec.Command("git", "worktree", "remove", "--force", dir) | |
| 66 | + | cmd.Dir = g.repo | |
| 67 | + | out, err := cmd.CombinedOutput() | |
| 68 | + | if err != nil { | |
| 69 | + | // `git worktree remove` refuses to operate on an unknown path; | |
| 70 | + | // in that case fall back to a plain rmdir so the user can | |
| 71 | + | // always clean up. | |
| 72 | + | if strings.Contains(string(out), "not a working tree") { | |
| 73 | + | return os.RemoveAll(dir) | |
| 74 | + | } | |
| 75 | + | return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(out)), err) | |
| 76 | + | } | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func isGitRepo(dir string) bool { | |
| 81 | + | if dir == "" { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir") | |
| 85 | + | return cmd.Run() == nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // memWorktree is a WorktreeManager used by tests. It does no git | |
| 89 | + | // operations; it just makes a fresh subdirectory under root. | |
| 90 | + | type memWorktree struct{ root string } | |
| 91 | + | ||
| 92 | + | // MemWorktree returns a WorktreeManager that creates plain | |
| 93 | + | // subdirectories. Exposed for tests and for callers running zot | |
| 94 | + | // outside a git repository. | |
| 95 | + | func MemWorktree(root string) WorktreeManager { return &memWorktree{root: root} } | |
| 96 | + | ||
| 97 | + | func (m *memWorktree) Create(id, branch, base string) (string, error) { | |
| 98 | + | dir := filepath.Join(m.root, id) | |
| 99 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 100 | + | return "", err | |
| 101 | + | } | |
| 102 | + | return dir, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (m *memWorktree) Remove(id, dir string) error { | |
| 106 | + | if dir == "" { | |
| 107 | + | return errors.New("empty worktree dir") | |
| 108 | + | } | |
| 109 | + | return os.RemoveAll(dir) | |
| 110 | + | } | |
M internal/tui/render.go +30 -0
| @@ -382,6 +382,20 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 382 | 382 | cursorTargetRow = len(chatFrame) + cursorBottomRow | |
| 383 | 383 | } | |
| 384 | 384 | ||
| 385 | + | // Idle no-op fast path. When the buffer AND the cursor position | |
| 386 | + | // haven't changed since the last DrawLog, emit nothing. The | |
| 387 | + | // alternative — always writing SeqHideCursor + cursor-position + | |
| 388 | + | // SeqShowCursor — resets the terminal's cursor blink timer on | |
| 389 | + | // every tick. At our 120ms animation cadence that means the | |
| 390 | + | // caret in an idle dialog editor (e.g. an open swarm transcript | |
| 391 | + | // for an agent that's currently idle) appears as a solid block | |
| 392 | + | // that never blinks, because we keep "showing" it before the | |
| 393 | + | // terminal can blink it off. Bailing out here lets the OS run | |
| 394 | + | // its blink cycle. | |
| 395 | + | if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { | |
| 396 | + | return | |
| 397 | + | } | |
| 398 | + | ||
| 385 | 399 | var w strings.Builder | |
| 386 | 400 | w.WriteString(SeqSynchronizedOn) | |
| 387 | 401 | w.WriteString(SeqHideCursor) | |
| @@ -612,6 +626,22 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 612 | 626 | r.cursorCol = cursorCol | |
| 613 | 627 | } | |
| 614 | 628 | ||
| 629 | + | // sameLines reports whether two []string have the exact same | |
| 630 | + | // length and per-row contents. Used by DrawLog's idle no-op fast | |
| 631 | + | // path; cheap enough at our frame rates and far simpler than | |
| 632 | + | // hashing every byte. | |
| 633 | + | func sameLines(a, b []string) bool { | |
| 634 | + | if len(a) != len(b) { | |
| 635 | + | return false | |
| 636 | + | } | |
| 637 | + | for i := range a { | |
| 638 | + | if a[i] != b[i] { | |
| 639 | + | return false | |
| 640 | + | } | |
| 641 | + | } | |
| 642 | + | return true | |
| 643 | + | } | |
| 644 | + | ||
| 615 | 645 | func writeBlock(w *strings.Builder, lines []string) { | |
| 616 | 646 | for i, line := range lines { | |
| 617 | 647 | w.WriteString("\x1b[0m") | |
A internal/tui/render_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestDrawLogIdleNoOpEmitsNothing pins the cursor-blink fix: when | |
| 10 | + | // DrawLog is called with the exact same buffer and cursor position | |
| 11 | + | // as the previous call, it must emit ZERO bytes. | |
| 12 | + | // | |
| 13 | + | // The bug this regresses: at the 120ms animation tick the renderer | |
| 14 | + | // used to always emit SeqHideCursor + cursor-position + | |
| 15 | + | // SeqShowCursor, which resets the terminal's blink timer. Faster | |
| 16 | + | // than the OS blink interval, so an idle dialog editor (e.g. a | |
| 17 | + | // re-opened swarm transcript whose agent isn't producing output) | |
| 18 | + | // rendered the caret as a solid non-blinking block. | |
| 19 | + | // | |
| 20 | + | // With the no-op fast path the renderer leaves the screen alone | |
| 21 | + | // on idle frames, letting the terminal run its own blink cycle. | |
| 22 | + | func TestDrawLogIdleNoOpEmitsNothing(t *testing.T) { | |
| 23 | + | var buf bytes.Buffer | |
| 24 | + | r := NewRenderer(&buf) | |
| 25 | + | r.Resize(80, 24) | |
| 26 | + | ||
| 27 | + | chat := []string{"hello", "world"} | |
| 28 | + | bottom := []string{"▌ "} | |
| 29 | + | // First draw populates the renderer's cached buffer. | |
| 30 | + | r.DrawLog(chat, bottom, 0, 2) | |
| 31 | + | first := buf.Len() | |
| 32 | + | if first == 0 { | |
| 33 | + | t.Fatal("first DrawLog wrote nothing; setup is broken") | |
| 34 | + | } | |
| 35 | + | buf.Reset() | |
| 36 | + | ||
| 37 | + | // Identical second draw: same content, same cursor placement. | |
| 38 | + | r.DrawLog(chat, bottom, 0, 2) | |
| 39 | + | if buf.Len() != 0 { | |
| 40 | + | t.Fatalf("idle re-draw emitted %d bytes; expected 0 so terminal blink keeps ticking\n%q", | |
| 41 | + | buf.Len(), buf.String()) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // TestDrawLogContentChangeBreaksFastPath proves the no-op fast path | |
| 46 | + | // only fires when nothing changed. A buffer mutation must still | |
| 47 | + | // produce output, otherwise streaming agent replies would freeze on | |
| 48 | + | // screen. | |
| 49 | + | func TestDrawLogContentChangeBreaksFastPath(t *testing.T) { | |
| 50 | + | var buf bytes.Buffer | |
| 51 | + | r := NewRenderer(&buf) | |
| 52 | + | r.Resize(80, 24) | |
| 53 | + | ||
| 54 | + | r.DrawLog([]string{"hello"}, []string{"▌ "}, 0, 2) | |
| 55 | + | buf.Reset() | |
| 56 | + | ||
| 57 | + | // New chat row lands. | |
| 58 | + | r.DrawLog([]string{"hello", "world"}, []string{"▌ "}, 0, 2) | |
| 59 | + | if buf.Len() == 0 { | |
| 60 | + | t.Fatal("content change suppressed by fast path; streaming output would freeze") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | |
| 65 | + | // (no buffer change) still produces output. Without this, typing in | |
| 66 | + | // the editor would visually move the caret but the terminal would | |
| 67 | + | // keep drawing it at the old column. | |
| 68 | + | func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) { | |
| 69 | + | var buf bytes.Buffer | |
| 70 | + | r := NewRenderer(&buf) | |
| 71 | + | r.Resize(80, 24) | |
| 72 | + | ||
| 73 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) | |
| 74 | + | buf.Reset() | |
| 75 | + | ||
| 76 | + | // Same buffer, different cursor column. | |
| 77 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3) | |
| 78 | + | if buf.Len() == 0 { | |
| 79 | + | t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing") | |
| 80 | + | } | |
| 81 | + | // And the emitted bytes must at least reposition the cursor. | |
| 82 | + | if !strings.Contains(buf.String(), "\x1b[") { | |
| 83 | + | t.Errorf("cursor move emission missing CSI escapes: %q", buf.String()) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // TestDrawLogResizeForcesFullRedraw confirms a resize invalidates | |
| 88 | + | // the cache so the next DrawLog with identical inputs still emits. | |
| 89 | + | // Resize sets logInit=false; without that, a resize followed by an | |
| 90 | + | // identical buffer would falsely no-op and leave a stale frame. | |
| 91 | + | func TestDrawLogResizeForcesFullRedraw(t *testing.T) { | |
| 92 | + | var buf bytes.Buffer | |
| 93 | + | r := NewRenderer(&buf) | |
| 94 | + | r.Resize(80, 24) | |
| 95 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) | |
| 96 | + | buf.Reset() | |
| 97 | + | ||
| 98 | + | r.Resize(100, 30) | |
| 99 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) | |
| 100 | + | if buf.Len() == 0 { | |
| 101 | + | t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal") | |
| 102 | + | } | |
| 103 | + | } | |