Mechanism 2 of 4 · The fleet

A fleet is a trust problem wearing a scheduling problem's clothes.

The absence this makes loud: nobody independent looked.

Git worktrees stop several agents clobbering each other at the byte level. They cannot stop two agents solving the same problem twice, and they have nothing at all to say about whether anyone checked the result.

Running one agent? Read the review handoff and skip the rest. Everything here still applies at N=1 — the collision check just returns a cluster of one, and the independence check resolves to "a human reviewed it" and records that instead of implying more.

Roles

The role is checked at the call, not held as a badge.

There are four roles — planner, worker, reviewer, and all-in-one, which is the default un-enrolled posture and is deliberately unrestricted. Roles are enforced when a tool is called, not when an agent registers.

That timing is the design. A manifest can only fail to mention a tool; the gate refuses it. So narrowing an agent's authority takes effect on its very next call, with no session to revoke and no cached permission to expire. Refusals name the next move in a hint — a role change isn't an error, so it doesn't arrive as one.

A planner's calls pass through the role gate at call time; propose_allocation is allowed while claim_next is refused, and three refused calls lead to quarantine. registration planner check_tool_role at the call propose_allocation ✓ claim_next → refused "the orchestrator plans; it does not quietly do the work." three refused calls quarantine — items released
The role is a property of the call, not a badge held from registration — so re-tasking takes effect immediately, and a refusal costs the agent something. Quarantine force-releases its items and reservations and records the event.
claim_next · claim_cluster — worker only refused to a planner. A planner with no authored work has nothing to launder — which is exactly why it is the role allowed to mint credentials.

Collisions

Two agents in the same file is a data race with a standup.

Work is not handed out one item at a time. It's partitioned into clusters that provably share no files, so N agents can run in N worktrees without coordinating — and the files are reserved in the same write that claims the work.

Items resolve to touch areas — declared, or predicted by semantic search — which are compared by three match modes to build connected components; claiming a cluster and reserving its areas happen in one transaction. touch_areas touchpoints collide? BEGIN … COMMIT GRPH-380 declares its touchpoints GRPH-381 semantic code search top 5 · score ≥ 0.15 ∪ linked items' areas declares none → predicted GRPH-382 services/fleet.py services/* services/ exact glob same parent dir collide iff they share a touchpoint by ANY of these one component union-find reserve services/fleet.py reserve services/* reserve services/ the claim and the reservations are ONE write. There is no window where the cluster is claimed and the areas are not. expiry is evaluated at read time against the lease horizon. No background job — so there is nothing that can be down.
The reservation is not a follow-up write and the expiry is not a job — both are the kind of thing that cannot silently fail to happen. Reservations ride the lease horizon, extend on heartbeat, and expire lazily when read.

Two details that matter more than they look. First, an item that declares no touchpoints is not treated as touching nothing — its areas are predicted from a semantic search over its title and description plus the touchpoints of items already linked to it, and the result is flagged as predicted so you know the confidence is lower. An undeclared touchpoint is exactly the absence that would otherwise read as "safe to run in parallel".

Second, when a prediction is wrong and two live clusters genuinely do collide, Graphban lets it ride — it records the collision with both agent ids and the overlapping area, lets the actual touchpoints replace the predictions, and computes the next partition from ground truth. Aborting would throw away real work to prevent a merge conflict git is going to surface anyway.

Allocation

The server proposes. The planner commits.

propose_allocation reads the live agents and the free clusters and returns a mapping with its reasoning. It writes nothing. Committing it is a separate call, and it's the same row whether a human clicks it or an orchestrating agent makes it.

Review handoff

An agent cannot pass its own work, and there is no single check to bypass.

Completed work goes to review, not to done. A reviewer leases an item it did not build and gets the branch back with it — a reviewer in another terminal on another machine has nothing to review unless the handoff names where the work is.

Two independent gates, claim_review and sign_off, each refuse the author of an item; a different reviewer passes both. claim_review sign_off a reviewer who did not build it the author …and if it were somehow past the first: keyed on agent identity, not role — promoting a worker to reviewer while it holds its own item does not launder it.
Two independent gates, both keyed on identity, so there is no single check to bypass. One filters what you can claim; the other asserts at sign-off. Authorship also outlives the lease, so releasing the item doesn't reset it.
sign_off(item_id, evidence[]) above an effort threshold, refuses without a sabotage receipt — proof something was broken on purpose and a test caught it. tests_failed: 0 is recorded as a finding, not a pass: the test could not fail.

The reasoning is that reviewer and adversary are different jobs and must not become one habit — a reviewer converges, an adversary diverges. Making adversarial checking a fourth role was considered and rejected; it's a precondition the reviewer satisfies however it likes. It's gated on effort so a one-line fix isn't taxed.

bounce sends work back with a required reason that travels with the item, and pins it to its author for one lease period — they still hold the worktree and the context — after which it opens to the fleet. A permanent author-only pin was rejected because it strands the item when the author never comes back, which is the common case.

Independence

"We can't tell them apart" fails closed.

Being a different agent is not the same as being an independent one. The check is an ordered ladder, and the last rung is the one that matters.

parent_agent_idDeclared in either direction → not independent. A subagent cannot review its parent, and siblings under one parent are one call tree.
different credentialsIndependent. Two keys are two principals.
different enrolment seatsIndependent — the server decided it, not the agent.
otherwise: show a differenceInstance, worktree, host or vendor must actually differ.
absence is not a differenceA missing field does not count as "different". This polarity was once backwards, which meant the missing datum granted the permission.

Preferring a different vendor

When choosing a reviewer, Graphban prefers one whose vendor differs from the author's, falling back to merely agent-distinct. A Claude reviewer approving Claude work is a different agent but not a different error distribution. This is a preference in ranking, not a refusal — but it's what upgrades the invariant from "no self-review" to "no monoculture review", and it's the payoff for running four heterogeneous terminals instead of four identical ones.

Enrolment seats

The thing that lives forever carries no role. The thing that carries a role dies in thirty minutes.

MCP gives a client exactly one static header per server entry — and that one string was carrying four things at once: identity, authorization ceiling, role, and wave membership. Two of those are long-lived and two are ephemeral. So they were split.

credential

Written into a client config once, ever

Identity, project scope, read/write ceiling, and which roles this key is eligible for. It never changes when the work does.

enrolment seat

One role, one project, one session

Single-use, 30-minute TTL, consumed at registration. Pasting it into a prompt leaks a role for half an hour, not an API key — which makes the thing humans will actually do the safe thing to do.

mint_enrolment(role, project_id) — planner only and that restriction is the whole safety argument. A worker that could mint would build an item, mint itself a reviewer seat, register as a fresh agent — new id, new enrolment, therefore "independent" — and sign off its own work straight past a ban keyed on agent identity. Planners are refused claim_next, so a planner has no authored work to launder.

A field note that cost a day: Cursor does not interpolate ${env:VAR} in mcp.json at all — probed at 3.16.2. It doesn't send them as literals either; it silently drops them, so the server logs no failed auth. Nothing looks wrong. That's most of why seats exist as a separate concept rather than "just use a different env var per agent".

Delegation

A delegated item comes back as the item moving, not as a reply.

This is the difference between delegating and supervising, and it is the reason one person can run twelve agents: your context does not grow when the fleet does.

A parent that wants one item built somewhere cheaper does it in two calls and carries on working. delegate(id, lane, tier, seat=true) mints a worker seat bound to that item and returns its code; spawn starts a child on it. Registering on a bound seat claims the item server-side, so the child holds it from its first call and never touches the collision board — and if somebody else already holds it, the reply says who, and the child exits rather than doing the work twice.

Nothing about the outcome returns to the parent's session. It lands on the board, with its evidence, its branch and its reviewer. A fleet that reports into one context window is not a fleet; it is one context window doing twelve times the reading, and it stops working at about three.

delegate(…, seat=true)A seat bound to one item. Not a role, not a credential — the right to register once, for half an hour.
spawn(code, tier)The supervisor cuts a worktree, starts the child, and reaps it. It holds no authority of its own.
the child claims at registrationBound means bound. It cannot drift onto adjacent work while nobody is watching.
no reply to the parentThe outcome is the item's state and its evidence. Asking the parent to read a transcript is how orchestration collapses back into supervision.

You ask for a tier; the table answers with a model

cheap and frontier are the vocabulary. Which harness and which model that resolves to comes from a committed table of rows that have actually been verified for a given lane and role, narrowed by the project's policy, then by the key owner's preferences, then by what is installed on the machine — and every spawn records what it chose, how many rows each step dropped, and the runner-up. An empty resolution is an error naming the step that emptied it; there is no silent default.

Measured quality and latency feed back into that ranking per lane, and only once a cell has five samples. Below that it says unmeasured rather than guessing, because a ranking built on one result is a ranking that recommends whatever ran first.

What a wave tells you afterwards

The supervisor measures each child's diff against the branch it was cut from — one worker, one worktree, one branch makes that boundary exact — and reports three things nothing else in the system can see: files two workers both changed, files no touchpoint declared (the partition's input was wrong), and how many commits the trunk moved under a branch while its child worked. It reports them; it decides nothing. Rebasing somebody's work is not a supervisor's call.

The supervisor

The thing that starts the agents is the thing with the least authority.

Somebody has to open the terminals. That job kept landing on a human pasting seat codes into four windows, so it became a program — and the interesting constraint is what that program is not allowed to do.

what it does

One wave: spawn, wait, reap

A child per seat, each in its own git worktree, launched against a named vendor binary whose version is checked before anything starts. Then it waits, and it cleans up — including the orphans left by a previous run that died badly. until is a planner loop around that wave, not a thicker supervisor: keep going while there is work, spawn reviewers when review appears, and on restart adopt the children already running.

what it cannot do

It holds no authority of its own

It can only launch a process holding a seat the server issued, to do work the server arbitrates. It cannot mint a seat, grant a role, or approve anything. Delete it and every invariant still holds; the fleet just needs a human to open terminals again.

gbfleet up(--seats-file, --adapter, --repo) a separate distribution that depends on httpx and nothing else. It runs on a laptop next to the agents, not on the server, and it never imports the backend.

gban fleet up … is the same command with the server and project you are already logged into filled in, and the supervisor's exit code returned unchanged — 75 is stuck, 69 an unreachable model endpoint, 55 a spent budget, and folding those into one "the wrapper failed" code would destroy a taxonomy worth having. It is a subprocess, never an import: that is what keeps gban free of the supervisor's dependencies and leaves gbfleet --help authoritative about its own commands. If the supervisor is not installed, it offers to install it and asks first.

What the walk found: sixteen steps passed, one is blocked on a decision rather than a budget, and it found nothing in the supervisor. The walk refuses to run unless pointed at a real server and refuses any project it did not create itself — a walk that quietly passed by not running would be the worst possible version of it. The one thing it stands in for is the model: the child is a real MCP client with the reasoning removed, so what is proven is the plumbing, not the judgement.

← previousSpec → done All four mechanisms ↑ next →The living graph

What's new

Every entry is work that merged. The full history is on GitHub.