fix(engine): preserve an attempt's commits before auto-rollback hard …#29
Conversation
…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.
|
Warning Review limit reached
Next review available in: 33 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
WalkthroughAdds ChangesAttempt Commit Preservation
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
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 |
🤖 Augment PR SummarySummary: This PR prevents silent loss of committed work when an in-place attempt is auto-rolled-back. Changes:
Technical Notes: The recovery ref is created via 🤖 Was this summary useful? React with 👍 or 👎 |
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/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
📒 Files selected for processing (4)
src/automator/engine.pysrc/automator/verify.pytests/test_engine.pytests/test_verify.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
left a comment
There was a problem hiding this comment.
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):
- Bounded-retention prune. Keep the N most-recent
attempt-preserve/*refs and delete the tail — a natural place is run start, alongsideruns.prune_sessions:Sort bygit for-each-ref --sort=-committerdate \ --format='%(refname:short)' refs/heads/attempt-preserve/ # keep the first N; `git branch -D` the restcommitterdateso 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. - Expose it as an
ScmPolicyfield 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. - (Optional) a
bmad-automaintenance subcommand to list/pruneattempt-preserve/*on demand.
Two minor notes
_preserve_attempt_commitswrapspreserve_commitsintry/except verify.GitErrorbut not the leadingcommits_abovecall, so a bad baseline there would propagate out of the rollback. It mirrors today's behavior (the basegit reset --hardalready 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.
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 runninggit reset --hard <baseline>, so committed work is always recoverable by name.Why
With
scm.rollback_on_failureenabled (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
verify.commits_above/verify.preserve_commits(pure git mechanics): enumeratebaseline..HEADand, when non-empty, create a gc-safe recovery branch at HEAD.engine._preserve_attempt_commitsinto 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.run_idin 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 throughreset --hard+git gc, the pause-on-failure safety path, and the resolved-re-drive no-pause path.Full
test_verify.py+test_engine.pyare green on both Linux (131 passed) and Windows.Summary by CodeRabbit