Skip to content

fix(engine): preserve an attempt's commits before auto-rollback hard …#29

Merged
pbean merged 2 commits into
bmad-code-org:mainfrom
dracic:fix/engine-rollback-safety
Jul 1, 2026
Merged

fix(engine): preserve an attempt's commits before auto-rollback hard …#29
pbean merged 2 commits into
bmad-code-org:mainfrom
dracic:fix/engine-rollback-safety

Conversation

@dracic

@dracic dracic commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What

When an in-place attempt is auto-rolled-back, the engine now parks any commits the attempt made above its baseline under a named attempt-preserve/<run_id>-<sha> branch before running git reset --hard <baseline>, so committed work is always recoverable by name.

Why

With scm.rollback_on_failure enabled (or on a resolved re-drive), a deferred or stopped attempt's committed work was discarded by the hard reset and recoverable only from git's reflog until garbage collection — a silent data-loss risk.

How

  • Add verify.commits_above / verify.preserve_commits (pure git mechanics): enumerate baseline..HEAD and, when non-empty, create a gc-safe recovery branch at HEAD.
  • Wire engine._preserve_attempt_commits into the rollback path before the reset — a plain rollback that cannot create the ref pauses for manual recovery instead of destroying work, while a (contractually pause-free) re-drive journals the failure and proceeds.
  • Sanitize run_id in the ref name; leave uncommitted-only attempts and the sweep ledger-recovery reset path untouched.

Testing

New unit tests (tests/test_verify.py) and engine-path tests (tests/test_engine.py) cover preservation, ref survival through reset --hard + git gc, the pause-on-failure safety path, and the resolved-re-drive no-pause path.
Full test_verify.py + test_engine.py are green on both Linux (131 passed) and Windows.

Summary by CodeRabbit

  • Bug Fixes
    • Rollbacks now better protect committed work by preserving attempt commits before resetting.
    • If commit preservation fails during a normal rollback, the system can pause for recovery instead of discarding work.
    • During re-drive recovery, rollback behavior is now more consistent and won’t pause unexpectedly.
    • Recovery information is now recorded more reliably, making rollback outcomes easier to trace.

…reset

When scm.rollback_on_failure is ON (or on a resolved re-drive), a deferred or
stopped attempt was rolled back via `git reset --hard <baseline>`, silently
discarding any commits the attempt had made on top of baseline — recoverable
only from git's reflog until gc.

Before the reset, park those commits under a named
`attempt-preserve/<run_id>-<head8>` branch (survives gc, recoverable by name)
and journal it. Uncommitted-only attempts and the sweep ledger-recovery reset
are unchanged. A plain rollback that cannot create the ref refuses to reset and
pauses for manual recovery; a re-drive (contractually pause-free) journals the
failure and proceeds. Exotic run_id characters are slugified so the ref always
creates.

Adds verify.commits_above / verify.preserve_commits and
engine._preserve_attempt_commits, with unit + engine-path tests.
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@dracic, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 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

Run ID: 47694499-f6a1-40ec-8d34-700f843b9553

📥 Commits

Reviewing files that changed from the base of the PR and between 563b0ed and 1ba72e2.

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

Walkthrough

Adds commits_above and preserve_commits git helper functions in verify.py to detect and preserve commits above a baseline. Engine integrates a new _preserve_attempt_commits method into rollback/pause logic to park attempt commits under a recovery ref before resetting, with journal events and re-drive-aware pause behavior. Tests added for both layers.

Changes

Attempt Commit Preservation

Layer / File(s) Summary
Git helpers for commit detection and preservation
src/automator/verify.py, tests/test_verify.py
Adds commits_above to list commits above a baseline via git rev-list and preserve_commits to create/update a recovery branch ref via git branch -f; tests verify empty results at baseline, listing of newly created commits, ref survival through git reset --hard/git gc, and no-op behavior without commits.
Engine rollback integration with commit preservation
src/automator/engine.py, tests/test_engine.py
Engine._rollback_or_pause calls new _preserve_attempt_commits(task, allow_pause=not redrive) before auto-rollback; the helper computes commits above baseline, creates a named recovery ref, journals attempt-commits-preserved or attempt-preserve-failed, and pauses via _pause_for_manual_recovery when allowed or continues silently on re-drives; tests cover ref creation, RunPaused on failure, and the resolved-path behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant Engine
  participant PreserveHelper as _preserve_attempt_commits
  participant Verify
  participant Journal

  Engine->>PreserveHelper: _preserve_attempt_commits(task, allow_pause)
  PreserveHelper->>Verify: commits_above(repo, baseline_commit)
  Verify-->>PreserveHelper: commit list
  alt commits exist
    PreserveHelper->>Verify: preserve_commits(repo, baseline, ref_name)
    alt ref created
      PreserveHelper->>Journal: attempt-commits-preserved
    else ref creation failed
      PreserveHelper->>Journal: attempt-preserve-failed
      alt allow_pause
        PreserveHelper->>Engine: pause for manual recovery
      else
        PreserveHelper-->>Engine: continue without pausing
      end
    end
  end
  Engine->>Engine: proceed with reset/rollback
Loading

Possibly related PRs

  • bmad-code-org/bmad-auto#3: Extends the same Engine._rollback_or_pause decision flow that this PR builds upon by adding commit preservation.

Poem

A commit above, about to be lost,
I park it safe, whatever the cost.
A little ref, a burrow so neat,
Survives the reset, survives the gc feat.
Hop hop hooray, no work left behind! 🐇

🚥 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 matches the main change: preserving an attempt's commits before an automatic rollback reset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@augmentcode

augmentcode Bot commented Jul 1, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR prevents silent loss of committed work when an in-place attempt is auto-rolled-back.

Changes:

  • Adds verify.commits_above() and verify.preserve_commits() to enumerate baseline..HEAD and park a gc-safe recovery branch at HEAD.
  • Wires a new Engine._preserve_attempt_commits() step into the rollback path before git reset --hard <baseline>.
  • On a normal rollback, preservation failure pauses for manual recovery; on a resolved/mid-re-drive path, it journals the failure and proceeds (contractually pause-free).
  • Sanitizes user-supplied run_id when building the recovery ref name.
  • Adds tests covering preservation through reset --hard + git gc, pause-on-failure safety, and the resolved re-drive no-pause behavior.

Technical Notes: The recovery ref is created via git branch -f attempt-preserve/<run_id>-<sha> HEAD and success/failure is recorded in the engine journal.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode 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.

Review completed. 3 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread src/automator/verify.py
Comment thread src/automator/verify.py
Comment thread src/automator/engine.py Outdated

@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/automator/engine.py`:
- Around line 759-763: The pause notice for preserve failures is using
rollback-off recovery wording even when rollback_on_failure is enabled, so
update the messaging in _pause_for_manual_recovery to be parameterized by the
failure reason passed from _preserve_attempt_commits. Make the preserve_failed
path describe that this attempt produced commits that could not be parked on a
recovery ref, and ensure the operator guidance matches the actual state instead
of telling them to just enable rollback_on_failure. Add or adjust a test for the
preserve_failed case to assert the notice text mentions the commits and is
accurate.
🪄 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: ceb76dcc-b1d5-4a53-aacd-7e71ada170d7

📥 Commits

Reviewing files that changed from the base of the PR and between f79bd12 and 563b0ed.

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

Comment thread src/automator/engine.py
…ise on ref failure

- _pause_for_manual_recovery gains a `preserve_failed` notice that names the
  at-risk commits (intact at HEAD) and never tells the operator to blindly
  `reset --hard`, instead of reusing the rollback-OFF wording that is wrong when
  rollback is ON.
- verify.preserve_commits raises GitError when the branch cannot be created;
  `None` now only ever means "nothing to preserve", so a caller can't mistake a
  preservation failure for a harmless no-op and reset past committed work.
- Bound the run_id slug length and derive the ref/journal head from
  `rev-parse HEAD` (not rev-list order); soften commits_above's ordering docstring.

@pbean pbean left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed this end-to-end — it's a solid, correct safety fix and the direction is right. The ordering is correct (_preserve_attempt_commits runs before _safe_reset, so the recovery ref is created at the attempt tip before the branch is moved to baseline), the plain-rollback-pauses vs. re-drive-proceeds split matches the pause-free re-drive contract, and I confirmed the core mechanic locally on git 2.54: a git branch ref keeps the commits reachable through git reset --hard <baseline> + git gc --prune=now and recoverable by name. No cross-platform concern either — it's all _git subprocess calls with /-delimited ref names and pure-Python slug sanitization, so Linux/macOS/Windows behave identically.

One follow-up worth addressing (non-blocking):

attempt-preserve/* refs accumulate with no lifecycle

Every auto-rollback that had committed work leaves a permanent attempt-preserve/<run_id>-<sha> branch, and nothing ever removes it. In a long-lived project with scm.rollback_on_failure on, these grow unbounded — cluttering git branch output and eventually adding overhead to ref-walking operations.

Scoping the risk: there's no git push anywhere in src/automator (in-place mode never pushes; worktree mode merges locally), so these refs are local-only — this is repo clutter, not a remote-leak. That keeps the severity low, but the unbounded growth is still worth capping.

Suggested resolution, mirroring the retention patterns already in the codebase (runs.prune_sessions / keep_n, and the delete_branch / keep_failed config style in ScmPolicy):

  1. Bounded-retention prune. Keep the N most-recent attempt-preserve/* refs and delete the tail — a natural place is run start, alongside runs.prune_sessions:
    git for-each-ref --sort=-committerdate \
      --format='%(refname:short)' refs/heads/attempt-preserve/
    # keep the first N; `git branch -D` the rest
    
    Sort by committerdate so the most recently preserved work is the last thing pruned, and default N to a safely non-zero value so an aggressive default can't defeat the safety net.
  2. Expose it as an ScmPolicy field to match the existing config surface (e.g. preserve_keep: int = 20) — operators who want zero clutter can lower it, those who want maximum safety can raise it.
  3. (Optional) a bmad-auto maintenance subcommand to list/prune attempt-preserve/* on demand.

Two minor notes

  • _preserve_attempt_commits wraps preserve_commits in try/except verify.GitError but not the leading commits_above call, so a bad baseline there would propagate out of the rollback. It mirrors today's behavior (the base git reset --hard already fails on a bad baseline, and baseline is trusted — captured at attempt start), so it's fine as-is; just flagging the asymmetry in case you'd prefer a uniform failure surface.
  • Consider a CHANGELOG entry if that's the convention here (I realize it's often curated at release time via the release driver).

Nice work overall — the fail-safe preserve_failed wording that names the at-risk commits instead of the misleading "just reset --hard" instruction is a particularly good touch.

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.

2 participants