Two agents, one working directory, and a merge nobody asked for
Parallel agents editing one checkout interleave their changes. Isolation costs disk, not correctness.
TL;DR
Running four agents in parallel on four independent tasks is an obvious throughput win — until they share a working directory. Then one agent's half-finished edit is another's input, test runs see a mixture of four changes, and the final commit contains work from tasks nobody associated.
It is not a race in the usual sense; nothing crashes. The agents each behave correctly against a filesystem that is changing underneath them, and the result is a coherent-looking diff that combines four tasks.
git worktree gives each agent its own checkout against a shared object store. The cost is disk
and a few hundred milliseconds of setup, and it makes the whole class impossible.
The case this post is about
A fan-out over four independent tasks, in one repository:
tasks = [
"add input validation to the signup endpoint",
"fix the N+1 in the orders list",
"update the README install steps",
"add a missing index migration",
]
# four agents, one cwd
await asyncio.gather(*[run_agent(t, cwd="/workspace/repo") for t in tasks])
Each task touches different files. That was the reasoning — the tasks are independent, so sharing a checkout is fine.
The symptom
A commit containing all four tasks, attributed to the first agent that finished:
$ git log -1 --stat
feat(signup): add input validation
api/signup.py | 34 ++++++++---
api/orders.py | 18 ++++--- ← task 2
README.md | 9 ++-- ← task 3
migrations/0042_idx.py | 12 +++++ ← task 4
Agent 1 ran git add -A && git commit, which staged everything in the directory — including three
other agents' in-progress work.
Test runs seeing a mixture. Agent 2 ran the suite to verify its N+1 fix and saw a failure from agent 1's half-written validation:
agent 2: ./pytest
FAILED api/test_signup.py::test_rejects_blank_email ← not agent 2's change
agent 2: "the test suite is broken; investigating test_signup"
→ spent 6 iterations debugging another agent's incomplete work
And a file genuinely clobbered. Two agents both touched requirements.txt — not in the planned
scope, but both needed to add a dependency:
agent 2 reads requirements.txt (47 lines)
agent 4 reads requirements.txt (47 lines)
agent 2 writes 48 lines (+ sqlalchemy-utils)
agent 4 writes 48 lines (+ alembic) ← overwrites agent 2's line
final: alembic present, sqlalchemy-utils lost
Read-modify-write with no coordination. Agent 2's dependency vanished, and its code referencing it was committed.
Across 20 parallel runs of 4 agents:
runs with cross-task contamination in a commit: 17 / 20
runs where an agent debugged another's work: 11 / 20
runs with a lost edit (read-modify-write): 6 / 20
runs that completed cleanly: 1 / 20
One in twenty.
Why the obvious fix failed
"Give each agent a file lock." We added advisory locking per file. It prevents the clobber and not the other two problems — tests still see everyone's changes, because a lock protects a write, not the visible state of the tree.
"Have agents declare their files up front and check for overlap." Declared scopes were wrong
about requirements.txt, which nobody planned to touch. An agent's file set is discovered during
the work, not before it.
"Run them sequentially." Correct and it gives up the parallelism that was the point. Four tasks at ~6 minutes each is 24 minutes sequential against ~7 in parallel.
"git stash between agents." Reintroduces serialisation and risks losing work — and stashing
another agent's in-progress edits mid-run is a reliable way to destroy them.
"Tell agents to only commit their own files." Prompt-level, and it gets 80% compliance. The
other 20% is the git add -A habit, which is deeply trained and perfectly reasonable in a
single-agent context.
The actual mechanism
A git working directory is shared mutable state, and it has three properties agents rely on:
The filesystem is the agent's memory. An agent reads a file, reasons about it, and writes it back. That read-modify-write has no transaction around it.
Test runs observe the whole tree. pytest does not run against "agent 2's changes"; it runs
against the directory. A mixture of four partial changes is what it sees.
And git add -A stages the directory, not the agent's work. There is no per-agent change set
to commit, because git tracks the tree and not who touched it.
The property that makes this worse than ordinary concurrent-write corruption is that the interleavings are plausible. Two threads racing on a counter produce an obviously wrong number. Two agents racing on a file produce valid code — agent A's import block with agent B's function body, both syntactically fine, both individually reasonable, together implementing neither change.
And each agent then verifies against a tree containing the others' work. A green test run tells agent C that its change is correct, when what it has actually established is that the current mixture passes — a result that evaporates the moment agent A finishes its next edit. So the feedback every agent is steering by is measuring something that does not exist for longer than a few seconds.
Which is why the fix is isolation rather than locking. A mutex around the tree serialises the agents and gives up the parallelism you were buying; separate worktrees let each one keep a private, stable, verifiable view, which is the thing the read-modify-write cycle needed in the first place.
So the failure is not a data race in the memory-model sense — each individual read and write is atomic. It is a consistency failure: every agent operates on a correct snapshot that is no longer true by the time it writes.

Parallel agents need isolation, and the unit of isolation for code work is a working directory. Anything less — locks, declared scopes, prompt discipline — protects individual operations while leaving the shared view intact.
The fix
One git worktree per agent. Separate working directories, one shared object database.
import asyncio, subprocess
from pathlib import Path
REPO = Path("/workspace/repo")
def create_worktree(name: str, base: str = "HEAD") -> Path:
path = Path(f"/workspace/wt/{name}")
subprocess.run(["git", "-C", REPO, "worktree", "add",
"--detach", str(path), base], check=True)
return path
def remove_worktree(path: Path):
# --force because the agent may have left untracked files
subprocess.run(["git", "-C", REPO, "worktree", "remove", "--force", str(path)], check=True)
async def run_isolated(task: str, name: str) -> Result:
wt = create_worktree(name)
try:
result = await run_agent(task, cwd=wt)
# the agent's work is a real branch, not loose files in a shared tree
if result.has_changes:
subprocess.run(["git", "-C", wt, "checkout", "-b", f"agent/{name}"], check=True)
subprocess.run(["git", "-C", wt, "add", "-A"], check=True) # safe: own tree
subprocess.run(["git", "-C", wt, "commit", "-m", result.message], check=True)
return result
finally:
remove_worktree(wt) # auto-cleanup, even on failure
results = await asyncio.gather(*[
run_isolated(t, f"task{i}") for i, t in enumerate(tasks)
])
Four properties this buys:
git add -A becomes safe. It stages that agent's tree, which contains only that agent's work.
The deeply-trained habit stops being a hazard.
Test runs are isolated. Agent 2's pytest sees agent 2's changes against a clean base. No
debugging someone else's half-written validation.
Read-modify-write is safe. Both agents edit their own requirements.txt. The conflict surfaces
later, at merge, where a conflict is visible and resolvable rather than silent.
And --detach plus a named branch per agent means each result is an independently reviewable
and mergeable unit.
Then the merge, which is where conflicts belong:
def integrate(branches: list[str]):
for b in branches:
r = subprocess.run(["git", "-C", REPO, "merge", "--no-ff", b],
capture_output=True, text=True)
if r.returncode != 0:
subprocess.run(["git", "-C", REPO, "merge", "--abort"], check=True)
yield Conflict(branch=b, detail=r.stdout) # surface it; do not guess
else:
yield Merged(branch=b)
A conflict on requirements.txt is now a reported conflict with both lines present, rather than a
lost dependency.
Two operational details worth knowing:
# 1. a shared object store means the worktrees are cheap, but NOT free
# measured: ~240ms setup, ~90ms teardown, ~180MB per worktree on our repo
# 2. a stale worktree after a crash blocks reuse of its path
subprocess.run(["git", "-C", REPO, "worktree", "prune"], check=True) # on startup
The prune on startup is the detail that bit us twice: a killed process leaves the worktree
registered, and the next run fails with "already exists".
How we proved it
The same 20 runs, isolated:
shared cwd worktrees
runs with cross-task contamination 17/20 0/20
agent debugged another's work 11/20 0/20
lost edits (read-modify-write) 6/20 0/20
clean completions 1/20 20/20
merge conflicts surfaced (and resolved) n/a 7/20
Zeros across the three failure modes, and seven runs produced a real conflict — which is the same underlying collision, now visible instead of silent. That row is the honest reframing: isolation does not remove conflicts, it moves them somewhere you can see them.
Wall-clock, since the whole point was parallelism:
4 tasks
sequential, shared cwd: 24m 10s (and 1-in-20 correctness)
parallel, shared cwd: 7m 02s (plus rework from contamination: +11m median)
parallel, worktrees: 7m 18s
Parallel-with-worktrees is 16 seconds slower than parallel-shared and does not incur the eleven minutes of median rework.
The cost:
per worktree
setup: 241ms median
teardown: 88ms median
disk: 183MB (repo is 1.1GB; worktrees share .git objects)
peak disk for 4 agents: 732MB
And the lost-dependency case specifically, reproduced deterministically by forcing both agents
to edit requirements.txt:
shared cwd: sqlalchemy-utils lost, 10/10 runs
worktrees: merge conflict on requirements.txt, 10/10 runs, both lines present after resolution
One limit: worktrees isolate the filesystem. They do not isolate anything else the agents share — we hit this with a shared test database, where two agents' migrations collided in a way no worktree could prevent. Full isolation means a per-agent database too (or a container), and the worktree is the cheapest 80% rather than the whole answer.
What this generalizes to
Shared mutable state plus concurrency needs isolation, and the unit matters. For code agents the unit is a working directory — not a file, not a lock, not a declared scope. Picking an isolation unit smaller than the state actually shared leaves the failure in place.
Making a conflict visible is a fix. Seven runs went from silently losing an edit to reporting a merge conflict. The collision is identical; what changed is that a human or a merge step now sees it. Converting silent corruption into a loud failure is usually the whole job.
Per-operation atomicity is not consistency. Every read and write was atomic, and the result was still wrong, because the agent's view of the world went stale between them. Transactions and snapshots exist for this, and "each operation is safe" is not the same guarantee.
Isolate everything the concurrent actors share, not just the obvious thing. The worktree fixed the filesystem and exposed a shared test database. Enumerate the shared resources — filesystem, database, network ports, caches, credentials — before declaring tasks independent.
Related
- An agent loop without a terminal condition is a budget with no bottom — bounding each agent's work
- The agent fixed the test, not the bug, and the test was right — constraining what an agent may touch
- The lock held across a network call, and the deadlock that followed — why locks were not the answer here
Keep reading
This is part of Agent tooling & workflows — the infrastructure that makes agents usable on real repositories. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".