The file-disjoint rule: 10 parallel AI agents, no merge pain
Parallel coding agents don't bottleneck on compute — they bottleneck on the merge. Two agents touching the same file cost more than one agent doing both edits. The fix is a partitioning rule we wish we'd adopted earlier.
Ilyos Olimov · · 5 min read
We run an AI coding team — a coordinator that fans tasks out to 5-12 LLM agents (Claude Code, Codex, aider, whatever) and merges the results back into one branch. The first thing you discover is that the bottleneck is never compute. Anthropic and OpenAI will happily sell you parallel tokens. The bottleneck is the merge.
If two agents touch the same file, you get a three-way merge between two LLM diffs and a base. The conflict markers don't resolve cleanly — both sides rewrote imports, both sides added a helper, both sides reformatted a closing brace. A human (or worse, a third agent) now has to re-read both diffs, reconcile intent, and re-run tests. You just paid for two agents and got the throughput of one and a half.
The rule that fixed this for us is embarrassingly simple: partition the work by file. If you can guarantee agent A's write-set and agent B's write-set are disjoint, you can run 10 agents in parallel and the merge is a concatenation. No conflict resolution, no re-cost, no taste arbitration.
The dispatch shape
flowchart TD
T["Task"] --> SP["Split by FILE, not by feature"]
SP --> A1["Agent 1 · files a, b"]
SP --> A2["Agent 2 · files c, d"]
SP --> A3["Agent 3 · files e, f"]
A1 --> MG["Merge"]
A2 --> MG
A3 --> MG
MG --> OK["No overlap → no conflicts"]
style OK fill:#13241a,stroke:#2ecc71,color:#d7f7e3
The coordinator's job is to take a task list and produce a partition. Pseudocode:
type Task = { id: string; description: string; files: string[] };
function partitionDisjoint(tasks: Task[]): Task[][] {
const buckets: Task[][] = [];
const owned = new Map<string, number>(); // file -> bucket index
for (const t of tasks) {
const collisions = new Set(
t.files.map(f => owned.get(f)).filter(i => i !== undefined),
);
if (collisions.size === 0) {
// New bucket — no file overlap with anyone running
const idx = buckets.push([t]) - 1;
t.files.forEach(f => owned.set(f, idx));
} else if (collisions.size === 1) {
// Single owner — bundle into their bucket, they run both tasks
const idx = [...collisions][0]!;
buckets[idx].push(t);
t.files.forEach(f => owned.set(f, idx));
} else {
// Multi-collision — serialize. Don't dispatch yet.
t.deferred = true;
}
}
return buckets.filter(b => b.length > 0);
}
Three rules fall out of this:
- Zero overlap → fan out. Each bucket becomes one agent. 12 buckets = 12 parallel agents.
- Overlap with one existing bucket → bundle. Two small tasks on the same file become one agent doing both. The agent reads the file once, applies both changes, writes once. Strictly cheaper than spawning two agents that race.
- Overlap with multiple buckets → serialize. The task touches a hot file that several agents need. Hold it. Run it after the first wave finishes, when the file is stable.
The bundling rule (#2) is the one that surprises people. The intuition is "more agents = more parallel = faster." Wrong. Two agents writing to the same file is negative throughput versus one agent doing both edits, because you pay for the merge.
When this fails
The file-disjoint rule has three real failure modes. None are theoretical.
Shared types files. If you have a types.ts or schema.ts that every feature touches, partition collapses. Every agent wants to add one field. You can't bundle ten tasks into one agent because the agent's context window fills up with unrelated work and it loses the plot around task six.
The mitigation is to split shared type files aggressively. One file per domain, re-exported through a barrel. The barrel itself is hot — see below — but the underlying domain files are cold.
Barrel exports (index.ts). Every new module wants a line in the barrel. If you have ten agents adding ten modules, all ten want to edit index.ts. We treat barrel files as a coordinator-owned resource: agents write their module, the coordinator appends to the barrel in a final serialized step. Don't let the agents touch it.
Refactoring a shared interface. If the task is "rename User.email to User.primaryEmail across the codebase," there is no partition. Every call site has to change in lockstep. This is the case where you serialize: one agent, full context, atomic commit. Don't try to be clever — the merge from two agents doing a rename across overlapping files is unrecoverable in practice.
The smell is: if the task description contains the word "across" or "everywhere," it's serial.
How we structure tasks to be partitionable
Most of the work the coordinator does is upstream of dispatch. Before any agent runs, we produce a per-task file manifest. The plan step is mandatory — an LLM that's allowed to discover files mid-task will inevitably wander into hot files and break the partition.
Concretely, every task in the queue looks like:
{
id: "add-export-csv-button",
description: "Add CSV export button to reports page...",
files: {
write: ["src/pages/reports/ExportButton.tsx",
"src/pages/reports/index.tsx"],
read: ["src/lib/csv.ts", "src/types/report.ts"],
},
}
The write set is what the partitioner uses. The read set is informational — multiple agents can read the same file without conflict. If an agent tries to write a file outside its declared write set, the coordinator rejects the diff and re-queues the task. This sounds harsh; it's the only thing that makes the system stable past 4 or 5 concurrent agents.
Takeaways
- The merge is the bottleneck, not compute. Two agents on one file is slower than one agent doing both edits.
- Partition tasks by their write set, not their description. Disjoint writes = safe parallel.
- Bundle multiple small tasks on the same file into one agent. It's strictly cheaper.
- Serialize anything that touches "shared" surface — types, barrels, cross-cutting renames.
- Require an explicit file manifest per task before dispatch. Reject diffs that escape the manifest.
- If you can't predict the write set ahead of time, you can't parallelize safely. Plan first, fan out second.
Keep going
More practical ways to move your app forward.
- How to describe your app to AI: a prompt template
Write a clear AI app-builder prompt with a copyable template, a practical example, acceptance checks, and focused follow-up edits.
- How to turn a client brief into a mobile app prototype
Turn a client brief into a mobile app prototype with a copyable AppX prompt, a practical test checklist, and a focused client-feedback workflow.
- Investigators before builders: a 2-phase LLM workflow
Most teams dispatch coding agents straight to the build. We run a read-only investigator phase first — and the findings have rewritten our plan in 30-40% of cases. Here's the shape, the brief format, and what to do when findings contradict the plan.
- Step 0: delete dead code before you refactor with an LLM
Every byte you load into an LLM agent's context is a byte it has to keep track of. Dead code looks like signal — and agents preserve, extend, or mimic it. Our rule: a pure-deletion commit before any structural refactor of a file over 300 LOC.
- The 0ms-zombie: a TOCTOU bug in AI-agent turn persistence
Two-phase write + process death = a row that lies about its own state. We call it the 0ms-zombie. Defines the bug class formally and ranks four mitigations by structural depth, from frontend clamp to DB CHECK constraint.
Ready to try your idea?
Start with a clear description. AppX will help you turn it into an app you can test.