Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ breaking changes may land in a minor release.
invalidates Codex hook trust: Codex re-prompts at the next launch, and hooks silently
do not fire until the new commands are accepted. Re-run `bmad-loop init` to migrate
managed registrations. `validate` warns when a hook still points to another installation.
- Name an earlier attempt's parked work in the retry dev prompt (sprint, stories, sweep)
once Git confirms the ref still resolves on this task's baseline and a dev session
produced it; commits-only preservation is labelled, and nothing is replayed (#777).
- Document the live-session removal guard's measured ceiling (#732): `delete`, `archive` and `clean` still remove a run directory when a listing omits a live session. Behavior unchanged; the psmux half is reported upstream (psmux/psmux#622), its retirement tracked in #754.

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se

- Bounded dev retries (default 2): verify-failures keep the tree and feed the failing output to the next session via `--feedback`; other failures roll back to baseline.
- Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification.
- An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families.
- An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. A fresh-baseline dev retry after such a rollback is told where that work is parked (#777) — the ref, a `git log`/`git diff` against the baseline, and that the work is unverified and every gate must pass fresh — but only when Git confirms the ref still resolves, carries this run's name and descends from this task's baseline, and a dev session of the rolled-back attempt was dispatched — recorded, or cut off mid-session by a hard stop or crash. A pruned ref, a resolve re-drive's reset, or a sweep bundle that adopted another bundle's ids gets no paragraph; commits-only preservation says so. Nothing is replayed onto the tree.
- Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342).
- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and `park_asserted: true` in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced `## Auto Run Result` marker reporting `awaiting-operator`; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or `operator_actions:` while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as `park-proof-of-work-skipped`; `zero_diff` reports whether the waived gate would have found non-excluded residue (`true` means none, `false` means some, `null` means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later `story-awaiting-operator` event. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`.
- A successful stories-mode plan halt is likewise journaled at the artifact-gate boundary as `plan-halt-proof-of-work-skipped`. Its `zero_diff` uses the same tri-state projection (`true` = the waived gate found no non-excluded residue, `false` = it found changes, `null` = Git could not answer), including the stories manifest/spec and orchestrator-written exclusions the live gate would have used. The record is emitted only after the `ready-for-dev` artifact verification passes and never substitutes for the independent `result.json` `plan_halt: true` marker that authorizes the halt; an absent marker or an earlier gate failure produces no waiver record.
Expand Down
26 changes: 25 additions & 1 deletion src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,8 @@ def __init__(
# active workspace; `escalate` routes an intent-gap restore failure through
# the engine's escalation; `escalation_pause` raises RunPaused for it
# (injected so recovery_flow need not import engine — that would reintroduce
# a runtime<->engine cycle).
# a runtime<->engine cycle); `dev_attempt_dispatched` is the preserve-ref
# provenance probe (#777).
self._recovery_flow = RecoveryFlow(
paths=self.paths,
policy=self.policy,
Expand All @@ -880,6 +881,7 @@ def __init__(
save=self._save,
escalate=self._escalate,
escalation_pause=self._escalation_pause,
dev_attempt_dispatched=self._dev_attempt_dispatched,
)

def _escalation_pause(
Expand Down Expand Up @@ -1548,6 +1550,9 @@ def _first(epic: int | None):
def _protected_relpaths(self) -> tuple[str, ...]:
return self._recovery_flow.protected_relpaths()

def _retry_preserve_notice(self, task: StoryTask) -> str:
return self._recovery_flow.retry_preserve_notice(task)

def _rollback_or_pause(self, task: StoryTask, *, cause: str = "stopped") -> None:
self._recovery_flow.rollback_or_pause(task, cause=cause)

Expand Down Expand Up @@ -2172,6 +2177,18 @@ def _current_dev_session_index(self, task: StoryTask) -> int | None:
return index
return None

def _dev_attempt_dispatched(self, task: StoryTask) -> bool:
"""Whether a dev session of the task's current attempt was dispatched —
the provenance a rollback stamps on the ref it parks (#777,
``StoryTask.preserve_from_attempt``). A recorded session proves it, but a
session is recorded only once it returns: a hard stop or host death
mid-session leaves none, and the restart arm then parks that session's
tree. A durable ``DEV_RUNNING`` covers that case — it is saved after
``attempt`` is bumped and before the launch, and a resolve re-drive's
reset runs from the ``PENDING`` that ``runs.rearm_escalation`` leaves
(under a bumped ``generation``, so no current-attempt record either)."""
return task.phase == Phase.DEV_RUNNING or self._current_dev_session_index(task) is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record launch evidence before attributing preserved work

When the process exits after _dev_phase saves DEV_RUNNING but before adapter.run actually launches—such as during prompt construction, after a mutating pre-session hook, or on a transport startup error—this returns true even though no dev session was dispatched. If the pre-launch path left changes, recovery parks them with preserve_from_attempt=True, and the next prompt incorrectly tells the agent that an earlier attempt produced those changes. Use a durable marker written at the actual launch boundary rather than treating DEV_RUNNING, which is persisted before launch, as proof of dispatch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this; the window is real but the extra tracking isn't justified.

  • Nothing gets parked unless something wrote to the tree before launch. A fresh-baseline leg enters DEV_RUNNING on a baseline tree. A failure before adapter.run leaves it clean, the rollback takes the rollback-skipped-clean return, and preserve_from_attempt is stamped only on the dirty path. The other things that dirty the tree in that window are the fixable-repair tree and the intent-gap patch restore. Both hold work from an earlier dispatched attempt, so the attribution is still correct.
  • What's left is a pre-session hook that mutates the tree, followed by a crash before launch. Those hook changes are already credited to the attempt on the normal path, because a recorded session's parked tree includes whatever its pre-session hooks wrote. A launch marker would not separate hook bytes from session bytes, so it would not make the claim any more exact.
  • The effect is limited to one informational paragraph. It still has to pass the ref-name, baseline, resolve and ancestry checks. It tells the session the work is unverified, has not been applied, and that every gate must pass fresh.

A durable launch marker would mean a new persisted field and a state save on every session launch, just to cover a hook-writes-then-crash window. Before 5dc5ca1, the hard-stop/crash-mid-session resume left the notice out, and that is a common path.


def _current_review_session_index(self, task: StoryTask) -> int | None:
"""Index of the newest review record for the current cycle."""
task_id = _session_task_id(task.story_key, "review", task.review_cycle, task.generation)
Expand Down Expand Up @@ -7084,6 +7101,13 @@ def _generic_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str:
f"the working tree after an intent-gap resolution; review it "
f"against the amended spec."
) + after_sentence
# The two fresh-baseline legs below may follow a rolled-back attempt:
# point at its parked work in a paragraph of its own (#777). It sits
# after the park clause, which stays last on the invocation line; these
# legs carry no feedback-file pointer for its backticks to be read as.
preserved = self._retry_preserve_notice(task)
after_sentence += f"\n\n{preserved}" if preserved else ""
after_key += f"\n\n{preserved}" if preserved else ""
# The attempt binding was resolved in the active workspace immediately
# before DEV_RUNNING became durable. A retained `spec_file` alone may
# name a discarded unit worktree, so it cannot authorize this route or
Expand Down
20 changes: 19 additions & 1 deletion src/bmad_loop/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ class StoryTask:
# writer wins, so one `git merge --ff-only <ref>` recovers the whole attempt
# — unless `preserve_partial` is set). Set by RecoveryFlow, cleared at the top
# of every auto-rollback so it can never name a *previous* attempt's ref; read
# by `_defer` (notification) and projected into `status`. None = the last
# by `_defer` (notification), projected into `status`, and — once verified
# against git — named in the retry dev prompt (#777). None = the last
# auto-rollback parked nothing (no commits above baseline and a clean or
# uncapturable tree, or the ref failed to take). Isolation-INDEPENDENT: a unit
# worktree's own dev-retry rollback parks on the same shared refs, so a
Expand All @@ -435,6 +436,21 @@ class StoryTask:
# cannot tell those apart, which is why this is recorded rather than derived.
# Cleared with `preserve_ref`. Survives the resume serialization round-trip.
preserve_partial: bool = False
# provenance of `preserve_ref`, the one input the retry dev prompt's pointer
# at it cannot get from git (#777, `recovery_flow.retry_preserve_paragraph`):
# True when the auto-rollback that cleared the previous ref ran after a dev
# session of the current attempt was dispatched — a recorded session, or a
# durable DEV_RUNNING that a hard stop or crash mid-session left without one
# (`Engine._dev_attempt_dispatched`) — so whatever it parks is that attempt's
# work. False when neither holds — a resolve re-drive resets a tree no
# dispatched attempt produced — and set back to False by `SweepEngine.
# _reset_superseded_bundle_state`, which keeps a superseded bundle's ref
# (clearing the name would orphan the work) that shares this task's run, key
# and baseline. Positive evidence
# only: a state.json written before this field loads False, so an unproven ref
# is never offered. Reset with `preserve_ref`. Survives the resume
# serialization round-trip.
preserve_from_attempt: bool = False
# set by runs.rearm_escalation: this task was re-armed out of ESCALATED for a
# clean rebuild against the corrected spec (not a failed attempt). Lets the
# resume-time manual-recovery notice describe the real cause; cleared once the
Expand Down Expand Up @@ -571,6 +587,7 @@ def to_dict(self) -> dict[str, Any]:
"defer_reason": self.defer_reason,
"preserve_ref": self.preserve_ref,
"preserve_partial": self.preserve_partial,
"preserve_from_attempt": self.preserve_from_attempt,
"rearmed": self.rearmed,
"resolved_redrive": self.resolved_redrive,
"plan_checkpoint_pending": self.plan_checkpoint_pending,
Expand Down Expand Up @@ -814,6 +831,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
defer_reason=d.get("defer_reason"),
preserve_ref=d.get("preserve_ref"),
preserve_partial=bool(d.get("preserve_partial", False)),
preserve_from_attempt=bool(d.get("preserve_from_attempt", False)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
rearmed=bool(d.get("rearmed", False)),
resolved_redrive=bool(d.get("resolved_redrive", False)),
plan_checkpoint_pending=bool(d.get("plan_checkpoint_pending", False)),
Expand Down
Loading
Loading