Agent Fleets

Run many tasks at once with parallel workers in one shared tree, coordinated by file claims. Turn on worktree isolation per task when you need it.

stella fleet runs many tasks at once. Each task gets its own full stella worker. By default, all the workers share one tree: every worker runs in the repository root, coordinated by cooperative file claims. If two workers try to touch the same file, the conflict shows up right away and names which worker is holding it. Commits from different workers interleave on one branch, and the build cache stays warm. A task marked isolation = "isolated" instead gets its own git worktree on its own branch, kept separate from your working copy and from every other worker.

stella fleet \
  "Add unit tests for the parser module" \
  "Document every public function in src/api/" \
  "Fix the clippy warnings in the store crate"
Fleet fan-out over git worktreesbasepinned SHAfleet/t1 — its own worktreefleet/t2 — its own worktreefleet/t3 — its own worktreereviewmerge on your terms

How a fleet run works

  1. Plan. Task prompts, whether typed inline or loaded from a --plan file, form a dependency graph: tasks with depends_on wait for their dependencies to finish first. Plans are checked before anything runs. A duplicate id, a task that depends on itself, an unknown dependency, or a dependency cycle gets rejected, and stella names the task or cycle that caused it.

  2. Share or isolate. By default, every worker runs directly in the repository root, using the cooperative file claims described below. A task marked isolation = "isolated" instead gets a fresh git worktree under .stella/worktrees/<slug>, branched from a pinned base commit. That base is the current HEAD or --base-ref, locked to a specific commit at the start of the run so later commits can't shift it. The worktree lands on a fleet/<slug>-<hash> branch. The 16-character hash keeps re-runs, which reuse task ids like t1, from colliding with branches and worktrees left over from an earlier fleet. Isolated workers can't see each other's uncommitted work and can't touch your checkout.

  3. Dispatch in waves. Tasks run in dependency order, in waves of up to --max-concurrency tasks at once. The default is the governor's number. A failed task does not unblock the tasks waiting on it; those end the run marked skipped. Nothing retries on its own. The ledger records every attempt, but re-running is up to you.

  4. Work. Each worker runs the full engine: the plain step loop by default, with the standard set of tools, following your agent-engine config. Workers run headless on purpose: no MCP, no interactive prompts, nothing that can get stuck waiting for input. Pass --pipeline <variant> to route each worker through an installed wrapper plugin instead.

  5. Record. Every attempt, commit, and dollar spent lands in the fleet ledger at .stella/private/fleet.db. Like all stella telemetry, it's a local SQLite database, with runs, tasks, attempts, and commits tables you can query directly. Past runs also show up on the Fleet runs card of the Observatory dashboard.

    # Which tasks failed in the most recent run, and what the worker said.
    sqlite3 .stella/private/fleet.db \
      "SELECT a.task_id, a.branch, a.summary
         FROM attempts a
        WHERE a.run_id = (SELECT id FROM runs ORDER BY created_at_ms DESC LIMIT 1)
          AND a.success = 0;"

    In the shared tree, the commits table records the commits each worker was observed making. While a worker commits, it holds a workspace-wide commit lane, so no other worker can move HEAD during that window. Each commit belongs to exactly one task. If a commit happens some other way in the shared tree, for example through the shell (which is off by default), it's left out of the table rather than guessed at. An isolated task has only one writer in its own worktree, so every commit it makes gets recorded, no matter how it was made.

  6. Review. Shared-tree work lands directly on your current branch as interleaved commits. Isolated tasks' worktrees and fleet/* branches stay in place on purpose; those branches are the actual work product. Inspect, test, and merge them whenever you're ready. git worktree list shows all of them.

Plan files

For anything beyond a handful of independent prompts, declare a plan (.toml or .json):

release-prep.toml
[[tasks]]
id = "t1"
title = "Bump the version and changelog"
prompt = "Bump the workspace version to 1.4.0 and update CHANGELOG.md"
claims = ["Cargo.toml", "CHANGELOG.md"]

[[tasks]]
id = "t2"
title = "Update the release notes page"
prompt = "Update the release notes for 1.4.0 from the merged PRs"
depends_on = ["t1"]

The task fields the plan parser accepts:

idRequired

A unique identifier within the plan. It's what depends_on refers to, what breaks ties when dispatching, and what names this task's worktree directory and branch.

titleRequired

A one-line label for people, shown in the run report and stored in the ledger's tasks row. stella doesn't read any meaning into it.

promptRequired

What the worker is asked to do. A full stella prompt.

depends_on

Ids of tasks that must succeed before this one starts. This is what decides the wave order. Every id must name a task in the same plan.

Default []

isolation

shared_tree runs directly in the repo root, using cooperative file claims. isolated gets its own worktree on its own branch. Choose this when the work is genuinely meant to diverge, like several competing attempts at the same files, or a task that changes checkout state in ways that would affect other workers. File claims can't help there, since conflict is the whole point. The trade-off is real: each worktree starts with a cold build cache, and any conflicts get pushed to when you merge the work back.

Default shared_tree

claims

Paths, relative to the workspace root, held as file locks for the length of the attempt. Paths are matched as exact strings, so spell each one the same way everywhere in the plan.

Default []

test_command

The command that decides whether this task passed. Under --pipeline <variant>, a wrapper plugin runs this command before and after the attempt to check whether it went from failing to passing on that task's own tree. Set per task, since two tasks touching different parts of the codebase are checked with different commands. It must be a command type stella recognizes (cargo, pytest, sh, and a few others); anything else fails that one task by name and leaves the rest of the run going. A task with no test_command set, including every task typed directly as a stella fleet "…" prompt, gives an evidence-gathering plugin nothing to check, so it reports that task as Undecided instead of a pass.

Default none

This plan uses all of it: three waves, one isolated task, and claims that keep the parallel writers apart. Claims match as exact strings, so name individual files, not directories:

feature-buildout.toml
[[tasks]]
id = "schema"
title = "Add the migration"
prompt = "Add a migration creating the `receipts` table with an execution_id foreign key"
claims = ["src/store/schema.rs", "src/store/migrations.rs"]

[[tasks]]
id = "reader"
title = "Read path"
prompt = "Add Store::receipts_for_execution reading the new receipts table"
depends_on = ["schema"]
claims = ["src/store/read.rs"]

[[tasks]]
id = "writer"
title = "Write path"
prompt = "Record one receipt row per model call from the engine's emit path"
depends_on = ["schema"]
claims = ["src/store/write.rs"]

[[tasks]]
id = "docs"
title = "Rewrite the receipts page"
prompt = "Rewrite docs/receipts.md against the new table; try two structures and keep the better one"
depends_on = ["reader", "writer"]
isolation = "isolated"
stella --spend-limit 6 fleet --plan feature-buildout.toml

schema runs alone in wave one. reader and writer run together in wave two, each with its own claims so they don't overlap. docs runs last, in wave three, in its own worktree on its own fleet/docs-<hash> branch, since it's divergent work that needs isolation rather than claims. If reader and writer had both claimed src/store/schema.rs, the second one to dispatch would have failed by name instead of racing the first.

File claims — cooperative locks

A task that declares claims (paths it plans to touch, relative to the workspace) holds those paths as file locks for as long as the attempt runs. If a second task, or even a second fleet run, tries to claim a path that's already held, that dispatch fails by name instead of silently racing another worker for the same file. The locks live in the file_locks table of .stella/private/store.db, separate from the fleet ledger, so they coordinate across fleet runs happening at the same time too.

Spend limit across a fleet

The spend limit comes from the global --spend-limit flag (or STELLA_SPEND_LIMIT), which goes before the subcommand:

stella --spend-limit 5 fleet --plan release-prep.toml
# equivalently: STELLA_SPEND_LIMIT=5 stella fleet --plan release-prep.toml

It's enforced in two ways. Each worker runs under its own guard, which is the total cap divided by the concurrency width (spend limit ÷ max-concurrency), so one wave's workers can't collectively spend more than their share. On top of that, the fleet stops launching new waves once total spend crosses the cap. Workers already running are allowed to finish cleanly; stella never kills one mid-task.

Watch CI: --watch

After the fan-out finishes, --watch keeps an eye on every branch that has successful work on it. It watches each branch's CI run through to completion using the gh CLI, keeps PR status updated live, and exits with a non-zero code if any watched branch ends up red. It checks every 30 seconds, gives CI 10 minutes to start showing up, gives up on a branch after 20 minutes with no progress, and stops watching entirely after 2 hours.

stella fleet --plan release-prep.toml --max-concurrency 2 --watch

--watch only has something to watch once branches are pushed, for example when your task prompts push and open pull requests. It needs gh installed and logged in.

Flags

<task>...Required

One or more task prompts, each becoming its own shared-tree task. Required unless you pass --plan; you can't use both at once.

--plan <FILE>

A .json or .toml file with [[tasks]] entries instead of inline prompts. This is the only way to set depends_on, claims, or isolation.

--max-concurrency <N>

The most tasks that can run at once within one wave. The default is the self-driving governor's number: machine probes crossed with the learned cap.

Default the governor's number

--base-ref <ref>

The git ref that isolation = "isolated" worktrees branch from. It's locked to a specific commit at the start of the run, so later commits can't shift the base. Shared-tree tasks ignore this flag.

Default current HEAD

--watch

After the fan-out, watch each fleet branch's CI through to completion and keep its PR status updated using gh. Exits with a non-zero code if any watched branch ends red.

--pipeline <VARIANT>

Route each worker through the installed wrapper plugin whose manifest declares this [wrapper] id. Leave it off to use the plain step loop.

The spend-limit flag is global, so it goes before the subcommand. This is the one ordering mistake worth remembering:

stella --spend-limit 8 fleet --plan release-prep.toml --max-concurrency 3 --watch

Clean up

Once you've merged, or rejected, the branches your isolated tasks left behind, clean them up. The branch names carry a hash suffix, so list them instead of guessing:

git worktree list                  # what the fleet left in .stella/worktrees/
git worktree remove <path>         # per worktree
git branch --list 'fleet/*'        # the fleet branches
git branch -D fleet/<slug>-<hash>  # per branch, exactly as listed

Once every branch is merged or rejected, this sweeps them all in two lines:

git worktree list --porcelain | awk '/^worktree .*\.stella\/worktrees\//{print $2}' \
  | xargs -r -n1 git worktree remove
git branch --list 'fleet/*' --format='%(refname:short)' | xargs -r git branch -D

That second command deletes every fleet/* branch, merged or not. For isolated tasks, those branches are the work product. Run git branch --list 'fleet/*' by itself first, and make sure the list only has branches you're really done with.

Next