Orchestration
Running agents across many repos raises a question that one repo never does: where does the plan live? This page describes the control plane — the shape that falls out of Thurbox's primitives once you use them at more than one repo — and points at a template you can start from.
The problem #
One repo, one agent session, one task: nothing to orchestrate. The repo holds the code, the session holds the context, and the pull request is the record of what happened.
At five repos this breaks in a specific place. The work still lands in the repos — that part is fine. But the plan has nowhere to live. A goal that spans three repos is not a file in any of them. Neither is the context that makes the goal legible: what each project is for, which ones depend on each other, which are dormant. Neither is the log of what you launched last Tuesday and what came back.
So it ends up in a chat transcript — not durable, not diffable, and not readable by the next session.
The control plane #
Give the plan its own repo, holding two things and nothing else:
- The map. An always-current index of your repos, generated from the GitHub API so it cannot drift, plus hand-written context files holding the judgement a generated index can't: what a project is for , how it relates to the others, what its current goals are.
- The orchestration. Reusable recipes (playbooks) and one log per run.
The defining rule is what the control plane does not hold:
The control plane holds the plan and the log . It never holds the workers' branches.
Each unit of work becomes one Thurbox worker session, targeting a real repo in its own git worktree , driven by a single self-contained prompt. Workers share no context with the control plane and none with each other, so every prompt restates the goal, the constraints, and what "done" means, from scratch.
Why split generated from hand-written? They have different failure modes. An index of repo names and default branches goes stale the moment you rename something, so it must be regenerated and never hand-edited. Why a project exists cannot be generated at all, and changes on a human timescale. One file for both guarantees that refreshing the half that must be fresh destroys the half that must be preserved.
Why a separate repo? The plan outlives every branch it spawns. A run log inside a worker repo would collide with the branches it describes, and would need a new home the moment a run touched a second repo. The split also makes the rule enforceable rather than aspirational: a control plane with no worktrees cannot accidentally become a place where work happens.
The layout #
registry/
owners.txt GitHub owners to index, one per line
repos.generated.yaml generated; never hand-edited
context/_TEMPLATE.md copy this to add a project
orchestration/
playbooks/_TEMPLATE.md copy this to add a recipe
runs/_TEMPLATE.md copy this per run
scripts/
sync-registry.sh regenerate the index via `gh`
install-extension.sh render extension.toml, then install
extension.toml.in manifest template (rendered at install time)
FLEET.md standing context for the long-lived sessionThe run loop #
- Clarify the goal. Pick a playbook, or write one from the template.
-
Open a run log, named
<YYYY-MM-DD>-<slug>.md. - For each unit of work, launch a worker session with one self-contained prompt, in its own worktree on the target repo.
- Record every session — name, repo, prompt intent, outcome, PR — in the run log as it happens . A log written afterwards is a summary; one written during is the source of truth, and it survives the lead session dying.
- Drain results from the mailbox as workers report.
- Review the PRs. Delete each session as it closes out.
Steps 3 and 5, concretely — the lead spawns, then reads its mail:
# The lead spawns a worker: its own worktree, parented to the lead.
thurbox-cli session create \
--name ship-auth-fix \
--repo-path /abs/path/to/repo \
--worktree-branch fix/auth --base-branch main \
--parent "$THURBOX_SESSION" \
--json
# The worker, when it is done, mails the lead. `send` wakes the recipient.
thurbox-cli message send --to "$LEAD" --kind result --body 'https://github.com/owner/repo/pull/42'
# The lead drains its inbox exactly once. No polling.
thurbox-cli message inbox --for "$LEAD" --claim --json
# Afterwards, enumerate the run's workers — including the silent ones.
thurbox-cli session list --parent "$LEAD" --jsonWhy it is a Thurbox pattern #
Nothing above is invented. Each piece is a Thurbox primitive doing load-bearing work.
Worktree-per-session
A worker creates a git worktree on its own branch. Two workers on the same repo cannot collide, and an abandoned worker costs you a directory, not a dirty checkout on a branch someone else needs. This is what makes "one unit of work, one session" safe to say.
The lead is a real session
The control plane installs itself as an
extension
with one long-lived
[[sessions]]
entry, which Thurbox
self-heals
: delete the session and it comes back at the next startup or automation tick. A control plane
that evaporates when someone tidies their session list is not a control plane.
And because it
is
a real session rather than an ad-hoc terminal, it can be addressed: workers can mail it, it has a
UUID to hand out as
--parent
, and it gets
THURBOX_SESSION
in its environment like any other session.
The mailbox, not polling
Workers report through
inter-session messages
.
send
wakes the recipient, so the lead never polls. Thurbox injects
THURBOX_SESSION
into every session's environment, and both
--from
and
inbox --for
default to it — a worker needs no ids to mail home.
Why not poll
gh pr list
?
It is
exact
(a message comes from a worker that knows it finished, not from a side effect),
immediate
(a poll adds latency proportional to its interval), and it can report
"not applicable"
. A worker that correctly concludes there was nothing to do says so in one message. A PR poll
cannot distinguish "no PR because nothing needed fixing" from "no PR because the worker is still
thinking" — and those demand opposite responses from the lead.
Deliberately no automations
Thurbox has automations , and this pattern does not use them. The one scheduled candidate — the registry sync — commits and pushes, so a human runs it and reads the diff. Nothing else is periodic: a run starts because someone has a goal. Restraint is part of the pattern. A control plane that fires unattended writes is a second actor in a system whose whole point is that there is one.
Two constraints #
Both cost real time to rediscover.
There is no status field to poll
session get --json
returns identity and layout — id, name, agent, backend, cwd, parent, worktrees. It exposes
no lifecycle state
. The
working
/
done
state that
agent hooks
report via
session signal
is persisted for the TUI to render, not for the CLI to serve.
So headless completion detection is the mailbox, or a printed sentinel the lead greps out of
session capture
. There is no third option. This is the strongest argument for the mailbox: it is not merely
nicer than polling, it is the only thing that works.
Fast-forward the base branch first
A worktree inherits whatever the
local
base branch points at, not what the remote does. A stale local
main
yields a worker that does perfectly correct work against a month-old tree and opens a conflicting
PR — a failure that looks like a bad agent and is really a bad base.
git -C "$REPO" fetch origin
git -C "$REPO" merge --ff-only origin/main
git -C "$REPO" rev-list --count main..origin/main # expect 0Start from the template #
A working, public implementation of everything above lives at
Thurbeen/fleet-template
. Click
"Use this template"
, edit
registry/owners.txt
, and run:
./scripts/sync-registry.sh # regenerate the repo index via `gh`
./scripts/install-extension.sh # render the manifest, then install itThat leaves you with a working control-plane session.
Why does the manifest ship as
extension.toml.in
?
A
[[sessions]]
entry needs an absolute
repo_path
, and Thurbox does not expand
~
there — a tilde is taken literally and the session lands in a directory named
~
. A template cannot hardcode a path that exists on one machine, so it ships a
__REPO_PATH__
placeholder that
install-extension.sh
renders from
git rev-parse --show-toplevel
before installing.