You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Found while reviewing #376. That PR made the exclude write atomic (atomic_write_text), which closes the truncation hole (#375) and the fixed-.tmp-name collision. It does not — and cannot — close the read-modify-write around it.
install._worktree_local_exclude reads the exclude, merges the new patterns, and writes the whole file back:
The read and the write are not serialized. Atomicity per write is not isolation across the pair, so two provisioning runs that interleave produce a clean lost update.
This is specifically reachable here rather than theoretical: every linked worktree of a repo resolves to the same --git-common-dir, so all worktrees of one repo contend on one .git/info/exclude. The comment at install.py:806-811 posits exactly this scenario ("two runs provisioning against one repo") as the motivation for the unique temp name.
Reproduction
Two linked worktrees of one repo; wt2's full cycle interleaved between wt1's read and wt1's write:
The helper's docstring is explicit about what a missing exclude costs:
Silence here is not cosmetic: without the exclude the unit's git add -A commits the tool files this provisioning just wrote into the story's merge.
A lost update produces precisely that outcome, with a None return — the "nothing to report" signal — on both sides. The degrade-reason channel this helper exists to feed never fires.
No in-process concurrency: engine.py / worktree_flow.py run no threads or pools, so a single bmad-loop run cannot hit this. It needs two bmad-loop run processes against one repo — the scenario the code comment itself posits.
Fix shapes
Lock the common dir — O_EXCL lockfile next to the exclude (git's own .lock convention), read-merge-write inside it, best-effort on failure to stay consistent with the helper's contract. Matches what git does to the same directory.
Retry on changed mtime/size — re-read and re-merge if the file moved under us. Cheaper, still racy in principle, but the merge is idempotent (set union), so a bounded retry converges.
Append instead of rewrite — open("a") a single anchored block. Sidesteps the read entirely for the common case, but loses dedup against existing lines and cannot be made idempotent across runs.
(1) is the honest fix; (2) is the cheap one. Either way, a lost update should surface through the existing degrade-reason channel rather than returning None.
Found while reviewing #376. That PR made the exclude write atomic (
atomic_write_text), which closes the truncation hole (#375) and the fixed-.tmp-name collision. It does not — and cannot — close the read-modify-write around it.install._worktree_local_excludereads the exclude, merges the new patterns, and writes the whole file back:The read and the write are not serialized. Atomicity per write is not isolation across the pair, so two provisioning runs that interleave produce a clean lost update.
This is specifically reachable here rather than theoretical: every linked worktree of a repo resolves to the same
--git-common-dir, so all worktrees of one repo contend on one.git/info/exclude. The comment atinstall.py:806-811posits exactly this scenario ("two runs provisioning against one repo") as the motivation for the unique temp name.Reproduction
Two linked worktrees of one repo; wt2's full cycle interleaved between wt1's read and wt1's write:
Both calls returned
None. Script:Why the silence is the harmful part
The helper's docstring is explicit about what a missing exclude costs:
A lost update produces precisely that outcome, with a
Nonereturn — the "nothing to report" signal — on both sides. The degrade-reason channel this helper exists to feed never fires.Scope / relationship to other issues
write_textlost the update the same way. The PR narrows the window (the write is now oneos.replace) but the race is between the read and the write, so narrowing does not remove it.atomic_write_textfixes Non-atomic read-modify-rewrite in ~10 places; a short write truncates ledgers, specs, and the board #379's defect and leaves this one. Worth checking the Non-atomic read-modify-rewrite in ~10 places; a short write truncates ledgers, specs, and the board #379 sites for read-modify-write pairs once they are converted —deferredwork(next_seq()derives ids from surviving text) andsprintstatus(the board) look like the same shape.Reachability
No in-process concurrency:
engine.py/worktree_flow.pyrun no threads or pools, so a singlebmad-loop runcannot hit this. It needs twobmad-loop runprocesses against one repo — the scenario the code comment itself posits.Fix shapes
O_EXCLlockfile next to the exclude (git's own.lockconvention), read-merge-write inside it, best-effort on failure to stay consistent with the helper's contract. Matches what git does to the same directory.open("a")a single anchored block. Sidesteps the read entirely for the common case, but loses dedup against existing lines and cannot be made idempotent across runs.(1) is the honest fix; (2) is the cheap one. Either way, a lost update should surface through the existing degrade-reason channel rather than returning
None.