Skip to content

Name the attempt-preserve ref in the defer notification, status, and --json #333

Description

@pbean

When a story is deferred after review-budget exhaustion, the engine parks the attempt's work on a git ref and then throws the ref away. Nothing the operator can see — the notification, bmad-loop status, and status --json — ever names it. The reporter of the umbrella field report recovered their work by running git log --all and guessing.

Evidence (validated at 3c68153)

The ref is created, named well, and journaled:

  • Committed attempt work → branch attempt-preserve/<run-id-slug>-<head8>recovery_flow.py:317-324, via verify.preserve_commits (verify.py:266), which returns the ref.
  • Dirty worktree → refs/attempt-preserve-dirty/<slug>-<baseline8>-<attempt>recovery_flow.py:360.
  • Retention pruned to scm.preserve_keep (default 20).
  • Recorded only as journal events attempt-commits-preserved / attempt-worktree-preservedrecovery_flow.py:335-337, :374.

Then it is dropped:

  • preserve_attempt_commits returns None (recovery_flow.py:292) — the ref never reaches task or run state.
  • No src/ consumer of those journal events. Not in diagnostics.py, not in the TUI.
  • Status text prints only defer_reason / commit_shacli.py:1581-1585.
  • --json projection has no field — documents.py:227-239. cmd_status never opens journal.jsonl.

The defer notification is also thinner than escalation's. _defer does call gates.notify on both arms (engine.py:3002 worktree, :3022 in-place) — the message body is just the bare reason, with no action tail. Contrast escalation, which composes f"{reason} — resolve, then \bmad-loop resume {run_id}`" (engine.py:3079-3084). Prior art for a good recovery message already exists: recovery_flow.py:409-459manual-recovery instructions do "your work is at X, rungit -C …`".

Zero test coverage pins that _defer / _escalate notify at all (grep ATTENTION tests/ → gates unit tests + adapter budget-trip only), so this is regression-invisible.

Fix outline

  • New StoryTask.preserve_ref: str | None (model.py after :176; additive to_dict/from_dict, drop-in like restore_patch).
  • Set it inside RecoveryFlow (its methods already receive the task): clear to None at the top of the auto-recover arm (recovery_flow.py:165), assign in preserve_attempt_commits (:335) and preserve_attempt_worktree (:373). Dirty (last writer) wins — the dirty snapshot is parented at attempt HEAD (verify.py:446-449), so it subsumes the commits branch and one uniform git merge --ff-only <ref> recovers either case.
  • _defer in-place arm: notify message = reason + — attempt work parked at `<ref>`; recover with `git -C "<root>" merge --ff-only <ref>` (engine.py:3022-3027), plus a preserve_ref= field on the story-deferred journal entry (:3021).
  • Isolated (worktree) arm: when scm.keep_failed and task.branch, append — failed work kept on branch `<branch>`. Do not set preserve_ref to the unit branch.
  • Projections: documents.py:227-239 adds preserve_ref; cli.py:1581 text; README.md:553/:465; docs/FEATURES.md:62.
  • Escalation path out of scope (no rollback there). No policy / core.toml changes. Not cleared on DONE — it stays as a mid-retry rollback breadcrumb.

Tests

Extend test_recovery_flow.py:269/:297/:336 plus a new both-families test (assert the dirty ref and git merge-base --is-ancestor subsumption); test_engine.py:3204 defer test asserting ATTENTION content; test_engine_worktree.py:297/:320 branch naming; test_model.py round-trip; test_cli.py:865/:1076 json + text.

Two ablations required before trusting the suite (repo rule): (1) delete the None-clear → the stale-ref test must fail; (2) make the message tail unconditional → the bare-reason-when-no-ref test must fail.

Edge cases already analysed: replay re-defer re-parks under the same names (branch -f, attempt-keyed dirty ref); the pruned-ref caveat is documented, never validated at status time (status must not run git); message truncation rides the message, not the title.

Acceptance

  • The defer notification names the ref and gives a runnable git merge --ff-only command.
  • bmad-loop status and status --json expose preserve_ref additively--json schema stays v1.

Part of the 0.9.0 field-report umbrella (linked parent).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclebugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions