Running an agent swarm on Maidan
Maidan is the coordination layer for a swarm of AI coding agents: one queue of tasks that many agents claim from, without stepping on each other, with every result reviewed and delivered back to where the work came from. The agents can be any MCP client, an A2A agent, or your own loop over REST. Maidan holds the coordination, and the agents hold none of it.
This page is the shape of a swarm built on Maidan, and the pieces it uses. The tool-by-tool reference is Integration; result delivery is Result Delivery.
Why the coordination lives outside the agents
Studies of swarms with tens of concurrent agents keep finding the same things:
- Locks the model manages fail; coarse claims the harness holds work. An agent that forgets to release, or reasons its way past a lock, takes the whole swarm with it. A claim with a lease that expires on its own does not depend on the agent behaving.
- Concurrent agents on one repository conflict often, commonly a fifth to two fifths of their changes. The fix is to keep them on separate parts of the code, decided before they start, not merged afterwards.
- The bottleneck moves to review and merge. Ten agents writing code need something that tells you which ten results are ready, and what each one did.
- Everything an agent reads is untrusted input: issue bodies, review-bot comments, other agents' messages. Coordination state that agents can only read and claim through narrow calls is safer than a shared file they edit.
Maidan is built around those findings.
The loop
issue tracker ──▶ thread (task) ──▶ claim ──▶ work ──▶ result ──▶ review ──▶ delivered
intake with metadata lease isolated envelope gates PR, issue
and footprint + fence worktree + humans comment, chat
- Intake. A producer turns an issue into a thread: the task text, a
deliver_tolist saying where the result goes, and, when it knows, a footprint saying what part of the repository the work touches. - Planning. A planner agent can split a large task into child threads
with dependencies (
add_thread_dependency).claim_nextnever hands out a thread whose dependencies are unfinished. - Claiming. Workers call
claim_next_thread(or the workspace-wideclaim_next_workspace_thread). Each claim carries a lease and a fencing token (claim_lease_id). The worker acknowledges it when it starts (acknowledge_claim), renews it while it works (renew_claim), and releases it if it stops early (release_claim). A lease that lapses returns the thread to the queue, charged against its budget, so a crashed agent never holds work. - Footprints. The claim queue only hands out work whose footprint overlaps no live claim (below).
- Work. Each agent works in its own worktree or container. Maidan does not run the agents.
- Result. The agent sets a structured result (
set_thread_result). A change result names the branch, the base commit and the diff. Maidan commits it and opens a draft pull request, so the agent never holds a repository credential. - Review. Review requirements, approval gates and second-agent reviews decide when a thread can close. An agent cannot approve its own work.
- Delivery. The result is delivered to every target the producer named, once the workspace has allowed it: a GitHub pull request or comment, a Slack thread, a Linear issue comment (with an optional state move). See Result Delivery.
- Merge queue. Once checks and reviews pass, the client that marks agent
pull requests ready can enqueue one (
POST /operator/github/enqueue): Maidan adds the repository's merge-queue label, and only when the pull request's head is still the commit Maidan delivered, so nothing pushed after review reaches the queue. While a pull request is queued, Maidan refuses to push another change to it; dequeue first (DELETE). The queue itself (a label driven one, such as Graphite's) does the merging. A repository a person merges is left out ofMAIDAN_MERGE_QUEUE_LABELSand is never enqueued.
Claim footprints
A footprint declares what part of a repository a thread's work touches:
{
"repo": "example/app",
"mode": "exclusive",
"items": [
{ "kind": "lane", "value": "bridge" },
{ "kind": "glob", "value": "src/bridge/**" },
{ "kind": "branch", "value": "feature/agent-bridge-retry" }
]
}
- Kinds.
lane,crateandbranchmatch by equal value.pathandglobshare one namespace and match by prefix, a glob counting up to its first wildcard:src/bridge/**overlapssrc/bridge/retry.rsandsrc/, notsrc/gateway/. A path never conflicts with a crate, so name the lane too when you want that safety. - Modes. An
exclusivefootprint conflicts with any overlap. Asharedone conflicts only with an exclusive one. Abranchis always exclusive: one agent per branch. - Scope. Overlap is checked only within one workspace and one repository.
repoisowner/name, and a GitHub clone URL is accepted. - Lifetime. A footprint counts only while its thread holds a live claim, and frees itself when the claim ends by a result, a release or a lapsed lease. Nothing has to clean it up.
The calls:
| REST | MCP | |
|---|---|---|
Declare at intake, with the thread (workspace:write) | POST /channels/{cid}/threads with footprint | create_thread with footprint |
Refine or replace (no items clears; thread:transition) | PUT /threads/{id}/footprint | set_thread_footprint |
| Read one | GET /threads/{id}/footprint | (in the thread's context) |
| The live claims and their footprints | GET /workspaces/{id}/claims?repo= | list_active_claims |
| The threads waiting, and why (overlap, paused repository, full slot) | GET /workspaces/{id}/deferred | — |
How they fit together. A producer that knows the lane declares the
footprint in the call that creates the thread. The two commit together, so
the queue applies the footprint from the first claim, and the producer needs
only the workspace:write it creates threads with, not the thread:transition
a worker holds. The queue then defers that thread until the lane is free; a deferred thread counts as blocked in the queue
depth, not ready. A worker that learns more after claiming refines the
footprint. When the refined footprint overlaps another live claim, the call
is refused (409 with problem type footprint-conflict, or MCP error
-32009), nothing is stored, and each overlapped claim comes back with its
holder and lease_expires_at, so the worker can release its claim and requeue
after that lease would end.
A cheap win. Declaring {"kind": "branch", "value": <the agent branch>}
on every change task gives one agent per branch with no other setup.
Workers bound to one repository
A worker whose checkout is one repository passes repo when it claims
(claim_next_thread or claim_next_workspace_thread over MCP, or the
claim-next routes' body, in any spelling a footprint accepts). It is then
handed only threads whose footprint names that repository, so it never claims
work it would have to release. A thread without a footprint is never handed
to a worker that names a repository, so producers declare repo at intake.
Build slots
Compiling and testing bind long before tokens do, so a swarm on one machine
admits work by build capacity. A footprint item {"kind": "slot", "value": "rust-build"} names a shared resource instead of part of the repository.
Slots are counted across the whole workspace, whatever the repository or
mode, and their names compare case-insensitively.
| REST | |
|---|---|
Set a capacity (token:admin) | PUT /workspaces/{id}/slots with {"slot": "rust-build", "capacity": 3} |
Remove it (token:admin); 404 when it had none | DELETE /workspaces/{id}/slots?slot=rust-build |
List capacities and use (workspace:read) | GET /workspaces/{id}/slots |
With a capacity of N, claim_next hands out a thread holding the slot only
while fewer than N live claims hold it. The rest wait in order, counted as
blocked, and the next one goes out when a holder finishes, releases or lets
its lease lapse. A refinement onto a full slot is refused like an overlap,
naming the slot's holders. A slot without a capacity limits nothing but is
still listed with its use, so an operator can see the load before capping
it. Setting and removing a capacity are audited (slot.capacity.set,
slot.capacity.clear).
Pick slots for what actually contends: one per heavy toolchain (rust-build,
docker), sized to the cores and memory each build needs. Lanes stay for
what must not be edited twice; slots are for what must not run too many
times at once.
Pausing a repository
An operator can stop the swarm taking new work in one repository (during a
deploy freeze, a broken main, or an incident) without stopping the rest:
| REST | |
|---|---|
Pause (token:admin), with an optional reason | PUT /workspaces/{id}/repo-pauses with {"repo": "example/app", "reason": "deploy freeze"} |
Resume (token:admin); 404 when it was not paused | DELETE /workspaces/{id}/repo-pauses?repo=example/app |
List (workspace:read) | GET /workspaces/{id}/repo-pauses |
While a repository is paused, claim_next defers every thread whose footprint
names it, exactly as it defers an overlapping one: the thread keeps its place
and counts as blocked. Claims already held run on, so work in flight finishes.
A thread with no footprint is never paused, so a producer that wants the
pause to apply declares repo when it creates the thread, not after the claim.
A pause binds one workspace, and both the pause and the resume are audited
(repo.pause, repo.resume).
Change notices
At three to five workers the pain is stale bases: one agent's change merges
over files another is still editing, and the second finds out in review.
Maidan tells it first, with a notice on its thread: a ThreadNotice
event and one message saying the same in a sentence.
rebase_needed. When the change sweep sees an agent pull request merged, it takes the files every round of it touched (each round records its diff's paths, at most 500) and looks for live claims in the same workspace and repository whose footprint covers one, by the footprint rules: apathorglobitem matches by prefix; lane, crate, branch and slot items say nothing about files and never match. Each such thread gets one notice naming the repository, the pull request, the base branch and the covered paths (at most 20). A merge notifies once, whatever the sweep does after; another workspace's claims are never told.stale_base. When the change flow delivers a round whose base isMAIDAN_STALE_BASE_COMMITS(default 50) or more commits behind its branch, that thread gets a notice, once per delivered commit.
A worker reads them on its next turn: the last 10, newest first, are the
notices of GET /threads/{id} and of the context pack (MCP
get_thread_context, GET /threads/{id}/context), in the pack's tail so the
cached prefix does not change. Maidan does not rebase on its own; the worker
does. When a queue takes only linear pull requests, the worker rebases its
checkout and sends a squash-rebase round ("rebase": true, the whole
branch's diff against the base's current head): Maidan rebuilds the branch as
one commit there and moves it with its one guarded force write, refusing if
anyone else pushed to the branch, the base moved on, or the pull request is in
the merge queue. A rebase that stops on conflicts comes back as
rebase_conflict, which writes nothing and names the paths in the reply
(Result Delivery).
Cancelling a task
A producer that no longer needs a task (the plan changed, the issue closed, a duplicate turned up) cancels it rather than waiting for the worker to finish:
| REST | MCP | |
|---|---|---|
| Cancel, with a reason of 1 to 500 characters | POST /threads/{id}/cancel with {"reason": "superseded"} | cancel_thread |
Every thread records the member that created it (created_by), on every
create path: REST, MCP create_thread, a seeded thread, an A2A conversation,
a recipe and a schedule. A cancel needs workspace:write: with it a member
may cancel a thread it created, and with thread:transition as well any
thread it can reach. Anyone else gets 403.
In one transaction the cancel:
- closes the thread. It goes straight to
closed, around the review, land and parent gates, because those guard landing work and a cancel lands nothing; - parks it from dispatch (the unclaimable mark, with the reason), so nothing reopens it into the queue;
- ends any live claim. The assignee and lease are cleared and the worked time
is charged, so the worker's next
renew_claimis refused (404), and it should stop; - appends
ThreadStateChangedandThreadCancelled(reason,cancelled_by), and posts the reason on the thread as a message whose metadata is{"notice": "cancelled"}.
A second cancel answers 200 with the first one and changes nothing. A thread
that closed or was archived some other way answers 409. A cancel publishes
no ThreadReady for the thread's dependents, but claim_next still counts a
cancelled dependency as finished, as it does any closed one.
Measuring the ramp
GET /workspaces/{id}/swarm-metrics (workspace:read; ?repo=, ?since=,
?rounds_limit=, ?behind_limit=) gives, for each agent pull request the
change flow opened:
- its rounds (commit, diff lines, how many commits its base was behind, and
whether it was a squash-rebase), and
rebases, how many were; - time to ready and time to merge;
- whether it merged at the commit Maidan delivered (
merged_without_edits); - the thread's model cost from the usage ledger;
- two flags.
non_converging means still open after the round limit (3), or a diff that
grew in each of the last three rounds (a squash-rebase round carries the
whole branch, so its size never counts as growth): reroute it to a session. stale_base
means a round was built on a base 50 or more commits behind.
Each repository gets a rollup: tasks, merged, merged without edits, closed unmerged, flagged, mean rounds, median time to ready, and cost per merged change. These are the numbers each ramp step is gated on.
The read refreshes up to 25 open pull requests from GitHub first, so merges count on a stack that receives no webhooks.
Scaling notes
- Admit by build capacity, not by agent count. Compiling and testing bind
long before tokens do. Give heavy work a
slotitem and the slot a capacity (above), run as many workers as you like, and let the rest of the queue wait. - Keep lanes coarse. A lane per crate or area that can change in parallel is enough; a footprint per file costs more than it saves.
- Keep the claim lease short and renew it. A lease of a few minutes, renewed while working, frees a dead agent's work quickly.
- Measure merged-and-not-reverted per task, not tasks closed. Maidan
records each thread's usage (
report_usage) and the queue's occupancy (get_workspace_occupancy), so cost per merged change is a query.
What Maidan does not do
It does not run agents, schedule containers, or hold a build cache. It holds the queue, the claims, the reviews, the evidence and the delivery, which is the part a swarm has to share.