Orchestrating many agents
Concepts names the pieces and the API reference lists the verbs. This page is the missing middle: how you actually run several agents at once — when to isolate, how wide to fan out, how results come back, and what goes wrong.
Everything here works from a plain shell. It also works from inside an
engine session Rove manages: an agent with rove on its PATH can spawn and
supervise its own workers with the same commands, and Rove records who
spawned whom so replies route home automatically.
Task, tab, or split?
The most common orchestration mistake is picking the wrong isolation level. The three units nest — Task ⊃ Terminal Tab ⊃ Split — and isolation drops at each level down:
new Task → own worktree + own branch (parallel work can't collide)
new Tab → own conversation, SAME files (a helper in the same checkout)
new Split → same screen, one tab divided (a monitor beside the work)| You want | Use | Because |
|---|---|---|
| Independent attempts, comparable results | New tasks (add) | Each gets its own worktree and branch; nothing can overwrite anything |
| A second opinion on the same working tree | New tab (send --tab new) | Separate conversation, shared files — deliberate, and deliberate collisions too |
| A different engine on the same files | New tab with --command | The tab pins its own engine; the task's default is untouched |
Logs, tests, btop next to the work | Split (pane-open) | A split is layout, not delegation — it isolates nothing |
The rule that decides every ambiguous case: a new tab shares the worktree and branch; only a new task gets its own. Two tabs editing the same file will conflict — that's a feature when you asked for a helper, a bug when you wanted parallel attempts. When in doubt, take a new task: it is the only choice whose isolation cannot corrupt work in progress.
One boundary the worktree isolation does NOT cover: git stash is never
safe in a managed worktree. The stash stack lives in the repo's common dir
(.git/refs/stash) and is shared by every linked worktree — two parallel
tasks that stash can pop or drop each other's work. Commit instead; a commit
is per-branch and isolates exactly the way the model promises.
Fan out
add creates one task; --count makes it a parallel round — N sibling
tasks of the same prompt, each in its own worktree and branch, sharing a
groupId and #i/N titles:
rove api add --repo "$PWD" --count 3 \
--prompt "Simplify the auth flow. Commit when tests pass."
# Mixed fleet: engine ids from `rove api engine-list`, with per-engine counts.
rove api add --repo "$PWD" --agents claude:2,codex:1 \
--prompt "Simplify the auth flow. Commit when tests pass."Ground rules that make rounds worth running:
- 3–4 attempts is the sweet spot. The hard cap is 10 from a shell and 5
from the TUI's fork composer (
ctrl+af, ATTEMPTS row), but comparing ten diffs costs more than it buys. Fan out wide only when attempts are cheap to judge (a failing test either passes or it doesn't). - The prompt is the whole brief. Workers don't share your conversation;
each one starts cold in a fresh worktree. Scope, constraints, and the
definition of done all go in
--prompt— including "commit your work", or you'll get green tests and an empty branch. - One level of fan-out. Workers should not spawn their own rounds; a tree of agents spending tokens on the same problem is how budgets die.
- Don't fan out for a fix you'd accept from anyone. One task is fine. Parallel rounds pay off when approaches genuinely differ.
Start every turn with context
A coordinator that remembers is a coordinator that is wrong. Between two of its turns a worker can finish, crash, hit a permission prompt or have its PR approved — none of which the coordinator witnessed. So begin each turn with one read:
rove api context --repo "$PWD" --textrepo /Users/j/rove · 4 tasks · 1 in the inbox
waiting-on-you 4KN2SM simplify auth [fix/auth] — permission_needed 6m
landing 9QT1XZ cache the index [perf/index] — idle 12m · ci:passing · PR#1041
ready-for-review 2FF0LA flaky sync test [fix/sync] — turn_complete 2m · “root cause was a shared clock”
working 7BBQ3M docs pass [docs/api] — running 1m
inbox (1 unhandled):
permission_needed 4KN2SM
field notes (2, newest first):
#7 test/daemon needs ROVE_INCLUDE_SOCKET=1 or it silently skipsThe rows are sorted so the first line is what needs you next, and the
group is derived — from the worker's report, the daemon's PR
observation, arbitrated engine activity and tab liveness — not from the
status field a crashed worker left behind. unknown is its own group: when
the activity registry cannot answer, context says so instead of calling the
task idle. Full field list and the debounce rules: API reference.
Drop --text for the structured rows when you are going to act on the
fields (group, rank, checkState, report) rather than read them.
context is the orientation read; collect is still the one you open before
landing anything, because it is the one that shows the branch's real
ahead-count and diffstat.
The report-back loop
Rove's completion contract is a message, not stored state. When a task is
created from inside another Rove session, the creator's task + tab are
recorded as the new task's dispatcher. When the worker finishes, one
bare send — no --task-id — routes its outcome back to that exact tab:
rove api send --prompt "succeeded: auth flow simplified (branch fix/auth-simplify)"What makes this loop work:
- Coordinators don't poll. Each outcome arrives in the coordinator's own
chat as a
[ROVE PEER]-prefixed message carrying the sender's task id and a ready-made reply command. Keep working, or end the turn; the messages come to you. Everysendcosts the receiver a full engine turn, so a polling loop burns both sides' budgets for nothing. - Workers report the branch name. The coordinator needs it to
land. - "Succeeded" means committed. The only thing
landcan merge is commits on the worker's branch. Green tests in a dirty working tree are not a deliverable —landwill refuse an empty branch (EMPTY_BRANCH).sendrefuses the claim earlier: asucceeded:report from a managed task with 0 commits fails withEMPTY_SUCCESS_REPORTand never reaches you. A worker whose task legitimately produced no commits passes--allow-empty. - "CI is green" means
checkState: passing. Rove polls each task's PR checks onto.task.prStatus.checkState(rove api get-task), so a coordinator reads CI truth instead of trusting a worker's summary of its own local test run. - A report is a claim, not a verification. Read the winner's actual diff
before merging it (
collectshows ahead counts and diffstats;git login the worktree shows the truth).
Peer messages aren't limited to the dispatcher. Any session can message any
task's engine tab — send --task-id <id> --tab tab-N — and because peers
share a filesystem, a prompt can carry file paths: a screenshot, a log, a
diff. The receiver opens them with its own tools. That is the whole
coordination surface; there is no separate message bus to learn.
Fan in
Compare, pick, close the round:
# One snapshot of the whole round: branch, running state, tabs,
# uncommitted changes, ahead count + diffstat vs base per task.
rove api collect --task-ids <a>,<b>,<c> --pretty
# Merge the winner's branch into the base repo's CURRENT branch —
# check the base checkout is on the branch you mean, first.
rove api land --task-id <winner>
# Losers: delete removes the worktree but keeps the branch (git is the durable record).
# Removal runs in the background, so a plain delete only reports that it was
# QUEUED. Cleaning up a batch? Pass --wait and read `status` — a worktree git
# cannot remove comes back as `failed` with the reason instead of vanishing
# into daemon.log.
rove api delete --task-id <loser1> --wait
rove api delete --task-id <loser2> --wait
# Or close the whole round in one call, by the groupId `add --count` returned:
# one entry per sibling, and a refusal on one (a dirty worktree) is recorded
# there rather than aborting the rest.
rove api delete --group <groupId> --waitland refuses a dirty base checkout, and on merge conflict aborts cleanly
and returns the conflicted files for manual resolution. delete removes a
loser's worktree but keeps its branch (git is the durable record); land
removes the winner's worktree in the same call by default. Finish rounds —
a sidebar full of stale attempts is where the next round's confusion comes from.
Failure modes
Silence is a checkpoint, never a verdict. A quiet worker may be mid-turn, or stuck on a permission prompt. Don't mark it failed, don't auto-retry — peek and nudge:
rove api get-task --task-id <id> # .running, per-tab alive
rove api read-output --task-id <id> # its actual session output
rove api send --task-id <id> --prompt "Status? Reply succeeded/failed + branch."A test failure in a fresh worktree may be fake. A managed task's worktree
is a brand-new checkout: no node_modules, no build artifacts. Missing
dependencies masquerade as product bugs ("Could not resolve: react-dom/client"
reads like a regression). Repos with an install step should commit
.rove/init.sh, which runs once per worktree before the engine starts — see
Configuration. Before believing a failure a
worker reports, confirm the install ran, and compare against the same command
on the base branch.
"Succeeded" without commits. The classic empty-merge: a worker ran
everything, passed everything, committed nothing. land fails with
EMPTY_BRANCH — send the worker back to commit rather than merging by hand.
send refuses instead of guessing. A task whose live tabs include no
engine gets NO_ENGINE_TAB (address one with --tab tab-N from get-task's
.tabs[], or spawn one with --tab new); an engine that exited back to its
shell gets ENGINE_NOT_RUNNING. Errors carry a hint and often
nextCommandArgs — argv you can run verbatim to recover.
Everything else: rove api inspect is the one-read diagnostic — daemon
activity, live PTY sessions, death records with output tails, and the tab
snapshots the sidebar renders from.
End to end
A complete round, from a plain shell, in a repo with a committed
.rove/init.sh:
# 1. Fan out three attempts.
rove api add --repo "$PWD" --count 3 \
--prompt "The flaky test in test/sync.test.ts fails ~1 in 5 runs.
Find the root cause and fix it. Run the suite 20x to confirm.
Commit with a descriptive message when green."
# → { "tasks": [ { "taskId": "…", "branch": "…" }, … ] }
# 2. Do something else. From inside a Rove session, outcomes arrive as
# messages. From a plain shell, check in when you're ready:
rove api collect --repo "$PWD" --pretty
# 3. Verify the most promising attempt — its worktree is a real checkout:
rove api get-task --task-id <winner> # .task.worktreePath, .task.branch
git -C <worktreePath> log --oneline main..HEAD
git -C <worktreePath> diff main...HEAD
# 4. Land it, delete the losers' worktrees (branches stay). `delete --group`
# closes the round in one call — including the landed winner's own row,
# which is spent once its branch is merged.
rove api land --task-id <winner>
rove api delete --task-id <loser1> --wait
rove api delete --task-id <loser2> --waitThe same round run by an agent differs only in step 2: the workers'
succeeded:/failed: messages land in its chat unprompted, because its own
task + tab were recorded as each worker's dispatcher at add time.
See also
- API reference — every verb and flag;
rove api schemawhen the page and the binary disagree. - Concepts — Task, Worktree, Terminal Tab, Engine, Daemon.
- Sessions — what survives detach, daemon restarts, reboots.
- Routines — the scheduled cousin: cron-fired prompts that each create a fresh task.