fix(platform): overwrite-safe deferred stash + ref-safe unit branch names#103
Conversation
…ames #101 — _stash_deferred_artifacts staged a re-defer's spec straight over the prior stash with shutil.move. Its os.rename raises FileExistsError on Windows, which move catches and papers over with a non-atomic copy2 + unlink: that tears the stash on a mid-copy crash, and re-fails outright when an AV/indexer handle turns the rename into a sharing violation and copy2 cannot open the locked target either. Stage a copy inside the destination dir, then route through platform_util.atomic_replace — one-step overwrite, #98's win32 retry, and the replace stays same-filesystem so move's cross-device tolerance is preserved. #102 — unit_branch_name built bmad-loop/<run_id>/<unit_key> from the raw ids, so a key or --run-id carrying `:`, `..`, `@{`, a space or a trailing `.lock` passed #98's directory sanitization only to die at `git worktree add` with "is not a valid branch name". New platform_util.safe_ref_segment mirrors safe_segment's contract (identity for clean input, -<hex8> digest suffix whenever anything changed, never raises) on git's alphabet rather than Windows' — the two rule sets are disjoint in both directions, so a consumer deriving both a directory and a branch from one key must run both. Applied to both segments; a `git check-ref-format` oracle test pins the agreement over a nasty corpus.
|
Caution Review failedAn error occurred during the review process. Please try again later. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/bmad_loop/engine.py`:
- Around line 2337-2374: The source cleanup in _stash_deferred_artifacts still
unlinks spec_path unguarded after atomic_replace succeeds, so a Windows sharing
violation can escape and fail the run even though the deferred artifact was
already stashed. Update _stash_deferred_artifacts to treat spec_path.unlink() as
best-effort cleanup like the tmp unlink path: wrap it in the same
suppressed/retry handling or convert it to a non-fatal logged cleanup failure,
while keeping the successful stash and journal append behavior intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: be3f9c3c-84f9-4639-a9e3-bffe3d7bb5fb
📒 Files selected for processing (7)
CHANGELOG.mdsrc/bmad_loop/engine.pysrc/bmad_loop/platform_util.pysrc/bmad_loop/workspace.pytests/test_engine.pytests/test_engine_worktree.pytests/test_platform_util.py
Review catch on #103. Windows denies a *delete* against an open handle exactly as it denies a rename-over, so hardening only the replace left the staged move half-hardened: an AV/indexer handle on the just-written source spec fails `spec_path.unlink()` after the stash has already landed. `_defer` calls _stash_deferred_artifacts before the rollback and the `story-deferred` journal append, so that raise aborts the whole deferral. Suppressing the unlink is wrong — it silently leaves the stale in-review spec this function exists to remove. Instead extract atomic_replace's win32 backoff into _retry_on_sharing_violation and reuse it for a new retrying_unlink, giving both halves of the move the same transient-lock tolerance. A permanent failure still propagates, as before. Replace-then-unlink ordering is deliberate: a failure can leave a duplicate spec, never a hole where the work was.
|
@coderabbitai Addressed in f2b965a, though with a different remedy than suggested — worth writing down why. The finding lands: after One correction to the framing: this is not a regression the PR introduces. I did not take the suggested fix (suppress / log the unlink failure). Silently leaving the source spec in place recreates precisely the condition this function exists to prevent — "a leftover in-review spec would confuse the next attempt" — and it would do so quietly, on Windows only, at the exact moment a human is already being asked to look at a deferral. Trading a loud crash for a silent stale artifact is a bad trade here. Instead: Covered by |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
git log --oneline -5
git show f2b965a --stat || true
git show f2b965a -- src/bmad_loop/platform_util.py | head -150Length of output: 6714
Nice catch and a solid remedy. Thanks for writing down the rationale. ✏️ Learnings added
|
Both recovery-ref sites re-implemented ref sanitization inline (alnum/_- join, 64-char cap). Never illegal — but a second rule set to keep in agreement with git, and lossier than the real one: dots in a clean run id were mangled, and distinct exotic ids could collapse to the same slug. Route both through safe_ref_segment: identity for clean ids, digest suffix keeps dirty ids distinct, single source of truth. Characterization test only — no red-first guard is possible because the old inline slug also produced legal refs; the test pins the invariant (real git resolves the slugged ref) and the new digest-suffixed shape.
Closes #101, Closes #102
Both issues came out of the consumer audit of PR #98 — the win32 hardening pass that added
platform_util.atomic_replaceandsafe_segment. That audit asked a narrow question of the merged code: for every site #98 hardened, is every consumer of that site hardened too? Two gaps fell out. #98 swept the package's rename-over sites but skipped the one whose target is a possibly-existing file rather than a fresh temp-rename (the deferred-artifact stash), and it sanitized the unit worktree directory while leaving the unit branch built from the raw key — hardening the dir/branch pair inconsistently, which reads as a false sense of safety. This PR closes both.#101 — deferred stash
_stash_deferred_artifactsmoved a re-deferred story's spec straight over the prior stash withshutil.move. Now it stages acopy2inside the destination dir and routes throughatomic_replace.The issue's stated mechanism is wrong and the PR does not reproduce it. #101 claims
shutil.move"raisesFileExistsError" on Windows when the target exists. It does not:movewrapsos.renamein a bareexcept OSErrorand falls back tocopy2+unlink, which overwrites. Verified against the CPython source and by emulating Windows rename semantics. Two real hazards ride on that fallback instead, and they are what this change fixes:os.renamefail with WinError 5/32;movefalls back tocopy2, which opens the same locked target and fails too, propagatingPermissionErrorand crashing the defer flow.atomic_replaceretries the replace until the handle clears.copy2fallback truncates the live target before writing, so a crash mid-copy leaves a torn stash. Stage-then-replace never exposes a partial file.Both halves of the move are retried, not just the replace (added in review): Windows denies a delete against an open handle exactly as it denies a rename-over, so an unretried
spec_path.unlink()would fail after the stash had already landed — and_defercalls this before the rollback, thestory-deferredjournal append, and_save(), so that raise aborts the entire deferral.atomic_replace's backoff is extracted into_retry_on_sharing_violationand reused by a newplatform_util.retrying_unlink. Suppressing the unlink instead would silently leave the stale in-review spec this function exists to remove.Test consequences worth flagging for review: the plan's suggested "pre-create the dest file → stash replaces it" test is a characterization test on every platform, not a regression guard — old code passes it too. The two tests that actually fail on
shutil.movearetest_stash_deferred_artifacts_survives_a_win32_sharing_violation(simulated WinError 32, asserts the retry lands) and..._keeps_source_and_cleans_tmp_on_replace_failure(source survives a failed replace; no.tmpresidue). Both were confirmed red against the pre-change body.#102 — ref-safe branch names
New
platform_util.safe_ref_segment, applied to both segments ofunit_branch_name. Same contract assafe_segment— identity for clean input,-<hex8>digest suffix whenever anything changed, never raises, pure Python (git can only validate, not sanitize) — but on git's alphabet, which is disjoint from Windows' in both directions:CONis a legal ref and an illegal filename;a..bis the reverse. A consumer deriving both a directory and a branch from one key must therefore run both sanitizers, and the module docstring says so.Sanitizes control chars/space/DEL and
~^:?*[\/→_,..→__,@{→_{, leading.→_, lone@→_, caps length. Trailing.and trailing.lockare ref-illegal but get no rewrite — flagging them un-clean routes them to the digest path, and the-<hex8>suffix is itself the fix. A leading-is deliberately preserved: it is legal in a component, and the porcelain's separate "must not start with-" check reads the whole name, which callers always prefix.Unlike #101, this issue's mechanism reproduces exactly as written — reverting
unit_branch_namefails the new test withfatal: 'bmad-loop/test-run/story/1:2..3@{now}.lock' is not a valid branch name.Oracle test.
git check-ref-format --branchis run over a 42-entry corpus (clean keys, one entry per coercion rule, adversarial combinations likestory/1:2..3@{now}.lock,....,..lock,a.lock.lock,@{u}, 500 chars), in each of the three positionsunit_branch_nameplaces a segment —bmad-loop/rid/{},bmad-loop/{}/1-1-a,bmad-loop/{}. Pure-Python sanitization is only as good as its agreement with git; this pins it. Plus an end-to-end test where real git mounts a worktree for a dirty unit key.The clean-key branch-name tests (
test_branch_per_story_naming,test_branch_per_run_naming) are unchanged and still assert byte-identical names.Verification
.venv/bin/python -m pytest— 1922 passed, 1 skipped.trunk check(no filter) — clean.Summary by CodeRabbit