swarm: drop git-worktree / isolation; agents share the host cwd
Each swarm subagent now runs with cwd == the parent zot's RepoRoot, just like the main agent. No per-agent git worktree, no swarm/<id> branch, no SetIsolation toggle, no '\''i'\'' dashboard shortcut, no --isolated flag. The previous worktree flow was confusing (toggling '\''i'\'' on a running agent couldn'\''t reseat its cwd, so edits kept landing in the host repo anyway) and shipped without a real use case. Concretely: - delete internal/swarm/worktree.go and the WorktreeManager interface. - Config loses Worktree; SpawnReq loses Isolated; Agent loses Branch and Isolated; AgentSnapshot loses Branch and Isolated; agentMeta loses branch and isolated (older meta.json files still decode \u2014 unknown JSON keys are ignored \u2014 and buildDetachedAgent coerces any stale per- worktree Dir back to the live RepoRoot so detached agents resume in the right place). - Swarm.Remove no longer calls into any worktree manager, so it can'\''t accidentally git-worktree-remove the user'\''s actual source tree; it only clears <swarm-root>/agents/<id>/. - runner.go drops the <Dir>/.zot/session.json fallback (every plausible Dir is now the user'\''s repo, where a stray .zot/ would litter the source tree); SessionPath is required and Spawn always populates it under <swarm-root>/agents/<id>/session.json. - swarm dialog: remove isolate/SetIsolateFunc, the '\''i'\'' key handler, the MODE column, the mode/branch lines in the transcript header. Fix the transcript-view cursor row math (row += 4 was counting a now-removed branch row, leaving the caret one row above the editor accent bar). - swarm slash command: drop /swarm isolate, /swarm unisolate, and the --isolated flag on /swarm new; trim the spawn-flag parser and tests. - README and slash-suggest description updated; site copy updated in a separate commit. Tests adjusted accordingly; full suite green.
- commit
- 1aea23e419f122901935fc81fa45a4697cb67953
- author
- patriceckhart · · 2026-05-16 22:01 UTC
- parent
- 63e28ad
- signature
- unsigned
- refs
- tag: v0.1.81
14 files · +166 -247
M README.md +25 -23
| @@ -187,7 +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 | + | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | |
| 191 | 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 192 | 192 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 193 | 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. | | |
| @@ -210,7 +210,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r | |||
| 210 | 210 | ||
| 211 | 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 | 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. | |
| 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, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually. | |
| 214 | 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). | |
| 215 | 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. | |
| 216 | 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. | |
| @@ -236,45 +236,47 @@ Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes th | |||
| 236 | 236 | ||
| 237 | 237 | ### `/swarm` | |
| 238 | 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. | |
| 239 | + | Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, its own persistent session file, and its own chat in the dashboard — but they all run in **the same working directory as the host**, so they see and edit the same files you do. Spawn one for a side task (“draft the migration”, “investigate this stack trace”, “write the test harness for module X”), keep going in the main thread, check in on it whenever you want. | |
| 240 | 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. | |
| 241 | + | > **Agents edit the same files you do.** They use the same `read` / `write` / `edit` / `bash` tools as the main agent against the host's working directory. There's no per-agent worktree or branch. If you need parallel edits on isolated checkouts, set that up yourself with `git worktree` outside zot. | |
| 242 | 242 | ||
| 243 | 243 | ``` | |
| 244 | 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 | |
| 245 | + | /swarm new <task> # spawn an agent | |
| 246 | + | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | |
| 247 | 247 | /swarm logs <id> # jump straight into one agent's transcript | |
| 248 | 248 | /swarm send <id> <text> # send a follow-up without opening the dashboard | |
| 249 | 249 | /swarm resume # pick a stopped agent to bring back | |
| 250 | 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 | |
| 251 | + | /swarm kill <id> # stop a running agent (its state stays) | |
| 252 | + | /swarm remove <id> # delete the agent's session and state | |
| 253 | 253 | /swarm list # alias for opening the dashboard | |
| 254 | 254 | ``` | |
| 255 | 255 | ||
| 256 | - | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys: | |
| 256 | + | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys: | |
| 257 | 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. | |
| 258 | + | | Key | Action | | |
| 259 | + | |---|---| | |
| 260 | + | | `↑` / `↓` | Move cursor between rows. | | |
| 261 | + | | `enter` | Open the highlighted agent's transcript view. | | |
| 262 | + | | `n` | Spawn a new agent (opens an inline task editor; inherits the host's current model). | | |
| 263 | + | | `p` | One-off prompt editor for the selected row (without entering the transcript). | | |
| 264 | + | | `R` | Resume a stopped agent in place. | | |
| 265 | + | | `k` | Kill the selected running agent. Its session and state stay so you can resume it later. | | |
| 266 | + | | `r` | Remove the selected agent entirely (session + meta gone). | | |
| 267 | + | | `esc` | Close the dashboard. | | |
| 266 | 268 | ||
| 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. | |
| 269 | + | **Inside an agent's transcript** — a chat overlay with an always-on inline composer at the bottom. The conversation flows above it; type and `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 | 270 | ||
| 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. | |
| 271 | + | **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 standard `/model` picker 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 | 272 | ||
| 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. | |
| 273 | + | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | |
| 272 | 274 | ||
| 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. | |
| 275 | + | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. | |
| 274 | 276 | ||
| 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. | |
| 277 | + | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. | |
| 276 | 278 | ||
| 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. | |
| 279 | + | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | |
| 278 | 280 | ||
| 279 | 281 | ### `/skills` | |
| 280 | 282 | ||
| @@ -187,7 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||||
| 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`. | | 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 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 188 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| 189 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | 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 | + | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | |
| 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | ||
| 192 | | `/compact` | Summarize the transcript into one message to free up context. | | 192 | | `/compact` | Summarize the transcript into one message to free up context. | | ||
| 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. | | 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. | | ||
| @@ -210,7 +210,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r | |||||
| 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. | 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 | 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. | 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, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually. |
| 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). | 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). | ||
| 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. | 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. | ||
| 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. | 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. | ||
| @@ -236,45 +236,47 @@ Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes th | |||||
| 236 | 236 | ||||
| 237 | ### `/swarm` | 237 | ### `/swarm` | ||
| 238 | 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. | 239 | + | Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, its own persistent session file, and its own chat in the dashboard — but they all run in **the same working directory as the host**, so they see and edit the same files you do. Spawn one for a side task (“draft the migration”, “investigate this stack trace”, “write the test harness for module X”), keep going in the main thread, check in on it whenever you want. |
| 240 | 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. | 241 | + | > **Agents edit the same files you do.** They use the same `read` / `write` / `edit` / `bash` tools as the main agent against the host's working directory. There's no per-agent worktree or branch. If you need parallel edits on isolated checkouts, set that up yourself with `git worktree` outside zot. |
| 242 | 242 | ||||
| 243 | ``` | 243 | ``` | ||
| 244 | /swarm # open the dashboard | 244 | /swarm # open the dashboard | ||
| 245 | - | /swarm new <task> # spawn an agent on a fresh worktree | 245 | + | /swarm new <task> # spawn an agent |
| 246 | - | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | 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 | 247 | /swarm logs <id> # jump straight into one agent's transcript | ||
| 248 | /swarm send <id> <text> # send a follow-up without opening the dashboard | 248 | /swarm send <id> <text> # send a follow-up without opening the dashboard | ||
| 249 | /swarm resume # pick a stopped agent to bring back | 249 | /swarm resume # pick a stopped agent to bring back | ||
| 250 | /swarm resume <id> # bring a specific agent back | 250 | /swarm resume <id> # bring a specific agent back | ||
| 251 | - | /swarm kill <id> # stop a running agent (worktree stays) | 251 | + | /swarm kill <id> # stop a running agent (its state stays) |
| 252 | - | /swarm remove <id> # delete the worktree + session for an agent | 252 | + | /swarm remove <id> # delete the agent's session and state |
| 253 | /swarm list # alias for opening the dashboard | 253 | /swarm list # alias for opening the dashboard | ||
| 254 | ``` | 254 | ``` | ||
| 255 | 255 | ||||
| 256 | - | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys: | 256 | + | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys: |
| 257 | 257 | ||||
| 258 | - | - `↑` / `↓` move the cursor between rows. | 258 | + | | Key | Action | |
| 259 | - | - `enter` opens the highlighted agent's transcript view. | 259 | + | |---|---| |
| 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). | 260 | + | | `↑` / `↓` | Move cursor between rows. | |
| 261 | - | - `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript). | 261 | + | | `enter` | Open the highlighted agent's transcript view. | |
| 262 | - | - `R` resumes a stopped agent in place. | 262 | + | | `n` | Spawn a new agent (opens an inline task editor; inherits the host's current model). | |
| 263 | - | - `k` kills the selected running agent (its worktree and session stay so you can resume later). | 263 | + | | `p` | One-off prompt editor for the selected row (without entering the transcript). | |
| 264 | - | - `r` removes the selected agent entirely (worktree + session + meta gone). | 264 | + | | `R` | Resume a stopped agent in place. | |
| 265 | - | - `esc` closes the dashboard. | 265 | + | | `k` | Kill the selected running agent. Its session and state stay so you can resume it later. | |
| 266 | + | | `r` | Remove the selected agent entirely (session + meta gone). | | |||
| 267 | + | | `esc` | Close the dashboard. | | |||
| 266 | 268 | ||||
| 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. | 269 | + | **Inside an agent's transcript** — a chat overlay with an always-on inline composer at the bottom. The conversation flows above it; type and `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 | 270 | ||||
| 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. | 271 | + | **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 standard `/model` picker 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 | 272 | ||||
| 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. | 273 | + | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. |
| 272 | 274 | ||||
| 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. | 275 | + | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. |
| 274 | 276 | ||||
| 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. | 277 | + | **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. |
| 276 | 278 | ||||
| 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. | 279 | + | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. |
| 278 | 280 | ||||
| 279 | ### `/skills` | 281 | ### `/skills` | ||
| 280 | 282 | ||||
M internal/agent/modes/slash_suggest.go +1 -1
| @@ -45,7 +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 | + | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, | |
| 49 | 49 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | |
| 50 | 50 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | |
| 51 | 51 | {Name: "/clear", Desc: "clear the chat transcript"}, | |
| @@ -45,7 +45,7 @@ var slashCatalog = []slashCommand{ | |||||
| 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | ||
| 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | ||
| 47 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | 47 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | ||
| 48 | - | {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"}, | 48 | + | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, |
| 49 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | 49 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | ||
| 50 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | 50 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | ||
| 51 | {Name: "/clear", Desc: "clear the chat transcript"}, | 51 | {Name: "/clear", Desc: "clear the chat transcript"}, | ||
M internal/agent/modes/swarm_dialog.go +11 -6
| @@ -23,7 +23,7 @@ import ( | |||
| 23 | 23 | // ↑/↓ move cursor | |
| 24 | 24 | // enter show transcript tail for the selected agent | |
| 25 | 25 | // k kill (Stop) the selected running agent | |
| 26 | - | // r remove a terminated agent (deletes worktree) | |
| 26 | + | // r remove a terminated agent (clears its state) | |
| 27 | 27 | // esc / q close | |
| 28 | 28 | // | |
| 29 | 29 | // Keys (transcript view): | |
| @@ -44,8 +44,8 @@ type swarmDialog struct { | |||
| 44 | 44 | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | |
| 45 | 45 | send func(id, text string) error | |
| 46 | 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. | |
| 47 | + | // session. Wired by Open(); when nil the inline 'R' shortcut is | |
| 48 | + | // disabled. | |
| 49 | 49 | resume func(id string) error | |
| 50 | 50 | ||
| 51 | 51 | rows []swarm.AgentSnapshot | |
| @@ -240,7 +240,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||
| 240 | 240 | return -1 | |
| 241 | 241 | } | |
| 242 | 242 | row := 1 // frame header | |
| 243 | - | row += 4 // task / branch / dir / status | |
| 243 | + | row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows) | |
| 244 | 244 | if a.Model != "" { | |
| 245 | 245 | row++ | |
| 246 | 246 | } | |
| @@ -400,7 +400,7 @@ func promptDisabledHint(s swarm.Status) string { | |||
| 400 | 400 | // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut. | |
| 401 | 401 | // Kill only makes sense on running / pending agents; on detached and | |
| 402 | 402 | // terminal ones it's a no-op and the user usually wants 'r' (remove) | |
| 403 | - | // to clean up the worktree instead. | |
| 403 | + | // to clear out the agent's state instead. | |
| 404 | 404 | func killDisabledHint(s swarm.Status) string { | |
| 405 | 405 | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | |
| 406 | 406 | } | |
| @@ -1033,7 +1033,6 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||
| 1033 | 1033 | header := []string{ | |
| 1034 | 1034 | frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width), | |
| 1035 | 1035 | " " + th.FG256(th.Muted, "task: "+a.Task), | |
| 1036 | - | " " + th.FG256(th.Muted, "branch: "+a.Branch), | |
| 1037 | 1036 | " " + th.FG256(th.Muted, "dir: "+a.Dir), | |
| 1038 | 1037 | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | |
| 1039 | 1038 | } | |
| @@ -1321,6 +1320,12 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) | |||
| 1321 | 1320 | } | |
| 1322 | 1321 | ||
| 1323 | 1322 | // formatSwarmRow is the one-line summary shown per agent. | |
| 1323 | + | // | |
| 1324 | + | // Layout (fixed-width columns, then free-form activity): | |
| 1325 | + | // | |
| 1326 | + | // STATUS ID AGE ACTIVITY | |
| 1327 | + | // ● run fix-login-12345 3m editing main.go | |
| 1328 | + | // ✓ done write-tests-67890 1h done | |
| 1324 | 1329 | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | |
| 1325 | 1330 | status := statusLabel(r.Status) | |
| 1326 | 1331 | age := formatAge(r.Started) | |
| @@ -23,7 +23,7 @@ import ( | |||||
| 23 | // ↑/↓ move cursor | 23 | // ↑/↓ move cursor | ||
| 24 | // enter show transcript tail for the selected agent | 24 | // enter show transcript tail for the selected agent | ||
| 25 | // k kill (Stop) the selected running agent | 25 | // k kill (Stop) the selected running agent | ||
| 26 | - | // r remove a terminated agent (deletes worktree) | 26 | + | // r remove a terminated agent (clears its state) |
| 27 | // esc / q close | 27 | // esc / q close | ||
| 28 | // | 28 | // | ||
| 29 | // Keys (transcript view): | 29 | // Keys (transcript view): | ||
| @@ -44,8 +44,8 @@ type swarmDialog struct { | |||||
| 44 | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | 44 | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | ||
| 45 | send func(id, text string) error | 45 | send func(id, text string) error | ||
| 46 | // resume restarts a detached or terminated agent on its existing | 46 | // resume restarts a detached or terminated agent on its existing | ||
| 47 | - | // worktree/session. Wired by Open(); when nil the inline 'R' | 47 | + | // session. Wired by Open(); when nil the inline 'R' shortcut is |
| 48 | - | // shortcut is disabled. | 48 | + | // disabled. |
| 49 | resume func(id string) error | 49 | resume func(id string) error | ||
| 50 | 50 | ||||
| 51 | rows []swarm.AgentSnapshot | 51 | rows []swarm.AgentSnapshot | ||
| @@ -240,7 +240,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||||
| 240 | return -1 | 240 | return -1 | ||
| 241 | } | 241 | } | ||
| 242 | row := 1 // frame header | 242 | row := 1 // frame header | ||
| 243 | - | row += 4 // task / branch / dir / status | 243 | + | row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows) |
| 244 | if a.Model != "" { | 244 | if a.Model != "" { | ||
| 245 | row++ | 245 | row++ | ||
| 246 | } | 246 | } | ||
| @@ -400,7 +400,7 @@ func promptDisabledHint(s swarm.Status) string { | |||||
| 400 | // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut. | 400 | // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut. | ||
| 401 | // Kill only makes sense on running / pending agents; on detached and | 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) | 402 | // terminal ones it's a no-op and the user usually wants 'r' (remove) | ||
| 403 | - | // to clean up the worktree instead. | 403 | + | // to clear out the agent's state instead. |
| 404 | func killDisabledHint(s swarm.Status) string { | 404 | func killDisabledHint(s swarm.Status) string { | ||
| 405 | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | 405 | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | ||
| 406 | } | 406 | } | ||
| @@ -1033,7 +1033,6 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||||
| 1033 | header := []string{ | 1033 | header := []string{ | ||
| 1034 | frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width), | 1034 | frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width), | ||
| 1035 | " " + th.FG256(th.Muted, "task: "+a.Task), | 1035 | " " + th.FG256(th.Muted, "task: "+a.Task), | ||
| 1036 | - | " " + th.FG256(th.Muted, "branch: "+a.Branch), | |||
| 1037 | " " + th.FG256(th.Muted, "dir: "+a.Dir), | 1036 | " " + th.FG256(th.Muted, "dir: "+a.Dir), | ||
| 1038 | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | 1037 | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), | ||
| 1039 | } | 1038 | } | ||
| @@ -1321,6 +1320,12 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) | |||||
| 1321 | } | 1320 | } | ||
| 1322 | 1321 | ||||
| 1323 | // formatSwarmRow is the one-line summary shown per agent. | 1322 | // formatSwarmRow is the one-line summary shown per agent. | ||
| 1323 | + | // | |||
| 1324 | + | // Layout (fixed-width columns, then free-form activity): | |||
| 1325 | + | // | |||
| 1326 | + | // STATUS ID AGE ACTIVITY | |||
| 1327 | + | // ● run fix-login-12345 3m editing main.go | |||
| 1328 | + | // ✓ done write-tests-67890 1h done | |||
| 1324 | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | 1329 | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | ||
| 1325 | status := statusLabel(r.Status) | 1330 | status := statusLabel(r.Status) | ||
| 1326 | age := formatAge(r.Started) | 1331 | age := formatAge(r.Started) | ||
M internal/agent/modes/swarm_dialog_test.go +1 -1
| @@ -100,7 +100,7 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) { | |||
| 100 | 100 | _ = d.Render(tui.Theme{}, 80) | |
| 101 | 101 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 102 | 102 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |
| 103 | - | for _, want := range []string{"task:", "branch:", "line a", "line b"} { | |
| 103 | + | for _, want := range []string{"task:", "dir:", "line a", "line b"} { | |
| 104 | 104 | if !strings.Contains(out, want) { | |
| 105 | 105 | t.Fatalf("transcript view missing %q:\n%s", want, out) | |
| 106 | 106 | } | |
| @@ -100,7 +100,7 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) { | |||||
| 100 | _ = d.Render(tui.Theme{}, 80) | 100 | _ = d.Render(tui.Theme{}, 80) | ||
| 101 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 101 | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
| 102 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | 102 | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | ||
| 103 | - | for _, want := range []string{"task:", "branch:", "line a", "line b"} { | 103 | + | for _, want := range []string{"task:", "dir:", "line a", "line b"} { |
| 104 | if !strings.Contains(out, want) { | 104 | if !strings.Contains(out, want) { | ||
| 105 | t.Fatalf("transcript view missing %q:\n%s", want, out) | 105 | t.Fatalf("transcript view missing %q:\n%s", want, out) | ||
| 106 | } | 106 | } | ||
M internal/agent/modes/swarm_slash.go +2 -2
| @@ -110,9 +110,9 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 110 | 110 | return | |
| 111 | 111 | } | |
| 112 | 112 | if model != "" { | |
| 113 | - | i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "") | |
| 113 | + | i.swarmStatus("spawned "+a.ID+" (model "+model+")", "") | |
| 114 | 114 | } else { | |
| 115 | - | i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "") | |
| 115 | + | i.swarmStatus("spawned "+a.ID, "") | |
| 116 | 116 | } | |
| 117 | 117 | case "kill", "stop": | |
| 118 | 118 | if rest == "" { | |
| @@ -110,9 +110,9 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 110 | return | 110 | return | ||
| 111 | } | 111 | } | ||
| 112 | if model != "" { | 112 | if model != "" { | ||
| 113 | - | i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "") | 113 | + | i.swarmStatus("spawned "+a.ID+" (model "+model+")", "") |
| 114 | } else { | 114 | } else { | ||
| 115 | - | i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "") | 115 | + | i.swarmStatus("spawned "+a.ID, "") |
| 116 | } | 116 | } | ||
| 117 | case "kill", "stop": | 117 | case "kill", "stop": | ||
| 118 | if rest == "" { | 118 | if rest == "" { | ||
M internal/agent/modes/swarm_slash_test.go +0 -3
| @@ -2,7 +2,6 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "path/filepath" | |
| 6 | 5 | "testing" | |
| 7 | 6 | "time" | |
| 8 | 7 | ||
| @@ -19,7 +18,6 @@ func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { | |||
| 19 | 18 | f := swarm.New(swarm.Config{ | |
| 20 | 19 | Root: root, | |
| 21 | 20 | RepoRoot: root, | |
| 22 | - | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |
| 23 | 21 | NewRunner: func(a *swarm.Agent) swarm.Runner { | |
| 24 | 22 | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |
| 25 | 23 | <-ctx.Done() | |
| @@ -107,7 +105,6 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||
| 107 | 105 | f := swarm.New(swarm.Config{ | |
| 108 | 106 | Root: root, | |
| 109 | 107 | RepoRoot: root, | |
| 110 | - | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |
| 111 | 108 | NewRunner: func(a *swarm.Agent) swarm.Runner { | |
| 112 | 109 | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |
| 113 | 110 | // Stand up a real Listener on the agent's inbox path so | |
| @@ -2,7 +2,6 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "path/filepath" | |||
| 6 | "testing" | 5 | "testing" | ||
| 7 | "time" | 6 | "time" | ||
| 8 | 7 | ||||
| @@ -19,7 +18,6 @@ func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { | |||||
| 19 | f := swarm.New(swarm.Config{ | 18 | f := swarm.New(swarm.Config{ | ||
| 20 | Root: root, | 19 | Root: root, | ||
| 21 | RepoRoot: root, | 20 | RepoRoot: root, | ||
| 22 | - | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |||
| 23 | NewRunner: func(a *swarm.Agent) swarm.Runner { | 21 | NewRunner: func(a *swarm.Agent) swarm.Runner { | ||
| 24 | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | 22 | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | ||
| 25 | <-ctx.Done() | 23 | <-ctx.Done() | ||
| @@ -107,7 +105,6 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||||
| 107 | f := swarm.New(swarm.Config{ | 105 | f := swarm.New(swarm.Config{ | ||
| 108 | Root: root, | 106 | Root: root, | ||
| 109 | RepoRoot: root, | 107 | RepoRoot: root, | ||
| 110 | - | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |||
| 111 | NewRunner: func(a *swarm.Agent) swarm.Runner { | 108 | NewRunner: func(a *swarm.Agent) swarm.Runner { | ||
| 112 | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | 109 | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | ||
| 113 | // Stand up a real Listener on the agent's inbox path so | 110 | // Stand up a real Listener on the agent's inbox path so | ||
M internal/swarm/agent.go +1 -2
| @@ -14,8 +14,7 @@ import ( | |||
| 14 | 14 | type Agent struct { | |
| 15 | 15 | ID string | |
| 16 | 16 | Task string | |
| 17 | - | Branch string | |
| 18 | - | Dir string | |
| 17 | + | Dir string // always the host's RepoRoot; agents share its cwd. | |
| 19 | 18 | Started time.Time | |
| 20 | 19 | ||
| 21 | 20 | // Model and Provider, when non-empty, override the child | |
| @@ -14,8 +14,7 @@ import ( | |||||
| 14 | type Agent struct { | 14 | type Agent struct { | ||
| 15 | ID string | 15 | ID string | ||
| 16 | Task string | 16 | Task string | ||
| 17 | - | Branch string | 17 | + | Dir string // always the host's RepoRoot; agents share its cwd. |
| 18 | - | Dir string | |||
| 19 | Started time.Time | 18 | Started time.Time | ||
| 20 | 19 | ||||
| 21 | // Model and Provider, when non-empty, override the child | 20 | // Model and Provider, when non-empty, override the child | ||
M internal/swarm/persist.go +17 -8
| @@ -33,10 +33,13 @@ import ( | |||
| 33 | 33 | // the supervisor needs to rebuild an Agent after a restart live here. | |
| 34 | 34 | // Adding a field is backwards-compatible (older meta.json files just | |
| 35 | 35 | // leave it zero); removing or renaming one is not. | |
| 36 | + | // | |
| 37 | + | // Historical fields like `branch` and `isolated` are silently dropped | |
| 38 | + | // by encoding/json's permissive decoder when an older meta.json is | |
| 39 | + | // loaded; we don't need to keep them in the struct. | |
| 36 | 40 | type agentMeta struct { | |
| 37 | 41 | ID string `json:"id"` | |
| 38 | 42 | Task string `json:"task"` | |
| 39 | - | Branch string `json:"branch"` | |
| 40 | 43 | Dir string `json:"dir"` | |
| 41 | 44 | Started time.Time `json:"started"` | |
| 42 | 45 | Model string `json:"model,omitempty"` | |
| @@ -51,8 +54,7 @@ type agentMeta struct { | |||
| 51 | 54 | // (and agents spawned outside of any session, e.g. by tests or | |
| 52 | 55 | // scripted callers that didn't call SetActiveSession) have an | |
| 53 | 56 | // 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. | |
| 57 | + | // backward-compat fallback. | |
| 56 | 58 | SessionID string `json:"session_id,omitempty"` | |
| 57 | 59 | } | |
| 58 | 60 | ||
| @@ -65,7 +67,6 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 65 | 67 | m := agentMeta{ | |
| 66 | 68 | ID: a.ID, | |
| 67 | 69 | Task: a.Task, | |
| 68 | - | Branch: a.Branch, | |
| 69 | 70 | Dir: a.Dir, | |
| 70 | 71 | Started: a.Started, | |
| 71 | 72 | Model: a.Model, | |
| @@ -187,11 +188,20 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 187 | 188 | // The returned Agent has a closed `done` channel because Wait should | |
| 188 | 189 | // return instantly: there is nothing to wait for. | |
| 189 | 190 | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |
| 191 | + | // Older meta.json files may still record a per-agent worktree | |
| 192 | + | // path under Dir. They predate the decision to run every agent | |
| 193 | + | // in the host's repo and shouldn't continue editing that stale | |
| 194 | + | // checkout, which most likely no longer matches HEAD. Coerce | |
| 195 | + | // the dir back to the live RepoRoot so resume picks up where | |
| 196 | + | // the host is now. | |
| 197 | + | dir := m.Dir | |
| 198 | + | if f.cfg.RepoRoot != "" { | |
| 199 | + | dir = f.cfg.RepoRoot | |
| 200 | + | } | |
| 190 | 201 | a := &Agent{ | |
| 191 | 202 | ID: m.ID, | |
| 192 | 203 | Task: m.Task, | |
| 193 | - | Branch: m.Branch, | |
| 194 | - | Dir: m.Dir, | |
| 204 | + | Dir: dir, | |
| 195 | 205 | Started: m.Started, | |
| 196 | 206 | Model: m.Model, | |
| 197 | 207 | Provider: m.Provider, | |
| @@ -334,7 +344,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 334 | 344 | // (e.g. tests that hand-built an Agent) don't accidentally route | |
| 335 | 345 | // the new runner at the wrong paths. | |
| 336 | 346 | m := agentMeta{ | |
| 337 | - | ID: existing.ID, Task: existing.Task, Branch: existing.Branch, | |
| 347 | + | ID: existing.ID, Task: existing.Task, | |
| 338 | 348 | Dir: existing.Dir, Started: existing.Started, | |
| 339 | 349 | Model: existing.Model, Provider: existing.Provider, | |
| 340 | 350 | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | |
| @@ -344,7 +354,6 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 344 | 354 | a := &Agent{ | |
| 345 | 355 | ID: m.ID, | |
| 346 | 356 | Task: m.Task, | |
| 347 | - | Branch: m.Branch, | |
| 348 | 357 | Dir: m.Dir, | |
| 349 | 358 | Started: m.Started, | |
| 350 | 359 | Model: m.Model, | |
| @@ -33,10 +33,13 @@ import ( | |||||
| 33 | // the supervisor needs to rebuild an Agent after a restart live here. | 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 | 34 | // Adding a field is backwards-compatible (older meta.json files just | ||
| 35 | // leave it zero); removing or renaming one is not. | 35 | // leave it zero); removing or renaming one is not. | ||
| 36 | + | // | |||
| 37 | + | // Historical fields like `branch` and `isolated` are silently dropped | |||
| 38 | + | // by encoding/json's permissive decoder when an older meta.json is | |||
| 39 | + | // loaded; we don't need to keep them in the struct. | |||
| 36 | type agentMeta struct { | 40 | type agentMeta struct { | ||
| 37 | ID string `json:"id"` | 41 | ID string `json:"id"` | ||
| 38 | Task string `json:"task"` | 42 | Task string `json:"task"` | ||
| 39 | - | Branch string `json:"branch"` | |||
| 40 | Dir string `json:"dir"` | 43 | Dir string `json:"dir"` | ||
| 41 | Started time.Time `json:"started"` | 44 | Started time.Time `json:"started"` | ||
| 42 | Model string `json:"model,omitempty"` | 45 | Model string `json:"model,omitempty"` | ||
| @@ -51,8 +54,7 @@ type agentMeta struct { | |||||
| 51 | // (and agents spawned outside of any session, e.g. by tests or | 54 | // (and agents spawned outside of any session, e.g. by tests or | ||
| 52 | // scripted callers that didn't call SetActiveSession) have an | 55 | // scripted callers that didn't call SetActiveSession) have an | ||
| 53 | // empty SessionID and are visible from every session as a | 56 | // empty SessionID and are visible from every session as a | ||
| 54 | - | // backward-compat fallback. Added in 2026 — a fresh field on a | 57 | + | // backward-compat fallback. |
| 55 | - | // json struct is backwards-compatible by design. | |||
| 56 | SessionID string `json:"session_id,omitempty"` | 58 | SessionID string `json:"session_id,omitempty"` | ||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| @@ -65,7 +67,6 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 65 | m := agentMeta{ | 67 | m := agentMeta{ | ||
| 66 | ID: a.ID, | 68 | ID: a.ID, | ||
| 67 | Task: a.Task, | 69 | Task: a.Task, | ||
| 68 | - | Branch: a.Branch, | |||
| 69 | Dir: a.Dir, | 70 | Dir: a.Dir, | ||
| 70 | Started: a.Started, | 71 | Started: a.Started, | ||
| 71 | Model: a.Model, | 72 | Model: a.Model, | ||
| @@ -187,11 +188,20 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||||
| 187 | // The returned Agent has a closed `done` channel because Wait should | 188 | // The returned Agent has a closed `done` channel because Wait should | ||
| 188 | // return instantly: there is nothing to wait for. | 189 | // return instantly: there is nothing to wait for. | ||
| 189 | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | 190 | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | ||
| 191 | + | // Older meta.json files may still record a per-agent worktree | |||
| 192 | + | // path under Dir. They predate the decision to run every agent | |||
| 193 | + | // in the host's repo and shouldn't continue editing that stale | |||
| 194 | + | // checkout, which most likely no longer matches HEAD. Coerce | |||
| 195 | + | // the dir back to the live RepoRoot so resume picks up where | |||
| 196 | + | // the host is now. | |||
| 197 | + | dir := m.Dir | |||
| 198 | + | if f.cfg.RepoRoot != "" { | |||
| 199 | + | dir = f.cfg.RepoRoot | |||
| 200 | + | } | |||
| 190 | a := &Agent{ | 201 | a := &Agent{ | ||
| 191 | ID: m.ID, | 202 | ID: m.ID, | ||
| 192 | Task: m.Task, | 203 | Task: m.Task, | ||
| 193 | - | Branch: m.Branch, | 204 | + | Dir: dir, |
| 194 | - | Dir: m.Dir, | |||
| 195 | Started: m.Started, | 205 | Started: m.Started, | ||
| 196 | Model: m.Model, | 206 | Model: m.Model, | ||
| 197 | Provider: m.Provider, | 207 | Provider: m.Provider, | ||
| @@ -334,7 +344,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 334 | // (e.g. tests that hand-built an Agent) don't accidentally route | 344 | // (e.g. tests that hand-built an Agent) don't accidentally route | ||
| 335 | // the new runner at the wrong paths. | 345 | // the new runner at the wrong paths. | ||
| 336 | m := agentMeta{ | 346 | m := agentMeta{ | ||
| 337 | - | ID: existing.ID, Task: existing.Task, Branch: existing.Branch, | 347 | + | ID: existing.ID, Task: existing.Task, |
| 338 | Dir: existing.Dir, Started: existing.Started, | 348 | Dir: existing.Dir, Started: existing.Started, | ||
| 339 | Model: existing.Model, Provider: existing.Provider, | 349 | Model: existing.Model, Provider: existing.Provider, | ||
| 340 | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | 350 | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | ||
| @@ -344,7 +354,6 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 344 | a := &Agent{ | 354 | a := &Agent{ | ||
| 345 | ID: m.ID, | 355 | ID: m.ID, | ||
| 346 | Task: m.Task, | 356 | Task: m.Task, | ||
| 347 | - | Branch: m.Branch, | |||
| 348 | Dir: m.Dir, | 357 | Dir: m.Dir, | ||
| 349 | Started: m.Started, | 358 | Started: m.Started, | ||
| 350 | Model: m.Model, | 359 | Model: m.Model, | ||
M internal/swarm/persist_test.go +12 -26
| @@ -21,7 +21,6 @@ func TestSpawnWritesMetaJSON(t *testing.T) { | |||
| 21 | 21 | f := New(Config{ | |
| 22 | 22 | Root: root, | |
| 23 | 23 | RepoRoot: root, | |
| 24 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 25 | 24 | NewRunner: func(a *Agent) Runner { | |
| 26 | 25 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 27 | 26 | <-ctx.Done() | |
| @@ -51,7 +50,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) { | |||
| 51 | 50 | if got.Task != "investigate widget" { | |
| 52 | 51 | t.Errorf("meta.Task = %q", got.Task) | |
| 53 | 52 | } | |
| 54 | - | if got.Branch != a.Branch || got.Dir != a.Dir { | |
| 53 | + | if got.Dir != a.Dir { | |
| 55 | 54 | t.Errorf("meta paths drifted: %+v vs agent %+v", got, a) | |
| 56 | 55 | } | |
| 57 | 56 | if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" { | |
| @@ -74,7 +73,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||
| 74 | 73 | first := New(Config{ | |
| 75 | 74 | Root: root, | |
| 76 | 75 | RepoRoot: root, | |
| 77 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 78 | 76 | NewRunner: func(a *Agent) Runner { | |
| 79 | 77 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 80 | 78 | <-ctx.Done() | |
| @@ -101,7 +99,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||
| 101 | 99 | second := New(Config{ | |
| 102 | 100 | Root: root, | |
| 103 | 101 | RepoRoot: root, | |
| 104 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 105 | 102 | }) | |
| 106 | 103 | loaded, errs := second.Reload() | |
| 107 | 104 | if len(errs) > 0 { | |
| @@ -140,7 +137,6 @@ func TestReloadIsIdempotent(t *testing.T) { | |||
| 140 | 137 | root := t.TempDir() | |
| 141 | 138 | first := New(Config{ | |
| 142 | 139 | Root: root, RepoRoot: root, | |
| 143 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 144 | 140 | NewRunner: func(a *Agent) Runner { | |
| 145 | 141 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 146 | 142 | }, | |
| @@ -150,7 +146,7 @@ func TestReloadIsIdempotent(t *testing.T) { | |||
| 150 | 146 | } | |
| 151 | 147 | first.StopAll() | |
| 152 | 148 | ||
| 153 | - | second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 149 | + | second := New(Config{Root: root, RepoRoot: root}) | |
| 154 | 150 | loaded1, _ := second.Reload() | |
| 155 | 151 | loaded2, errs := second.Reload() | |
| 156 | 152 | if len(errs) > 0 { | |
| @@ -177,8 +173,8 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||
| 177 | 173 | } | |
| 178 | 174 | // meta.json | |
| 179 | 175 | 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), | |
| 176 | + | ID: id, Task: "do thing", | |
| 177 | + | Dir: root, Started: time.Now().Add(-time.Hour), | |
| 182 | 178 | InboxPath: filepath.Join(stateDir, "in.sock"), | |
| 183 | 179 | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| 184 | 180 | SessionPath: filepath.Join(stateDir, "session.json"), | |
| @@ -198,7 +194,7 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||
| 198 | 194 | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"})) | |
| 199 | 195 | _ = log.Close() | |
| 200 | 196 | ||
| 201 | - | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 197 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 202 | 198 | loaded, errs := f.Reload() | |
| 203 | 199 | if len(errs) > 0 || loaded != 1 { | |
| 204 | 200 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| @@ -246,11 +242,11 @@ func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) { | |||
| 246 | 242 | good := "good-1" | |
| 247 | 243 | stateDir := filepath.Join(agentsDir, good) | |
| 248 | 244 | _ = os.MkdirAll(stateDir, 0o755) | |
| 249 | - | m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()} | |
| 245 | + | m := agentMeta{ID: good, Task: "x", Dir: "/tmp/x", Started: time.Now()} | |
| 250 | 246 | mb, _ := json.MarshalIndent(m, "", " ") | |
| 251 | 247 | _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644) | |
| 252 | 248 | ||
| 253 | - | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 249 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 254 | 250 | loaded, errs := f.Reload() | |
| 255 | 251 | if loaded != 1 { | |
| 256 | 252 | t.Errorf("loaded = %d; want 1", loaded) | |
| @@ -276,7 +272,6 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||
| 276 | 272 | ) | |
| 277 | 273 | f := New(Config{ | |
| 278 | 274 | Root: root, RepoRoot: root, | |
| 279 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 280 | 275 | NewRunner: func(a *Agent) Runner { | |
| 281 | 276 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 282 | 277 | mu.Lock() | |
| @@ -358,7 +353,6 @@ func TestResumeSetsResumingFlag(t *testing.T) { | |||
| 358 | 353 | root := t.TempDir() | |
| 359 | 354 | f := New(Config{ | |
| 360 | 355 | Root: root, RepoRoot: root, | |
| 361 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 362 | 356 | NewRunner: func(a *Agent) Runner { | |
| 363 | 357 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 364 | 358 | }, | |
| @@ -392,7 +386,6 @@ func TestResumeRejectsRunningAgent(t *testing.T) { | |||
| 392 | 386 | root := t.TempDir() | |
| 393 | 387 | f := New(Config{ | |
| 394 | 388 | Root: root, RepoRoot: root, | |
| 395 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 396 | 389 | NewRunner: func(a *Agent) Runner { | |
| 397 | 390 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 398 | 391 | }, | |
| @@ -430,7 +423,6 @@ func TestResumeAfterReload(t *testing.T) { | |||
| 430 | 423 | // Process A | |
| 431 | 424 | a := New(Config{ | |
| 432 | 425 | Root: root, RepoRoot: root, | |
| 433 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 434 | 426 | NewRunner: func(ag *Agent) Runner { | |
| 435 | 427 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 436 | 428 | sink.Transcript("first run for " + ag.ID) | |
| @@ -456,7 +448,6 @@ func TestResumeAfterReload(t *testing.T) { | |||
| 456 | 448 | resumed := make(chan struct{}, 1) | |
| 457 | 449 | b := New(Config{ | |
| 458 | 450 | Root: root, RepoRoot: root, | |
| 459 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 460 | 451 | NewRunner: func(ag *Agent) Runner { | |
| 461 | 452 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |
| 462 | 453 | sink.Transcript("second run for " + ag.ID) | |
| @@ -507,7 +498,6 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 507 | 498 | root := t.TempDir() | |
| 508 | 499 | f := New(Config{ | |
| 509 | 500 | Root: root, RepoRoot: root, | |
| 510 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 511 | 501 | NewRunner: func(a *Agent) Runner { | |
| 512 | 502 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 513 | 503 | }, | |
| @@ -547,7 +537,7 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 547 | 537 | // Reload in a fresh Swarm and confirm the detached agent still | |
| 548 | 538 | // carries the model/provider so Resume can route the child | |
| 549 | 539 | // subprocess back to the same model. | |
| 550 | - | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 540 | + | g := New(Config{Root: root, RepoRoot: root}) | |
| 551 | 541 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | |
| 552 | 542 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 553 | 543 | } | |
| @@ -605,8 +595,8 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |||
| 605 | 595 | t.Fatal(err) | |
| 606 | 596 | } | |
| 607 | 597 | m := agentMeta{ | |
| 608 | - | ID: id, Task: "t", Branch: "swarm/" + id, | |
| 609 | - | Dir: filepath.Join(root, "wt", id), | |
| 598 | + | ID: id, Task: "t", | |
| 599 | + | Dir: root, | |
| 610 | 600 | Started: time.Now().Add(-time.Hour), | |
| 611 | 601 | InboxPath: filepath.Join(stateDir, "in.sock"), | |
| 612 | 602 | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| @@ -617,7 +607,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |||
| 617 | 607 | t.Fatal(err) | |
| 618 | 608 | } | |
| 619 | 609 | ||
| 620 | - | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 610 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 621 | 611 | if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 { | |
| 622 | 612 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 623 | 613 | } | |
| @@ -654,7 +644,6 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||
| 654 | 644 | root := t.TempDir() | |
| 655 | 645 | f := New(Config{ | |
| 656 | 646 | Root: root, RepoRoot: root, | |
| 657 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 658 | 647 | NewRunner: func(a *Agent) Runner { | |
| 659 | 648 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 660 | 649 | }, | |
| @@ -679,7 +668,7 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||
| 679 | 668 | } | |
| 680 | 669 | ||
| 681 | 670 | // A fresh Swarm + Reload should find nothing. | |
| 682 | - | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |
| 671 | + | g := New(Config{Root: root, RepoRoot: root}) | |
| 683 | 672 | if loaded, _ := g.Reload(); loaded != 0 { | |
| 684 | 673 | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | |
| 685 | 674 | } | |
| @@ -694,7 +683,6 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||
| 694 | 683 | root := t.TempDir() | |
| 695 | 684 | f := New(Config{ | |
| 696 | 685 | Root: root, RepoRoot: root, | |
| 697 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 698 | 686 | NewRunner: func(a *Agent) Runner { | |
| 699 | 687 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 700 | 688 | }, | |
| @@ -759,7 +747,6 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||
| 759 | 747 | mkSwarm := func() *Swarm { | |
| 760 | 748 | return New(Config{ | |
| 761 | 749 | Root: root, RepoRoot: root, | |
| 762 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 763 | 750 | NewRunner: func(a *Agent) Runner { | |
| 764 | 751 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 765 | 752 | }, | |
| @@ -803,7 +790,6 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||
| 803 | 790 | root := t.TempDir() | |
| 804 | 791 | f := New(Config{ | |
| 805 | 792 | Root: root, RepoRoot: root, | |
| 806 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 807 | 793 | NewRunner: func(a *Agent) Runner { | |
| 808 | 794 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 809 | 795 | }, | |
| @@ -21,7 +21,6 @@ func TestSpawnWritesMetaJSON(t *testing.T) { | |||||
| 21 | f := New(Config{ | 21 | f := New(Config{ | ||
| 22 | Root: root, | 22 | Root: root, | ||
| 23 | RepoRoot: root, | 23 | RepoRoot: root, | ||
| 24 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 25 | NewRunner: func(a *Agent) Runner { | 24 | NewRunner: func(a *Agent) Runner { | ||
| 26 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | 25 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | ||
| 27 | <-ctx.Done() | 26 | <-ctx.Done() | ||
| @@ -51,7 +50,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) { | |||||
| 51 | if got.Task != "investigate widget" { | 50 | if got.Task != "investigate widget" { | ||
| 52 | t.Errorf("meta.Task = %q", got.Task) | 51 | t.Errorf("meta.Task = %q", got.Task) | ||
| 53 | } | 52 | } | ||
| 54 | - | if got.Branch != a.Branch || got.Dir != a.Dir { | 53 | + | if got.Dir != a.Dir { |
| 55 | t.Errorf("meta paths drifted: %+v vs agent %+v", got, a) | 54 | t.Errorf("meta paths drifted: %+v vs agent %+v", got, a) | ||
| 56 | } | 55 | } | ||
| 57 | if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" { | 56 | if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" { | ||
| @@ -74,7 +73,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||||
| 74 | first := New(Config{ | 73 | first := New(Config{ | ||
| 75 | Root: root, | 74 | Root: root, | ||
| 76 | RepoRoot: root, | 75 | RepoRoot: root, | ||
| 77 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 78 | NewRunner: func(a *Agent) Runner { | 76 | NewRunner: func(a *Agent) Runner { | ||
| 79 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | 77 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | ||
| 80 | <-ctx.Done() | 78 | <-ctx.Done() | ||
| @@ -101,7 +99,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||||
| 101 | second := New(Config{ | 99 | second := New(Config{ | ||
| 102 | Root: root, | 100 | Root: root, | ||
| 103 | RepoRoot: root, | 101 | RepoRoot: root, | ||
| 104 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 105 | }) | 102 | }) | ||
| 106 | loaded, errs := second.Reload() | 103 | loaded, errs := second.Reload() | ||
| 107 | if len(errs) > 0 { | 104 | if len(errs) > 0 { | ||
| @@ -140,7 +137,6 @@ func TestReloadIsIdempotent(t *testing.T) { | |||||
| 140 | root := t.TempDir() | 137 | root := t.TempDir() | ||
| 141 | first := New(Config{ | 138 | first := New(Config{ | ||
| 142 | Root: root, RepoRoot: root, | 139 | Root: root, RepoRoot: root, | ||
| 143 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 144 | NewRunner: func(a *Agent) Runner { | 140 | NewRunner: func(a *Agent) Runner { | ||
| 145 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 141 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 146 | }, | 142 | }, | ||
| @@ -150,7 +146,7 @@ func TestReloadIsIdempotent(t *testing.T) { | |||||
| 150 | } | 146 | } | ||
| 151 | first.StopAll() | 147 | first.StopAll() | ||
| 152 | 148 | ||||
| 153 | - | second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | 149 | + | second := New(Config{Root: root, RepoRoot: root}) |
| 154 | loaded1, _ := second.Reload() | 150 | loaded1, _ := second.Reload() | ||
| 155 | loaded2, errs := second.Reload() | 151 | loaded2, errs := second.Reload() | ||
| 156 | if len(errs) > 0 { | 152 | if len(errs) > 0 { | ||
| @@ -177,8 +173,8 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||||
| 177 | } | 173 | } | ||
| 178 | // meta.json | 174 | // meta.json | ||
| 179 | m := agentMeta{ | 175 | m := agentMeta{ | ||
| 180 | - | ID: id, Task: "do thing", Branch: "swarm/alpha-9", | 176 | + | ID: id, Task: "do thing", |
| 181 | - | Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour), | 177 | + | Dir: root, Started: time.Now().Add(-time.Hour), |
| 182 | InboxPath: filepath.Join(stateDir, "in.sock"), | 178 | InboxPath: filepath.Join(stateDir, "in.sock"), | ||
| 183 | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | 179 | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | ||
| 184 | SessionPath: filepath.Join(stateDir, "session.json"), | 180 | SessionPath: filepath.Join(stateDir, "session.json"), | ||
| @@ -198,7 +194,7 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||||
| 198 | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"})) | 194 | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"})) | ||
| 199 | _ = log.Close() | 195 | _ = log.Close() | ||
| 200 | 196 | ||||
| 201 | - | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | 197 | + | f := New(Config{Root: root, RepoRoot: root}) |
| 202 | loaded, errs := f.Reload() | 198 | loaded, errs := f.Reload() | ||
| 203 | if len(errs) > 0 || loaded != 1 { | 199 | if len(errs) > 0 || loaded != 1 { | ||
| 204 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | 200 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | ||
| @@ -246,11 +242,11 @@ func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) { | |||||
| 246 | good := "good-1" | 242 | good := "good-1" | ||
| 247 | stateDir := filepath.Join(agentsDir, good) | 243 | stateDir := filepath.Join(agentsDir, good) | ||
| 248 | _ = os.MkdirAll(stateDir, 0o755) | 244 | _ = os.MkdirAll(stateDir, 0o755) | ||
| 249 | - | m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()} | 245 | + | m := agentMeta{ID: good, Task: "x", Dir: "/tmp/x", Started: time.Now()} |
| 250 | mb, _ := json.MarshalIndent(m, "", " ") | 246 | mb, _ := json.MarshalIndent(m, "", " ") | ||
| 251 | _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644) | 247 | _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644) | ||
| 252 | 248 | ||||
| 253 | - | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | 249 | + | f := New(Config{Root: root, RepoRoot: root}) |
| 254 | loaded, errs := f.Reload() | 250 | loaded, errs := f.Reload() | ||
| 255 | if loaded != 1 { | 251 | if loaded != 1 { | ||
| 256 | t.Errorf("loaded = %d; want 1", loaded) | 252 | t.Errorf("loaded = %d; want 1", loaded) | ||
| @@ -276,7 +272,6 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||||
| 276 | ) | 272 | ) | ||
| 277 | f := New(Config{ | 273 | f := New(Config{ | ||
| 278 | Root: root, RepoRoot: root, | 274 | Root: root, RepoRoot: root, | ||
| 279 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 280 | NewRunner: func(a *Agent) Runner { | 275 | NewRunner: func(a *Agent) Runner { | ||
| 281 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 276 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 282 | mu.Lock() | 277 | mu.Lock() | ||
| @@ -358,7 +353,6 @@ func TestResumeSetsResumingFlag(t *testing.T) { | |||||
| 358 | root := t.TempDir() | 353 | root := t.TempDir() | ||
| 359 | f := New(Config{ | 354 | f := New(Config{ | ||
| 360 | Root: root, RepoRoot: root, | 355 | Root: root, RepoRoot: root, | ||
| 361 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 362 | NewRunner: func(a *Agent) Runner { | 356 | NewRunner: func(a *Agent) Runner { | ||
| 363 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 357 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 364 | }, | 358 | }, | ||
| @@ -392,7 +386,6 @@ func TestResumeRejectsRunningAgent(t *testing.T) { | |||||
| 392 | root := t.TempDir() | 386 | root := t.TempDir() | ||
| 393 | f := New(Config{ | 387 | f := New(Config{ | ||
| 394 | Root: root, RepoRoot: root, | 388 | Root: root, RepoRoot: root, | ||
| 395 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 396 | NewRunner: func(a *Agent) Runner { | 389 | NewRunner: func(a *Agent) Runner { | ||
| 397 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 390 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 398 | }, | 391 | }, | ||
| @@ -430,7 +423,6 @@ func TestResumeAfterReload(t *testing.T) { | |||||
| 430 | // Process A | 423 | // Process A | ||
| 431 | a := New(Config{ | 424 | a := New(Config{ | ||
| 432 | Root: root, RepoRoot: root, | 425 | Root: root, RepoRoot: root, | ||
| 433 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 434 | NewRunner: func(ag *Agent) Runner { | 426 | NewRunner: func(ag *Agent) Runner { | ||
| 435 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 427 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 436 | sink.Transcript("first run for " + ag.ID) | 428 | sink.Transcript("first run for " + ag.ID) | ||
| @@ -456,7 +448,6 @@ func TestResumeAfterReload(t *testing.T) { | |||||
| 456 | resumed := make(chan struct{}, 1) | 448 | resumed := make(chan struct{}, 1) | ||
| 457 | b := New(Config{ | 449 | b := New(Config{ | ||
| 458 | Root: root, RepoRoot: root, | 450 | Root: root, RepoRoot: root, | ||
| 459 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 460 | NewRunner: func(ag *Agent) Runner { | 451 | NewRunner: func(ag *Agent) Runner { | ||
| 461 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | 452 | return RunnerFunc(func(ctx context.Context, sink Sink) error { | ||
| 462 | sink.Transcript("second run for " + ag.ID) | 453 | sink.Transcript("second run for " + ag.ID) | ||
| @@ -507,7 +498,6 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 507 | root := t.TempDir() | 498 | root := t.TempDir() | ||
| 508 | f := New(Config{ | 499 | f := New(Config{ | ||
| 509 | Root: root, RepoRoot: root, | 500 | Root: root, RepoRoot: root, | ||
| 510 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 511 | NewRunner: func(a *Agent) Runner { | 501 | NewRunner: func(a *Agent) Runner { | ||
| 512 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 502 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 513 | }, | 503 | }, | ||
| @@ -547,7 +537,7 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 547 | // Reload in a fresh Swarm and confirm the detached agent still | 537 | // Reload in a fresh Swarm and confirm the detached agent still | ||
| 548 | // carries the model/provider so Resume can route the child | 538 | // carries the model/provider so Resume can route the child | ||
| 549 | // subprocess back to the same model. | 539 | // subprocess back to the same model. | ||
| 550 | - | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | 540 | + | g := New(Config{Root: root, RepoRoot: root}) |
| 551 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | 541 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | ||
| 552 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | 542 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | ||
| 553 | } | 543 | } | ||
| @@ -605,8 +595,8 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |||||
| 605 | t.Fatal(err) | 595 | t.Fatal(err) | ||
| 606 | } | 596 | } | ||
| 607 | m := agentMeta{ | 597 | m := agentMeta{ | ||
| 608 | - | ID: id, Task: "t", Branch: "swarm/" + id, | 598 | + | ID: id, Task: "t", |
| 609 | - | Dir: filepath.Join(root, "wt", id), | 599 | + | Dir: root, |
| 610 | Started: time.Now().Add(-time.Hour), | 600 | Started: time.Now().Add(-time.Hour), | ||
| 611 | InboxPath: filepath.Join(stateDir, "in.sock"), | 601 | InboxPath: filepath.Join(stateDir, "in.sock"), | ||
| 612 | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | 602 | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | ||
| @@ -617,7 +607,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |||||
| 617 | t.Fatal(err) | 607 | t.Fatal(err) | ||
| 618 | } | 608 | } | ||
| 619 | 609 | ||||
| 620 | - | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | 610 | + | f := New(Config{Root: root, RepoRoot: root}) |
| 621 | if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 { | 611 | if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 { | ||
| 622 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | 612 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | ||
| 623 | } | 613 | } | ||
| @@ -654,7 +644,6 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||||
| 654 | root := t.TempDir() | 644 | root := t.TempDir() | ||
| 655 | f := New(Config{ | 645 | f := New(Config{ | ||
| 656 | Root: root, RepoRoot: root, | 646 | Root: root, RepoRoot: root, | ||
| 657 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 658 | NewRunner: func(a *Agent) Runner { | 647 | NewRunner: func(a *Agent) Runner { | ||
| 659 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 648 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 660 | }, | 649 | }, | ||
| @@ -679,7 +668,7 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||||
| 679 | } | 668 | } | ||
| 680 | 669 | ||||
| 681 | // A fresh Swarm + Reload should find nothing. | 670 | // A fresh Swarm + Reload should find nothing. | ||
| 682 | - | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | 671 | + | g := New(Config{Root: root, RepoRoot: root}) |
| 683 | if loaded, _ := g.Reload(); loaded != 0 { | 672 | if loaded, _ := g.Reload(); loaded != 0 { | ||
| 684 | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | 673 | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | ||
| 685 | } | 674 | } | ||
| @@ -694,7 +683,6 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||||
| 694 | root := t.TempDir() | 683 | root := t.TempDir() | ||
| 695 | f := New(Config{ | 684 | f := New(Config{ | ||
| 696 | Root: root, RepoRoot: root, | 685 | Root: root, RepoRoot: root, | ||
| 697 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 698 | NewRunner: func(a *Agent) Runner { | 686 | NewRunner: func(a *Agent) Runner { | ||
| 699 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 687 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 700 | }, | 688 | }, | ||
| @@ -759,7 +747,6 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||||
| 759 | mkSwarm := func() *Swarm { | 747 | mkSwarm := func() *Swarm { | ||
| 760 | return New(Config{ | 748 | return New(Config{ | ||
| 761 | Root: root, RepoRoot: root, | 749 | Root: root, RepoRoot: root, | ||
| 762 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 763 | NewRunner: func(a *Agent) Runner { | 750 | NewRunner: func(a *Agent) Runner { | ||
| 764 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 751 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 765 | }, | 752 | }, | ||
| @@ -803,7 +790,6 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||||
| 803 | root := t.TempDir() | 790 | root := t.TempDir() | ||
| 804 | f := New(Config{ | 791 | f := New(Config{ | ||
| 805 | Root: root, RepoRoot: root, | 792 | Root: root, RepoRoot: root, | ||
| 806 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 807 | NewRunner: func(a *Agent) Runner { | 793 | NewRunner: func(a *Agent) Runner { | ||
| 808 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 794 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 809 | }, | 795 | }, | ||
M internal/swarm/runner.go +29 -5
| @@ -14,7 +14,8 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | |
| 17 | - | // the agent's worktree and consumes its JSONL event stream on stdout. | |
| 17 | + | // the host's working directory (Agent.Dir, which is always the parent | |
| 18 | + | // zot's RepoRoot) and consumes its JSONL event stream on stdout. | |
| 18 | 19 | // | |
| 19 | 20 | // Why a long-lived daemon and not `zot --print`: the supervisor and | |
| 20 | 21 | // the user expect agents to keep accepting follow-up prompts. A | |
| @@ -40,9 +41,14 @@ type execRunner struct { | |||
| 40 | 41 | // tested without a real child. Production code leaves it nil. | |
| 41 | 42 | Command []string | |
| 42 | 43 | ||
| 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. | |
| 44 | + | // SessionPath is the agent's session file. Empty means "defer | |
| 45 | + | // to r.agent.SessionPath", which Swarm.Spawn always populates | |
| 46 | + | // with <swarm-root>/agents/<id>/session.json. Tests that | |
| 47 | + | // hand-build an Agent without going through Spawn must set | |
| 48 | + | // one of the two; the runner refuses to invent a fallback | |
| 49 | + | // because the only plausible one (<Dir>/.zot/session.json) | |
| 50 | + | // would litter the user's repo — every agent's Dir points | |
| 51 | + | // at it directly. | |
| 46 | 52 | SessionPath string | |
| 47 | 53 | } | |
| 48 | 54 | ||
| @@ -116,9 +122,27 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 116 | 122 | } | |
| 117 | 123 | ||
| 118 | 124 | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |
| 125 | + | // SessionPath resolution order: | |
| 126 | + | // 1. explicit r.SessionPath set by the test / caller | |
| 127 | + | // 2. r.agent.SessionPath baked in by Swarm.Spawn — the | |
| 128 | + | // production path. Always lives under | |
| 129 | + | // <swarm-root>/agents/<id>/session.json so the per- | |
| 130 | + | // agent state is entirely outside the working tree. | |
| 131 | + | // Crucial because Agent.Dir points at the user's repo; | |
| 132 | + | // any .zot/ scratch directory under Dir would litter | |
| 133 | + | // their source tree. | |
| 134 | + | // | |
| 135 | + | // There is no third fallback. If neither path is set we | |
| 136 | + | // refuse to start instead of inventing a directory; that | |
| 137 | + | // way a misconfigured caller fails loudly the first time | |
| 138 | + | // instead of silently dumping session data into someone's | |
| 139 | + | // repo. | |
| 119 | 140 | sessionPath := r.SessionPath | |
| 120 | 141 | if sessionPath == "" { | |
| 121 | - | sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json") | |
| 142 | + | sessionPath = r.agent.SessionPath | |
| 143 | + | } | |
| 144 | + | if sessionPath == "" { | |
| 145 | + | return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)") | |
| 122 | 146 | } | |
| 123 | 147 | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | |
| 124 | 148 | return fmt.Errorf("session dir: %w", err) | |
| @@ -14,7 +14,8 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | 16 | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | ||
| 17 | - | // the agent's worktree and consumes its JSONL event stream on stdout. | 17 | + | // the host's working directory (Agent.Dir, which is always the parent |
| 18 | + | // zot's RepoRoot) and consumes its JSONL event stream on stdout. | |||
| 18 | // | 19 | // | ||
| 19 | // Why a long-lived daemon and not `zot --print`: the supervisor and | 20 | // 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 | // the user expect agents to keep accepting follow-up prompts. A | ||
| @@ -40,9 +41,14 @@ type execRunner struct { | |||||
| 40 | // tested without a real child. Production code leaves it nil. | 41 | // tested without a real child. Production code leaves it nil. | ||
| 41 | Command []string | 42 | Command []string | ||
| 42 | 43 | ||||
| 43 | - | // SessionPath is the agent's session file. When empty the | 44 | + | // SessionPath is the agent's session file. Empty means "defer |
| 44 | - | // runner derives it as <Dir>/.zot/session.json so each agent | 45 | + | // to r.agent.SessionPath", which Swarm.Spawn always populates |
| 45 | - | // owns its own session inside its worktree. | 46 | + | // with <swarm-root>/agents/<id>/session.json. Tests that |
| 47 | + | // hand-build an Agent without going through Spawn must set | |||
| 48 | + | // one of the two; the runner refuses to invent a fallback | |||
| 49 | + | // because the only plausible one (<Dir>/.zot/session.json) | |||
| 50 | + | // would litter the user's repo — every agent's Dir points | |||
| 51 | + | // at it directly. | |||
| 46 | SessionPath string | 52 | SessionPath string | ||
| 47 | } | 53 | } | ||
| 48 | 54 | ||||
| @@ -116,9 +122,27 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||||
| 116 | } | 122 | } | ||
| 117 | 123 | ||||
| 118 | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | 124 | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | ||
| 125 | + | // SessionPath resolution order: | |||
| 126 | + | // 1. explicit r.SessionPath set by the test / caller | |||
| 127 | + | // 2. r.agent.SessionPath baked in by Swarm.Spawn — the | |||
| 128 | + | // production path. Always lives under | |||
| 129 | + | // <swarm-root>/agents/<id>/session.json so the per- | |||
| 130 | + | // agent state is entirely outside the working tree. | |||
| 131 | + | // Crucial because Agent.Dir points at the user's repo; | |||
| 132 | + | // any .zot/ scratch directory under Dir would litter | |||
| 133 | + | // their source tree. | |||
| 134 | + | // | |||
| 135 | + | // There is no third fallback. If neither path is set we | |||
| 136 | + | // refuse to start instead of inventing a directory; that | |||
| 137 | + | // way a misconfigured caller fails loudly the first time | |||
| 138 | + | // instead of silently dumping session data into someone's | |||
| 139 | + | // repo. | |||
| 119 | sessionPath := r.SessionPath | 140 | sessionPath := r.SessionPath | ||
| 120 | if sessionPath == "" { | 141 | if sessionPath == "" { | ||
| 121 | - | sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json") | 142 | + | sessionPath = r.agent.SessionPath |
| 143 | + | } | |||
| 144 | + | if sessionPath == "" { | |||
| 145 | + | return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)") | |||
| 122 | } | 146 | } | ||
| 123 | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | 147 | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | ||
| 124 | return fmt.Errorf("session dir: %w", err) | 148 | return fmt.Errorf("session dir: %w", err) | ||
M internal/swarm/runner_e2e_test.go +0 -1
| @@ -46,7 +46,6 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||
| 46 | 46 | f := New(Config{ | |
| 47 | 47 | Root: root, | |
| 48 | 48 | RepoRoot: repo, | |
| 49 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |
| 50 | 49 | NewRunner: func(a *Agent) Runner { | |
| 51 | 50 | return &execRunner{ | |
| 52 | 51 | agent: a, | |
| @@ -46,7 +46,6 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||||
| 46 | f := New(Config{ | 46 | f := New(Config{ | ||
| 47 | Root: root, | 47 | Root: root, | ||
| 48 | RepoRoot: repo, | 48 | RepoRoot: repo, | ||
| 49 | - | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 50 | NewRunner: func(a *Agent) Runner { | 49 | NewRunner: func(a *Agent) Runner { | ||
| 51 | return &execRunner{ | 50 | return &execRunner{ | ||
| 52 | agent: a, | 51 | agent: a, | ||
M internal/swarm/swarm.go +45 -52
| @@ -1,20 +1,26 @@ | |||
| 1 | 1 | // Package swarm implements zot's multi-agent supervisor. | |
| 2 | 2 | // | |
| 3 | 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. | |
| 4 | + | // that share the host's working directory. The interactive TUI | |
| 5 | + | // exposes the supervisor through the /swarm slash command and a | |
| 6 | + | // dashboard dialog; non-TUI code can drive it directly through | |
| 7 | + | // this package. | |
| 8 | + | // | |
| 9 | + | // Every agent runs with cwd == the parent zot's RepoRoot — the | |
| 10 | + | // same files the user sees, the same files the main agent edits. | |
| 11 | + | // There is no git worktree, no per-agent branch, no isolation. If | |
| 12 | + | // you want parallel edits on a separate branch, use normal git | |
| 13 | + | // tooling (a real worktree, a different terminal) yourself. | |
| 7 | 14 | // | |
| 8 | 15 | // Each Agent has: | |
| 9 | 16 | // - a unique id (short slug + nanoseconds) | |
| 10 | - | // - a branch name on a fresh git worktree under <root>/swarm/<id> | |
| 11 | 17 | // - a Runner (the thing that actually executes the task) | |
| 12 | 18 | // - a Status string + Activity string that the dashboard reads | |
| 13 | 19 | // | |
| 14 | 20 | // The Runner abstraction means tests can swap a fake in instead of | |
| 15 | 21 | // 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. | |
| 22 | + | // `zot --swarm-agent ...` so we reuse zot's own model resolution | |
| 23 | + | // and tooling without re-implementing the agent loop. | |
| 18 | 24 | package swarm | |
| 19 | 25 | ||
| 20 | 26 | import ( | |
| @@ -43,32 +49,24 @@ const ( | |||
| 43 | 49 | ||
| 44 | 50 | // Config configures a Swarm. | |
| 45 | 51 | type Config struct { | |
| 46 | - | // Root is the directory under which worktrees + state files live. | |
| 52 | + | // Root is the directory under which per-agent state files live. | |
| 47 | 53 | // Typically <ZotHome>/swarm, but tests pass a tempdir. | |
| 48 | 54 | Root string | |
| 49 | 55 | ||
| 50 | - | // RepoRoot is the path to the user's git repo (CWD of the parent | |
| 51 | - | // zot). New worktrees branch off this repo. | |
| 56 | + | // RepoRoot is the working directory every spawned agent runs | |
| 57 | + | // in — the same cwd the parent zot is using. There is no | |
| 58 | + | // per-agent isolation: agents edit the host's files directly. | |
| 52 | 59 | RepoRoot string | |
| 53 | 60 | ||
| 54 | - | // Worktree creates the per-agent working directory. If nil, the | |
| 55 | - | // default git-worktree implementation is used. | |
| 56 | - | Worktree WorktreeManager | |
| 57 | - | ||
| 58 | 61 | // NewRunner produces the Runner for an Agent. If nil, the default | |
| 59 | - | // `zot --print` exec runner is used. Tests inject a fake here. | |
| 62 | + | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake | |
| 63 | + | // here. | |
| 60 | 64 | NewRunner func(a *Agent) Runner | |
| 61 | 65 | ||
| 62 | 66 | // Now is a clock seam for tests; defaults to time.Now. | |
| 63 | 67 | Now func() time.Time | |
| 64 | 68 | } | |
| 65 | 69 | ||
| 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 | 70 | // Runner executes one agent task. Run blocks until the task finishes, | |
| 73 | 71 | // is cancelled via ctx, or hits an unrecoverable error. | |
| 74 | 72 | // | |
| @@ -118,9 +116,6 @@ func New(cfg Config) *Swarm { | |||
| 118 | 116 | if cfg.Now == nil { | |
| 119 | 117 | cfg.Now = time.Now | |
| 120 | 118 | } | |
| 121 | - | if cfg.Worktree == nil { | |
| 122 | - | cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot} | |
| 123 | - | } | |
| 124 | 119 | if cfg.NewRunner == nil { | |
| 125 | 120 | cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} } | |
| 126 | 121 | } | |
| @@ -158,7 +153,7 @@ func (f *Swarm) ActiveSession() string { | |||
| 158 | 153 | // events.jsonl durable event log (runner-owned) | |
| 159 | 154 | // in.sock unix socket inbox (child-owned) | |
| 160 | 155 | // session.json persistent agent session (child-owned) | |
| 161 | - | // meta.json static metadata (id, task, branch, dir) | |
| 156 | + | // meta.json static metadata (id, task) | |
| 162 | 157 | func (f *Swarm) agentStateDir(id string) string { | |
| 163 | 158 | return filepath.Join(f.cfg.Root, "agents", id) | |
| 164 | 159 | } | |
| @@ -173,13 +168,13 @@ type SpawnRequest struct { | |||
| 173 | 168 | Provider string // optional override; usually paired with Model | |
| 174 | 169 | } | |
| 175 | 170 | ||
| 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. | |
| 171 | + | // Spawn creates a new Agent for the given task, allocates its | |
| 172 | + | // on-disk state directory (events log, inbox socket path, session | |
| 173 | + | // file path), and starts the Runner on a background goroutine. The | |
| 174 | + | // returned Agent is already in StatusRunning (or StatusFailed if | |
| 175 | + | // state setup failed before the goroutine started). This is the | |
| 176 | + | // historical signature; callers that want to override the child's | |
| 177 | + | // model use SpawnReq instead. | |
| 183 | 178 | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |
| 184 | 179 | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | |
| 185 | 180 | } | |
| @@ -187,17 +182,17 @@ func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |||
| 187 | 182 | // SpawnReq is the full-fat variant of Spawn that accepts a | |
| 188 | 183 | // SpawnRequest. Existing callers can keep using Spawn; new code that | |
| 189 | 184 | // wants to pin the child's model uses this. | |
| 185 | + | // | |
| 186 | + | // Every spawned agent runs with cwd == cfg.RepoRoot — the same | |
| 187 | + | // working directory as the host. No per-agent worktree, no branch, | |
| 188 | + | // no isolation. The user explicitly opted out of the worktree flow. | |
| 190 | 189 | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | |
| 191 | 190 | task := strings.TrimSpace(req.Task) | |
| 192 | 191 | if task == "" { | |
| 193 | 192 | return nil, errors.New("swarm: empty task") | |
| 194 | 193 | } | |
| 195 | 194 | 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 | - | } | |
| 195 | + | dir := f.cfg.RepoRoot | |
| 201 | 196 | ||
| 202 | 197 | stateDir := f.agentStateDir(id) | |
| 203 | 198 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| @@ -226,7 +221,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 226 | 221 | a := &Agent{ | |
| 227 | 222 | ID: id, | |
| 228 | 223 | Task: task, | |
| 229 | - | Branch: branch, | |
| 230 | 224 | Dir: dir, | |
| 231 | 225 | Started: f.cfg.Now(), | |
| 232 | 226 | Model: strings.TrimSpace(req.Model), | |
| @@ -385,11 +379,15 @@ func (f *Swarm) StopAll() { | |||
| 385 | 379 | } | |
| 386 | 380 | } | |
| 387 | 381 | ||
| 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. | |
| 382 | + | // Remove tears down the per-agent state for a terminated agent. It | |
| 383 | + | // is an error to remove an agent that's still running; call Stop | |
| 384 | + | // first and wait for the status to settle. Detached agents | |
| 385 | + | // (reloaded from disk) remove cleanly because they have no live | |
| 386 | + | // runner racing for the same files. | |
| 387 | + | // | |
| 388 | + | // Agents share the host's working tree, so Remove never touches | |
| 389 | + | // any source file — it only deletes the agent's state directory | |
| 390 | + | // under <root>/agents/<id>/. | |
| 393 | 391 | func (f *Swarm) Remove(id string) error { | |
| 394 | 392 | a := f.Get(id) | |
| 395 | 393 | if a == nil { | |
| @@ -397,18 +395,14 @@ func (f *Swarm) Remove(id string) error { | |||
| 397 | 395 | } | |
| 398 | 396 | a.mu.Lock() | |
| 399 | 397 | st := a.status | |
| 400 | - | dir := a.Dir | |
| 401 | 398 | a.mu.Unlock() | |
| 402 | 399 | if st == StatusRunning || st == StatusPending { | |
| 403 | 400 | return fmt.Errorf("agent %s still %s", a.ID, st) | |
| 404 | 401 | } | |
| 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. | |
| 402 | + | // Best-effort cleanup of the per-agent state directory | |
| 403 | + | // (meta.json, events.jsonl, session.json, in.sock if it's | |
| 404 | + | // local). Failing here would leave the user with no recourse, | |
| 405 | + | // so swallow the error. | |
| 412 | 406 | _ = os.RemoveAll(f.agentStateDir(a.ID)) | |
| 413 | 407 | f.mu.Lock() | |
| 414 | 408 | delete(f.agents, a.ID) | |
| @@ -427,7 +421,6 @@ func (f *Swarm) Remove(id string) error { | |||
| 427 | 421 | type AgentSnapshot struct { | |
| 428 | 422 | ID string | |
| 429 | 423 | Task string | |
| 430 | - | Branch string | |
| 431 | 424 | Dir string | |
| 432 | 425 | Status Status | |
| 433 | 426 | Activity string | |
| @@ -465,7 +458,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 465 | 458 | errStr = a.lastErr.Error() | |
| 466 | 459 | } | |
| 467 | 460 | return AgentSnapshot{ | |
| 468 | - | ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir, | |
| 461 | + | ID: a.ID, Task: a.Task, Dir: a.Dir, | |
| 469 | 462 | Status: a.status, Activity: a.activity, | |
| 470 | 463 | Started: a.Started, Finished: a.finished, | |
| 471 | 464 | Err: errStr, Tail: tail, Lines: lines, | |
| @@ -1,20 +1,26 @@ | |||||
| 1 | // Package swarm implements zot's multi-agent supervisor. | 1 | // Package swarm implements zot's multi-agent supervisor. | ||
| 2 | // | 2 | // | ||
| 3 | // A Swarm manages a set of headless zot subprocesses ("agents") | 3 | // A Swarm manages a set of headless zot subprocesses ("agents") | ||
| 4 | - | // working in their own git worktrees. The interactive TUI exposes | 4 | + | // that share the host's working directory. The interactive TUI |
| 5 | - | // the supervisor through the /swarm slash command and a dashboard | 5 | + | // exposes the supervisor through the /swarm slash command and a |
| 6 | - | // dialog; non-TUI code can drive it directly through this package. | 6 | + | // dashboard dialog; non-TUI code can drive it directly through |
| 7 | + | // this package. | |||
| 8 | + | // | |||
| 9 | + | // Every agent runs with cwd == the parent zot's RepoRoot — the | |||
| 10 | + | // same files the user sees, the same files the main agent edits. | |||
| 11 | + | // There is no git worktree, no per-agent branch, no isolation. If | |||
| 12 | + | // you want parallel edits on a separate branch, use normal git | |||
| 13 | + | // tooling (a real worktree, a different terminal) yourself. | |||
| 7 | // | 14 | // | ||
| 8 | // Each Agent has: | 15 | // Each Agent has: | ||
| 9 | // - a unique id (short slug + nanoseconds) | 16 | // - 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) | 17 | // - a Runner (the thing that actually executes the task) | ||
| 12 | // - a Status string + Activity string that the dashboard reads | 18 | // - a Status string + Activity string that the dashboard reads | ||
| 13 | // | 19 | // | ||
| 14 | // The Runner abstraction means tests can swap a fake in instead of | 20 | // The Runner abstraction means tests can swap a fake in instead of | ||
| 15 | // really spawning a subprocess; the production Runner shells out to | 21 | // really spawning a subprocess; the production Runner shells out to | ||
| 16 | - | // `zot --print` so we reuse zot's own model resolution and tooling | 22 | + | // `zot --swarm-agent ...` so we reuse zot's own model resolution |
| 17 | - | // without re-implementing the agent loop. | 23 | + | // and tooling without re-implementing the agent loop. |
| 18 | package swarm | 24 | package swarm | ||
| 19 | 25 | ||||
| 20 | import ( | 26 | import ( | ||
| @@ -43,32 +49,24 @@ const ( | |||||
| 43 | 49 | ||||
| 44 | // Config configures a Swarm. | 50 | // Config configures a Swarm. | ||
| 45 | type Config struct { | 51 | type Config struct { | ||
| 46 | - | // Root is the directory under which worktrees + state files live. | 52 | + | // Root is the directory under which per-agent state files live. |
| 47 | // Typically <ZotHome>/swarm, but tests pass a tempdir. | 53 | // Typically <ZotHome>/swarm, but tests pass a tempdir. | ||
| 48 | Root string | 54 | Root string | ||
| 49 | 55 | ||||
| 50 | - | // RepoRoot is the path to the user's git repo (CWD of the parent | 56 | + | // RepoRoot is the working directory every spawned agent runs |
| 51 | - | // zot). New worktrees branch off this repo. | 57 | + | // in — the same cwd the parent zot is using. There is no |
| 58 | + | // per-agent isolation: agents edit the host's files directly. | |||
| 52 | RepoRoot string | 59 | RepoRoot string | ||
| 53 | 60 | ||||
| 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 | 61 | // NewRunner produces the Runner for an Agent. If nil, the default | ||
| 59 | - | // `zot --print` exec runner is used. Tests inject a fake here. | 62 | + | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake |
| 63 | + | // here. | |||
| 60 | NewRunner func(a *Agent) Runner | 64 | NewRunner func(a *Agent) Runner | ||
| 61 | 65 | ||||
| 62 | // Now is a clock seam for tests; defaults to time.Now. | 66 | // Now is a clock seam for tests; defaults to time.Now. | ||
| 63 | Now func() time.Time | 67 | Now func() time.Time | ||
| 64 | } | 68 | } | ||
| 65 | 69 | ||||
| 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, | 70 | // Runner executes one agent task. Run blocks until the task finishes, | ||
| 73 | // is cancelled via ctx, or hits an unrecoverable error. | 71 | // is cancelled via ctx, or hits an unrecoverable error. | ||
| 74 | // | 72 | // | ||
| @@ -118,9 +116,6 @@ func New(cfg Config) *Swarm { | |||||
| 118 | if cfg.Now == nil { | 116 | if cfg.Now == nil { | ||
| 119 | cfg.Now = time.Now | 117 | cfg.Now = time.Now | ||
| 120 | } | 118 | } | ||
| 121 | - | if cfg.Worktree == nil { | |||
| 122 | - | cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot} | |||
| 123 | - | } | |||
| 124 | if cfg.NewRunner == nil { | 119 | if cfg.NewRunner == nil { | ||
| 125 | cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} } | 120 | cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} } | ||
| 126 | } | 121 | } | ||
| @@ -158,7 +153,7 @@ func (f *Swarm) ActiveSession() string { | |||||
| 158 | // events.jsonl durable event log (runner-owned) | 153 | // events.jsonl durable event log (runner-owned) | ||
| 159 | // in.sock unix socket inbox (child-owned) | 154 | // in.sock unix socket inbox (child-owned) | ||
| 160 | // session.json persistent agent session (child-owned) | 155 | // session.json persistent agent session (child-owned) | ||
| 161 | - | // meta.json static metadata (id, task, branch, dir) | 156 | + | // meta.json static metadata (id, task) |
| 162 | func (f *Swarm) agentStateDir(id string) string { | 157 | func (f *Swarm) agentStateDir(id string) string { | ||
| 163 | return filepath.Join(f.cfg.Root, "agents", id) | 158 | return filepath.Join(f.cfg.Root, "agents", id) | ||
| 164 | } | 159 | } | ||
| @@ -173,13 +168,13 @@ type SpawnRequest struct { | |||||
| 173 | Provider string // optional override; usually paired with Model | 168 | Provider string // optional override; usually paired with Model | ||
| 174 | } | 169 | } | ||
| 175 | 170 | ||||
| 176 | - | // Spawn creates a new Agent for the given task, sets up its worktree, | 171 | + | // Spawn creates a new Agent for the given task, allocates its |
| 177 | - | // allocates the on-disk state directory (events log, inbox socket | 172 | + | // on-disk state directory (events log, inbox socket path, session |
| 178 | - | // path, session file path), and starts the Runner on a background | 173 | + | // file path), and starts the Runner on a background goroutine. The |
| 179 | - | // goroutine. The returned Agent is already in StatusRunning (or | 174 | + | // returned Agent is already in StatusRunning (or StatusFailed if |
| 180 | - | // StatusFailed if worktree setup failed before the goroutine | 175 | + | // state setup failed before the goroutine started). This is the |
| 181 | - | // started). This is the historical signature; callers that want to | 176 | + | // historical signature; callers that want to override the child's |
| 182 | - | // override the child's model use SpawnReq instead. | 177 | + | // model use SpawnReq instead. |
| 183 | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | 178 | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | ||
| 184 | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | 179 | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | ||
| 185 | } | 180 | } | ||
| @@ -187,17 +182,17 @@ func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |||||
| 187 | // SpawnReq is the full-fat variant of Spawn that accepts a | 182 | // SpawnReq is the full-fat variant of Spawn that accepts a | ||
| 188 | // SpawnRequest. Existing callers can keep using Spawn; new code that | 183 | // SpawnRequest. Existing callers can keep using Spawn; new code that | ||
| 189 | // wants to pin the child's model uses this. | 184 | // wants to pin the child's model uses this. | ||
| 185 | + | // | |||
| 186 | + | // Every spawned agent runs with cwd == cfg.RepoRoot — the same | |||
| 187 | + | // working directory as the host. No per-agent worktree, no branch, | |||
| 188 | + | // no isolation. The user explicitly opted out of the worktree flow. | |||
| 190 | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | 189 | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | ||
| 191 | task := strings.TrimSpace(req.Task) | 190 | task := strings.TrimSpace(req.Task) | ||
| 192 | if task == "" { | 191 | if task == "" { | ||
| 193 | return nil, errors.New("swarm: empty task") | 192 | return nil, errors.New("swarm: empty task") | ||
| 194 | } | 193 | } | ||
| 195 | id := newAgentID(task, f.cfg.Now()) | 194 | id := newAgentID(task, f.cfg.Now()) | ||
| 196 | - | branch := "swarm/" + id | 195 | + | dir := f.cfg.RepoRoot |
| 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 | 196 | ||||
| 202 | stateDir := f.agentStateDir(id) | 197 | stateDir := f.agentStateDir(id) | ||
| 203 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | 198 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | ||
| @@ -226,7 +221,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 226 | a := &Agent{ | 221 | a := &Agent{ | ||
| 227 | ID: id, | 222 | ID: id, | ||
| 228 | Task: task, | 223 | Task: task, | ||
| 229 | - | Branch: branch, | |||
| 230 | Dir: dir, | 224 | Dir: dir, | ||
| 231 | Started: f.cfg.Now(), | 225 | Started: f.cfg.Now(), | ||
| 232 | Model: strings.TrimSpace(req.Model), | 226 | Model: strings.TrimSpace(req.Model), | ||
| @@ -385,11 +379,15 @@ func (f *Swarm) StopAll() { | |||||
| 385 | } | 379 | } | ||
| 386 | } | 380 | } | ||
| 387 | 381 | ||||
| 388 | - | // Remove tears down the worktree for a terminated agent. It is an | 382 | + | // Remove tears down the per-agent state for a terminated agent. It |
| 389 | - | // error to remove an agent that's still running; call Stop first and | 383 | + | // is an error to remove an agent that's still running; call Stop |
| 390 | - | // wait for the status to settle. Detached agents (reloaded from | 384 | + | // first and wait for the status to settle. Detached agents |
| 391 | - | // disk) remove cleanly because they have no live runner racing for | 385 | + | // (reloaded from disk) remove cleanly because they have no live |
| 392 | - | // the same files. | 386 | + | // runner racing for the same files. |
| 387 | + | // | |||
| 388 | + | // Agents share the host's working tree, so Remove never touches | |||
| 389 | + | // any source file — it only deletes the agent's state directory | |||
| 390 | + | // under <root>/agents/<id>/. | |||
| 393 | func (f *Swarm) Remove(id string) error { | 391 | func (f *Swarm) Remove(id string) error { | ||
| 394 | a := f.Get(id) | 392 | a := f.Get(id) | ||
| 395 | if a == nil { | 393 | if a == nil { | ||
| @@ -397,18 +395,14 @@ func (f *Swarm) Remove(id string) error { | |||||
| 397 | } | 395 | } | ||
| 398 | a.mu.Lock() | 396 | a.mu.Lock() | ||
| 399 | st := a.status | 397 | st := a.status | ||
| 400 | - | dir := a.Dir | |||
| 401 | a.mu.Unlock() | 398 | a.mu.Unlock() | ||
| 402 | if st == StatusRunning || st == StatusPending { | 399 | if st == StatusRunning || st == StatusPending { | ||
| 403 | return fmt.Errorf("agent %s still %s", a.ID, st) | 400 | return fmt.Errorf("agent %s still %s", a.ID, st) | ||
| 404 | } | 401 | } | ||
| 405 | - | if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil { | 402 | + | // Best-effort cleanup of the per-agent state directory |
| 406 | - | return err | 403 | + | // (meta.json, events.jsonl, session.json, in.sock if it's |
| 407 | - | } | 404 | + | // local). Failing here would leave the user with no recourse, |
| 408 | - | // Best-effort cleanup of the per-agent state directory (meta.json, | 405 | + | // so swallow the error. |
| 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)) | 406 | _ = os.RemoveAll(f.agentStateDir(a.ID)) | ||
| 413 | f.mu.Lock() | 407 | f.mu.Lock() | ||
| 414 | delete(f.agents, a.ID) | 408 | delete(f.agents, a.ID) | ||
| @@ -427,7 +421,6 @@ func (f *Swarm) Remove(id string) error { | |||||
| 427 | type AgentSnapshot struct { | 421 | type AgentSnapshot struct { | ||
| 428 | ID string | 422 | ID string | ||
| 429 | Task string | 423 | Task string | ||
| 430 | - | Branch string | |||
| 431 | Dir string | 424 | Dir string | ||
| 432 | Status Status | 425 | Status Status | ||
| 433 | Activity string | 426 | Activity string | ||
| @@ -465,7 +458,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 465 | errStr = a.lastErr.Error() | 458 | errStr = a.lastErr.Error() | ||
| 466 | } | 459 | } | ||
| 467 | return AgentSnapshot{ | 460 | return AgentSnapshot{ | ||
| 468 | - | ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir, | 461 | + | ID: a.ID, Task: a.Task, Dir: a.Dir, |
| 469 | Status: a.status, Activity: a.activity, | 462 | Status: a.status, Activity: a.activity, | ||
| 470 | Started: a.Started, Finished: a.finished, | 463 | Started: a.Started, Finished: a.finished, | ||
| 471 | Err: errStr, Tail: tail, Lines: lines, | 464 | Err: errStr, Tail: tail, Lines: lines, | ||
M internal/swarm/swarm_test.go +22 -7
| @@ -3,7 +3,6 @@ package swarm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "path/filepath" | |
| 7 | 6 | "strings" | |
| 8 | 7 | "sync" | |
| 9 | 8 | "testing" | |
| @@ -11,16 +10,15 @@ import ( | |||
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | // 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. | |
| 13 | + | // and a Runner factory the test controls. Returns the swarm plus a | |
| 14 | + | // slice of runners keyed by spawn order so tests can assert they | |
| 15 | + | // were actually invoked. | |
| 17 | 16 | func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | |
| 18 | 17 | t.Helper() | |
| 19 | 18 | root := t.TempDir() | |
| 20 | 19 | return New(Config{ | |
| 21 | 20 | Root: root, | |
| 22 | 21 | RepoRoot: root, | |
| 23 | - | Worktree: MemWorktree(filepath.Join(root, "worktrees")), | |
| 24 | 22 | NewRunner: mk, | |
| 25 | 23 | }) | |
| 26 | 24 | } | |
| @@ -58,8 +56,25 @@ func TestSpawnRunsAndCompletes(t *testing.T) { | |||
| 58 | 56 | if !strings.Contains(a.ID, "do-a-thing") { | |
| 59 | 57 | t.Fatalf("id %q missing slug", a.ID) | |
| 60 | 58 | } | |
| 61 | - | if a.Branch != "swarm/"+a.ID { | |
| 62 | - | t.Fatalf("branch = %q", a.Branch) | |
| 59 | + | // Every agent shares the host's RepoRoot. | |
| 60 | + | if a.Dir != f.cfg.RepoRoot { | |
| 61 | + | t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support: | |
| 66 | + | // every spawned agent points its cwd at the parent zot's RepoRoot. | |
| 67 | + | func TestSpawnAgentSharesRepoRoot(t *testing.T) { | |
| 68 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 69 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| 70 | + | }) | |
| 71 | + | a, err := f.Spawn(context.Background(), "share me") | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | a.Wait() | |
| 76 | + | if a.Dir != f.cfg.RepoRoot { | |
| 77 | + | t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot) | |
| 63 | 78 | } | |
| 64 | 79 | } | |
| 65 | 80 | ||
| @@ -3,7 +3,6 @@ package swarm | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | - | "path/filepath" | |||
| 7 | "strings" | 6 | "strings" | ||
| 8 | "sync" | 7 | "sync" | ||
| 9 | "testing" | 8 | "testing" | ||
| @@ -11,16 +10,15 @@ import ( | |||||
| 11 | ) | 10 | ) | ||
| 12 | 11 | ||||
| 13 | // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory | 12 | // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory | ||
| 14 | - | // worktree and a Runner factory the test controls. Returns the swarm | 13 | + | // and a Runner factory the test controls. Returns the swarm plus a |
| 15 | - | // plus a slice of runners keyed by spawn order so tests can assert | 14 | + | // slice of runners keyed by spawn order so tests can assert they |
| 16 | - | // they were actually invoked. | 15 | + | // were actually invoked. |
| 17 | func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | 16 | func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | ||
| 18 | t.Helper() | 17 | t.Helper() | ||
| 19 | root := t.TempDir() | 18 | root := t.TempDir() | ||
| 20 | return New(Config{ | 19 | return New(Config{ | ||
| 21 | Root: root, | 20 | Root: root, | ||
| 22 | RepoRoot: root, | 21 | RepoRoot: root, | ||
| 23 | - | Worktree: MemWorktree(filepath.Join(root, "worktrees")), | |||
| 24 | NewRunner: mk, | 22 | NewRunner: mk, | ||
| 25 | }) | 23 | }) | ||
| 26 | } | 24 | } | ||
| @@ -58,8 +56,25 @@ func TestSpawnRunsAndCompletes(t *testing.T) { | |||||
| 58 | if !strings.Contains(a.ID, "do-a-thing") { | 56 | if !strings.Contains(a.ID, "do-a-thing") { | ||
| 59 | t.Fatalf("id %q missing slug", a.ID) | 57 | t.Fatalf("id %q missing slug", a.ID) | ||
| 60 | } | 58 | } | ||
| 61 | - | if a.Branch != "swarm/"+a.ID { | 59 | + | // Every agent shares the host's RepoRoot. |
| 62 | - | t.Fatalf("branch = %q", a.Branch) | 60 | + | if a.Dir != f.cfg.RepoRoot { |
| 61 | + | t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support: | |||
| 66 | + | // every spawned agent points its cwd at the parent zot's RepoRoot. | |||
| 67 | + | func TestSpawnAgentSharesRepoRoot(t *testing.T) { | |||
| 68 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 69 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |||
| 70 | + | }) | |||
| 71 | + | a, err := f.Spawn(context.Background(), "share me") | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | a.Wait() | |||
| 76 | + | if a.Dir != f.cfg.RepoRoot { | |||
| 77 | + | t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot) | |||
| 63 | } | 78 | } | ||
| 64 | } | 79 | } | ||
| 65 | 80 | ||||
D internal/swarm/worktree.go +0 -110
| @@ -1,110 +0,0 @@ | |||
| 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 | - | } | |
| @@ -1,110 +0,0 @@ | |||||
| 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 | - | } | |||