Skip to content

fix(farm): serialize git worktree registry access so a task stops escalating under load (#515) - #534

Merged
SUaDtL merged 2 commits into
mainfrom
fix/515-worktree-registry-race
Jul 28, 2026
Merged

fix(farm): serialize git worktree registry access so a task stops escalating under load (#515)#534
SUaDtL merged 2 commits into
mainfrom
fix/515-worktree-registry-race

Conversation

@SUaDtL

@SUaDtL SUaDtL commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #515.

AC-1 — the cause

Neither of the issue's leading candidates. Not mock-server contention, not a gate timeout under load: it is a race between concurrent git invocations over shared repository state.

.git/worktrees/ is a shared registry and git mutates it non-atomically. git worktree add scans the existing entries while creating its own, so a second concurrent add can read a sibling's directory after it exists but before its commondir is written.

prepareWorktree ran worktree removermbranch -Dworktree add with no lock, from up to FARM_CONCURRENCY workers at once.

Reproduced at 1 of 3 full coverage runs. #518's diagnostic named it on the first reproduction — exactly what it was built for:

[bounded] bulk4: escalate after 0 attempt(s) — worktree add failed: Preparing worktree (new branch 'farm/bulk4')
fatal: failed to read .git/worktrees/bulk0/commondir: No error

Then measured directly against real git, outside the suite entirely — six concurrent prepare sequences, 40 rounds each way:

CONCURRENT   6 workers x 40 rounds -> 2 rounds had a failed worktree add
    e.g. Preparing worktree (new branch 'farm/bulk3') | fatal: failed to read .git/worktrees/bulk2/commondir: No error
SERIALIZED   6 workers x 40 rounds -> 0

AC-3 — fixed in farm.ts

Worktree creation, removal and prune now run under a dedicated worktreeChain lock.

  • Separate from mergeChain deliberately. A merge holds its lock across a git merge plus its gate, which is long; registry calls are short. Sharing one lock would park every worktree setup behind an unrelated merge for no correctness gain. Nothing takes both in the opposite order, so they cannot deadlock.
  • prune is the most dangerous of the three to leave unserialized — it deletes sibling metadata directories, which is precisely what a concurrent add is scanning.
  • The retry backoff runs outside the lock, so one worker's retry schedule cannot stall the others.

AC-4 — satisfied, not dodged

FARM_CONCURRENCY stays at 6 and the dispatcher's parallelism is unchanged. The worker API call, the gate and the tests — everything that actually takes time — still run concurrently. Only the brief registry mutations queue.

Verification

Post-fix: 4/4 coverage runs green (the mode that reproduced it), full suite 521 passed.

The tests assert the property — no two registry commands ever in flight at once — rather than the absence of the flake. Asserting "the suite went green" would have passed ~95% of the time on the unfixed dispatcher and proved nothing.

4/4 mutants killed: lock-is-a-passthrough, chain-head-never-advances, prepare-drops-the-lock, remove-drops-the-lock.

What mutation testing caught

Three of four mutants survived the first round, including one that removed the lock from prepareWorktree entirely — the function the bug is actually in. Every test I had written exercised removeWorktreeVerified and the lock helper directly, and none of them touched prepareWorktree, which used a module-scope git and so could not be observed at all.

It now takes an injectable runner, defaulted, for the same reason removeWorktreeVerified already did. Two other survivors were genuinely equivalent mutants and are documented as such in the runner rather than papered over with a test; one test that could not distinguish its own mutant was deleted rather than kept.

Line endings — the half with teeth

This commit was blocked by H-09b demanding a crypto-compliance pass, for a concurrency fix that touches no crypto.

An editor pass flipped farm.ts and farm.unit.test.ts to CRLF. Git then read both entire files as added — and the H-09b backstop scans added lines for sensitive patterns. Every pre-existing hash call and every credential-shaped test fixture in those files counted as newly introduced.

README.md was immune, and its diff stayed at 2 lines while farm.ts's was 6165 — because /*.md was already pinned in .gitattributes and the .ts trees were not.

So .gitattributes now pins LF for the TS/JS/JSON plugin and site trees. #533 pinned the workflow tree for the cosmetic half of this; this is the half that fires gates on code nobody wrote. Verified before adding: no committed file under those globs is CRLF today, so it normalizes nothing retroactively.

Version

ca 2.10.0 → 2.10.1. farm.js is a declared shipped artifact, so this is a payload change and the gate landed in #532 requires the manifest to advance — its first real exercise, and it behaved correctly.

SUaDtL added 2 commits July 28, 2026 18:34
…alating under load (#515)

`.git/worktrees/` is a SHARED REGISTRY and git mutates it non-atomically.
`git worktree add` scans the existing entries while creating its own, so a
second concurrent add can read a sibling's directory after it exists but before
its `commondir` is written:

  fatal: failed to read .git/worktrees/bulk0/commondir: No error

prepareWorktree ran `worktree remove` -> rm -> `branch -D` -> `worktree add`
with no lock, from up to FARM_CONCURRENCY workers at once. One task would
intermittently escalate at attempts:0 and flip a green run's exit code to 2 -
#515, reproduced here at 1 of 3 full coverage runs.

AC-1, the cause: not mock-server contention and not a gate timeout, which were
the issue's other two candidates. It is a genuine race between concurrent git
invocations over shared repository state. #518's diagnostic named it on the
first reproduction, which is exactly what it was built for.

Measured directly against real git, outside the suite: six concurrent prepare
sequences over 40 rounds produced 2 failures with that exact signature, and 0
when serialized.

AC-3, fixed in farm.ts: worktree creation, removal and prune now run under a
dedicated worktreeChain lock, separate from mergeChain so short registry calls
never queue behind a long merge; nothing takes both in the opposite order so
they cannot deadlock. `prune` is the most dangerous of the three to leave
unserialized - it DELETES sibling metadata directories, which is precisely what
a concurrent add is scanning.

AC-4 is satisfied rather than dodged: FARM_CONCURRENCY stays 6 and parallelism
is unchanged. The worker API call, the gate and the tests all still run
concurrently; only the brief registry mutations queue, and the retry backoff
runs OUTSIDE the lock so one worker's retry schedule cannot stall the others.

prepareWorktree now takes an injectable git runner, defaulted, for the same
reason removeWorktreeVerified already did: it is the function the race lives in,
and a test that cannot observe its git calls cannot prove they are serialized.
Mutation testing is what surfaced that - three of four mutants initially
survived, including one that removed the lock from prepareWorktree entirely.

The tests assert the PROPERTY - no two registry commands ever in flight at once
- rather than the absence of the flake, which would have passed ~95% of the time
on the unfixed dispatcher. 4/4 mutants killed.

Also pins LF for the TS/JS/JSON plugin and site trees. Not cosmetic: an editor
pass flipped farm.ts and farm.unit.test.ts to CRLF, git read both entire files
as ADDED, and the H-09b backstop scans added lines for sensitive patterns - so
pre-existing hash calls and credential-shaped test fixtures counted as newly
introduced, and this commit was blocked demanding a security pass for a change
touching none of that. #533 pinned the workflow tree for the cosmetic half of
this; this is the half with teeth.

ca 2.10.0 -> 2.10.1: farm.js is a declared shipped artifact, so this is a
payload change and the gate landed in #532 requires the manifest to advance.

Claude-Session: https://claude.ai/code/session_01WJgVfZw7J81PB7mwpHyUxx
@SUaDtL
SUaDtL merged commit b57e7d1 into main Jul 28, 2026
36 checks passed
@SUaDtL
SUaDtL deleted the fix/515-worktree-registry-race branch July 28, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

flaky: a task escalates under full-suite load, flipping exit 0 to 2 in the farm artifact-publication tests

1 participant