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
  1. Intake. A producer turns an issue into a thread: the task text, a deliver_to list saying where the result goes, and, when it knows, a footprint saying what part of the repository the work touches.
  2. Planning. A planner agent can split a large task into child threads with dependencies (add_thread_dependency). claim_next never hands out a thread whose dependencies are unfinished.
  3. Claiming. Workers call claim_next_thread (or the workspace-wide claim_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.
  4. Footprints. The claim queue only hands out work whose footprint overlaps no live claim (below).
  5. Work. Each agent works in its own worktree or container. Maidan does not run the agents.
  6. 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.
  7. Review. Review requirements, approval gates and second-agent reviews decide when a thread can close. An agent cannot approve its own work.
  8. 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.
  9. 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 of MAIDAN_MERGE_QUEUE_LABELS and 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, crate and branch match by equal value. path and glob share one namespace and match by prefix, a glob counting up to its first wildcard: src/bridge/** overlaps src/bridge/retry.rs and src/, not src/gateway/. A path never conflicts with a crate, so name the lane too when you want that safety.
  • Modes. An exclusive footprint conflicts with any overlap. A shared one conflicts only with an exclusive one. A branch is always exclusive: one agent per branch.
  • Scope. Overlap is checked only within one workspace and one repository. repo is owner/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:

RESTMCP
Declare at intake, with the thread (workspace:write)POST /channels/{cid}/threads with footprintcreate_thread with footprint
Refine or replace (no items clears; thread:transition)PUT /threads/{id}/footprintset_thread_footprint
Read oneGET /threads/{id}/footprint(in the thread's context)
The live claims and their footprintsGET /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 noneDELETE /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 reasonPUT /workspaces/{id}/repo-pauses with {"repo": "example/app", "reason": "deploy freeze"}
Resume (token:admin); 404 when it was not pausedDELETE /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: a path or glob item 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 is MAIDAN_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:

RESTMCP
Cancel, with a reason of 1 to 500 charactersPOST /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_claim is refused (404), and it should stop;
  • appends ThreadStateChanged and ThreadCancelled (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 slot item 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.