Two agents, one codebase, no lost work
Pandora's Router runs many AI coding agents on one codebase at once. It proves their declared file scopes disjoint before any of them start, and it independently verifies what each one claims it finished.
Most of the field solves parallel agents with isolation. Give each agent its own git worktree or its own container and they stop fighting over one working directory. That's real, and it isn't enough. Two agents in two copies can still rewrite the same file, and nothing notices until the merge, by which point both pieces of work exist and one of them has to lose.
The second half is quieter. An agent's report that it finished is the least reliable signal in the system, and it's the one almost every orchestrator takes at face value. Merged is not shipped. A green build is not a served page. A change nobody can see in a browser is indistinguishable from success until somebody looks.
Declared file scope, before dispatch
A brief declares the files it will write, on one line:
- **Touches:** `src/providers/pool.ts`, `src/providers/limits.ts`, `test/pool-test.mjs`
The allocator normalizes every declared path to one canonical form, then compares paths
exactly or as directory containment on whole path segments. Never as a string prefix, which says
app contains application/x and is wrong, and never by similarity. Two
lanes may run in one repo only when their declared scopes have zero intersection.
Undeclared never means probably fine. A brief with no
Touches:line owns its whole repository and serializes against everything in it.
Touches: none is a different claim: that lane writes no repo file, holds no
writer slot, blocks nobody, and its close fails by name if it writes one. Where a scope can't be
compared exactly, a mid-path glob for instance, it widens to the parent directory. Widening
over-reports overlap, which costs a wait. Narrowing would under-report it, which costs somebody's
work, so narrowing is never done.
Some paths are global machinery rather than lane-local files: a numbered migrations directory,
a deploy-all command that ships every function in one folder, package.json. A repo
can declare those exclusive. A lane touching one holds that path alone and leaves the rest of the
repo open to everybody else.
The declaration is written into the ledger when the lane opens, and the in-scope gate later checks every file the branch actually touched against it.
Verification, after
An agent says it finished. The close finds out. It measures seven gates, and DONE requires all of them; anything else is PARTIAL with the failing gates named. An eighth input, the report's own STATUS word, can only lower the grade: a report that says PARTIAL in its own words is never graded DONE by gates that happened to pass.
One of those gates is the one none of the tools compared runs. Every other check asks a question about the repository, and all of them can be true while the page a person opens is last week's build. So the close issues a GET and reads what came back. It returns three values, not two:
- yes The surface answered 200 and carried what it was supposed to carry.
- no The surface answered and it isn't serving this build. A real red.
- skipped Nothing was measured, for a named reason. A skip is never a pass, and the grader counts it as a failure.
No URL in policy, a named credential that is unset, a socket that never answered: each of
those measured nothing. The value is the literal word skipped and the reason is
always named.
Quickstart
Node 22 or newer. Zero npm dependencies, no build step, nothing to install before the tests run.
git clone https://github.com/hoplight-ai/pandoras-router
cd pandoras-router
npm test
Every suite prints its own assertion count as it runs, and how many assert a refusal, so deleting a guard turns them red rather than quietly widening what the tool allows. CI runs the same command on Node 22 and 24 across Linux, macOS and Windows.
Now build a throwaway workspace out of examples/. A workspace is any directory
holding a _handoffs/ bridge and your repos:
mkdir -p /tmp/pandoras-demo/_handoffs/_lanes
cp examples/POLICY.md examples/PREFIXES.md examples/CLAIMS.md examples/LANES.md /tmp/pandoras-demo/_handoffs/_lanes/
cp examples/Web-CEILING1-Raise-The-Per-Provider-Cap.md /tmp/pandoras-demo/_handoffs/
PANDORAS_ROOT=/tmp/pandoras-demo node src/bin/router.mjs alloc
You get one lane card: the repo, the branch, the worktree, the dev port, the derived report filename, how that repo deploys, how a deploy gets proved, and the three files the brief declared. Every field is read from a file. None of it is inferred from the brief's wording.
Add a second brief over the same files and run it again:
cp examples/Web-CEILING1-Raise-The-Per-Provider-Cap.md /tmp/pandoras-demo/_handoffs/Web-POOL2-Same-Files.md
PANDORAS_ROOT=/tmp/pandoras-demo node src/bin/router.mjs alloc
One card now says FIRE NOW and the other says QUEUED, naming the lane it waits on and the three paths where the two scopes intersect. That refusal is the whole product.
Configuration
Four files under _handoffs/_lanes/ at your workspace root, all of them tables read
by code and prose read by people. Copy them out of examples/ and edit.
| File | What it holds |
|---|---|
| POLICY.md | One row per repo: writer cap, deploy style, how a deploy is proved, the liveness URL, exclusive paths, traps. A repo with no row here routes nothing, and the allocator says so by name. |
| PREFIXES.md | The filename vocabulary. An unrecognized lifecycle word routes nothing and is printed with the reason, rather than defaulting to live and getting executed twice. |
| CLAIMS.md | The visible lock, one line per active lane. A clean working tree doesn't prove absence; an agent can be open and mid-read for twenty minutes before its first edit. Ships empty. |
| LANES.md | The append-only ledger of every lane opened, landed and closed, carrying each lane's declared scope. Ships empty. |
Subcommands
| Command | What it does |
|---|---|
| alloc | Print ready-to-fire lane cards. Writes nothing. |
| open | Open a lane from a card: worktree, branch, claim, ledger record. Creates only, deletes nothing. |
| close | Run the gates and grade. Measures only; --apply renames the brief and records the CLOSE. |
| land | Land a lane on main as one merge commit, with a LAND record linking the change back to its brief and report. |
| claim | Take or release a claim without a lane, for direct work. |
| apply | Apply a unified diff atomically, by index, so a concurrent reader never sees a half-written file. |
Prior art
Most of the field solves parallel agents with isolation alone, and this project doesn't improve on that. Two of the three entries written up go further, but none did both scope-before-dispatch and verify-after, and none checked that a merged change is actually live at a URL. That gap, rather than either half on its own, is what this fills.
Bernstein does declare owned files,
refuse overlapping jobs, and verify completions. It's the closest thing to this that exists, and
it got there first. The differences are in where each one puts the burden of proof. Bernstein is
demoting file-overlap checking to a legacy fallback in favour of an author-declared
parallel_safe flag; it infers a job's file scope from the task's wording when none is
declared; an empty declaration silently disables its guard; and a scope violation raises a
question rather than refusing. Here, an undeclared scope is the whole repository and serializes,
an unparseable scope widens rather than narrows, and a violation is a refusal with the
overlapping paths named. Those are judgment calls about false positives against lost work, not a
claim that one design is correct.
Agent Orchestrator never stores a status at all. It recomputes state from pull-request and CI facts every time it's asked, so a stored status can never go stale against reality. That's a cleaner idea than a ledger and worth reading for its own sake. This project keeps an append-only ledger instead, because it needs a lane's declared scope and its open timestamp recorded at the moment of dispatch, which no PR or CI fact carries.