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.

Any agent

Every developer brings their own agent. All of them play by one set of rules.

The gates live on the server, not in the harness, so it doesn't matter which agent did the work. A frontier model and a local one claim cards, hand off for review and get signed off the same way, and the record reads the same.

AgentAdapterNotes
Claude CodeclaudePrompt on stdin, MCP config in a private temp file
Cursorcursor-agentReads the project's MCP config
GrokgrokProject-scoped config inside the worktree
Gemini, via AntigravityagyWorkspace MCP config
DeepSeekdshConfig overlay per child
Qwen Codeqwen-codeMCP servers allow-listed by name
Local modelsgbagentFirst-party; any model your endpoint serves
Codex—Connects over MCP for interactive work; no fleet adapter yet

Every adapter is named, never guessed, and its binary's version is checked against a supported range before a child starts.

the setup travels with the repo

Your best developer's setup, for everyone

Every child works from the repo's AGENTS.md and project skills, and the repo names the MCP servers every wave is granted. Where harnesses load skills or MCP servers differently, the Fleet view says so, so a gap shows up instead of being assumed away.

measured, not assumed

A capability matrix built from real sign-offs

Models are graded per function set from work that was actually reviewed, plus probes, and the grades are learned across users. A cell with fewer than five samples says unmeasured. A harness mix sets each vendor's share of launches, which keeps review diverse.

Capacity is the other way a tier resolves. Cheap or frontier still comes from those measured sign-offs. The Harness Capacity tab is a routing mode beside that: entitlements, a subscription hierarchy you set, and a forecast of spend. A routing verdict the router never recorded is null, with a note. It is not zero.

The path

Nobody becomes an orchestrator. You delegate one item, and then another.

Each rung is one command more than the rung above it, and none of them asks anyone to become a manager, because the arbitration is the server's job.

one agent What your developers already have: Claude Code, Cursor or Codex with the MCP attached. The grill, the collision check, independence and completeness all fire at N=1. The developer is the reviewer, and the record says so.
delegate one item delegate(id, lane, tier, seat=true) mints a seat bound to that item. The child claims it when it registers, so it can't drift onto something else.
spawn a wave One command, one child per seat, each in its own git worktree, reaped when it exits. The wave reports what two workers both changed and what nobody declared.
let them review each other Independence is enforced on authorship, not on a role, so no promotion launders it. This is the rung where a person stops being the bottleneck.

Where self-hosting stops: a second human

Multi-agent and multi-human are different problems. Any number of agents is free on your own machine, but a self-hosted install has one owner and no way to add a teammate. Sharing a backlog with other people is what the platform adds.

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.

  • Agents beyond the number of free clusters are proposed as reviewers — because a worker with no non-colliding cluster is an agent the divvy refuses every time it asks.
  • Idle agents are preferred. Re-tasking an agent that holds work releases that work in the same transaction, and the UI names what will be released before you commit.

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.

The loop →

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 seat is a seat, not a role. Two workers need two codes — independence derives from the enrolment, so two agents sharing one code would be one session and could not review each other. There is deliberately no max_uses.
  • Ceiling conflicts refuse, they never clamp. A reviewer seat on a worker-only credential fails and names both sides — checked before consumption, so the refusal doesn't burn the seat. Clamping would leave the roster showing a worker where a human deliberately issued a reviewer: the silent-downgrade shape.
  • No enrolment is a legitimate posture. Un-enrolled agents are grouped on the roster as "single-agent posture, no role gate" — grouped, never hidden.
  • Ending a wave expires seats, not credentials. No config is touched and nothing reconnects; the next wave is a new set of seats.

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.

getting them
# the client — anywhere. No dependencies at all.
uv tool install graphban-cli

# the same client from the Homebrew tap, which moves on its own
brew install asc-me/tap/gban

# apply the playbook pack this deployment publishes
gban update

# the supervisor, if you run waves. Also installs gbagent.
uv tool install graphban-fleet

Two packages rather than one, and the split is the same argument as everything else on this page: gban installs on laptops that never run a wave and pulls nothing — its HTTP client is the standard library — while the supervisor brings httpx and its transitives. Only the client has a Homebrew formula, because a formula for the supervisor would carry a resource stanza per transitive dependency to be kept in sync by hand, and the supervisor exists to be resolved on PATH by whatever launched it rather than typed by a person.

  • The credential goes in the config file, never in argv. Argv is world-readable on a shared box. The seat code travels as an argument to register_agent instead — it reaches the child as an instruction rather than as configuration, and it is single-use with a thirty-minute life.
  • A spawned process is not a subagent. It registers in its own right, gets its own identity, and is therefore not independent of its parent for review purposes — which the server decides, not the supervisor.
  • Adapters are named, never guessed. Seven of them, listed above, each resolved explicitly and version-asserted before a child starts.
  • A reviewer can be review-only. A review-only seat may take review work and never build work, so a supervisor started to review cannot quietly build a slice itself.
  • Branches are published at review. When a card reaches review the supervisor pushes its branch and opens a draft PR, one card per PR. With --merge, which is opt-in, it marks the PR ready and enables squash auto-merge once the card is signed off.
  • Retiring a wave expires seats, not credentials. A planner can retire only what it minted, scoped by who minted it.

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.

What's new

latest 2026-11-10
run

The playbook pack, applied with gban update

A deployment publishes the playbook pack at GET /api/playbooks. gban update applies that pack to the checkout. The fleet supervisor still holds no authority of its own.

fleet

Harness capacity is a routing mode beside the grades

Cheap or frontier still comes from measured sign-offs, and a cell under five samples stays unmeasured. The Harness Capacity tab is the other mode: entitlements, a subscription hierarchy you set, and a forecast of spend. A routing verdict the router never recorded is null, with a note. It is not zero.

run

First run is a checklist, not an empty form

After the stack starts, first run is a checklist, a setup-status card that stays until the first job is proven, and a one-time tour. Finish later with no project and the shell keeps the setup chip. It does not drop you into an empty create form.

run

Specs is a library, and a canvas finding stays open

The Specs list is the PRD, its preamble, and the design-set documents, plus the folder they sync to. search_docs searches baselined PRD bodies; drafts and design rows stay out until the call opts in. A design set compiles a prompt, stores the canvas, and can file a finding with a canvas check. That finding stays open until the bound canvas for the latest compiled prompt passes it.

honesty

An unloaded rule pack is not off

Build → Rules draws each rule at a strength: enforced, checked, external, advisory, unmeasured, or not used. library_loaded: false means no pack is loaded. It does not mean every rule is off, and it does not mean nothing was adopted. Turning a rule on or off is a person's REST action. get_rule_toggles has no MCP write.

run

The tracker board has a Blocked column

The tracker defaults to a board. The columns are Backlog, Next, In progress, Review, Blocked, and Done. Blocked sits beside Review, so a card that cannot move is its own column.

graph

Dead code and test results join the Stack overlay

Two new code-health categories on every component. Dead code reads vulture, knip and ts-prune. Tests reads pytest, vitest, jest or any JUnit report, with passed, failed and skipped counts, and each failing test becomes a finding you can Defrag into a card. A run that executed zero tests is recorded as a finding, not a pass, and a suite that hasn't run at the current commit shows as stale or never run.

graph

The Stack view: an architecture map nobody has to draw

Build → Stack maps the running system from the topology the API reports: your deployables grouped by host, the infrastructure you run, and the external services you depend on, in three nested rings. A code-health overlay records scan findings per component and colours each box by its worst severity. A box that was never scanned reads as never scanned, not as clean.

graph

The code graph groups by area

Nodes now cluster by area and draw their kind as their shape, so the map reads at a glance before you open a single label.

fleet

Gemini and DeepSeek join the fleet

Two new named adapters, agy for Google's Antigravity CLI and dsh for DeepSeek, bring the fleet to seven harnesses alongside Claude Code, Cursor, Grok, Qwen Code and local models through gbagent. Each is version-checked before a child starts. Codex still connects over MCP but has no fleet adapter.

honesty

Hand-merged work can be closed without faking a sign-off

Work merged outside the fleet used to sit open forever, or get a sign-off it never had. A project owner can now close it as merged, and the record says that is what happened.

honesty

A check that did not run is not a pass

Sign-off now refuses evidence that denies an acceptance clause, counts only the reviewer's own tests, and treats a skipped check as missing rather than green. A refusal names the word it matched and the rule behind it.

memory

The decider sorts the memory queue

A project can opt in to a fast System One model that grades each memory candidate: grounded, ready, and whether it contradicts a published lesson. Thresholds are calibrated against human decisions, the uncertain middle stays with a person, and a decider that cannot answer says so on the page.

fleet

Review-only seats, and branches published at review

A review-only seat can review but never build, so a reviewer cannot quietly take a slice. When a card reaches review the supervisor pushes its branch and opens a draft PR, one card per PR. With --merge it enables auto-merge after sign-off; without it, a person merges.

run

Usage, Activity and OTLP log export

A deployment-wide Usage page with model usage, an Activity view rebuilt around lenses and facets, and log export over OTLP to the observability stack you already run.

run

The app, reorganised: Plan, Build, Observe

The redesign groups every surface into three sections: Plan (tracker, requests, PRDs, roadmap), Build (code graph, stack, MCP tools, fleet, outposts) and Observe (memory review, lessons, activity, live, harness). The same in self-host and hosted.

run

Feedback Kit: public boards, votes and a roadmap

Collect feedback through an ingest API or the embeddable widget, publish boards people can vote on, and show a public roadmap. Submissions land in triage and become cards.

fleet

Outposts and the harness mix

The Fleet view is rebuilt around a catalog of harnesses, a mix that sets each vendor's share of launches, and outposts: the macOS, Linux and Windows machines your fleets run on.

fleet

A capability matrix built from real sign-offs

Models are graded per function set from reviewed work and probes, learned across users. Ask for a tier and the resolver picks from what has been measured. A cell with under five samples says unmeasured instead of guessing.

honesty

Done cannot be minted at the door

Two ways around the completion gate existed: create an item already marked done, or release a claimed one into done. Both now refuse. Signing work off still requires an attestation — the status is not a field you get to set because the form accepted it.

honesty

Agents are told the git process, or told it is unmeasured

Nothing used to name a base branch, so agents opened PRs against main because that is what models do. The org — or the local project — now stores a sparse delivery contract: base branch, whether they may push to it, optional naming. get_context returns it. An unset field stays unmeasured, not a silent main. Graphban still does not open PRs, merge, or rebase.

fleet

until is a planner loop, not a thicker supervisor

gbfleet up is still one wave: spawn, wait, reap. until sits around it — keep going while there is work, spawn reviewers when review appears, and on restart adopt the children that are already running rather than launching a second set beside them. The supervisor still holds no authority of its own.

fleet

A wait for a human is a blocked item

When an agent needs a person, that wait is a blocked item on the same tracker rather than a side channel nobody will look at. The work stays visible, and the reason is on the record.

memory

A published lesson that nobody retrieved is a miss

Observe now has a Lessons catalog. Reach and outcomes are computed rather than assumed, and publishing something the next agent never found is recorded as a miss — not as a silent success that looks like the lesson landed.

run

Install without Docker, as a system service

Compose is still the default. A Mac or Linux box can now run Graphban as launchd or systemd, with one CLI for install, upgrade and uninstall. Postgres with pgvector is required and verified, never installed for you. There is no .app.

fleet

The caller picks the model a child runs

Every spawned child used to run whatever its vendor CLI defaulted to, with no way to say otherwise — the cost lever the whole fleet idea rests on, missing. The supervisor now passes a model through, along with the two knobs one vendor each exposes. Pass-through, not selection: the supervisor still does not choose models on your behalf, and a knob the chosen vendor does not have is refused rather than quietly ignored.

graph

The living graph answers back

Hubs, shortest path and two-hop reach reach the UI rather than staying in the API. Paths say which way each edge points, and the whole graph is one tab stop — arrows move real focus, so it is navigable without a mouse.

honesty

The reference docs cannot silently fall behind

Three documents presented themselves as complete inventories and were not: the API reference covered 87 of 160 routes, configuration 19 of 51 settings, the data model 15 of 48 tables. A ratchet now records the known gap and fails on any new one, in both directions — documenting something and leaving it listed as missing fails too.

fleet

The fleet supervisor

gbfleet runs one wave: a child per seat, spawned in its own worktree, waited on, reaped. It is a separate distribution that depends on httpx and nothing else, and it holds no authority of its own — it can only launch a process holding a seat the server issued. Delete it and every invariant still holds; the fleet just needs a human to open terminals again.

fleet

Vendor adapters, named and version-checked

claude, cursor-agent, grok and codex, each resolved by name rather than guessed at, with the supported version asserted before a child is spawned. The credential is written to the child's config file and never to argv, because argv is world-readable on a shared box.

fleet

Mint, list and retire became one capability

A planner can retire the seats it minted and only those, scoped by minted_by. Ending a wave expires seats, never credentials — no client config is touched and nothing has to reconnect.

proof

The supervisor walk, run against a real server

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 instance 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.

fix

A closed PRD no longer blanks the PRD page

The status map covered three states and the server sends four. One closed PRD threw during render and took the whole list and editor with it. An unrecognised status now costs one oddly-labelled chip instead of a white screen, and a test compares the two lists across the stack.

fix

Linking a request to an item works on a renamed project

Item ids are frozen at issue time while the key you see is rendered from the project's current tag. The link stored the key you sent instead of the id it resolved to, so on any renamed project it violated a foreign key and returned a 500.

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