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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ breaking changes may land in a minor release.

### Added

- **Defer notifications name where the work survives (#333).** A deferred story's rollback parked
the attempt on an `attempt-preserve/*` branch (or a `refs/attempt-preserve-dirty/*` snapshot),
but the ref only ever reached `journal.jsonl` — the notification carried a bare reason, leaving
the operator to find their work with `git log --all`. The notice now names the ref and the
`git merge --ff-only` command that restores it — downgraded to commits-only when the dirty
snapshot could not be captured; an isolated unit's notice names its kept-failed branch and any
earlier rolled-back attempt's ref. New `preserve_ref` on each task, projected into `bmad-loop status` and
`--json` (additive; schema stays 1). Reported verbatim from run state — `status` never runs
git, so a ref since pruned by `scm.preserve_keep` is still named.

- **Stories can close deferred-work entries (#234).** The ledger was one-way: entries are filed
automatically but were only ever marked resolved by hand. A story can now declare the entries
its work closes — `closes_deferred: [DW-5, DW-6]`, on its `stories.yaml` entry (stories mode)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ By default work happens **in place** on the checked-out branch (`[scm] isolation
- **`branch_per`** — `story` (a branch per story) or `run` (one shared branch across the run; this forces `delete_branch = false` so the shared branch survives between units).
- **`target_branch`** — the branch every unit merges into; empty means the branch checked out at run start. A configured branch is created if missing (a detached HEAD or unborn repo pauses the run rather than merging onto an unreferenced commit).
- **`keep_failed`** (default on) — a deferred/escalated unit's worktree + branch stay mounted for inspection, and its full diff (tracked + untracked) is preserved to `run_dir/failed/<unit>/changes.patch`. `failed_diff_max_mb` caps the per-file size of untracked files in that patch (oversized files skipped with a marker); `failed_diff_unlimited` lifts the cap.
- **`rollback_on_failure`** (default off) — governs in-place recovery when `isolation = "none"`. Off pauses a failed unattended attempt with manual-recovery steps and leaves the tree untouched; on auto-reverts the attempt's tracked changes and removes only the untracked files the run created (never a blanket `git clean`). A resolved escalation's re-drive always auto-recovers regardless of this flag. Under `isolation = "worktree"` the worktree is disposable, so a failed attempt's commits are parked on `attempt-preserve/*` refs and the worktree resets regardless — **`preserve_keep`** (default 20) bounds how many of those recovery refs are kept per family at run start, pruning the oldest (`0` = never prune).
- **`rollback_on_failure`** (default off) — governs in-place recovery when `isolation = "none"`. Off pauses a failed unattended attempt with manual-recovery steps and leaves the tree untouched; on auto-reverts the attempt's tracked changes and removes only the untracked files the run created (never a blanket `git clean`). A resolved escalation's re-drive always auto-recovers regardless of this flag. Under `isolation = "worktree"` the worktree is disposable, so a failed attempt's commits are parked on `attempt-preserve/*` refs and the worktree resets regardless — **`preserve_keep`** (default 20) bounds how many of those recovery refs are kept per family at run start, pruning the oldest (`0` = never prune). Whenever an attempt is parked, the ref is named in the story's defer notification (with the `git merge --ff-only` line that recovers it, flagged as commits-only when the uncommitted snapshot could not be captured) and in `bmad-loop status`/`--json` as `preserve_ref` — reported verbatim from run state, so a ref that a later run's `preserve_keep` pruning has since deleted is still shown.
- **`commit_message_template`** — when set, the message used for story/bundle commits (`{story_key}` / `{run_id}` substituted).
- **`seed_adapter_defaults`** (default on) / **`worktree_seed`** — a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) are absent from a fresh worktree — without them an isolated session can't reach its MCP server and stalls on readiness. With `seed_adapter_defaults` on, each loaded adapter's own configs are copied in from the main repo before the session launches (the defaults live in each CLI profile's `seed_files`); `worktree_seed` adds extra project-relative paths on top. Seeding is copy-when-absent and runs before the signal-hook merge, so a seeded `settings.json` keeps its real content and just gains the Stop hook — and the seeded paths are shielded from the unit's `git add -A`.

Expand Down Expand Up @@ -550,7 +550,7 @@ Each run drives its agents inside a dedicated tmux session, `bmad-loop-<run-id>`

### Scripting `status`

`bmad-loop status --json` is the **supported machine-readable surface**: stdout becomes a single JSON document (nothing else is printed), so `bmad-loop status --json | jq '.tasks[].phase'` just works. The document carries `schema_version` (currently 1; changes are additive, anything breaking bumps it), the run's identity and state (`run_id`, `run_type`, `source`, `started_at`, a derived `status` — `finished`/`paused`/`crashed`/`stopped`/`in-progress` — beside the raw flags and `paused_stage`/`paused_reason`/`paused_story_key`, plus a boolean `graceful_stop_pending` — true while a graceful stop (`stop --graceful`) is on disk and the engine is still live), the snapshot's `cache_read_weight`, run-level `tokens` (`raw` + `weighted`), and per-story `tasks` entries: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `commit_sha`, `defer_reason`, and a `tokens` object with the four raw counters plus derived `raw` and `weighted`. Everything except `graceful_stop_pending` is computed from the run's `state.json` alone — the weight comes from the persisted policy snapshot, so the numbers match what the run actually enforced; `graceful_stop_pending` additionally reflects the pending-request control file and the live-engine check, which `state.json` does not record. On error (no runs, unknown ref) stdout stays empty and the exit code is 1. The human-readable text output, by contrast, is **best-effort and not a stable interface** — parse the JSON, not the text.
`bmad-loop status --json` is the **supported machine-readable surface**: stdout becomes a single JSON document (nothing else is printed), so `bmad-loop status --json | jq '.tasks[].phase'` just works. The document carries `schema_version` (currently 1; changes are additive, anything breaking bumps it), the run's identity and state (`run_id`, `run_type`, `source`, `started_at`, a derived `status` — `finished`/`paused`/`crashed`/`stopped`/`in-progress` — beside the raw flags and `paused_stage`/`paused_reason`/`paused_story_key`, plus a boolean `graceful_stop_pending` — true while a graceful stop (`stop --graceful`) is on disk and the engine is still live), the snapshot's `cache_read_weight`, run-level `tokens` (`raw` + `weighted`), and per-story `tasks` entries: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `commit_sha`, `defer_reason`, `preserve_ref` (the recovery ref a rolled-back attempt was parked on, else `null`), and a `tokens` object with the four raw counters plus derived `raw` and `weighted`. Everything except `graceful_stop_pending` is computed from the run's `state.json` alone — the weight comes from the persisted policy snapshot, so the numbers match what the run actually enforced; `graceful_stop_pending` additionally reflects the pending-request control file and the live-engine check, which `state.json` does not record. On error (no runs, unknown ref) stdout stays empty and the exit code is 1. The human-readable text output, by contrast, is **best-effort and not a stable interface** — parse the JSON, not the text.

### Scripting `validate`

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

- 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.
- Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues.
- Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues. The defer notification names where the attempt survives — in place, the recovery ref the rollback parked it on plus the `git merge --ff-only` line that restores it, flagged as commits-only when the uncommitted snapshot could not be captured; isolated, the kept-failed unit branch plus any earlier rolled-back attempt's ref (named, not offered as a merge — it is not fast-forwardable there). That recovery ref is projected as `preserve_ref` in `status` and `--json`; the unit branch never is (#333).
- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues.
- Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a deterministic verify command whose _environment_ is broken (`sh` reports `rc 126/127`; `cmd` has no such convention, so on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, anything outside `PATHEXT` — is a fault rather than the silent `rc 0` pass it used to be, #302), **or** a dev/review/fix/workflow/sweep session whose pane log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt/cycle and deferring the story as if its code were broken. Re-arm (or a sweep's escalation-resume) restores the budget, so a resume after the outage clears re-drives from a clean slate. Patterns are per-profile (seeded only for `claude`; extend/disable via a project profile overlay).
- CRITICAL resolution: `bmad-loop resolve <run-id>` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself.
Expand Down
2 changes: 2 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1579,6 +1579,8 @@ def cmd_status(args: argparse.Namespace) -> int:
# to `if weighted` now that weighted is the displayed value.
tokens = f"{task.tokens.weighted_total(weight):,}t ({raw:,} raw)" if raw else "-"
extra = task.defer_reason or task.commit_sha or ""
if task.preserve_ref:
extra = f"{extra} [{task.preserve_ref}]".lstrip()
print(
f" {key:40s} {task.phase:16s} dev×{task.attempt} review×{task.review_cycle} "
f"{tokens} {extra}"
Expand Down
4 changes: 4 additions & 0 deletions src/bmad_loop/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ def status_document(state: RunState, *, graceful_stop_pending: bool = False) ->
"tokens": tokens,
"commit_sha": task.commit_sha,
"defer_reason": task.defer_reason,
# the recovery ref a rolled-back attempt's work was parked on, or
# null. Reported verbatim from state.json — never re-validated
# against git here (retention may since have pruned it).
"preserve_ref": task.preserve_ref,
"adapters_used": adapters_used,
}
)
Expand Down
70 changes: 66 additions & 4 deletions src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2991,15 +2991,72 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False)
note,
)

def _defer_recovery_note(self, task: StoryTask) -> str:
"""Message tail naming where the deferred attempt's work survives (#333).

A bare reason leaves the operator to find the rolled-back work themselves
(the reporter used `git log --all`). In-place, the auto-rollback parked the
attempt on `task.preserve_ref`, which fast-forwards straight back. Isolated,
*this* defer rolls nothing back — a kept-failed unit's branch stays mounted —
but an EARLIER attempt's in-worktree dev-retry rollback parks on the same
shared refs, so `preserve_ref` can be set here too. Both are named; the
isolated arm deliberately prints no `merge --ff-only` line, because that ref
is not fast-forwardable from either tree (the unit branch has since moved on
with the deferred attempt's commits, and fast-forwarding it into the
operator's checkout would land a *discarded* attempt on their branch).

Empty when there is nothing to point at — a clean-tree defer that parked
nothing, a commits-preserve failure, rollback off, or `keep_failed` off with
no earlier ref — so the notice can never advertise a ref that was not
created. A *worktree*-snapshot failure is the one case that still points
somewhere: `preserve_partial` then downgrades the claim to the committed
half instead of hiding a ref that does exist. The pointer is a name, not a
promise: `scm.preserve_keep` prunes the oldest refs at a later run's start,
and nothing here re-validates it (this must stay git-free — `status` reads
`state.json` only)."""
if self._isolated:
note = ""
if self.policy.scm.keep_failed and task.branch:
note = f" — failed work kept on branch `{task.branch}`"
if task.preserve_ref:
half = " (commits only)" if task.preserve_partial else ""
note += (
f" — an earlier rolled-back attempt is parked at `{task.preserve_ref}`{half}"
)
return note
if not task.preserve_ref:
return ""
recover = (
f'; recover with `git -C "{self.workspace.root}" '
f"merge --ff-only {task.preserve_ref}`"
)
if task.preserve_partial:
return (
f" — attempt COMMITS parked at `{task.preserve_ref}`{recover}; the "
f"uncommitted changes could not be captured (journal: "
f"attempt-worktree-preserve-failed) and did not survive the rollback"
)
return f" — attempt work parked at `{task.preserve_ref}`{recover}"

def _defer(self, task: StoryTask, reason: str) -> None:
task.defer_reason = reason
advance(task, Phase.DEFERRED)
if self._isolated:
# the failed work lives in the unit's worktree; the diff is captured
# and the worktree kept/dropped by _integrate_unit. Don't touch the
# tree here (no reset into the main repo — there's nothing to undo).
self.journal.append("story-deferred", story_key=task.story_key, reason=reason)
gates.notify(self.policy, self.run_dir, f"story deferred: {task.story_key}", reason)
self.journal.append(
"story-deferred",
story_key=task.story_key,
reason=reason,
preserve_ref=task.preserve_ref or "",
)
gates.notify(
self.policy,
self.run_dir,
f"story deferred: {task.story_key}",
reason + self._defer_recovery_note(task),
)
self._save()
return
if task.baseline_commit:
Expand All @@ -3018,12 +3075,17 @@ def _defer(self, task: StoryTask, reason: str) -> None:
if current != snapshot:
deferred_work.parent.mkdir(parents=True, exist_ok=True)
deferred_work.write_text(snapshot, encoding="utf-8")
self.journal.append("story-deferred", story_key=task.story_key, reason=reason)
self.journal.append(
"story-deferred",
story_key=task.story_key,
reason=reason,
preserve_ref=task.preserve_ref or "",
Comment thread
greptile-apps[bot] marked this conversation as resolved.
)
gates.notify(
self.policy,
self.run_dir,
f"story deferred: {task.story_key}",
reason,
reason + self._defer_recovery_note(task),
)
self._save()

Expand Down
30 changes: 30 additions & 0 deletions src/bmad_loop/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,32 @@ class StoryTask:
spec_file: str | None = None
commit_sha: str | None = None
defer_reason: str | None = None
# the recovery ref this attempt's work was parked on by the last auto-rollback
# — an `attempt-preserve/*` branch (commits above baseline) or, when the tree
# was also dirty, the `refs/attempt-preserve-dirty/*` snapshot, which is
# parented at the attempt's HEAD and therefore subsumes the branch (last
# 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
# 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
# deferred isolated unit can carry BOTH a kept-failed branch (the final
# attempt) and a preserve_ref (an earlier, rolled-back one) — `_defer` names
# both. The unit branch itself is never written here: a live branch is not a
# parked snapshot. Not cleared on success — a mid-retry rollback's breadcrumb
# stays readable. Survives the resume serialization round-trip.
preserve_ref: str | None = None
# set when the auto-rollback's *worktree* snapshot was attempted and raised
# (journalled `attempt-worktree-preserve-failed`), so `preserve_ref` names an
# `attempt-preserve/*` commits branch ALONE and the reset that followed
# discarded the uncommitted half. False both when the snapshot succeeded (the
# dirty ref subsumes the branch) and when the tree was clean (nothing to
# capture, so the commits branch IS the whole attempt) — the ref name alone
# 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
# 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 @@ -266,6 +292,8 @@ def to_dict(self) -> dict[str, Any]:
"spec_file": self._serialized_spec_file(),
"commit_sha": self.commit_sha,
"defer_reason": self.defer_reason,
"preserve_ref": self.preserve_ref,
"preserve_partial": self.preserve_partial,
"rearmed": self.rearmed,
"resolved_redrive": self.resolved_redrive,
"plan_checkpoint_pending": self.plan_checkpoint_pending,
Expand Down Expand Up @@ -313,6 +341,8 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
spec_file=d.get("spec_file"),
commit_sha=d.get("commit_sha"),
defer_reason=d.get("defer_reason"),
preserve_ref=d.get("preserve_ref"),
preserve_partial=bool(d.get("preserve_partial", False)),
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