Two Agents, One File: What Actually Gets Lost

TL;DR

  • Two agents with disjoint jobs were pointed at one file, two ways: a shared directory (Arm S) and a worktree each (Arm W), N=10 per arm. Arm S came back 10/10 clean, nothing lost. Arm W came back 10/10 clean and merged without conflict, 10/10.
  • The honest reason Arm S survived: string-anchored, content-disjoint edits, not a structural guarantee. This run discloses that mechanism; it does not claim shared directories are safe.
  • Worktree isolation’s own price, measured here for the first time: about 0.066s of setup-plus-merge per node, git plumbing only. The categorical argument survives the tie. Only the isolated arm turns a real overlap into a decidable event instead of a silent one.

📊 Result, up front. One real file, scripts/run-content-series.sh, 5 shellcheck findings split across two agents, edited two ways, N=10 per arm. Arm S (shared directory): 10/10 clean, no lost update. Arm W (worktree per node): 10/10 clean commits, 10/10 clean auto-merges. Isolation cost: 0.066s mean per node, git plumbing only.

ArmSetupClean result (10 trials)
Sone shared directory, both agents edit it10/10
Wone git worktree per agent, harness merges10/10 commits, 10/10 merges

That’s the whole result, stated before the argument for it: one file, two agents with disjoint jobs, two isolation policies, ten trials each, both came back clean.

This is a post about agent worktree isolation, measured rather than asserted. The scenario is built, not found. Part 3 fanned 43 files out and never collided because the corpus handed every node its own file. This post deliberately breaks that, points two agents at one file, and measures what a shared directory actually loses. The honest answer this run measured: nothing, at this sample size, for a reason that isn’t a safety guarantee.

Prerequisites:

Step 1: Where you are, the assumption that never got tested

Part 3’s 43-file fan-out never collided, and this post finally tests why that mattered: independence held by accident of corpus shape, not by design. The loop leaned on the same trick first, giving each parallel worker its own worktree so lanes couldn’t collide (loop-parallel-worktrees-subagents). This series promoted that into a graph property and never once pressured it.

Part 3’s fan-out worked because the corpus gave it an out: 43 tracked .sh files, 43 distinct targets, one agent per file, no two nodes ever aimed at the same bytes (graph-fan-out-one-agent-per-file). That was corpus shape, not a designed guarantee. Part 6 then added an independent verifier and set the rule this post’s harness inherits: ground truth is run by the harness, never taken from an agent’s own claim (graph-verifier-clean-context).

Before trusting that your own fan-out is collision-free, check whether it’s independent by design or just by corpus shape. List every node’s write target and look for duplicates:

Terminal window
# Does any two-node pair in your graph aim at the same file?
git ls-files '*.sh' | sort | uniq -d # empty output = distinct targets, independence by luck of shape

Verify: if that list is non-empty, or if your fan-out assigns targets dynamically, node independence isn’t guaranteed by the filesystem and this post’s failure mode applies to you.

Step 2: Agent worktree isolation is a filesystem property, not a prompt instruction

Fan-out’s contract is independent nodes, and independence is a property of the filesystem, not of the prompt. Two agents that can write the same file aren’t independent, no matter how carefully their jobs are scoped. You can name the exact lines each one owns and forbid the rest, and both processes can still open the same bytes.

Here is what “disjoint bytes” looks like concretely. Agent X and Agent Y patch different regions of the same file, so no text span is shared between them:

@@ Agent X: fixes SC2034 (ALLOW_BOOTSTRAP), above the case @@
+# shellcheck disable=SC2034
case "$1" in
@@ Agent Y: fixes SC2164 (cd guard), line 25 @@
-cd "$REPO_ROOT"
+cd "$REPO_ROOT" || exit

A worktree per node restores independence structurally. Give each agent its own checkout on its own branch and there’s no shared byte for either to race on. The collision doesn’t disappear; it changes form. A shared directory resolves overlap at write time, silently, whoever writes last. A worktree defers overlap to a merge, an explicit artifact you can inspect. That maps cleanly onto Part 4’s arrange/decide test: a clean merge of disjoint diffs is arrange-work, a genuine conflict is decide-work (graph-reduce-is-free). I earn that mapping with numbers in Step 5; here it’s just the frame.

Step 3: Build the constructed collision, disclosed as constructed

The corpus has no natural collision, 43 tracked files and 43 distinct targets, so this scenario is built, and I say so in the same breath as every number that follows. The fixture is scripts/run-content-series.sh, re-verified against repo HEAD with ShellCheck 0.11.0 pinned, the version Part 3 used. It carries 5 findings, exit 1, before either agent runs.

$ shellcheck -f json1 scripts/run-content-series.sh
# 5 findings, exit 1:
# line 25 SC2164 cd "$REPO_ROOT" without || exit
# line 59 SC2034 ALLOW_BOOTSTRAP appears unused
# line 110 SC2034 PRIMARY_LOCALE appears unused
# line 116 SC2016 x2 single quotes block expansion (two spans, one line)

The two jobs are disjoint by assignment, not by content risk. Agent X owns the two unused-variable findings; Agent Y owns the cd guard and both single-quote findings. Both variables are genuinely unused elsewhere in the file, confirmed by grep before X’s task was written, so neither job needs to read the other’s region to do its own.

AgentFindings ownedLines
XSC2034, SC203459, 110
YSC2164, SC2016 x225, 116

Two arms test the same split. In Arm S, both agents point at the identical file path in one shared scratch directory, dispatched concurrently, two Agent calls in one message. In Arm W, each trial gets a throwaway git repo with two git worktree add checkouts, wt-x on branch node-x and wt-y on branch node-y; each agent edits and commits inside its own worktree, and the harness runs git merge node-y from inside wt-x after both return. This post does not re-teach git worktree add; that mechanics tutorial is its own post. N=10 per arm.

Ground truth is harness-run only, never a self-report: bash -n for parse validity, shellcheck -f json1 for which findings actually ended fixed, and for Arm W, git merge’s own exit status plus a post-merge re-check. Every scratch copy’s md5 was verified identical to the seed before any agent touched it. Two earlier rounds were voided before the counted ten and are disclosed separately below; they were fixture-authoring corrections, not collision findings, and they are not part of any N=10 here.

Verify: re-run shellcheck -f json1 against the seed file yourself and confirm the same 5 findings at exit 1 before trusting either arm’s result.

Step 4: Run both arms, read the honest result

Arm S came back 10/10 clean and Arm W came back 10/10 clean-and-merged, a genuine null result on the rate. The load-bearing part is the reason why. There’s no lost update in this run to report a loss rate on, the same shape Part 6 hit: the rate question wasn’t the strongest thing either run found.

Arm S trialbash -nfindings remainingshellcheck exit
S01-S10OK x100 x100 x10
Arm W trialboth commitsmerge from wt-xpost-merge bash -npost-merge shellcheck
W01-W10ok x10clean auto-merge x10OK x100 findings, exit 0 x10

Every Arm S trial ended with all 5 findings fixed, valid bash, shellcheck exit 0, checked against a hand-built fully-fixed reference. Every Arm W trial committed both agents’ work, merged without a conflict marker, and passed the same post-merge checks. git merge reported Auto-merging run-content-series.sh every time, because X’s and Y’s hunks sit far enough apart that git’s 3-way merge never needed to ask a human.

Here’s why Arm S survived, mechanistically, not by luck. Every self-report, all 20 across both agents, described re-reading the file before a second edit, or noticing the other agent’s change and leaving it alone. That’s consistent with each agent using an exact-string-match edit primitive on a content-disjoint target. X’s insertions and Y’s insertions never share a text span. A string-anchored patch to a region the other agent never touches doesn’t collide. It survives genuine concurrent dispatch, no matter how line numbers shift after the other agent’s edits land. That’s a property of this task shape and this editing primitive, disclosed as such. It isn’t a property of shared directories being safe. Change either condition, overlapping targets or a read-then-rewrite-whole-file strategy, and the mechanism that saved every Arm S trial stops applying.

As flagged in Step 3, both void rounds sat in exactly this territory, and they’re worth one honest sentence. The first was a prompt error of mine, a wrong line reference that let Agent X self-correct onto the wrong finding. The second surfaced a real shellcheck fact: a # shellcheck disable directive can be syntactically valid bash and still break the linter’s own parser, depending on placement. That changed the fixture’s instructions before the counted trials ran. Neither is a collision result, and neither is counted in the ten.

Verify: re-run the harness against the same seed fixture and diff your Arm S and Arm W tables against the two above. Harness-run shellcheck -f json1 and git merge’s own exit status are the ground truth here, not either agent’s self-report.

Step 5: Price the isolation, close the loop to Part 4

Worktree isolation’s own price, measured here for the first time in the series, was two git worktree add calls plus one merge, a mean of 0.066s per node, git plumbing only. This is the number the competitive pieces never put on the table. They describe the setup and assert the danger, and none of them priced the alternative they recommend.

💰 Value breakdown. The structural guarantee costs two git worktree add calls plus one git merge per node: 0.066s mean, 0.062s min, 0.071s max, git plumbing only. Agent think time is identical between the two arms by construction, so this is the entire price isolation adds on this fixture.

meanminmax
git worktree add x2 per trial0.043s0.040s0.047s
git merge node-y0.023s0.021s0.025s
total isolation cost, per trial0.066s0.062s0.071s

Agent think time is excluded because it is identical between arms by construction: same job, same prompts, tens of seconds per node either way. What the table isolates is the cost of the structural guarantee itself, and on this fixture it is well under a tenth of a second, a rounding error next to either agent’s own wall clock.

Now the Part 4 callback (graph-reduce-is-free). This clean merge of disjoint diffs was arrange-work, an edge, plain code, effectively free. A genuinely overlapping diff would have been decide-work, a node, with a contract, because something has to adjudicate it. Isolation doesn’t remove the merge. It moves the collision from a silent race at write time to an explicit, decidable artifact at merge time, where the arrange/decide test can even be applied. Arm S’s safety was contingent on a coincidence this run happened to hold. Arm W’s wasn’t contingent on it at all. A filesystem-level fork means there’s no shared byte to race on, so git surfaces a real overlap as a conflict instead of resolving it as a coincidence.

Verify: time your own git worktree add plus git merge on a real repo with date +%s.%N around the git calls; if the plumbing lands in the tens of milliseconds, that is consistent with what this run measured, and you can budget for it accordingly.

Try It Now

Reproduce the isolation measurement on a throwaway repo in under a minute. Every step below is harness-run ground truth, not a self-report.

Terminal window
# 1. Pin the linter and confirm the fixture's baseline findings
shellcheck --version | grep 0.11.0
shellcheck -f json1 scripts/run-content-series.sh # expect 5 findings, exit 1
# 2. Build the isolated arm: one worktree per node on a scratch repo
tmp=$(mktemp -d) && git -C "$tmp" init -q
cp scripts/run-content-series.sh "$tmp"/f.sh
git -C "$tmp" add -A && git -C "$tmp" commit -qm seed
git -C "$tmp" worktree add -q -b node-x wt-x
git -C "$tmp" worktree add -q -b node-y wt-y
# 3. After each node edits and commits in its own worktree, merge and read the exit status
git -C "$tmp/wt-x" merge --no-edit node-y
echo "merge exit: $?" # 0 = clean auto-merge, non-zero = a conflict a human can see

Wrap date +%s.%N around steps 2 and 3 to time the git plumbing and compare it against this run’s 0.066s mean per node.

FAQ

Does a shared directory always corrupt when two agents edit it?

No. This run’s own numbers, 10/10 clean in the shared-directory arm, directly contradict that. The honest limit: these two jobs targeted content-disjoint spans and the edit tool patched by string match. A task shape with genuinely overlapping edits, or an editing strategy that rewrites the whole file, was not tested here.

Do worktree merges always resolve cleanly?

Not claimed. This run’s 10/10 clean merges reflect diffs that happened to sit far apart in the file. A worktree still turns a real overlap into a conflict a human or a later node can see, rather than a silent loss. It doesn’t guarantee the conflict never happens.

What does this run actually prove?

That a specific, disclosed editing shape, string-anchored and content-disjoint patches, can survive concurrent shared-directory access, and that worktree isolation’s own setup-plus-merge cost on this fixture was near zero, 0.066s mean. It doesn’t prove shared directories are generally safe.

Why not just tell agents to avoid touching the same file?

That’s a prompting instruction, not a guarantee, exactly the distinction this post’s concept names. Independence enforced by a filesystem boundary doesn’t depend on every agent’s instructions being followed correctly. Independence enforced by a shared-directory convention does.

Close

Independence is a property of the filesystem, not of the prompt, and a worktree per node makes a real collision decidable instead of silent. This run’s tie doesn’t weaken that. Both arms came back 10/10, and the categorical point never rested on a rate.

Carry this test into your own graph: arrange-work is free, decide-work has a contract, and isolation decides which one a merge is. A shared directory that survives is surviving on a coincidence about diff shape. A worktree per node doesn’t need that coincidence to hold.

The pitfall to watch: don’t read this run’s clean sweep as permission to share a directory between nodes whose jobs might really overlap. The mechanism that saved every trial here was specific to disjoint targets and a string-anchored editor, and it’s exactly the mechanism a worktree makes you stop depending on. The next measured problem ships next in this series.

{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Two Agents, One File: What Actually Gets Lost",
"author": { "@type": "Organization", "name": "ShipWithAI" },
"datePublished": "2026-08-18",
"description": "Agent worktree isolation, measured: two agents edit one file two ways, shared directory and a worktree per node, N=10 each. Here's what changed.",
"image": "/images/blog/graph-worktree-isolation-cover.png",
"articleSection": "tutorial",
"keywords": "claude-code, automation, ai, tutorial, english",
"mainEntityOfPage": "https://shipwithai.io/blog/graph-worktree-isolation/"
}
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Two Agents, One File: What Actually Gets Lost",
"description": "Agent worktree isolation, measured: two agents edit one file two ways, shared directory and a worktree per node, N=10 each. Here's what changed.",
"image": "/images/blog/graph-worktree-isolation-cover.png",
"step": [
{
"@type": "HowToStep",
"name": "Step 1: Where you are, the assumption that never got tested",
"text": "Part 3's 43-file fan-out never collided because the corpus handed every node its own file, an accident of corpus shape, not a designed guarantee. This post breaks node independence on purpose for the first time in the series."
},
{
"@type": "HowToStep",
"name": "Step 2: Agent worktree isolation is a filesystem property, not a prompt instruction",
"text": "Fan-out's contract is independent nodes, and independence is a property of the filesystem, not of the prompt. A worktree per node restores independence structurally and turns a collision into a merge."
},
{
"@type": "HowToStep",
"name": "Step 3: Build the constructed collision, disclosed as constructed",
"text": "One real file with 5 shellcheck findings, split into two disjoint jobs, edited two ways: a shared directory (Arm S) and a worktree per node (Arm W), N=10 per arm, ground truth run by the harness."
},
{
"@type": "HowToStep",
"name": "Step 4: Run both arms, read the honest result",
"text": "Arm S came back 10/10 clean and Arm W 10/10 clean-and-merged. The shared-directory arm survived because the edits were string-anchored to content-disjoint spans, a property of this task, not of shared directories being safe."
},
{
"@type": "HowToStep",
"name": "Step 5: Price the isolation, close the loop to Part 4",
"text": "Worktree isolation cost 0.066s mean per node, git plumbing only. Isolation does not remove the merge; it moves the collision from a silent race at write time to a decidable artifact at merge time."
},
{
"@type": "HowToStep",
"name": "Close",
"text": "Independence is a property of the filesystem, not the prompt, and a worktree per node makes a real collision decidable instead of silent."
}
]
}
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Does a shared directory always corrupt when two agents edit it?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. This run's own numbers, 10/10 clean in the shared-directory arm, directly contradict that. The honest limit: these two jobs targeted content-disjoint spans and the edit tool patched by string match. A task shape with genuinely overlapping edits, or an editing strategy that rewrites the whole file, was not tested here."
}
},
{
"@type": "Question",
"name": "Do worktree merges always resolve cleanly?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Not claimed. This run's 10/10 clean merges reflect diffs that happened to sit far apart in the file. A worktree still turns a real overlap into a conflict a human or a later node can see, rather than a silent loss. It doesn't guarantee the conflict never happens."
}
},
{
"@type": "Question",
"name": "What does this run actually prove?",
"acceptedAnswer": {
"@type": "Answer",
"text": "That a specific, disclosed editing shape, string-anchored and content-disjoint patches, can survive concurrent shared-directory access, and that worktree isolation's own setup-plus-merge cost on this fixture was near zero, 0.066s mean. It doesn't prove shared directories are generally safe."
}
},
{
"@type": "Question",
"name": "Why not just tell agents to avoid touching the same file?",
"acceptedAnswer": {
"@type": "Answer",
"text": "That's a prompting instruction, not a guarantee, exactly the distinction this post's concept names. Independence enforced by a filesystem boundary doesn't depend on every agent's instructions being followed correctly. Independence enforced by a shared-directory convention does."
}
}
]
}
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://shipwithai.io/blog/" },
{ "@type": "ListItem", "position": 2, "name": "Two Agents, One File: What Actually Gets Lost", "item": "https://shipwithai.io/blog/graph-worktree-isolation/" }
]
}