Running two agents against the same file at the same time does not double the work. It starts a silent argument about whose edit survives, and the loser is never told. There is no merge conflict, no red build, no exception in a log — the second agent simply writes back the file it read, on top of a version the first agent already changed, and the first agent’s work is gone as if it were never done. We learned this the way these things are always learned: a change was made, the tests came back green, and the change was not actually in the file.
The failure with no error message
Start with the mechanism, because the whole
problem hides in one word: shared. When several dispatches run
in parallel against a single checkout, they are not working on private
copies that get reconciled later. They are all editing the same files on
the same disk. Agent A reads game_bloc.dart at its current
state and starts working. Agent B reads the same file, at the same
state, and starts working too. A writes its version back. Then B writes
its version back — the one built from the file before
A touched it — and A’s edit is overwritten in place. Not
conflicted. Not queued. Overwritten.
The reason this is so quiet is that a merge
conflict needs two committed histories to disagree about. This is one
working tree with no commit in between, so there is nothing for the tools
to flag — the second write is just a normal save. The tell is
perverse: the test suite goes green over a file that lost half of what
it was supposed to contain, because the half that survived still
compiles. A handful of files attract this every time, the ones almost
every task wants to touch — the central game logic, the app root,
main.dart, pubspec.yaml, the changelog. We call
them hot files, and they are hot precisely because they sit where
everyone’s work crosses.
Why parallelism looked free and wasn’t
The appeal of fanning work out is real, and it is worth being fair to it: when two tasks touch genuinely different files, running them at once is a clean win, and most of a sprint is exactly that kind of work. The trap is that a task list looks like a set of independent tickets when it is nothing of the sort. Two tickets can read as unrelated — “add the retry banner,” “wire the new sound cue” — and both still land in the same bloc, the same router, the same one-line-per-platform version file. The moment two units of work share a surface, they are not parallel; they only look parallel. The shared file is the serial part of the job wearing the costume of two separate jobs, and no amount of extra agents makes a serial part run in parallel. Throw more of them at it and you do not go faster — you just raise the odds that one of them is writing over another.
The rule that fixed most of it: one owner per file
The fix that removes the hazard rather than patching around it is embarrassingly simple to state and requires real discipline to keep: at most one agent may touch a given hot file in a given parallel batch. If two tasks both need the central bloc, they do not run at the same time — they are sequenced, one after the other, even when that stings. We plan this before dispatching, not after something breaks: every parallel batch ships with a file-ownership matrix that names exactly which agent owns which file, and two tasks that want the same one are a scheduling problem to be solved on paper, not a race to be discovered in the tree.
The honest cost is throughput we leave on the table. Work that could have run four-wide sometimes runs two-wide, or in sequence, because the pieces share a file and we refuse to gamble on the write order. We take that trade every time. A batch that finishes an hour later but contains all of the work it claims to is strictly better than one that finishes on time and quietly dropped a change nobody will notice until it ships. Coordination is not the tax you pay to avoid the real work; on shared state, coordination is the real work.
Trust, but grep
Ownership prevents the collision; it does not prove the edit landed. So the second rule is verification, and it is as blunt as the first. Every dispatch that changes a file introduces a unique marker as part of its change — a new identifier, a distinctive string, the name of the function it added — and then greps the file for that marker and asserts at least one hit before it reports success. No marker, no landing: if the string the agent claims to have written is not in the file, the edit did not take, and it is re-applied and re-checked rather than declared done. It is the software equivalent of reading back the order.
At the end of a multi-dispatch sprint we run the whole set of those checks in one pass — a change-survival sweep — before anything reaches review. A missing marker at that stage is not a QA finding; it is a re-dispatch, done before the gate, not surfaced by it. This matters because of something we wrote about in an AI QA gate for games: a review gate can only judge the code it is given, and it cannot know that the file it is reading is missing a change someone believes they made. A green gate over the wrong file is worse than a red one, because it launders a lost edit into an approval. The sweep exists so the gate is always looking at the code that was actually written.
The recovery that makes it worse
There is a third way to lose work, and it is
the cruelest, because it wears the face of a fix. When a dispatch hits a
compile error or runs out of budget mid-task, the reflex — a very
human reflex, and one an agent will happily imitate — is to
“get back to clean” with git stash or
git reset --hard. In a private checkout that is harmless. In a
shared working tree it is a loaded gun, because those commands do not
operate on your changes; they operate on the whole tree.
Reset it and you throw away every uncommitted edit sitting there,
including the work three sibling dispatches have not committed yet, and
including whatever the human happened to have open. The blind cleanup is
just a second overwrite, dressed as good hygiene.
So our rule is absolute and it points the
other way: on any failure, do not tidy — report and stop, leaving
the tree exactly as it is. “STOP: turn budget exceeded.”
“BLOCKED: compile error at file:line.” Both of
those leave every other dispatch’s work untouched, which is the only
property that matters when you cannot see what else is in flight. An agent
that stops with a mess intact is worth far more than one that hands you a
clean tree with a hole in it, because you can always finish an honest
job and you can never recover a silent one. Recovery that destroys is not
recovery; it is the same failure with better manners.
The real fix was fewer shared trees
Every rule above is a guardrail on a hazard, and the best thing you can do with a hazard is delete it where you can. For us that meant a structural change, not a procedural one: we split what had been a single repository into independent repos, one per app, so that work on two different games now happens in two entirely separate working trees with separate histories. Last-write-wins cannot cross a boundary that does not share a file, so the whole class of cross-project collisions simply stopped being possible — no matrix required, because there is nothing to contend over. It is the same instinct as giving each agent its own checkout, or reaching for git’s own worktrees: the cheapest race to win is the one you make impossible to start.
Inside a single repo the contention is still real, and there the matrix and the grep guard earn their keep. But the arc is the same one that runs through most of how this studio works, which we sketched in building games with an AI agent crew: agents multiply output beautifully across work that does not touch, and catastrophically across work that does. So we spend our effort mapping the “does not touch” before we fan anything out, because parallelism is a coordination problem wearing a throughput costume. Two agents in one file is not twice the work — it is a coin flip about whose work survives, and the loser is never told. The rest of how we try to keep our releases honest lives on the WizusLabs Engineering blog.
Notes
This is a first-hand account of one
studio’s conventions for running parallel AI-agent dispatches
against a shared repository, not a universal standard. It contains
no metrics — no count of lost edits, no failure rate, no
time saved or spent — because we did not measure those things and
inventing them would defeat the point of the piece. The underlying
behaviours are general properties of the tools: that concurrent writes to
one working tree resolve as last-write-wins, and that
git stash and git reset --hard act on the entire
working tree and can discard uncommitted work, are documented by Git and
cited below; tooling behaviour can change, so verify against the current
documentation before relying on specifics. The specific rules — one
owner per hot file per batch, a post-dispatch grep for a unique marker, a
change-survival sweep before the review gate, a no-stash recovery policy,
and splitting into independent repos to isolate working trees — are
our own working conventions, recorded in our engineering notes, not a
feature of any tool.
Sources
The Git behaviours this post relies on are documented by the tool’s maintainers (living documents; accessed 2026-07-25):
- Git — git-stash: that stashing records the state of the working directory and the index and then reverts the working directory to match
HEAD, moving your local modifications onto a stack where a careless drop or clear loses them. - Git — git-reset: that
--hardresets the index and the working tree so that any changes to tracked files in the working tree since the target commit are discarded. - Git — git-worktree: that a repository can have multiple working trees attached to it, allowing more than one branch to be checked out at once in isolated directories — the tooling analogue of giving each unit of work its own tree.
Keep reading: all posts on the WizusLabs Engineering blog.