Skip to content

fix(recovery): probe for a free attempt-preserve-dirty ref name - #353

Draft
Pawel-N-pl wants to merge 2 commits into
bmad-code-org:mainfrom
Pawel-N-pl:fix/preserve-ref-unique
Draft

fix(recovery): probe for a free attempt-preserve-dirty ref name#353
Pawel-N-pl wants to merge 2 commits into
bmad-code-org:mainfrom
Pawel-N-pl:fix/preserve-ref-unique

Conversation

@Pawel-N-pl

@Pawel-N-pl Pawel-N-pl commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What

The dirty-tree rollback snapshot now probes for an unused ref name instead of deriving one from
task.attempt alone.

Why

task.attempt is not monotonic across a story's life — runs.rearm_escalation sets it back to 0
— so a post-resolve re-drive recomputes the pre-resolve rollback's exact ref name and overwrites
the only copy of that attempt's uncommitted work. The comment above the ref construction states
the opposite premise, which is what sent me looking.

Fixes #349.

How

  • Add verify.ref_exists(repo, refname), a sibling of branch_exists that can see full refnames
    outside refs/heads/. Best-effort: any git failure reads as absent.
  • In recovery_flow._preserve_dirty_attempt, keep {slug}-{baseline}-{attempt} as the base name
    and append -r2, -r3, … until free, so uniqueness is enforced against the refs that exist.
  • Correct the comment.

Testing

Two tests, both confirmed to fail with the fix reverted: a re-arm that previously collided now
parks a second distinct ref with both snapshots intact, and ref_exists is covered directly.
Full suite: 3458 passed, 35 skipped.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented recovery snapshots from overwriting earlier snapshots when retry attempts reuse the same identifier.
    • Added unique suffixes to distinguish multiple preserved snapshots from the same baseline.
    • Improved handling of recovery references across repeated rollback and re-drive scenarios.
  • Tests

    • Added coverage confirming preserved snapshots retain the correct content and receive deterministic unique names.
    • Added validation for detecting existing repository references.

rearm_escalation resets task.attempt to 0, so a post-resolve re-drive's
rollback recomputed the same attempt-preserve-dirty ref name as the
pre-resolve rollback and overwrote that snapshot, destroying the only
copy of the first attempt's uncommitted work. Probe for a free refname
(new verify.ref_exists) and suffix -r2/-r3/... on collision instead of
trusting the counter.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Pawel-N-pl, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 74a6a1dd-6eff-4466-be81-8666372e7616

📥 Commits

Reviewing files that changed from the base of the PR and between cab1b1e and 04596ef.

📒 Files selected for processing (5)
  • src/bmad_loop/recovery_flow.py
  • src/bmad_loop/verify.py
  • tests/test_engine.py
  • tests/test_recovery_flow.py
  • tests/test_verify.py

Walkthrough

Dirty rollback snapshots now avoid overwriting existing preservation refs by probing full Git refnames and adding deterministic retry suffixes. Tests cover repeated attempt counters, ref types, absent refs, and Git failures.

Changes

Recovery ref uniqueness

Layer / File(s) Summary
Full-ref existence check
src/bmad_loop/verify.py, tests/test_verify.py
Adds and tests a best-effort git show-ref --verify --quiet helper for full refnames.
Collision-safe snapshot naming
src/bmad_loop/recovery_flow.py, tests/test_engine.py
Selects an unused recovery ref and verifies distinct snapshot contents when attempt counters repeat.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: pbean

Poem

I hop through refs beneath the moon,
No snapshot fades away too soon.
When attempt numbers start anew,
I add -r2, then -r3 too.
Safe little worktrees, tucked from harm—
A bunny guards each recovery charm.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the core change: probing for a free recovery ref name.
Linked Issues check ✅ Passed The PR addresses #349 by ensuring dirty rollback snapshots use distinct refs and adds tests covering the collision case.
Out of Scope Changes check ✅ Passed The helper and tests are directly related to the ref-collision fix and do not introduce unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/bmad_loop/recovery_flow.py
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a snapshot-collision bug where task.attempt being reset to 0 by rearm_escalation could cause a post-resolve re-drive to recompute the exact same ref name as the pre-resolve rollback and silently overwrite it. The fix probes for the first unused ref name before writing, with the probe correctly placed inside the existing try/except block so spawn/timeout faults still degrade gracefully.

  • verify.ref_exists: new helper using git show-ref --verify --quiet that resolves full refnames outside refs/heads/; non-zero exit returns False, spawn/timeout exceptions propagate to the caller.
  • recovery_flow._preserve_dirty_attempt: keeps {slug}-{baseline}-{attempt} as the base name and appends -r2, -r3, … until a free ref is found; the probe loop sits inside the try/except (GitError, OSError) block so a git fault during probing takes the same pause path as a failed snapshot.
  • Tests: two new targeted tests — one integration test covering the re-arm collision and one ablation test confirming a GitSpawnError from the probe is caught and yields a typed pause.

Confidence Score: 5/5

Safe to merge — the probe loop is correctly placed inside the existing try/except block and two targeted tests confirm the fix is correct and the fault-tolerance contract is preserved.

The fix is narrowly scoped: one helper function and a change to the ref-naming logic in a single method. No pre-existing behaviour is changed on the non-collision path.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/verify.py Adds ref_exists(repo, refname) using git show-ref --verify --quiet; correctly distinguishes non-zero exit (returns False) from spawn/timeout exceptions (propagates).
src/bmad_loop/recovery_flow.py Replaces the fixed ref name with a probe loop appending -r2, -r3 suffixes; probe loop is correctly inside the existing try/except (GitError, OSError) block.
tests/test_engine.py Adds integration test for the re-arm collision scenario covering three consecutive same-counter rollbacks with distinct refs.
tests/test_recovery_flow.py Adds ablation test confirming probe loop inside try: GitSpawnError from ref_exists yields a typed Pause.
tests/test_verify.py Adds unit coverage for ref_exists across full refnames, ordinary branches, absent refs, and git failure cases.

Reviews (2): Last reviewed commit: "fix(recovery): run the refname probe ins..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/verify.py`:
- Around line 527-534: Update ref_exists to catch the typed Git exceptions
raised by _git for timeouts and process-spawn failures, returning False for
those observation failures while preserving the existing rc == 0 check. Do not
broaden handling to repair/write operations or unrelated exceptions; preserve
preserve_attempt_worktree’s behavior for actual ref-write errors.
🪄 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 Plus

Run ID: ac32a5cc-eb31-411a-ab71-2e787f5e8c5c

📥 Commits

Reviewing files that changed from the base of the PR and between 8a3cf91 and cab1b1e.

📒 Files selected for processing (4)
  • src/bmad_loop/recovery_flow.py
  • src/bmad_loop/verify.py
  • tests/test_engine.py
  • tests/test_verify.py

Comment thread src/bmad_loop/verify.py
Comment on lines +527 to +534
def ref_exists(repo: Path, refname: str) -> bool:
"""Whether ``refname`` — a FULL refname, e.g. ``refs/attempt-preserve-dirty/…``
— currently exists. Sibling of :func:`branch_exists`, which prepends
``refs/heads/`` and so cannot see the snapshot refs that live outside it.
Best-effort by design: any git failure reads as "absent" (the caller's
subsequent ref write surfaces the real error)."""
rc, _ = _git(repo, "show-ref", "--verify", "--quiet", refname)
return rc == 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make ref_exists degrade typed Git failures to False.

_git raises on timeouts and spawn failures, but this function only handles nonzero return codes. Because preserve_attempt_worktree calls it before its try block, those failures can abort rollback instead of following the documented best-effort behavior.

Proposed fix
-    rc, _ = _git(repo, "show-ref", "--verify", "--quiet", refname)
+    try:
+        rc, _ = _git(repo, "show-ref", "--verify", "--quiet", refname)
+    except GitError:
+        return False
     return rc == 0

Based on coding guidelines, observation may degrade, but repair writes must raise.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def ref_exists(repo: Path, refname: str) -> bool:
"""Whether ``refname``a FULL refname, e.g. ``refs/attempt-preserve-dirty/``
currently exists. Sibling of :func:`branch_exists`, which prepends
``refs/heads/`` and so cannot see the snapshot refs that live outside it.
Best-effort by design: any git failure reads as "absent" (the caller's
subsequent ref write surfaces the real error)."""
rc, _ = _git(repo, "show-ref", "--verify", "--quiet", refname)
return rc == 0
def ref_exists(repo: Path, refname: str) -> bool:
"""Whether ``refname``a FULL refname, e.g. ``refs/attempt-preserve-dirty/``
currently exists. Sibling of :func:`branch_exists`, which prepends
``refs/heads/`` and so cannot see the snapshot refs that live outside it.
Best-effort by design: any git failure reads as "absent" (the caller's
subsequent ref write surfaces the real error)."""
try:
rc, _ = _git(repo, "show-ref", "--verify", "--quiet", refname)
except GitError:
return False
return rc == 0
🤖 Prompt for 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.

In `@src/bmad_loop/verify.py` around lines 527 - 534, Update ref_exists to catch
the typed Git exceptions raised by _git for timeouts and process-spawn failures,
returning False for those observation failures while preserving the existing rc
== 0 check. Do not broaden handling to repair/write operations or unrelated
exceptions; preserve preserve_attempt_worktree’s behavior for actual ref-write
errors.

Source: Coding guidelines

`ref_exists` spawns git, and `_run_git` translates a timeout or spawn
fault into GitError/GitSpawnError rather than a return code — so with the
probe above the try, either would have crashed the rollback uncaught,
which is precisely what that handler exists to prevent. Move the loop
inside it: a probe that cannot run now degrades into the same
"preservation is observation" pause as a snapshot that cannot be written.

`ref_exists` deliberately does not swallow those faults: reading "git
could not run" as "the name is free" would overwrite the snapshot the
probe exists to protect. Docstring corrected to say so.

Reported by Greptile on bmad-code-org#353.
@Pawel-N-pl
Pawel-N-pl force-pushed the fix/preserve-ref-unique branch from cab1b1e to 04596ef Compare July 28, 2026 15:39
@Pawel-N-pl

Copy link
Copy Markdown
Contributor Author

Good catch, and correct on both halves — thanks.

_run_git translates a timeout (#156) and a spawn OSError (#343) into GitError/GitSpawnError rather than a return code, so with the probe above the try either would have crashed the rollback uncaught. Moved the loop inside the handler, so a probe that cannot run degrades into the same "preservation is observation" pause as a snapshot that cannot be written.

I kept ref_exists propagating those faults rather than swallowing them: reading "git could not run" as "the name is free" would overwrite the snapshot the probe exists to protect. The docstring was overpromising — it now says a non-zero exit reads as absent while spawn/timeout faults propagate typed.

Added test_ref_probe_git_fault_degrades_like_a_failed_snapshot, with the ablation named in its docstring: move the loop back above the try and it fails with the raw GitSpawnError instead of the typed pause.

@Pawel-N-pl
Pawel-N-pl marked this pull request as draft July 28, 2026 16:12
@Pawel-N-pl

Copy link
Copy Markdown
Contributor Author

Moving this to draft — I gather a larger rework is planned, so I'd rather not sit in your review queue against code that's about to change.

The branch stays up and CI is green (py3.11–3.14, Windows, trunk, pyright). Happy to rebase it onto whatever lands, or close it if the rework covers this — just say the word. Issue #349 stays open either way, since the defect outlives the patch.

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.

A re-armed escalation overwrites the previous attempt's attempt-preserve-dirty snapshot

1 participant