From 48121c913951a602c44ad6a147e72529f2a47652 Mon Sep 17 00:00:00 2001 From: pbean Date: Thu, 23 Jul 2026 22:53:31 -0700 Subject: [PATCH 01/36] feat(engine): auto-resolve deferred-work entries a story declares via closes_deferred (#234) --- src/bmad_loop/engine.py | 41 +++++++++++++++ src/bmad_loop/sweep.py | 5 -- tests/test_engine.py | 113 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 6 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 33e20387..82914635 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1768,6 +1768,10 @@ def _dev_review_enabled(self) -> bool: return False return self.policy.review.enabled + # the date stamped into ledger edits; isolated for tests + def _today(self) -> str: + return time.strftime("%Y-%m-%d") + def _observed_frontmatter(self, spec_path: Path, story_key: str, site: str) -> dict | None: """Read a spec's frontmatter on a *bookkeeping* path, degrading an unreadable spec to ``None`` (journaled) instead of a whole-run crash. @@ -2048,6 +2052,43 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non return target = "review" if review_enabled else "done" sprint_advance(self.workspace.paths.sprint_status, task.story_key, target) + self._close_declared_deferred(task, fm) + + def _close_declared_deferred(self, task: StoryTask, fm: dict) -> None: + """At clean close, flip every ledger entry the spec declares via + ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note + (#234) — the regular-story counterpart of the sweep bundle close at + ``SweepEngine._close_bundle_ledger_when_spec_status``. + + Declaration is the only signal: closure is never inferred from a diff. + Ids are classified against a single ledger snapshot rather than from + ``mark_done``'s return value, because that return conflates two very + different cases — an id already ``done`` (a *resume* re-running a close + that already landed, which must stay silent) and an id absent from the + ledger (a typo or a reworded entry, which is worth a journal warning). + Neither ever fails the story: the annotation is traceability, not a gate. + + Called from ``_post_dev_state_sync``, i.e. *before* ``_commit`` — the + ledger path is rebased into the worktree, so marking here squashes the + annotation into the story's own commit, the same way the append hooks do. + """ + raw = fm.get("closes_deferred") or [] + ids = [str(x).strip() for x in raw] if isinstance(raw, list) else [] + if not ids: + return + ledger = self.workspace.paths.deferred_work + text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + present = {e.id for e in deferredwork.parse_ledger(text)} + note = f"resolved by story {task.story_key}" + today = self._today() + marked = [i for i in ids if i in present and deferredwork.mark_done(ledger, i, today, note)] + unknown = [i for i in ids if i not in present] + if marked: + self.journal.append("story-deferred-closed", story_key=task.story_key, dw_ids=marked) + if unknown: + self.journal.append( + "deferred-close-unmatched", story_key=task.story_key, dw_ids=unknown + ) def _extra_session_env( self, task: StoryTask, role: str, label: str | None = None diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 4a25d76d..84262442 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -13,7 +13,6 @@ import json import re -import time from dataclasses import dataclass from pathlib import Path from typing import Any, Callable @@ -403,10 +402,6 @@ def __init__( self._skipped_decisions: set[str] = set() self.state.run_type = "sweep" - # the date stamped into ledger edits; isolated for tests - def _today(self) -> str: - return time.strftime("%Y-%m-%d") - def _remaining_estimate(self) -> int | None: """Sweep override of the graceful-stop hint: how many deferred-work entries are still open in the ledger — the work a resume would pick up. diff --git a/tests/test_engine.py b/tests/test_engine.py index 78703b72..fb4b2489 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -21,11 +21,12 @@ review_effect, set_sprint, spec_path, + write_ledger, write_spec, write_sprint, ) -from bmad_loop import platform_util, verify +from bmad_loop import deferredwork, platform_util, verify from bmad_loop.adapters.base import SessionResult from bmad_loop.adapters.mock import MockAdapter from bmad_loop.engine import Engine, RunPaused, RunStopped, _run_depth @@ -1941,6 +1942,116 @@ def test_post_dev_state_sync_skips_on_unreadable_spec(project, monkeypatch): assert events[0]["story_key"] == "1-1-a" +# ------------------------------------------- closes_deferred auto-resolve (#234) + + +def _spec_declaring(path: Path, status: str, dw_ids: list[str] | None) -> None: + """A bmad-dev-auto-shaped spec that optionally declares `closes_deferred:`. + ``None`` omits the key entirely (the overwhelmingly common spec).""" + declare = f"closes_deferred: [{', '.join(dw_ids)}]\n" if dw_ids is not None else "" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"---\ntitle: 'test'\ntype: 'feature'\nstatus: '{status}'\n" + f"baseline_revision: 'abc123'\n{declare}---\n\n## Intent\n\ntest spec\n", + encoding="utf-8", + ) + + +def _ledger_entries(project) -> dict: + return { + e.id: e + for e in deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8")) + } + + +def _closes_deferred_engine(project, status: str, dw_ids: list[str] | None): + """Engine + a story whose spec is finalized at `status` and declares `dw_ids`, + over a ledger holding a single open DW-1. Uncommitted: these drive + `_post_dev_state_sync` directly, so no commit ever runs.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(project, []) + sp = spec_path(project, "1-1-a") + _spec_declaring(sp, status, dw_ids) + return engine, StoryTask(story_key="1-1-a", epic=1), {"spec_file": str(sp)} + + +def test_closes_deferred_marks_declared_entries_done(project): + """A story spec declaring `closes_deferred:` flips each referenced ledger + entry to `status: done ` + a `resolution:` note at clean close — the + write the loop never made, forcing retros to reconstruct closure by hand + (#234). Closure is declared, never inferred from the diff.""" + engine, task, rj = _closes_deferred_engine(project, "done", ["DW-1"]) + + engine._post_dev_state_sync(task, rj) + + entry = _ledger_entries(project)["DW-1"] + assert entry.status.startswith("done") and not entry.open + assert "resolution: resolved by story 1-1-a" in entry.body + closed = [e for e in engine.journal.entries() if e["kind"] == "story-deferred-closed"] + assert len(closed) == 1 + assert closed[0]["dw_ids"] == ["DW-1"] and closed[0]["story_key"] == "1-1-a" + + +def test_closes_deferred_idempotent_on_rerun(project): + """A resumed run re-drives the close for a story whose annotation already + landed. The second pass must write nothing and stay *silent*: an id that is + present-but-already-done is a satisfied declaration, not an unmatched one.""" + engine, task, rj = _closes_deferred_engine(project, "done", ["DW-1"]) + + engine._post_dev_state_sync(task, rj) + engine._post_dev_state_sync(task, rj) + + body = _ledger_entries(project)["DW-1"].body + assert body.count("resolution: resolved by story 1-1-a") == 1 # not doubled + kinds = [e["kind"] for e in engine.journal.entries()] + assert kinds.count("story-deferred-closed") == 1 # only the first pass wrote + assert "deferred-close-unmatched" not in kinds # already-done is NOT unknown + + +def test_closes_deferred_warns_on_unknown_id(project): + """An id absent from the ledger (a typo, or an entry since reworded) is + journaled and dropped — never a story failure, and never a ledger write.""" + engine, task, rj = _closes_deferred_engine(project, "done", ["DW-99"]) + before = project.deferred_work.read_bytes() + + engine._post_dev_state_sync(task, rj) + + assert project.deferred_work.read_bytes() == before # ledger untouched + events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-unmatched"] + assert len(events) == 1 + assert events[0]["dw_ids"] == ["DW-99"] and events[0]["story_key"] == "1-1-a" + assert "story-deferred-closed" not in [e["kind"] for e in engine.journal.entries()] + + +def test_closes_deferred_noop_when_field_absent(project): + """The default spec declares nothing: no ledger write and no journal noise on + the close path every ordinary story takes.""" + engine, task, rj = _closes_deferred_engine(project, "done", None) + before = project.deferred_work.read_bytes() + + engine._post_dev_state_sync(task, rj) + + assert project.deferred_work.read_bytes() == before + kinds = {e["kind"] for e in engine.journal.entries()} + assert "story-deferred-closed" not in kinds and "deferred-close-unmatched" not in kinds + + +def test_closes_deferred_skips_when_spec_unfinalized(project): + """The annotation rides the same clean-close gate as the sprint advance: a + session that leaves the spec short of the success status closes nothing, so a + failed story can never mark its declared entries resolved.""" + engine, task, rj = _closes_deferred_engine(project, "in-progress", ["DW-1"]) + before = project.deferred_work.read_bytes() + + engine._post_dev_state_sync(task, rj) + + assert project.deferred_work.read_bytes() == before + assert _ledger_entries(project)["DW-1"].open # still open + kinds = {e["kind"] for e in engine.journal.entries()} + assert "story-deferred-closed" not in kinds and "deferred-close-unmatched" not in kinds + + def test_transient_spec_read_fault_does_not_crash_run(project, monkeypatch): """Integration capstone for #97. A single transient OSError on the spec — a TOCTOU truncation while the dev skill rewrites the file the orchestrator is From f9939074508d89df8577dcc413f99667bdb1bc8d Mon Sep 17 00:00:00 2001 From: pbean Date: Thu, 23 Jul 2026 23:07:12 -0700 Subject: [PATCH 02/36] feat(validate,docs): pre-flight warning for unknown closes_deferred ids + document the field (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the stories-mode `validate` gate that names declared closes_deferred ids absent from the ledger (a typo, or an entry renumbered since the spec was written) as a warning — advisory only, so it never flips the exit code — and registers the new check id in VALIDATE_CHECKS. Also reconciles the gap phase 1 left: the close hook lived on the base Engine, i.e. sprint mode only, while StoriesEngine._post_dev_state_sync was a no-op. The deferred-work ledger is project-wide (the inherited review-followup refiles already write to it from stories runs), so the field would have been inert in exactly the mode this validate check preflights. StoriesEngine now closes declared entries too, resolving the spec by id rather than the session-claimed path. Documents closes_deferred in the canonical deferred-work format doc, README, and FEATURES; one consolidated CHANGELOG entry covers both phases. --- CHANGELOG.md | 16 +++ README.md | 8 ++ docs/FEATURES.md | 1 + src/bmad_loop/checks.py | 1 + src/bmad_loop/cli.py | 54 ++++++++++ .../bmad-loop-sweep/deferred-work-format.md | 33 ++++++ src/bmad_loop/stories_engine.py | 34 +++++- tests/test_cli.py | 69 ++++++++++++ tests/test_stories_engine.py | 102 ++++++++++++++++++ 9 files changed, 313 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15906aa5..f13ad032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ breaking changes may land in a minor release. ### Added +- **Stories can close deferred-work entries (#234).** The ledger was one-way: the loop reliably + _files_ `deferred-work.md` entries but never _marked_ one resolved when a later story closed it, + so a multi-epic run ended with entries satisfied epics ago still reading `open` and the retro + reconstructing by hand which story closed what. A story spec can now declare the entries its work + closes in its frontmatter — `closes_deferred: [DW-5, DW-6]` — and at clean story-close the + orchestrator writes the same annotation a sweep bundle writes: `status: done ` plus + `resolution: resolved by story `. The write happens before the commit, so it squashes into + the story's own commit, and it fires in both sprint and stories mode. Closure is declared, never + inferred from a diff; a story that fails, blocks, or stops short of its success status closes + nothing; an id already `done` is a silent no-op, so a resumed run re-driving the same close + neither doubles the `resolution:` line nor warns; and an id matching no ledger entry is journaled + (`deferred-close-unmatched`) rather than failing the story. Closes are journaled as + `story-deferred-closed`. `bmad-loop validate` adds a matching pre-flight warning + (`stories.closes-deferred-unknown`) naming any declared id absent from the ledger — a typo or a + renumbered entry — before the run starts; it is a warning, so it never changes the exit code. + - **`review.on_timeout` policy knob (#271).** A timeout-like review verdict (`timeout` / `stalled` / `over_budget`) previously always burned a review cycle (RETRY) until `max_review_cycles`, then deferred — even when the dev product was already finalized and diff --git a/README.md b/README.md index 715d9419..48330abd 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,14 @@ bmad-loop sweep [--no-prompt] [--decisions-only] [--max-bundles N] [--repeat] [- `status: done` in the ledger. ``` +**Closing entries from a story.** Sweeps are not the only way an entry gets closed. A story spec can declare the entries its work closes, in frontmatter: + +```yaml +closes_deferred: [DW-5, DW-6] # DW- ids this story closes +``` + +At clean story-close the orchestrator writes the same annotation a bundle close writes — `status: done ` and `resolution: resolved by story ` — into each declared entry, squashed into the story's own commit. Without it the ledger is one-way: entries are filed automatically but only ever marked resolved by hand, so a multi-epic run ends with entries that were satisfied epics ago still reading `open`. Closure is declared, never inferred from the diff; a story that fails or blocks closes nothing; a resume re-driving the same close is a no-op; and an id that matches no entry is journaled rather than failing the story — `bmad-loop validate` surfaces those as a warning before the run starts. + **Answering missed decisions later.** An unattended sweep (`--no-prompt`) skips decisions, and an interactive one can be abandoned before you answer them all — those answers would otherwise be lost, since triage re-derives the decision set from the ledger every run. `bmad-loop decisions` (or press `d` in the TUI) surfaces every decision past sweeps left unanswered, reconstructed from their triage output, and lets you answer them out of band. A `close` is applied immediately; a `build`/`keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep (build → bundle, keep-open → recorded) with no re-prompt. `--list` shows them without answering; `bmad-loop status` reports the outstanding count. Sweeps are their own resumable runs (`bmad-loop resume `). `[sweep] auto` in the policy fires an unattended sweep automatically at epic boundaries or run end; a failed/paused child sweep never interrupts the parent run. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f73edd02..95e97d07 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -104,6 +104,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Deferred-work sweeps - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. +- Story-declared closure (`closes_deferred: [DW-5, DW-6]` in a story spec's frontmatter): at clean story-close the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Declared, never inferred from a diff; skipped entirely when the story fails or blocks; idempotent across a resume; and an id matching no entry is journaled, never fatal. `bmad-loop validate` warns about such ids before the run starts. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index 2ffc2feb..10e2a809 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -71,6 +71,7 @@ "skills.stories-dispatch", "skills.stories-dispatch-missing", "skills.stories-dispatch-stale", + "stories.closes-deferred-unknown", } ) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index e8a2ac63..4c0b866a 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -20,6 +20,7 @@ decisions, deferredwork, envvars, + frontmatter, gates, install, machine, @@ -229,6 +230,7 @@ def cmd_validate(args: argparse.Namespace) -> int: _validate_stories_queue( project, paths, spec_folder, [p.skill_tree for p in profiles], report ) + _validate_closes_deferred(paths, spec_folder, report) else: try: ss = sprintstatus.load(paths.sprint_status) @@ -592,6 +594,58 @@ def _validate_stories_queue( report.extend(stories_probs) +def _validate_closes_deferred( + paths: bmadconfig.ProjectPaths, spec_folder: str, report: ValidationReport +) -> None: + """Warn when a story spec declares ``closes_deferred:`` ids the deferred-work + ledger does not carry (#234). + + At clean close the orchestrator flips each declared id to ``status: done + `` + a ``resolution:`` line. An id that names no entry — a typo, or an + entry reworded/renumbered since the spec was written — annotates nothing, and + the run says so only in the journal, where nobody looks until the retro that + the annotation exists to spare. Saying it at preflight is the whole point: + the declaration is fixable before the run, not after. + + Only *absent* ids warn. An id present but already ``done`` is a declaration a + prior run already satisfied (a resume re-drives the same close), so it stays + silent — the same present-vs-absent classification the engine's close hook + makes, for the same reason. + + Never a failure. The annotation is traceability, not a gate, so a stale + reference must not be able to block a run that would otherwise start. + """ + folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) + ledger = paths.deferred_work + try: + text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + story_set = stories_mod.load_stories(folder) + except (OSError, UnicodeDecodeError, stories_mod.StoriesError): + # An unparseable manifest is already a `queue.stories-manifest` failure from + # the gate above — don't double-report it. And an advisory check must never + # be the thing that crashes the preflight it is advising. + return + present = {e.id for e in deferredwork.parse_ledger(text)} + for entry in story_set.entries: + state = stories_mod.resolve_story_spec(folder, entry.id) + if state.kind != stories_mod.KIND_PRESENT or state.path is None: + continue # never dispatched, ambiguous, or a sentinel — no spec to read + try: + declared = frontmatter.read_frontmatter(state.path).get("closes_deferred") or [] + except OSError: + continue # unreadable spec: other gates own that, this one degrades + ids = [str(x).strip() for x in declared] if isinstance(declared, list) else [] + unknown = [i for i in ids if i and i not in present] + if unknown: + report.warn( + "stories.closes-deferred-unknown", + f"story {entry.id} declares closes_deferred ids that are not in " + f"{ledger.name}: {', '.join(unknown)} — nothing will be marked " + f"resolved for them (typo, or a renumbered/reworded entry?)", + {"story": entry.id, "unknown_ids": unknown}, + ) + + def _warn_unknown_keys(ss: sprintstatus.SprintStatus) -> None: """Surface sprint-status keys the parser could not classify. Silently dropping one reads to the operator as "that story is done, or not mine to diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 98685173..b316bb8d 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -59,3 +59,36 @@ decision: writes it when closing entries triage proved already resolved. - `decision:` records a human's sweep-time choice on an entry. It does not by itself change `status:` — a `keep-open` decision leaves the entry open. + +## Closure declared by a story + +A sweep bundle is not the only thing that closes an entry. A regular story spec +may declare the entries its work closes, in its frontmatter: + +```yaml +closes_deferred: [DW-5, DW-6] # DW- ids this story closes +``` + +At clean story-close the orchestrator annotates each declared id exactly as a +bundle close does — `status: done ` plus a `resolution:` line naming the +story: + +```markdown +status: done 2026-07-23 +resolution: resolved by story 3-2-export +``` + +The rules that keep this safe: + +- **Declared, never inferred.** Closure comes only from this field; the + orchestrator does not guess it from a diff. +- **Only on a clean close.** A story that fails, blocks, or ends short of its + success status closes nothing. +- **Idempotent.** An id already `done` is left untouched, so a resumed run + re-driving the same close neither doubles the `resolution:` line nor warns. +- **Never a gate.** An id that matches no entry is journaled and dropped — + it cannot fail the story. `bmad-loop validate` reports the same mismatch as a + warning before the run starts. + +Keep the ids stable when editing this file: a reworded title is fine, but +renumbering an entry orphans any declaration that already references it. diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index f60378ae..a3c53c8a 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -10,7 +10,7 @@ Like ``SweepEngine``, this is a thin override layer over the mature story pipeline: only the story source (``_pick_next``), the dispatch prompt -(``_dev_prompt``), the (absent) bookkeeping sync (``_post_dev_state_sync``), the +(``_dev_prompt``), the narrowed bookkeeping sync (``_post_dev_state_sync``), the artifact verification (``_verify_dev_artifacts``), the session env (``_extra_session_env``), and the HITL checkpoints differ. Everything else — dev/verify/review/commit, crash resume, worktree isolation, gates — is inherited @@ -413,10 +413,34 @@ def _plan_halt_leg(self, task: StoryTask, entry: stories.StoryEntry | None) -> b # ---------------------------------------------------------- sync + verify def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None: - """No-op: stories mode has no sprint board (and no deferred-work ledger to - flip). Honors the contract's "the orchestrator writes nothing" on the - happy path — the dev skill is the sole writer of each story spec's status.""" - return + """Stories mode has no sprint board, so the base sync's board write has + nothing to do here — the dev skill stays the sole writer of each story + spec's status, honoring the contract's "the orchestrator writes nothing" + on the happy path. + + The deferred-work ledger is a different matter. It is project-wide rather + than sprint-mode-specific — the inherited review-followup refiles already + file into it from stories runs — so a story declaring ``closes_deferred:`` + must have those entries annotated here too. Otherwise the field would be + inert in exactly the mode whose declarations ``validate`` preflights, and + a spec could name ids that nothing ever marks resolved (#234). + + The spec is resolved by id, never from the session-claimed path — the same + deterministic resolution ``verify_dev_stories`` performs a moment later. + """ + if not self._generic_dev(): + return + state = stories.resolve_story_spec(self._stories_folder(), task.story_key) + if state.kind != stories.KIND_PRESENT or state.path is None: + return + fm = self._observed_frontmatter(state.path, task.story_key, "post-dev-sync") + # `done` is where the generic skill self-finalizes (stories mode never hands + # off to `in-review`), and a plan-halt leg sits at `ready-for-dev` — so this + # is the same clean-close gate the sprint advance rides in the base sync: + # a failed, blocked, or plan-only session closes nothing. + if fm is None or verify.status_of(fm) != "done": + return + self._close_declared_deferred(task, fm) def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # The adapter marks a plan-halt leg's synthesized result `plan_halt`; latch diff --git a/tests/test_cli.py b/tests/test_cli.py index 76ae8f10..41cbf397 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,8 @@ install_bmad_config, install_dev_base_skills, machine_json, + mark_ledger_done, + write_ledger, write_sprint, ) @@ -3307,6 +3309,73 @@ def test_validate_silent_when_desktop_notifier_available(project, monkeypatch, c assert all(f["check"] != "notify.desktop-unavailable" for f in doc["findings"]) +def _declare_closes_deferred(project, declared, *, ledger_ids=("DW-1",)): + """Lay down a stories-mode fixture whose single story declares `declared`, over + a ledger holding `ledger_ids` (all open).""" + folder = _setup_stories_fixture(project, [_stories_entry("1")]) + write_ledger(project, {dw: "open" for dw in ledger_ids}, commit=False) + (folder / "stories" / "1-slug.md").write_text( + f"---\ntitle: 'test'\nstatus: 'ready-for-dev'\n" + f"closes_deferred: [{', '.join(declared)}]\n---\n\n## Intent\n\ntest\n", + encoding="utf-8", + ) + return folder + + +def _closes_deferred_findings(capsys): + doc = json.loads(capsys.readouterr().out) + return [f for f in doc["findings"] if f["check"] == "stories.closes-deferred-unknown"] + + +def test_validate_warns_on_unknown_closes_deferred(project, capsys): + """#234: a story declaring an id the ledger doesn't carry — a typo, or an entry + renumbered since the spec was written — would annotate nothing at close, and + only the journal would say so. Preflight names it while it is still fixable.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _declare_closes_deferred(project, ["DW-99"]) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) # rc varies by host (binary/skills) — parse the document + findings = _closes_deferred_findings(capsys) + assert len(findings) == 1 + assert findings[0]["severity"] == "warning" # advisory: never blocks a run + assert findings[0]["detail"] == {"story": "1", "unknown_ids": ["DW-99"]} + assert "DW-99" in findings[0]["message"] + + +def test_validate_silent_when_closes_deferred_all_present(project, capsys): + """The mirror case: every declared id is in the ledger, so nothing is emitted. + A present entry an earlier run already closed is a *satisfied* declaration, not + a mismatch — the check keys off presence, never status, so a resumed project + doesn't accumulate warnings for work that landed.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _declare_closes_deferred(project, ["DW-1", "DW-2"], ledger_ids=("DW-1", "DW-2")) + mark_ledger_done(project, ["DW-2"]) # already closed by an earlier run + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + assert _closes_deferred_findings(capsys) == [] + + +def test_validate_closes_deferred_warning_does_not_fail_the_run(project, capsys, monkeypatch): + """The warning must not flip the exit code. An otherwise-clean project carrying + a stale declaration still validates ok/rc 0 — a typo in a traceability field can + never be the thing that blocks a run from starting.""" + _make_validate_pass(project, monkeypatch, capsys) + _write_policy(project.project, CLAUDE_ONLY_POLICY + STORIES_POLICY) # same, stories mode + _declare_closes_deferred(project, ["DW-99"]) + git(project.project, "add", "-A") # the fixture above dirties the worktree gate + git(project.project, "commit", "-q", "-m", "stories fixture") + capsys.readouterr() + + doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys) # rc 0 + assert doc["ok"] is True + assert doc["counts"]["problem"] == 0 + assert [f for f in doc["findings"] if f["check"] == "stories.closes-deferred-unknown"] + + OPENCODE_QUALIFIED_POLICY = '[adapter]\nname = "opencode"\nmodel = "anthropic/claude-haiku-4-5"\n' diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 23ab58fc..f03b275b 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1220,3 +1220,105 @@ def test_entry_for_unreadable_manifest_journals_warning_once(project): # a second call for the same story does not re-journal (dedup per story key) assert engine._entry_for(task) is None assert len(_kinds(engine.journal, "stories-manifest-unreadable")) == 1 + + +# ------------------- closes_deferred auto-resolve in stories mode (#234) ------ + + +def _declare_spec(path: Path, status: str, dw_ids: list[str] | None) -> None: + """A story spec at `status` that optionally declares `closes_deferred:`.""" + declare = f"closes_deferred: [{', '.join(dw_ids)}]\n" if dw_ids is not None else "" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"---\ntitle: 'test'\ntype: 'feature'\nstatus: '{status}'\n" + f"baseline_revision: 'abc123'\n{declare}---\n\n## Intent\n\ntest spec\n", + encoding="utf-8", + ) + + +def _entries(project) -> dict: + from bmad_loop import deferredwork + + text = project.deferred_work.read_text(encoding="utf-8") + return {e.id: e for e in deferredwork.parse_ledger(text)} + + +def _closing_engine(project, status: str, dw_ids: list[str] | None): + """A stories-mode engine over one story whose on-disk spec sits at `status` + and declares `dw_ids`, against a ledger holding a single open DW-1.""" + from conftest import write_ledger + + setup_stories(project, [entry("1")]) + write_ledger(project, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(project, []) + _declare_spec(story_spec(project, "1"), status, dw_ids) + return engine, StoryTask(story_key="1", epic=0) + + +def test_stories_mode_closes_declared_deferred_entries(project): + """#234: stories mode has no sprint board, but it shares the project's + deferred-work ledger — so a story declaring `closes_deferred:` must still get + those entries annotated. Without this the field is inert in exactly the mode + whose declarations `validate` preflights.""" + engine, task = _closing_engine(project, "done", ["DW-1"]) + + engine._post_dev_state_sync(task, {"spec_file": str(story_spec(project, "1"))}) + + dw1 = _entries(project)["DW-1"] + assert dw1.status.startswith("done") and not dw1.open + assert "resolution: resolved by story 1" in dw1.body + closed = _kinds(engine.journal, "story-deferred-closed") + assert len(closed) == 1 and closed[0]["dw_ids"] == ["DW-1"] + + +def test_stories_mode_resolves_spec_by_id_not_session_claim(project): + """The spec is resolved by story id, exactly as `verify_dev_stories` does — + never from the session-claimed path. A result_json pointing somewhere else + (or carrying no path at all) must not change which spec is read.""" + engine, task = _closing_engine(project, "done", ["DW-1"]) + + engine._post_dev_state_sync(task, {"spec_file": "totally/wrong/path.md"}) + + assert not _entries(project)["DW-1"].open # the id-keyed spec was read anyway + + +def test_stories_mode_closes_nothing_when_story_unfinished(project): + """A session that leaves the spec short of `done` — failed, blocked, or a + plan-halt leg parked at ready-for-dev — closes nothing, so a story that never + landed can't mark its declared entries resolved.""" + engine, task = _closing_engine(project, "ready-for-dev", ["DW-1"]) + before = project.deferred_work.read_bytes() + + engine._post_dev_state_sync(task, {"spec_file": str(story_spec(project, "1"))}) + + assert project.deferred_work.read_bytes() == before + assert _entries(project)["DW-1"].open + assert not _kinds(engine.journal, "story-deferred-closed") + + +def test_stories_mode_close_is_silent_without_declaration(project): + """The ordinary story declares nothing: no ledger write, no journal noise — + and the sprint board stays untouched in a mode that has none.""" + engine, task = _closing_engine(project, "done", None) + before = project.deferred_work.read_bytes() + + engine._post_dev_state_sync(task, {"spec_file": str(story_spec(project, "1"))}) + + assert project.deferred_work.read_bytes() == before + assert not _kinds(engine.journal, "story-deferred-closed") + assert not _kinds(engine.journal, "deferred-close-unmatched") + + +def test_stories_mode_pending_story_closes_nothing(project): + """No spec on disk for the id (a story that never dispatched, or a sentinel): + there is no frontmatter to trust, so the close is skipped rather than guessed.""" + from conftest import write_ledger + + setup_stories(project, [entry("1")]) + write_ledger(project, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(project, []) + + engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + + assert _entries(project)["DW-1"].open + assert not _kinds(engine.journal, "story-deferred-closed") From b8ae6c0f6aa9a039ceed9a71f343a67d0cc7e84d Mon Sep 17 00:00:00 2001 From: pbean Date: Thu, 23 Jul 2026 23:31:40 -0700 Subject: [PATCH 03/36] feat(stories): declare closes_deferred on the stories.yaml entry, not just the spec (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontmatter-only declaration had no producer: `bmad-dev-auto` generates each story spec and knows nothing of the deferred-work ledger — its spec-template frontmatter carries title/type/created/status/review_loop_iteration/ followup_review_recommended/context/warnings and nothing else, and the ledger appears in that skill only as an append target for review defers. So closing an entry from a story required hand-editing a generated spec, which is exactly what does not happen in the unattended multi-epic run this feature exists for. stories.yaml is the channel the orchestrator owns and the human authors at breakdown time, with the ledger in view. `StoryEntry` gains an optional `closes_deferred` list (strict about the container, lenient + deduped per item, like ids); an older manifest without the field parses unchanged. `Engine._close_declared_deferred` takes `extra_ids` and unions the two channels order-preservingly, so an id named in both is marked and journaled once. `StoriesEngine` feeds its manifest entry in. The validate check reads both, and now flags manifest ids even when the story has no spec on disk yet — which makes it a real pre-flight rather than an after-the-first-dispatch one. --- CHANGELOG.md | 8 ++- README.md | 15 ++++- docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 25 ++++++--- .../bmad-loop-sweep/deferred-work-format.md | 5 +- src/bmad_loop/engine.py | 22 ++++++-- src/bmad_loop/stories.py | 46 ++++++++++++++-- src/bmad_loop/stories_engine.py | 9 ++- tests/test_cli.py | 50 ++++++++++++++--- tests/test_stories.py | 35 ++++++++++++ tests/test_stories_engine.py | 55 +++++++++++++++++++ 11 files changed, 238 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f13ad032..2b2903f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ breaking changes may land in a minor release. _files_ `deferred-work.md` entries but never _marked_ one resolved when a later story closed it, so a multi-epic run ended with entries satisfied epics ago still reading `open` and the retro reconstructing by hand which story closed what. A story spec can now declare the entries its work - closes in its frontmatter — `closes_deferred: [DW-5, DW-6]` — and at clean story-close the + closes — `closes_deferred: [DW-5, DW-6]`, on its `stories.yaml` entry (stories mode) or in the + story spec's frontmatter, the two unioned — and at clean story-close the orchestrator writes the same annotation a sweep bundle writes: `status: done ` plus `resolution: resolved by story `. The write happens before the commit, so it squashes into the story's own commit, and it fires in both sprint and stories mode. Closure is declared, never @@ -21,7 +22,10 @@ breaking changes may land in a minor release. nothing; an id already `done` is a silent no-op, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns; and an id matching no ledger entry is journaled (`deferred-close-unmatched`) rather than failing the story. Closes are journaled as - `story-deferred-closed`. `bmad-loop validate` adds a matching pre-flight warning + `story-deferred-closed`. The `stories.yaml` channel is what makes this work unattended: + `bmad-dev-auto` generates the story spec and knows nothing of the ledger, whereas the Story + Breakdown is authored while the ledger is in view. `bmad-loop validate` adds a matching + pre-flight warning (`stories.closes-deferred-unknown`) naming any declared id absent from the ledger — a typo or a renumbered entry — before the run starts; it is a warning, so it never changes the exit code. diff --git a/README.md b/README.md index 48330abd..eabaf580 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,18 @@ Turn it on per project with `[stories] source = "stories"` + `spec_folder = "` ledger ids that story's work closes (see [Deferred-work sweeps](#deferred-work-sweeps)): + +```yaml +- id: '3-2' + title: Export digests + description: … + closes_deferred: [DW-5, DW-6] +``` + +Declaring it here rather than in the story spec is what makes the annotation work unattended: `bmad-dev-auto` generates the spec and knows nothing of the ledger, whereas the breakdown is authored while the ledger is in view. A spec that _does_ carry the field in frontmatter is honored too — the two are unioned. + +A story may set both checkpoints (it pauses twice); `gates.mode` pauses stack on top. A blocked story escalates exactly as in sprint mode — `bmad-loop resolve`, then re-arm + resume — now with the story's title/description and the blocking condition surfaced; a pre-planning-halt **sentinel** spec is auto-deleted (a copy preserved under the run dir) on re-arm for a clean re-dispatch. `bmad-loop run --dry-run --spec ` prints the linear schedule (list order, checkpoint markers, live on-disk state); `bmad-loop status` shows the same stories board. @@ -241,6 +252,8 @@ bmad-loop sweep [--no-prompt] [--decisions-only] [--max-bundles N] [--repeat] [- closes_deferred: [DW-5, DW-6] # DW- ids this story closes ``` +In stories mode the same declaration can live on the `stories.yaml` entry instead (`closes_deferred: [DW-5, DW-6]`), which is where it belongs for an unattended run — the breakdown is authored while the ledger is in view, whereas the story spec is generated later by a dev skill that knows nothing of the ledger. Both channels are unioned. + At clean story-close the orchestrator writes the same annotation a bundle close writes — `status: done ` and `resolution: resolved by story ` — into each declared entry, squashed into the story's own commit. Without it the ledger is one-way: entries are filed automatically but only ever marked resolved by hand, so a multi-epic run ends with entries that were satisfied epics ago still reading `open`. Closure is declared, never inferred from the diff; a story that fails or blocks closes nothing; a resume re-driving the same close is a no-op; and an id that matches no entry is journaled rather than failing the story — `bmad-loop validate` surfaces those as a warning before the run starts. **Answering missed decisions later.** An unattended sweep (`--no-prompt`) skips decisions, and an interactive one can be abandoned before you answer them all — those answers would otherwise be lost, since triage re-derives the decision set from the ledger every run. `bmad-loop decisions` (or press `d` in the TUI) surfaces every decision past sweeps left unanswered, reconstructed from their triage output, and lets you answer them out of band. A `close` is applied immediately; a `build`/`keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep (build → bundle, keep-open → recorded) with no re-prompt. `--list` shows them without answering; `bmad-loop status` reports the outstanding count. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 95e97d07..c513a8b7 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -104,7 +104,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Deferred-work sweeps - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. -- Story-declared closure (`closes_deferred: [DW-5, DW-6]` in a story spec's frontmatter): at clean story-close the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Declared, never inferred from a diff; skipped entirely when the story fails or blocks; idempotent across a resume; and an id matching no entry is journaled, never fatal. `bmad-loop validate` warns about such ids before the run starts. +- Story-declared closure (`closes_deferred: [DW-5, DW-6]` on a `stories.yaml` entry, or in a story spec's frontmatter — the two are unioned): at clean story-close the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Declared, never inferred from a diff; skipped entirely when the story fails or blocks; idempotent across a resume; and an id matching no entry is journaled, never fatal. `bmad-loop validate` warns about such ids before the run starts. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 4c0b866a..45a4e284 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -597,7 +597,7 @@ def _validate_stories_queue( def _validate_closes_deferred( paths: bmadconfig.ProjectPaths, spec_folder: str, report: ValidationReport ) -> None: - """Warn when a story spec declares ``closes_deferred:`` ids the deferred-work + """Warn when a story declares ``closes_deferred:`` ids the deferred-work ledger does not carry (#234). At clean close the orchestrator flips each declared id to ``status: done @@ -607,6 +607,11 @@ def _validate_closes_deferred( the annotation exists to spare. Saying it at preflight is the whole point: the declaration is fixable before the run, not after. + Both declaration channels are checked, unioned per story: the ``stories.yaml`` + entry and, once the story has a spec on disk, that spec's frontmatter. The + manifest half is what makes this genuinely a *pre*-flight — those ids are + readable before the story has ever been dispatched. + Only *absent* ids warn. An id present but already ``done`` is a declaration a prior run already satisfied (a resume re-drives the same close), so it stays silent — the same present-vs-absent classification the engine's close hook @@ -627,15 +632,17 @@ def _validate_closes_deferred( return present = {e.id for e in deferredwork.parse_ledger(text)} for entry in story_set.entries: + declared = list(entry.closes_deferred) state = stories_mod.resolve_story_spec(folder, entry.id) - if state.kind != stories_mod.KIND_PRESENT or state.path is None: - continue # never dispatched, ambiguous, or a sentinel — no spec to read - try: - declared = frontmatter.read_frontmatter(state.path).get("closes_deferred") or [] - except OSError: - continue # unreadable spec: other gates own that, this one degrades - ids = [str(x).strip() for x in declared] if isinstance(declared, list) else [] - unknown = [i for i in ids if i and i not in present] + if state.kind == stories_mod.KIND_PRESENT and state.path is not None: + try: + raw = frontmatter.read_frontmatter(state.path).get("closes_deferred") or [] + except OSError: + raw = [] # unreadable spec: other gates own that, this one degrades + if isinstance(raw, list): + declared += [str(x).strip() for x in raw] + ids = dict.fromkeys(i for i in declared if i) # dedupe across both channels + unknown = [i for i in ids if i not in present] if unknown: report.warn( "stories.closes-deferred-unknown", diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index b316bb8d..3b619a2f 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -62,8 +62,9 @@ decision: ## Closure declared by a story -A sweep bundle is not the only thing that closes an entry. A regular story spec -may declare the entries its work closes, in its frontmatter: +A sweep bundle is not the only thing that closes an entry. A regular story may +declare the entries its work closes — on its `stories.yaml` entry (stories mode), +or in its story spec's frontmatter. The two are unioned: ```yaml closes_deferred: [DW-5, DW-6] # DW- ids this story closes diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 82914635..2a354a94 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -19,7 +19,7 @@ import traceback from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Callable, NoReturn +from typing import TYPE_CHECKING, Callable, NoReturn, Sequence from . import deferredwork, devcontract, envvars, gates, verify from .adapters.base import CodingCLIAdapter, SessionResult, SessionSpec, SpecSnapshot @@ -2054,12 +2054,21 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non sprint_advance(self.workspace.paths.sprint_status, task.story_key, target) self._close_declared_deferred(task, fm) - def _close_declared_deferred(self, task: StoryTask, fm: dict) -> None: - """At clean close, flip every ledger entry the spec declares via + def _close_declared_deferred( + self, task: StoryTask, fm: dict, *, extra_ids: Sequence[str] = () + ) -> None: + """At clean close, flip every ledger entry the story declares via ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note (#234) — the regular-story counterpart of the sweep bundle close at ``SweepEngine._close_bundle_ledger_when_spec_status``. + Two declaration channels feed this, unioned: the spec's own frontmatter, + and ``extra_ids`` — what the caller read from a manifest that named the + ids up front (stories mode passes its ``stories.yaml`` entry). Both + matter, because they are written at different times by different authors: + the breakdown is authored while the ledger is in view, whereas the spec + is generated later by a dev skill that knows nothing of the ledger. + Declaration is the only signal: closure is never inferred from a diff. Ids are classified against a single ledger snapshot rather than from ``mark_done``'s return value, because that return conflates two very @@ -2073,7 +2082,12 @@ def _close_declared_deferred(self, task: StoryTask, fm: dict) -> None: annotation into the story's own commit, the same way the append hooks do. """ raw = fm.get("closes_deferred") or [] - ids = [str(x).strip() for x in raw] if isinstance(raw, list) else [] + declared = [str(x).strip() for x in raw] if isinstance(raw, list) else [] + declared += [str(x).strip() for x in extra_ids] + # Order-preserving dedupe: a story that names the same id in both channels + # (the natural case once a planner writes it and the spec echoes it) must + # mark it once and report it once, not twice. + ids = list(dict.fromkeys(i for i in declared if i)) if not ids: return ledger = self.workspace.paths.deferred_work diff --git a/src/bmad_loop/stories.py b/src/bmad_loop/stories.py index 327b3294..10db8e7f 100644 --- a/src/bmad_loop/stories.py +++ b/src/bmad_loop/stories.py @@ -6,10 +6,10 @@ execution order — **there is no ``depends_on`` field**, so the schedule is a single left-to-right scan, not a DAG. Each entry pins a stable, prefix-free, machine-opaque ``id`` plus ``title``/``description`` and the caller-only knobs -``spec_checkpoint`` / ``done_checkpoint`` / ``invoke_dev_with``. ``status`` is -deliberately absent: bmad-spec is the sole writer of ``stories.yaml`` and -bmad-dev-auto is the sole writer of each story spec's status — the orchestrator -writes neither. +``spec_checkpoint`` / ``done_checkpoint`` / ``invoke_dev_with`` / +``closes_deferred``. ``status`` is deliberately absent: bmad-spec is the sole +writer of ``stories.yaml`` and bmad-dev-auto is the sole writer of each story +spec's status — the orchestrator writes neither. This module is the strict, typed parser the orchestrator reads it through. The upstream schema (validity rule 4) already says ids are quoted strings of @@ -84,7 +84,14 @@ class StoryEntry: """One story in the breakdown. ``id`` is stable once its spec file exists; the checkpoint flags are independent (a story may set both and pause twice). ``invoke_dev_with`` is free text appended verbatim to the dispatch prompt — - the single planner->dev channel, never interpreted here.""" + the single planner->dev channel, never interpreted here. + + ``closes_deferred`` names the deferred-work ledger ids this story closes + (#234). It is a *declaration channel*, not a status: the orchestrator marks + those entries resolved at clean close, and the ids are checked against the + ledger at preflight. It lives here as well as in the story spec's + frontmatter because the breakdown is written while the ledger is in view, + whereas the spec is generated later by a skill that knows nothing of it.""" id: str title: str @@ -92,6 +99,7 @@ class StoryEntry: spec_checkpoint: bool = False done_checkpoint: bool = False invoke_dev_with: str = "" + closes_deferred: tuple[str, ...] = () @dataclass(frozen=True) @@ -174,6 +182,7 @@ def _parse_entry(raw: object, index: int) -> StoryEntry: spec_checkpoint=_bool_field(raw, "spec_checkpoint", story_id), done_checkpoint=_bool_field(raw, "done_checkpoint", story_id), invoke_dev_with=_text_field(raw, "invoke_dev_with", story_id), + closes_deferred=_id_list_field(raw, "closes_deferred", story_id), ) @@ -231,6 +240,33 @@ def _text_field(raw: dict, key: str, story_id: str) -> str: return value +def _id_list_field(raw: dict, key: str, story_id: str) -> tuple[str, ...]: + """Optional list of ledger ids, defaulting empty when missing/null (#234). + + Strict about the *container*: a bare ``closes_deferred: DW-1`` is a schema + error, not a silently-wrapped single id — the same fail-loud rule + :func:`_bool_field` applies, because a string is iterable and a lenient + reading would quietly turn one id into a list of characters. + + Lenient about each *item*, exactly as :func:`_parse_id` is: an LLM-authored + manifest may emit an unquoted ``DW-1`` as a string but a bare ``5`` as an + int, so items are ``str()``-normalized and stripped. Blanks are dropped and + duplicates collapse (order-preserving), since both are noise rather than a + contradiction. Whether an id names a real entry is *not* checked here: this + module never reads the ledger, and a stale reference is a `validate` warning + and a journaled close-time note — never a parse failure. + """ + value = raw.get(key) + if value is None: + return () + if not isinstance(value, list): + raise StoriesError( + f"stories.yaml story {story_id!r} field {key!r} must be a list of " + f"deferred-work ids (got {type(value).__name__})" + ) + return tuple(dict.fromkeys(item for item in (str(x).strip() for x in value) if item)) + + def _validate_prefix_free(ids: list[str]) -> None: """No id may equal another id plus a ``-suffix`` (schema validity rule 2). diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index a3c53c8a..f61d410a 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -425,6 +425,12 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non inert in exactly the mode whose declarations ``validate`` preflights, and a spec could name ids that nothing ever marks resolved (#234). + Both declaration channels are honored: the manifest entry's + ``closes_deferred`` and the spec's own frontmatter. The manifest is the + one that makes this work unattended — ``bmad-dev-auto`` writes the spec + and knows nothing of the ledger, so with the frontmatter alone a human + would have to hand-edit every generated spec. + The spec is resolved by id, never from the session-claimed path — the same deterministic resolution ``verify_dev_stories`` performs a moment later. """ @@ -440,7 +446,8 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non # a failed, blocked, or plan-only session closes nothing. if fm is None or verify.status_of(fm) != "done": return - self._close_declared_deferred(task, fm) + entry = self._entry_for(task) # None on an unreadable manifest (journaled there) + self._close_declared_deferred(task, fm, extra_ids=entry.closes_deferred if entry else ()) def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # The adapter marks a plan-halt leg's synthesized result `plan_halt`; latch diff --git a/tests/test_cli.py b/tests/test_cli.py index 41cbf397..04e1e919 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3309,16 +3309,19 @@ def test_validate_silent_when_desktop_notifier_available(project, monkeypatch, c assert all(f["check"] != "notify.desktop-unavailable" for f in doc["findings"]) -def _declare_closes_deferred(project, declared, *, ledger_ids=("DW-1",)): - """Lay down a stories-mode fixture whose single story declares `declared`, over - a ledger holding `ledger_ids` (all open).""" - folder = _setup_stories_fixture(project, [_stories_entry("1")]) +def _declare_closes_deferred(project, declared, *, ledger_ids=("DW-1",), manifest=None): + """A stories-mode fixture for one story: `manifest` ids declared on its + stories.yaml entry, `declared` ids in its spec frontmatter (None writes no spec + at all), over a ledger holding `ledger_ids` — all open.""" + over = {"closes_deferred": list(manifest)} if manifest is not None else {} + folder = _setup_stories_fixture(project, [_stories_entry("1", **over)]) write_ledger(project, {dw: "open" for dw in ledger_ids}, commit=False) - (folder / "stories" / "1-slug.md").write_text( - f"---\ntitle: 'test'\nstatus: 'ready-for-dev'\n" - f"closes_deferred: [{', '.join(declared)}]\n---\n\n## Intent\n\ntest\n", - encoding="utf-8", - ) + if declared is not None: + (folder / "stories" / "1-slug.md").write_text( + f"---\ntitle: 'test'\nstatus: 'ready-for-dev'\n" + f"closes_deferred: [{', '.join(declared)}]\n---\n\n## Intent\n\ntest\n", + encoding="utf-8", + ) return folder @@ -3359,6 +3362,35 @@ def test_validate_silent_when_closes_deferred_all_present(project, capsys): assert _closes_deferred_findings(capsys) == [] +def test_validate_warns_on_unknown_closes_deferred_in_the_manifest(project, capsys): + """The manifest channel is what makes this a genuine *pre*-flight: a stories.yaml + entry declares its ids before the story has ever been dispatched, so a typo is + caught while no spec exists yet — the frontmatter check can't see that far.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _declare_closes_deferred(project, None, manifest=["DW-99"]) # no story spec on disk + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + findings = _closes_deferred_findings(capsys) + assert len(findings) == 1 + assert findings[0]["detail"] == {"story": "1", "unknown_ids": ["DW-99"]} + + +def test_validate_closes_deferred_dedupes_across_both_channels(project, capsys): + """An id declared in both the manifest and the spec is one mismatch, reported + once — not the same typo twice.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _declare_closes_deferred(project, ["DW-99"], manifest=["DW-99"]) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + findings = _closes_deferred_findings(capsys) + assert len(findings) == 1 + assert findings[0]["detail"]["unknown_ids"] == ["DW-99"] # once, not twice + + def test_validate_closes_deferred_warning_does_not_fail_the_run(project, capsys, monkeypatch): """The warning must not flip the exit code. An otherwise-clean project carrying a stale declaration still validates ok/rc 0 — a typo in a traceability field can diff --git a/tests/test_stories.py b/tests/test_stories.py index dae10978..809cb56f 100644 --- a/tests/test_stories.py +++ b/tests/test_stories.py @@ -217,6 +217,41 @@ def test_invoke_dev_with_non_string_rejected(tmp_path): stories.load_stories(tmp_path) +def test_closes_deferred_defaults_empty(tmp_path): + """The overwhelmingly common entry declares nothing — and an older manifest, + written before the field existed, must keep parsing unchanged (#234).""" + write_stories(tmp_path, '- id: "1"\n title: t\n description: d\n') + assert stories.load_stories(tmp_path).entries[0].closes_deferred == () + + +def test_closes_deferred_parses_ledger_ids(tmp_path): + write_stories( + tmp_path, + '- id: "1"\n title: t\n description: d\n closes_deferred: [DW-5, DW-6]\n', + ) + assert stories.load_stories(tmp_path).entries[0].closes_deferred == ("DW-5", "DW-6") + + +def test_closes_deferred_normalizes_items(tmp_path): + """Items are str()-normalized, stripped, blank-dropped, and de-duplicated, the + same leniency ids get: an LLM-authored manifest may emit a bare `5` as an int + or repeat an id, and neither is a contradiction worth failing a run over.""" + write_stories( + tmp_path, + '- id: "1"\n title: t\n description: d\n' + ' closes_deferred: [" DW-5 ", 5, "DW-5", ""]\n', + ) + assert stories.load_stories(tmp_path).entries[0].closes_deferred == ("DW-5", "5") + + +def test_closes_deferred_non_list_rejected(tmp_path): + """Strict about the container: a bare string is a schema error, never silently + wrapped — a lenient reading would iterate it into single characters.""" + write_stories(tmp_path, '- id: "1"\n title: t\n description: d\n closes_deferred: DW-5\n') + with pytest.raises(stories.StoriesError, match="must be a list of deferred-work ids"): + stories.load_stories(tmp_path) + + def test_top_level_must_be_list(tmp_path): write_stories(tmp_path, "development_status:\n a: b\n") with pytest.raises(stories.StoriesError, match="top-level list"): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index f03b275b..34b01fa2 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1322,3 +1322,58 @@ def test_stories_mode_pending_story_closes_nothing(project): assert _entries(project)["DW-1"].open assert not _kinds(engine.journal, "story-deferred-closed") + + +def test_stories_mode_closes_ids_declared_in_the_manifest(project): + """The channel that makes this work unattended (#234): `bmad-dev-auto` writes + the story spec and knows nothing of the ledger, so a frontmatter-only + declaration would have to be hand-edited into every generated spec. Declaring + on the stories.yaml entry — authored while the ledger is in view — closes the + loop with no upstream change.""" + from conftest import write_ledger + + setup_stories(project, [entry("1", closes_deferred=["DW-1"])]) + write_ledger(project, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(project, []) + _declare_spec(story_spec(project, "1"), "done", None) # spec declares nothing + + engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + + dw1 = _entries(project)["DW-1"] + assert dw1.status.startswith("done") and "resolution: resolved by story 1" in dw1.body + assert _kinds(engine.journal, "story-deferred-closed")[0]["dw_ids"] == ["DW-1"] + + +def test_stories_mode_unions_manifest_and_frontmatter_declarations(project): + """Both channels are honored, and an id named in both is marked and reported + once — the natural case once a planner declares it and the spec echoes it.""" + from conftest import write_ledger + + setup_stories(project, [entry("1", closes_deferred=["DW-1", "DW-2"])]) + write_ledger(project, {"DW-1": "open", "DW-2": "open", "DW-3": "open"}, commit=False) + engine, _ = make_engine(project, []) + _declare_spec(story_spec(project, "1"), "done", ["DW-2", "DW-3"]) # DW-2 in both + + engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + + entries = _entries(project) + assert all(not entries[dw].open for dw in ("DW-1", "DW-2", "DW-3")) + assert entries["DW-2"].body.count("resolution: resolved by story 1") == 1 # not doubled + closed = _kinds(engine.journal, "story-deferred-closed") + assert closed[0]["dw_ids"] == ["DW-2", "DW-3", "DW-1"] # frontmatter first, deduped + + +def test_stories_mode_manifest_declaration_skipped_when_story_unfinished(project): + """The manifest channel rides the same clean-close gate: a story parked short + of `done` closes nothing, however the declaration got there.""" + from conftest import write_ledger + + setup_stories(project, [entry("1", closes_deferred=["DW-1"])]) + write_ledger(project, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(project, []) + _declare_spec(story_spec(project, "1"), "in-progress", None) + + engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + + assert _entries(project)["DW-1"].open + assert not _kinds(engine.journal, "story-deferred-closed") From f1c13fdb8d9e1a07b6d755c2a6b2a44d6cd70d1a Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 08:12:40 -0700 Subject: [PATCH 04/36] fix(engine): close declared deferred-work at the commit boundary, not at dev sync (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story-declared ledger closure ran in _post_dev_state_sync, i.e. before artifact verification, the verify commands, checkpoints, the review loop and the commit. A story that finalized its spec and then failed any of those left the ledger permanently claiming its work resolved: - the in-place defer path snapshots the ledger AFTER the mutation and restores it over the rollback (the snapshot exists to preserve review-filed appends, so it faithfully preserved the false close too); - _escalate rolls nothing back at all, leaking the close into the operator's checkout where the next story's `git add -A` sweeps it into an unrelated commit. Closure now runs from _finalize_commit_phase, after the pre_commit veto and before finalize_commit's `git add -A` — so it is gated on the whole story succeeding and the annotation still rides the story's own commit. Closing after the commit was not an option: it would leave the tree dirty and story N+1's step-01 HALTs on that. Ledger location decides where the write lands. An externally configured artifact dir is shared between worktrees (ProjectPaths.rebased leaves it in place) and can never be staged, so an isolated unit parks its ids on task.pending_deferred_closes and WorktreeFlow.integrate_unit applies them only after merge_local returns. A merge that escalates never reaches it. Also: - sprint mode's session-supplied spec path is now held to the same verify.spec_within_roots rule the frontmatter reconcile applies, so a stale/hostile absolute path cannot steer a ledger write; - deferredwork gains parse_declaration/classify/mark_done_many: one reading of the field shared by every caller (a wrong container is journaled, not silently empty), four-way classification (a present entry with a garbled status is reported, not skipped in silence), and one atomic write instead of N read-modify-writes; - StoriesEngine._post_dev_state_sync returns to a pure no-op and declares its manifest ids through a _manifest_closes_deferred seam; SweepEngine explicitly opts out of the base hook, since bundle closure is owned by _close_bundle_ledger_when_spec_status and verify_review_bundle depends on it running at dev time. Tests move from calling the hook directly to driving engine.run(): the ledger stays open through defer, escalation and a pre_commit pause veto; the annotation is asserted present in `git show HEAD -- `; and the isolated external-ledger path is covered on both sides of integration. --- src/bmad_loop/deferredwork.py | 124 ++++++++++++++++-- src/bmad_loop/engine.py | 169 +++++++++++++++++++------ src/bmad_loop/model.py | 11 ++ src/bmad_loop/stories_engine.py | 56 ++++----- src/bmad_loop/sweep.py | 9 ++ src/bmad_loop/worktree_flow.py | 10 ++ tests/conftest.py | 44 ++++++- tests/test_engine.py | 214 +++++++++++++++++++++++++------- tests/test_engine_worktree.py | 72 ++++++++++- tests/test_stories_engine.py | 154 +++++++++++------------ tests/test_worktree_flow.py | 39 +++++- 11 files changed, 685 insertions(+), 217 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 6c90aaf3..e273e88d 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -13,9 +13,12 @@ import hashlib import re +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path +from .platform_util import atomic_replace + HEADING_RE = re.compile(r"^### (DW-\d+): (.+?)\s*$", re.MULTILINE) ANY_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE) STATUS_RE = re.compile(r"^status:[ \t]*(.*)$", re.MULTILINE) @@ -64,6 +67,77 @@ def open_ids(text: str) -> set[str]: return {e.id for e in parse_ledger(text) if e.open} +def parse_declaration(raw: object) -> tuple[tuple[str, ...], str | None]: + """The single reading of a ``closes_deferred:`` declaration (#234), shared by + the ``stories.yaml`` parser, the engine's close hook, and ``validate``. + + Returns the normalized ids plus an error describing a wrong *container*. + Missing / YAML-null is an empty declaration, not an error. + + Strict about the container, lenient about each item. A bare + ``closes_deferred: DW-1`` is a schema error rather than a silently-wrapped + single id — a string is iterable, so a lenient reading would quietly turn one + id into a list of characters — while items are ``str()``-normalized and + stripped, because an LLM-authored manifest may emit an unquoted ``DW-1`` as a + string but a bare ``5`` as an int. Blanks drop and duplicates collapse + (order-preserving): both are noise, not a contradiction. + + Callers decide the severity: the manifest parser raises, the engine journals, + ``validate`` warns. What they must NOT do is disagree — before this, a wrong + container was a hard schema error in ``stories.yaml`` and a silent empty + declaration in frontmatter, so the same mistake either failed the parse or + vanished depending on which file it was made in. + + Whether an id names a real entry is not decided here; that needs the ledger + (:func:`classify`). + """ + if raw is None: + return (), None + if not isinstance(raw, list): + return (), f"must be a list of deferred-work ids (got {type(raw).__name__})" + return tuple(dict.fromkeys(item for item in (str(x).strip() for x in raw) if item)), None + + +@dataclass(frozen=True) +class Declared: + """How declared ids line up against one ledger snapshot (#234). + + Four outcomes, not two, because "not open" hides two very different cases. + ``already_done`` is a satisfied declaration — a resume re-driving a close that + already landed — and must stay silent. ``malformed`` is an entry that exists + but carries neither an ``open`` nor a ``done`` status: nothing can be marked, + and saying nothing would leave the operator believing it was. + """ + + open_ids: tuple[str, ...] = () + already_done: tuple[str, ...] = () + unknown: tuple[str, ...] = () + malformed: tuple[str, ...] = () + + +def classify(text: str, ids: Sequence[str]) -> Declared: + """Partition `ids` against a single ledger snapshot, preserving order. + + Classifying from a snapshot rather than from :func:`mark_done`'s return value + is deliberate: that return conflates "already done" with "absent from the + ledger", and those need opposite treatment (silence vs. a warning).""" + by_id = {e.id: e for e in parse_ledger(text)} + buckets: dict[str, list[str]] = {"open": [], "done": [], "unknown": [], "malformed": []} + for dw_id in ids: + entry = by_id.get(dw_id) + if entry is None: + buckets["unknown"].append(dw_id) + continue + word = entry.status.split()[0] if entry.status else "" + buckets[word if word in ("open", "done") else "malformed"].append(dw_id) + return Declared( + open_ids=tuple(buckets["open"]), + already_done=tuple(buckets["done"]), + unknown=tuple(buckets["unknown"]), + malformed=tuple(buckets["malformed"]), + ) + + def _find_entry(text: str, dw_id: str) -> DWEntry | None: for entry in parse_ledger(text): if entry.id == dw_id: @@ -82,15 +156,13 @@ def _insert_after_status(text: str, entry: DWEntry, line: str) -> str: return text[:insert_at] + "\n" + line + text[insert_at:] -def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: - """Flip one entry to `status: done ` and record a resolution note. - Returns False (no write) when the entry is missing or already done.""" - if not path.is_file(): - return False - text = path.read_text(encoding="utf-8") +def _apply_done(text: str, dw_id: str, date: str, note: str) -> str | None: + """Flip one entry to `status: done ` + a resolution note *within* `text`. + None when the entry is missing or not open. The entry is re-located after the + status rewrite because that edit shifts every later span offset.""" entry = _find_entry(text, dw_id) if entry is None or not entry.open: - return False + return None status_m = STATUS_RE.search(entry.body) assert status_m is not None # open implies a status line start = entry.span[0] + status_m.start() @@ -98,9 +170,41 @@ def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: text = text[:start] + f"status: done {date}" + text[end:] entry = _find_entry(text, dw_id) assert entry is not None - text = _insert_after_status(text, entry, f"resolution: {note}") - path.write_text(text, encoding="utf-8") - return True + return _insert_after_status(text, entry, f"resolution: {note}") + + +def mark_done_many(path: Path, dw_ids: Sequence[str], date: str, note: str) -> list[str]: + """Flip every entry in `dw_ids` to `status: done ` + a resolution note, + in ONE read and ONE atomic write. Returns the ids actually flipped (missing + and already-done ids are skipped), in the order given. + + All-or-nothing on purpose. A per-id read-modify-write loop leaves marks on + disk when it raises partway through several ids — a half-applied closure the + caller never gets to journal, so the ledger claims resolutions the run has no + record of. Here a failure writes nothing, and the returned list is exactly + what landed.""" + if not path.is_file(): + return [] + text = path.read_text(encoding="utf-8") + marked: list[str] = [] + for dw_id in dw_ids: + updated = _apply_done(text, dw_id, date, note) + if updated is None: + continue + text = updated + marked.append(dw_id) + if not marked: + return [] + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + atomic_replace(tmp, path) + return marked + + +def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: + """Flip one entry to `status: done ` and record a resolution note. + Returns False (no write) when the entry is missing or already done.""" + return bool(mark_done_many(path, [dw_id], date, note)) def append_decision(path: Path, dw_id: str, date: str, label: str, detail: str) -> bool: diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 2a354a94..09a3c6b5 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -281,6 +281,7 @@ def __init__( escalation_pause=self._escalation_pause, workspace_get=lambda: self.workspace, workspace_set=lambda ws: setattr(self, "workspace", ws), + on_integrated=lambda t: self._flush_pending_deferred_closes(t), ) # Attempt rollback + recovery-ref preservation flow (issue #244 PR 2/2). # Same narrow-deps + engine-callbacks pattern as _worktree_flow: `emit` is @@ -1719,6 +1720,12 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: self._escalate(task, f"plugin {veto.plugin_id!r} vetoed pre_commit: {veto.reason}") if ctx.proposed_commit_message: message = ctx.proposed_commit_message + # The success boundary for story-declared ledger closure (#234): every + # verify gate, checkpoint, review cycle and pre-commit workflow is behind + # us, and a pre_commit pause veto has already raised out of _escalate + # above — but finalize_commit's `git add -A` is still ahead, so an in-repo + # annotation lands in this story's own commit. + self._close_declared_deferred(task) try: # bmad-dev-auto commits its own work each iteration; the orchestrator # squashes that chain plus its uncommitted bookkeeping back onto the @@ -2052,57 +2059,145 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non return target = "review" if review_enabled else "done" sprint_advance(self.workspace.paths.sprint_status, task.story_key, target) - self._close_declared_deferred(task, fm) - def _close_declared_deferred( - self, task: StoryTask, fm: dict, *, extra_ids: Sequence[str] = () - ) -> None: - """At clean close, flip every ledger entry the story declares via + def _manifest_closes_deferred(self, task: StoryTask) -> tuple[str, ...]: + """Deferred-work ids declared for this story by a *manifest* the + orchestrator reads, as opposed to the story spec's own frontmatter. + + Empty here: sprint mode has no manifest, so frontmatter is its only + channel. ``StoriesEngine`` overrides this with its ``stories.yaml`` + entry — the channel that matters for an unattended run, since the spec is + generated later by a dev skill that knows nothing of the ledger.""" + return () + + def _declared_deferred_ids(self, task: StoryTask) -> tuple[str, ...]: + """The ids this story declares it closes, unioned across both channels + and order-preserving (a story that names the same id in the manifest and + in its spec must be marked once and reported once). + + The spec read is a *verified* one: ``task.spec_file`` is recorded only by + a passing ``verify_dev``/``verify_dev_stories`` gate, and stories mode + resolves it deterministically by id rather than trusting the session's + claim. Sprint mode's path still came from the session, so it is held to + the same root-containment rule the frontmatter-status reconcile applies — + a surprising absolute path must not be able to steer a ledger write. + + A wrong-container declaration (``closes_deferred: DW-5``) is journaled + rather than silently read as empty: it names real intent that would + otherwise close nothing and say nothing (#234).""" + ids: list[str] = list(self._manifest_closes_deferred(task)) + spec_path = Path(task.spec_file) if task.spec_file else None + if spec_path is not None and spec_path.is_file(): + if not verify.spec_within_roots(spec_path, self.workspace.paths): + self.journal.append( + "deferred-close-skipped-out-of-tree", + story_key=task.story_key, + spec=str(spec_path), + ) + else: + fm = self._observed_frontmatter(spec_path, task.story_key, "deferred-close") + declared, error = deferredwork.parse_declaration((fm or {}).get("closes_deferred")) + if error: + self.journal.append( + "deferred-close-malformed", + story_key=task.story_key, + spec=str(spec_path), + error=f"closes_deferred {error}", + ) + ids += declared + return tuple(dict.fromkeys(ids)) + + def _close_declared_deferred(self, task: StoryTask) -> None: + """At the commit boundary, flip every ledger entry the story declares via ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note (#234) — the regular-story counterpart of the sweep bundle close at ``SweepEngine._close_bundle_ledger_when_spec_status``. - Two declaration channels feed this, unioned: the spec's own frontmatter, - and ``extra_ids`` — what the caller read from a manifest that named the - ids up front (stories mode passes its ``stories.yaml`` entry). Both - matter, because they are written at different times by different authors: - the breakdown is authored while the ledger is in view, whereas the spec - is generated later by a dev skill that knows nothing of the ledger. - Declaration is the only signal: closure is never inferred from a diff. - Ids are classified against a single ledger snapshot rather than from - ``mark_done``'s return value, because that return conflates two very - different cases — an id already ``done`` (a *resume* re-running a close - that already landed, which must stay silent) and an id absent from the - ledger (a typo or a reworded entry, which is worth a journal warning). - Neither ever fails the story: the annotation is traceability, not a gate. - - Called from ``_post_dev_state_sync``, i.e. *before* ``_commit`` — the - ledger path is rebased into the worktree, so marking here squashes the - annotation into the story's own commit, the same way the append hooks do. - """ - raw = fm.get("closes_deferred") or [] - declared = [str(x).strip() for x in raw] if isinstance(raw, list) else [] - declared += [str(x).strip() for x in extra_ids] - # Order-preserving dedupe: a story that names the same id in both channels - # (the natural case once a planner writes it and the spec echoes it) must - # mark it once and report it once, not twice. - ids = list(dict.fromkeys(i for i in declared if i)) + + **Placement is the whole safety story.** This runs from + ``_finalize_commit_phase`` — after artifact verification, the verify + commands, every checkpoint, the review loop, the ``pre_commit_gate`` + workflows and the ``pre_commit`` veto — and still *before* + ``finalize_commit``, whose ``git add -A`` stages the annotation into the + story's own commit. Marking at dev-sync time instead (where this first + landed) let a story that later failed verification, was rejected by + review, or escalated leave the ledger permanently claiming its work + resolved: the in-place defer path snapshots the ledger *after* the + mutation and restores it over the rollback, and ``_escalate`` rolls back + nothing at all. + + Where the write lands depends on where the ledger lives. Inside the repo + it rides the commit. Configured *outside* it (``ProjectPaths.rebased`` + deliberately shares an external artifact dir between worktrees, and + ``add -A`` can never stage it) an isolated story stashes its ids for + ``_flush_pending_deferred_closes`` to apply once the branch has merged — + otherwise a unit whose integration fails would still have edited the + shared ledger. In-place there is no integration step, so it writes here. + + Never a gate: an unmatched or malformed id is journaled, never fatal. + Idempotent, so the resume arm may re-drive the commit phase freely — ids + are classified against a ledger snapshot rather than from ``mark_done``'s + return value, which conflates "already done" (a resume re-running a close + that landed — must stay silent) with "absent" (a typo — worth saying).""" + ids = self._declared_deferred_ids(task) if not ids: return ledger = self.workspace.paths.deferred_work + if self._isolated and not ledger.is_relative_to(self.workspace.root): + task.pending_deferred_closes = list(ids) + self.journal.append( + "deferred-close-pending-integration", + story_key=task.story_key, + dw_ids=list(ids), + ledger=str(ledger), + ) + return + self._apply_deferred_closes(task, ids, ledger) + + def _apply_deferred_closes(self, task: StoryTask, ids: Sequence[str], ledger: Path) -> None: + """Write the closure for `ids` and journal exactly what landed.""" text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" - present = {e.id for e in deferredwork.parse_ledger(text)} - note = f"resolved by story {task.story_key}" - today = self._today() - marked = [i for i in ids if i in present and deferredwork.mark_done(ledger, i, today, note)] - unknown = [i for i in ids if i not in present] + declared = deferredwork.classify(text, ids) + marked = deferredwork.mark_done_many( + ledger, declared.open_ids, self._today(), f"resolved by story {task.story_key}" + ) if marked: self.journal.append("story-deferred-closed", story_key=task.story_key, dw_ids=marked) - if unknown: + if declared.unknown: self.journal.append( - "deferred-close-unmatched", story_key=task.story_key, dw_ids=unknown + "deferred-close-unmatched", story_key=task.story_key, dw_ids=list(declared.unknown) ) + if declared.malformed: + # Present in the ledger but carrying neither an `open` nor a `done` + # status: nothing was marked, and staying quiet would read to the + # operator exactly like a successful close. + self.journal.append( + "deferred-close-malformed", + story_key=task.story_key, + dw_ids=list(declared.malformed), + error="ledger entry status is neither open nor done", + ) + + def _flush_pending_deferred_closes(self, task: StoryTask) -> None: + """Apply closures held back for an out-of-repo ledger, once the story's + unit branch has merged. Called from the integration chokepoint, so a + story whose merge escalates never reaches it and the shared ledger keeps + reading ``open``. No-op in every in-repo configuration.""" + ids = tuple(task.pending_deferred_closes) + if not ids: + return + task.pending_deferred_closes = [] + ledger = self.workspace.paths.deferred_work + self.journal.append( + "deferred-close-external-ledger", + story_key=task.story_key, + dw_ids=list(ids), + ledger=str(ledger), + note="ledger is outside the repo; the annotation is not part of any commit", + ) + self._apply_deferred_closes(task, ids, ledger) + self._save() def _extra_session_env( self, task: StoryTask, role: str, label: str | None = None diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 8e8c6ea0..51b6e989 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -220,6 +220,15 @@ class StoryTask: # rendered intent file handed to dev sessions dw_ids: list[str] = field(default_factory=list) bundle_file: str | None = None + # deferred-work ids this story declared via `closes_deferred:` whose ledger + # annotation could not ride the story's own commit, because the ledger is + # configured OUTSIDE the repo (an external artifact dir is shared between + # worktrees, so `rebased` deliberately leaves it in place and `git add -A` + # can never stage it). Stashed at commit and applied only once the unit's + # branch has merged, so an isolated story whose integration fails never + # claims work resolved. Empty in every in-repo configuration. Survives the + # resume serialization round-trip. + pending_deferred_closes: list[str] = field(default_factory=list) # worktree-isolation mode only (scm.isolation = "worktree"): the unit's # mounted worktree dir and branch, recorded so a paused/crashed run can # reconstruct or discard the in-flight worktree on resume. @@ -274,6 +283,7 @@ def to_dict(self) -> dict[str, Any]: "restore_patch": self.restore_patch, "dw_ids": self.dw_ids, "bundle_file": self.bundle_file, + "pending_deferred_closes": self.pending_deferred_closes, "worktree_path": self.worktree_path, "branch": self.branch, "sessions": [s.to_dict() for s in self.sessions], @@ -321,6 +331,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": restore_patch=d.get("restore_patch"), dw_ids=[str(i) for i in d.get("dw_ids", [])], bundle_file=d.get("bundle_file"), + pending_deferred_closes=[str(i) for i in d.get("pending_deferred_closes", [])], worktree_path=str(d.get("worktree_path", "")), branch=str(d.get("branch", "")), sessions=[SessionRecord.from_dict(s) for s in d.get("sessions", [])], diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index f61d410a..515611fe 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -10,7 +10,7 @@ Like ``SweepEngine``, this is a thin override layer over the mature story pipeline: only the story source (``_pick_next``), the dispatch prompt -(``_dev_prompt``), the narrowed bookkeeping sync (``_post_dev_state_sync``), the +(``_dev_prompt``), the (absent) bookkeeping sync (``_post_dev_state_sync``), the artifact verification (``_verify_dev_artifacts``), the session env (``_extra_session_env``), and the HITL checkpoints differ. Everything else — dev/verify/review/commit, crash resume, worktree isolation, gates — is inherited @@ -413,41 +413,27 @@ def _plan_halt_leg(self, task: StoryTask, entry: stories.StoryEntry | None) -> b # ---------------------------------------------------------- sync + verify def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None: - """Stories mode has no sprint board, so the base sync's board write has - nothing to do here — the dev skill stays the sole writer of each story - spec's status, honoring the contract's "the orchestrator writes nothing" - on the happy path. - - The deferred-work ledger is a different matter. It is project-wide rather - than sprint-mode-specific — the inherited review-followup refiles already - file into it from stories runs — so a story declaring ``closes_deferred:`` - must have those entries annotated here too. Otherwise the field would be - inert in exactly the mode whose declarations ``validate`` preflights, and - a spec could name ids that nothing ever marks resolved (#234). - - Both declaration channels are honored: the manifest entry's - ``closes_deferred`` and the spec's own frontmatter. The manifest is the - one that makes this work unattended — ``bmad-dev-auto`` writes the spec - and knows nothing of the ledger, so with the frontmatter alone a human - would have to hand-edit every generated spec. - - The spec is resolved by id, never from the session-claimed path — the same - deterministic resolution ``verify_dev_stories`` performs a moment later. - """ - if not self._generic_dev(): - return - state = stories.resolve_story_spec(self._stories_folder(), task.story_key) - if state.kind != stories.KIND_PRESENT or state.path is None: - return - fm = self._observed_frontmatter(state.path, task.story_key, "post-dev-sync") - # `done` is where the generic skill self-finalizes (stories mode never hands - # off to `in-review`), and a plan-halt leg sits at `ready-for-dev` — so this - # is the same clean-close gate the sprint advance rides in the base sync: - # a failed, blocked, or plan-only session closes nothing. - if fm is None or verify.status_of(fm) != "done": - return + """No-op: stories mode has no sprint board. Honors the contract's "the + orchestrator writes nothing" on the happy path — the dev skill is the sole + writer of each story spec's status. + + Deferred-work closure is deliberately NOT here. It is project-wide state + (a story's ``closes_deferred:`` declaration applies in this mode too), but + it belongs at the commit boundary rather than at dev-sync time, so a story + that later fails verification or review never leaves the ledger claiming + its work resolved — see ``Engine._close_declared_deferred`` (#234).""" + return + + def _manifest_closes_deferred(self, task: StoryTask) -> tuple[str, ...]: + """The ``stories.yaml`` entry's ``closes_deferred`` ids. + + This is the channel that makes story-declared closure work unattended: + ``bmad-dev-auto`` writes the story spec and knows nothing of the ledger, + so with the spec frontmatter alone a human would have to hand-edit every + generated spec. The breakdown, by contrast, is authored while the ledger + is in view. Both channels compose — the base hook unions them.""" entry = self._entry_for(task) # None on an unreadable manifest (journaled there) - self._close_declared_deferred(task, fm, extra_ids=entry.closes_deferred if entry else ()) + return entry.closes_deferred if entry else () def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # The adapter marks a plan-halt leg's synthesized result `plan_halt`; latch diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 84262442..b7924d33 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1225,6 +1225,15 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non success_status = "in-review" if self._dev_review_enabled() else "done" self._close_bundle_ledger_when_spec_status(task, str(spec_file), success_status) + def _close_declared_deferred(self, task: StoryTask) -> None: + """No-op: a bundle's ledger closure is owned by + ``_close_bundle_ledger_when_spec_status``, which runs at dev-sync time + because ``verify_review_bundle`` *requires* those entries closed before it + will pass. Letting the base class's commit-boundary hook (#234) also fire + here would re-derive closure for a task whose ids come from + ``task.dw_ids``, not from a ``closes_deferred:`` declaration.""" + return + def _close_bundle_ledger_when_spec_status( self, task: StoryTask, diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 5b999a5b..900592ea 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -275,6 +275,7 @@ def __init__( escalation_pause: Callable[..., NoReturn], workspace_get: Callable[[], Workspace], workspace_set: Callable[[Workspace], None], + on_integrated: Callable[[StoryTask], None], ) -> None: self.paths = paths self.policy = policy @@ -294,6 +295,9 @@ def __init__( self._pause = escalation_pause self._workspace_get = workspace_get self._workspace_set = workspace_set + # Late-bound like the rest: post-integration bookkeeping the engine owns + # (the out-of-repo deferred-work closure a unit's commit could not carry). + self._on_integrated = on_integrated @property def isolated(self) -> bool: @@ -476,6 +480,12 @@ def integrate_unit(self, task: StoryTask, unit: UnitWorkspace) -> None: # ourselves by hand once the branch has landed; the orchestrator only # commits the worktree onto the selected target. self.merge_local(task, unit) + # The unit's work is now on the target branch — the only point at + # which bookkeeping that could NOT ride the unit's own commit (an + # out-of-repo deferred-work ledger) may safely claim it landed. A + # merge that escalates raises out of merge_local and never reaches + # here, leaving the shared ledger untouched (#234). + self._on_integrated(task) else: # DEFERRED — capture the diff, keep or drop per keep_failed patch = close_unit_workspace( unit, diff --git a/tests/conftest.py b/tests/conftest.py index 274fc56e..cc58dfba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -308,15 +308,32 @@ def set_sprint(paths: ProjectPaths, key: str, status: str) -> None: paths.sprint_status.write_text(yaml.safe_dump(doc, sort_keys=False)) -def write_spec(path: Path, status: str, baseline: str, *, prose_status: str | None = None) -> None: +def write_spec( + path: Path, + status: str, + baseline: str, + *, + prose_status: str | None = None, + closes_deferred: object = None, +) -> None: """Write a spec the way the real bmad-dev-auto skill does. The skill's step-03 stamps `baseline_revision` and NEVER `baseline_commit` (that name exists only in the orchestrator's synthesized result.json), so this fixture stamps the same key — a reader that only knows `baseline_commit` must fail a test here, - not sail through production (issue #89).""" + not sail through production (issue #89). + + ``closes_deferred`` writes the story-declared ledger-closure field (#234): a + list renders as a YAML flow sequence, and a bare string renders as a scalar — + the wrong-container mistake whose handling must not depend on which file it + was made in.""" + declare = "" + if isinstance(closes_deferred, list): + declare = f"closes_deferred: [{', '.join(closes_deferred)}]\n" + elif closes_deferred is not None: + declare = f"closes_deferred: {closes_deferred}\n" body = ( f"---\ntitle: 'test'\ntype: 'feature'\nstatus: '{status}'\n" - f"baseline_revision: '{baseline}'\n---\n\n## Intent\n\ntest spec\n" + f"baseline_revision: '{baseline}'\n{declare}---\n\n## Intent\n\ntest spec\n" ) if prose_status is not None: # mirror bmad-dev-auto's terminal finalize: it appends a `## Auto Run @@ -387,6 +404,7 @@ def dev_effect( prose_status: str | None = None, seen: list[str] | None = None, write_src: bool = True, + closes_deferred: object = None, ): """Simulate a successful bmad-dev-auto session: it self-finalizes the spec (no in-review handoff — always straight to ``done``) but never touches the @@ -414,7 +432,9 @@ def effect(spec: SessionSpec) -> SessionResult: if write_src: source.write_text(source.read_text() + f"change for {story_key}\n") sp = spec_path(paths, story_key) - write_spec(sp, final_status, baseline, prose_status=prose_status) + write_spec( + sp, final_status, baseline, prose_status=prose_status, closes_deferred=closes_deferred + ) # deliberately NO set_sprint: the dev skill does not write sprint-status return SessionResult( status="completed", @@ -460,7 +480,9 @@ def effect(spec: SessionSpec) -> SessionResult: sp = spec_path(paths, story_key) baseline = _spec_baseline(sp) status = "done" if finalized else "in-progress" - write_spec(sp, status, baseline) + # A review pass rewrites the status, not the whole frontmatter — carry any + # `closes_deferred:` declaration through verbatim, as the real skill does. + write_spec(sp, status, baseline, closes_deferred=_spec_closes_deferred(sp)) if finalized: set_sprint(paths, story_key, "done") return SessionResult( @@ -479,6 +501,18 @@ def effect(spec: SessionSpec) -> SessionResult: return effect +def _spec_closes_deferred(path: Path) -> object: + """The spec's `closes_deferred:` declaration as `write_spec` would re-render + it — a list for a flow sequence, the raw text otherwise. None when absent.""" + for line in path.read_text().splitlines(): + if line.startswith("closes_deferred:"): + value = line.split(":", 1)[1].strip() + if value.startswith("[") and value.endswith("]"): + return [p.strip() for p in value[1:-1].split(",") if p.strip()] + return value + return None + + def _spec_baseline(path: Path) -> str: """Read back whichever baseline key a spec carries: `write_spec` stamps `baseline_revision` like the real skill, but hand-rolled fixture specs (and diff --git a/tests/test_engine.py b/tests/test_engine.py index fb4b2489..6f943cf2 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1945,18 +1945,6 @@ def test_post_dev_state_sync_skips_on_unreadable_spec(project, monkeypatch): # ------------------------------------------- closes_deferred auto-resolve (#234) -def _spec_declaring(path: Path, status: str, dw_ids: list[str] | None) -> None: - """A bmad-dev-auto-shaped spec that optionally declares `closes_deferred:`. - ``None`` omits the key entirely (the overwhelmingly common spec).""" - declare = f"closes_deferred: [{', '.join(dw_ids)}]\n" if dw_ids is not None else "" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - f"---\ntitle: 'test'\ntype: 'feature'\nstatus: '{status}'\n" - f"baseline_revision: 'abc123'\n{declare}---\n\n## Intent\n\ntest spec\n", - encoding="utf-8", - ) - - def _ledger_entries(project) -> dict: return { e.id: e @@ -1964,27 +1952,54 @@ def _ledger_entries(project) -> dict: } -def _closes_deferred_engine(project, status: str, dw_ids: list[str] | None): - """Engine + a story whose spec is finalized at `status` and declares `dw_ids`, - over a ledger holding a single open DW-1. Uncommitted: these drive - `_post_dev_state_sync` directly, so no commit ever runs.""" +def _vetoing_emit(engine, stage: str, action: str): + """Wrap `engine._emit` so `stage` resolves to a plugin veto of `action`, + without a real plugin on disk — `_emit` returns None on the zero-plugin fast + path, so the context is built the same way the bus would.""" + from bmad_loop.plugins.context import Veto + + original = engine._emit + + def emit(s, task=None, **fields): + ctx = original(s, task, **fields) + if s != stage: + return ctx + ctx = ctx or engine._make_context(s, task, **fields) + ctx.add_veto(Veto(action, "test veto", "test-plugin")) + return ctx + + return emit + + +def _closes_deferred_run(project, dw_ids, *, ledger=None, **dev_kwargs): + """A whole sprint-mode story run whose dev session declares `closes_deferred: + dw_ids`, over a committed ledger. `followup_review=False` so the story takes + the skip-review path straight to commit — these assert the *lifecycle* + placement of the close, not the review loop. + + Everything is committed up front and the run is driven end to end, because + the defect this covers (#234 review) only appears downstream of the dev + session: the close used to land at dev-sync time, before verification, review + and commit could still reject the story.""" write_sprint(project, {"1-1-a": "ready-for-dev"}) - write_ledger(project, {"DW-1": "open"}, commit=False) - engine, _ = make_engine(project, []) - sp = spec_path(project, "1-1-a") - _spec_declaring(sp, status, dw_ids) - return engine, StoryTask(story_key="1-1-a", epic=1), {"spec_file": str(sp)} + write_ledger(project, ledger or {"DW-1": "open"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, closes_deferred=dw_ids, **dev_kwargs)], + ) + return engine def test_closes_deferred_marks_declared_entries_done(project): """A story spec declaring `closes_deferred:` flips each referenced ledger - entry to `status: done ` + a `resolution:` note at clean close — the - write the loop never made, forcing retros to reconstruct closure by hand + entry to `status: done ` + a `resolution:` note when the story commits — + the write the loop never made, forcing retros to reconstruct closure by hand (#234). Closure is declared, never inferred from the diff.""" - engine, task, rj = _closes_deferred_engine(project, "done", ["DW-1"]) + engine = _closes_deferred_run(project, ["DW-1"]) - engine._post_dev_state_sync(task, rj) + summary = engine.run() + assert summary.done == 1 entry = _ledger_entries(project)["DW-1"] assert entry.status.startswith("done") and not entry.open assert "resolution: resolved by story 1-1-a" in entry.body @@ -1993,14 +2008,75 @@ def test_closes_deferred_marks_declared_entries_done(project): assert closed[0]["dw_ids"] == ["DW-1"] and closed[0]["story_key"] == "1-1-a" -def test_closes_deferred_idempotent_on_rerun(project): - """A resumed run re-drives the close for a story whose annotation already - landed. The second pass must write nothing and stay *silent*: an id that is - present-but-already-done is a satisfied declaration, not an unmatched one.""" - engine, task, rj = _closes_deferred_engine(project, "done", ["DW-1"]) +def test_closes_deferred_annotation_rides_the_story_commit(project): + """The annotation must be IN the story's own commit, not left dirty behind it: + the loop has to end each story on a clean tree (story N+1's step-01 HALTs on a + dirty one), which is why closure happens just before `finalize_commit`'s + `git add -A` rather than after the commit.""" + engine = _closes_deferred_run(project, ["DW-1"]) - engine._post_dev_state_sync(task, rj) - engine._post_dev_state_sync(task, rj) + engine.run() + + committed = git(project.project, "show", "HEAD", "--", str(project.deferred_work)) + assert "+status: done" in committed + assert "+resolution: resolved by story 1-1-a" in committed + assert worktree_clean(project.project) + + +def test_closes_deferred_stays_open_when_verify_defers_the_story(project): + """The reviewer's repro (#234 review, finding 1). The dev session finalizes + its spec — declaration and all — but the story then fails deterministic + verification and defers. Closing at dev-sync time made that permanent: the + in-place defer path snapshots the ledger AFTER the mutation and restores it + over the rollback, so a rejected story left the ledger claiming its work + resolved. At the commit boundary there is nothing to undo.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}) + # write_src=False: no diff since baseline, so the proof-of-work gate fails and + # every attempt defers — the spec still reaches `done` with its declaration. + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", write_src=False, closes_deferred=["DW-1"])] * 3, + ) + + summary = engine.run() + + assert summary.deferred == 1 and summary.done == 0 + assert _ledger_entries(project)["DW-1"].open # never claimed resolved + kinds = {e["kind"] for e in engine.journal.entries()} + assert "story-deferred-closed" not in kinds + assert "story-deferred" in kinds # the story really did defer + + +def test_closes_deferred_stays_open_when_the_story_escalates(project): + """`_escalate` rolls nothing back — it advances to ESCALATED and pauses. A + close written before that point survives into the operator's checkout, where + the NEXT story's `git add -A` sweeps it into an unrelated commit.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}) + engine, _ = make_engine( + project, [dev_effect(project, "1-1-a", followup_review=False, closes_deferred=["DW-1"])] + ) + engine._emit = _vetoing_emit(engine, "pre_commit", "pause") + + summary = engine.run() + + assert summary.paused and summary.done == 0 + assert _ledger_entries(project)["DW-1"].open + assert "story-deferred-closed" not in {e["kind"] for e in engine.journal.entries()} + + +def test_closes_deferred_idempotent_on_resume(project): + """A host death in the commit window re-drives `_finalize_commit_phase` on + resume, so the close runs twice. The second pass must write nothing and stay + *silent*: an id that is present-but-already-done is a satisfied declaration, + not an unmatched one.""" + engine = _closes_deferred_run(project, ["DW-1"]) + engine.run() + task = load_state(engine.run_dir).tasks["1-1-a"] + + # re-drive the exact commit-phase step a resume performs + engine._close_declared_deferred(task) body = _ledger_entries(project)["DW-1"].body assert body.count("resolution: resolved by story 1-1-a") == 1 # not doubled @@ -2012,11 +2088,12 @@ def test_closes_deferred_idempotent_on_rerun(project): def test_closes_deferred_warns_on_unknown_id(project): """An id absent from the ledger (a typo, or an entry since reworded) is journaled and dropped — never a story failure, and never a ledger write.""" - engine, task, rj = _closes_deferred_engine(project, "done", ["DW-99"]) + engine = _closes_deferred_run(project, ["DW-99"]) before = project.deferred_work.read_bytes() - engine._post_dev_state_sync(task, rj) + summary = engine.run() + assert summary.done == 1 # the annotation is traceability, not a gate assert project.deferred_work.read_bytes() == before # ledger untouched events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-unmatched"] assert len(events) == 1 @@ -2024,32 +2101,73 @@ def test_closes_deferred_warns_on_unknown_id(project): assert "story-deferred-closed" not in [e["kind"] for e in engine.journal.entries()] +def test_closes_deferred_reports_a_malformed_ledger_entry(project): + """An entry that exists but carries neither an `open` nor a `done` status + cannot be marked. Reporting only present-vs-absent left that case in silence, + which reads to the operator exactly like a successful close (#234 review).""" + engine = _closes_deferred_run(project, ["DW-1"], ledger={"DW-1": "in-progress"}) + + engine.run() + + assert not _ledger_entries(project)["DW-1"].status.startswith("done") + events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-malformed"] + assert len(events) == 1 and events[0]["dw_ids"] == ["DW-1"] + + +def test_closes_deferred_reports_a_wrong_container_declaration(project): + """`closes_deferred: DW-1` (a scalar, not a list) is a real declaration of + intent. Reading it as an empty list closed nothing and said nothing, while + the same mistake in `stories.yaml` was a hard schema error — the two channels + now agree that it is a mistake worth surfacing.""" + engine = _closes_deferred_run(project, "DW-1") # bare scalar + before = project.deferred_work.read_bytes() + + summary = engine.run() + + assert summary.done == 1 # still never a gate + assert project.deferred_work.read_bytes() == before + events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-malformed"] + assert len(events) == 1 and "must be a list" in events[0]["error"] + + def test_closes_deferred_noop_when_field_absent(project): """The default spec declares nothing: no ledger write and no journal noise on the close path every ordinary story takes.""" - engine, task, rj = _closes_deferred_engine(project, "done", None) + engine = _closes_deferred_run(project, None) before = project.deferred_work.read_bytes() - engine._post_dev_state_sync(task, rj) + engine.run() assert project.deferred_work.read_bytes() == before kinds = {e["kind"] for e in engine.journal.entries()} - assert "story-deferred-closed" not in kinds and "deferred-close-unmatched" not in kinds + assert not kinds & { + "story-deferred-closed", + "deferred-close-unmatched", + "deferred-close-malformed", + } -def test_closes_deferred_skips_when_spec_unfinalized(project): - """The annotation rides the same clean-close gate as the sprint advance: a - session that leaves the spec short of the success status closes nothing, so a - failed story can never mark its declared entries resolved.""" - engine, task, rj = _closes_deferred_engine(project, "in-progress", ["DW-1"]) - before = project.deferred_work.read_bytes() +def test_closes_deferred_refuses_an_out_of_tree_spec(project, tmp_path): + """The sprint-mode spec path comes from the session's own result.json. A + stale or hostile absolute path carrying `status: done` + `closes_deferred` + must not be able to steer a ledger write — the same root-containment rule the + frontmatter-status reconcile already applies to its one session-supplied + path (#234 review, finding 4).""" + write_ledger(project, {"DW-1": "open"}) + engine, _ = make_engine(project, []) + outside = tmp_path / "elsewhere" / "spec.md" + outside.parent.mkdir(parents=True, exist_ok=True) + write_spec(outside, "done", "abc123", closes_deferred=["DW-1"]) + task = StoryTask(story_key="1-1-a", epic=1) + task.spec_file = str(outside) - engine._post_dev_state_sync(task, rj) + engine._close_declared_deferred(task) - assert project.deferred_work.read_bytes() == before - assert _ledger_entries(project)["DW-1"].open # still open - kinds = {e["kind"] for e in engine.journal.entries()} - assert "story-deferred-closed" not in kinds and "deferred-close-unmatched" not in kinds + assert _ledger_entries(project)["DW-1"].open + events = [ + e for e in engine.journal.entries() if e["kind"] == "deferred-close-skipped-out-of-tree" + ] + assert len(events) == 1 and events[0]["story_key"] == "1-1-a" def test_transient_spec_read_fault_does_not_crash_run(project, monkeypatch): diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index e3ccac4b..51ace3c5 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -57,7 +57,9 @@ def commit_sprint(project, statuses: dict[str, str]) -> None: git(project.project, "commit", "-q", "-m", "sprint") -def wt_dev_effect(project, story_key, *, final_status="done", followup_review=True): +def wt_dev_effect( + project, story_key, *, final_status="done", followup_review=True, closes_deferred=None +): """Dev session running inside the unit worktree (spec.cwd). Mirrors the bmad-dev-auto skill: self-finalizes the spec to done, never writes the sprint board (the orchestrator advances it via the B2 seam, inside the worktree). @@ -71,7 +73,7 @@ def effect(spec): src = cwd / "src.txt" src.write_text(src.read_text() + f"change for {story_key}\n") sp = wt.implementation_artifacts / f"spec-{story_key}.md" - write_spec(sp, final_status, baseline) + write_spec(sp, final_status, baseline, closes_deferred=closes_deferred) # NO set_sprint: the orchestrator is the single sprint-status writer return SessionResult( status="completed", @@ -1430,3 +1432,69 @@ def bad_dev(spec): src = (project.project / "src.txt").read_text() assert "change for 1-1-a" in src and "bad attempt" not in src assert worktree_clean(project.project) + + +# ------------------------------------ story-declared deferred-work closure (#234) + + +def _ledger_entry(paths, dw_id: str): + from bmad_loop import deferredwork + + text = paths.deferred_work.read_text(encoding="utf-8") + return next(e for e in deferredwork.parse_ledger(text) if e.id == dw_id) + + +def test_worktree_in_repo_ledger_closure_reaches_the_target_branch(project): + """An in-repo ledger is rebased into the unit worktree, so the closure rides + the unit's own commit and arrives on the target branch with the merge (#234).""" + from conftest import write_ledger + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}) + engine, _ = make_engine( + project, + [wt_dev_effect(project, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + + summary = engine.run() + + assert summary.done == 1 + entry = _ledger_entry(project, "DW-1") + assert entry.status.startswith("done") and not entry.open + assert "resolution: resolved by story 1-1-a" in entry.body + assert worktree_clean(project.project) + + +def test_worktree_external_ledger_closure_waits_for_integration(project, tmp_path): + """An artifact dir configured OUTSIDE the project is shared between worktrees + and can never be staged by the unit's `git add -A`. Writing it at commit time + would let a unit whose merge later fails still claim its work resolved, so the + ids are held until integration succeeds (#234 review, finding 2).""" + import dataclasses + + from conftest import write_ledger + + external = tmp_path / "shared-artifacts" + external.mkdir() + paths = dataclasses.replace(project, implementation_artifacts=external) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(paths, []) + task = StoryTask(story_key="1-1-a", epic=1) + sp = external / "spec-1-1-a.md" + write_spec(sp, "done", "abc123", closes_deferred=["DW-1"]) + task.spec_file = str(sp) + + engine._close_declared_deferred(task) + + # held back: nothing written, the ids parked on the task for after the merge + assert _ledger_entry(paths, "DW-1").open + assert task.pending_deferred_closes == ["DW-1"] + assert "deferred-close-pending-integration" in journal_kinds(engine) + + engine._flush_pending_deferred_closes(task) + + assert not _ledger_entry(paths, "DW-1").open + assert task.pending_deferred_closes == [] # cleared, so a re-drive is a no-op + kinds = journal_kinds(engine) + assert "deferred-close-external-ledger" in kinds # the operator is told it is uncommitted + assert "story-deferred-closed" in kinds diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 34b01fa2..78cff1a3 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -33,7 +33,7 @@ ) from bmad_loop.runs import STOP_REQUEST_FILE, graceful_stop_requested from bmad_loop.stories_engine import StoriesEngine -from bmad_loop.verify import read_frontmatter, rev_parse_head, status_of +from bmad_loop.verify import read_frontmatter, rev_parse_head, status_of, worktree_clean QUIET = NotifyPolicy(desktop=False, file=True) SPEC_FOLDER = "_bmad-output/epic-1" # under output_folder -> excluded from proof-of-work @@ -63,11 +63,20 @@ def setup_stories(paths, entries: list[dict], *, spec_folder: str = SPEC_FOLDER) def stories_dev_effect( - *, final_status: str = "done", followup_review: bool = False, prose_status: str | None = None + *, + final_status: str = "done", + followup_review: bool = False, + prose_status: str | None = None, + closes_deferred: object = None, + write_src: bool = True, ): """Simulate a bmad-dev-auto folder+id dispatch: read the story id + spec folder from the session env (as the real adapter does), write the id-keyed - story spec, and touch real code so proof-of-work passes.""" + story spec, and touch real code so proof-of-work passes. + + ``write_src=False`` skips the code change, so the proof-of-work gate fails and + the story defers with its spec still finalized — the shape a story-declared + ledger closure must survive without claiming anything resolved.""" def effect(spec) -> SessionResult: story_id = spec.env["BMAD_LOOP_STORY_KEY"] @@ -77,8 +86,11 @@ def effect(spec) -> SessionResult: stories_dir.mkdir(parents=True, exist_ok=True) sp = stories_dir / f"{story_id}-slug.md" src = Path(spec.cwd) / "src.txt" - src.write_text(src.read_text() + f"work for {story_id}\n") - write_spec(sp, final_status, baseline, prose_status=prose_status) + if write_src: + src.write_text(src.read_text() + f"work for {story_id}\n") + write_spec( + sp, final_status, baseline, prose_status=prose_status, closes_deferred=closes_deferred + ) return SessionResult( status="completed", result_json={ @@ -1225,17 +1237,6 @@ def test_entry_for_unreadable_manifest_journals_warning_once(project): # ------------------- closes_deferred auto-resolve in stories mode (#234) ------ -def _declare_spec(path: Path, status: str, dw_ids: list[str] | None) -> None: - """A story spec at `status` that optionally declares `closes_deferred:`.""" - declare = f"closes_deferred: [{', '.join(dw_ids)}]\n" if dw_ids is not None else "" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - f"---\ntitle: 'test'\ntype: 'feature'\nstatus: '{status}'\n" - f"baseline_revision: 'abc123'\n{declare}---\n\n## Intent\n\ntest spec\n", - encoding="utf-8", - ) - - def _entries(project) -> dict: from bmad_loop import deferredwork @@ -1243,16 +1244,18 @@ def _entries(project) -> dict: return {e.id: e for e in deferredwork.parse_ledger(text)} -def _closing_engine(project, status: str, dw_ids: list[str] | None): - """A stories-mode engine over one story whose on-disk spec sits at `status` - and declares `dw_ids`, against a ledger holding a single open DW-1.""" +def _closing_run(project, *, manifest=None, spec=None, ledger=None, attempts=1, **effect_kwargs): + """A whole stories-mode run of one story, declaring `manifest` ids on its + stories.yaml entry and `spec` ids in the generated story spec, over a + committed ledger. Driven end to end: the close lives at the commit boundary, + so only a real run exercises where it actually lands. `attempts` scripts the + same session repeatedly for a story expected to retry before deferring.""" from conftest import write_ledger - setup_stories(project, [entry("1")]) - write_ledger(project, {"DW-1": "open"}, commit=False) - engine, _ = make_engine(project, []) - _declare_spec(story_spec(project, "1"), status, dw_ids) - return engine, StoryTask(story_key="1", epic=0) + setup_stories(project, [entry("1", **({"closes_deferred": manifest} if manifest else {}))]) + write_ledger(project, ledger or {"DW-1": "open"}) + effect = stories_dev_effect(closes_deferred=spec, **effect_kwargs) + return make_engine(project, [effect] * attempts)[0] def test_stories_mode_closes_declared_deferred_entries(project): @@ -1260,10 +1263,11 @@ def test_stories_mode_closes_declared_deferred_entries(project): deferred-work ledger — so a story declaring `closes_deferred:` must still get those entries annotated. Without this the field is inert in exactly the mode whose declarations `validate` preflights.""" - engine, task = _closing_engine(project, "done", ["DW-1"]) + engine = _closing_run(project, spec=["DW-1"]) - engine._post_dev_state_sync(task, {"spec_file": str(story_spec(project, "1"))}) + summary = engine.run() + assert summary.done == 1 dw1 = _entries(project)["DW-1"] assert dw1.status.startswith("done") and not dw1.open assert "resolution: resolved by story 1" in dw1.body @@ -1271,27 +1275,27 @@ def test_stories_mode_closes_declared_deferred_entries(project): assert len(closed) == 1 and closed[0]["dw_ids"] == ["DW-1"] -def test_stories_mode_resolves_spec_by_id_not_session_claim(project): - """The spec is resolved by story id, exactly as `verify_dev_stories` does — - never from the session-claimed path. A result_json pointing somewhere else - (or carrying no path at all) must not change which spec is read.""" - engine, task = _closing_engine(project, "done", ["DW-1"]) +def test_stories_mode_annotation_rides_the_story_commit(project): + """An in-repo ledger annotation is part of the story's own commit, so the run + ends on the clean tree the next story's step-01 requires.""" + engine = _closing_run(project, spec=["DW-1"]) - engine._post_dev_state_sync(task, {"spec_file": "totally/wrong/path.md"}) + engine.run() - assert not _entries(project)["DW-1"].open # the id-keyed spec was read anyway + committed = git(project.project, "show", "HEAD", "--", str(project.deferred_work)) + assert "+resolution: resolved by story 1" in committed + assert worktree_clean(project.project) -def test_stories_mode_closes_nothing_when_story_unfinished(project): - """A session that leaves the spec short of `done` — failed, blocked, or a - plan-halt leg parked at ready-for-dev — closes nothing, so a story that never - landed can't mark its declared entries resolved.""" - engine, task = _closing_engine(project, "ready-for-dev", ["DW-1"]) - before = project.deferred_work.read_bytes() +def test_stories_mode_closes_nothing_when_the_story_defers(project): + """The story finalizes its spec — declaration and all — then fails the + proof-of-work gate and defers. Closing at dev-sync time made that permanent + (#234 review, finding 1); at the commit boundary the entry is never touched.""" + engine = _closing_run(project, manifest=["DW-1"], spec=["DW-1"], write_src=False, attempts=3) - engine._post_dev_state_sync(task, {"spec_file": str(story_spec(project, "1"))}) + summary = engine.run() - assert project.deferred_work.read_bytes() == before + assert summary.deferred == 1 and summary.done == 0 assert _entries(project)["DW-1"].open assert not _kinds(engine.journal, "story-deferred-closed") @@ -1299,28 +1303,26 @@ def test_stories_mode_closes_nothing_when_story_unfinished(project): def test_stories_mode_close_is_silent_without_declaration(project): """The ordinary story declares nothing: no ledger write, no journal noise — and the sprint board stays untouched in a mode that has none.""" - engine, task = _closing_engine(project, "done", None) + engine = _closing_run(project) before = project.deferred_work.read_bytes() - engine._post_dev_state_sync(task, {"spec_file": str(story_spec(project, "1"))}) + engine.run() assert project.deferred_work.read_bytes() == before assert not _kinds(engine.journal, "story-deferred-closed") assert not _kinds(engine.journal, "deferred-close-unmatched") -def test_stories_mode_pending_story_closes_nothing(project): - """No spec on disk for the id (a story that never dispatched, or a sentinel): - there is no frontmatter to trust, so the close is skipped rather than guessed.""" - from conftest import write_ledger - - setup_stories(project, [entry("1")]) - write_ledger(project, {"DW-1": "open"}, commit=False) - engine, _ = make_engine(project, []) +def test_stories_mode_dev_sync_writes_nothing(project): + """Stories mode keeps the contract's "the orchestrator writes nothing" at dev + time: the post-dev sync is a pure no-op, and in particular does NOT close + declared entries — that belongs after verification, at commit.""" + engine = _closing_run(project, manifest=["DW-1"], spec=["DW-1"]) + before = project.deferred_work.read_bytes() engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) - assert _entries(project)["DW-1"].open + assert project.deferred_work.read_bytes() == before assert not _kinds(engine.journal, "story-deferred-closed") @@ -1330,14 +1332,9 @@ def test_stories_mode_closes_ids_declared_in_the_manifest(project): declaration would have to be hand-edited into every generated spec. Declaring on the stories.yaml entry — authored while the ledger is in view — closes the loop with no upstream change.""" - from conftest import write_ledger - - setup_stories(project, [entry("1", closes_deferred=["DW-1"])]) - write_ledger(project, {"DW-1": "open"}, commit=False) - engine, _ = make_engine(project, []) - _declare_spec(story_spec(project, "1"), "done", None) # spec declares nothing + engine = _closing_run(project, manifest=["DW-1"]) # spec declares nothing - engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + engine.run() dw1 = _entries(project)["DW-1"] assert dw1.status.startswith("done") and "resolution: resolved by story 1" in dw1.body @@ -1347,33 +1344,32 @@ def test_stories_mode_closes_ids_declared_in_the_manifest(project): def test_stories_mode_unions_manifest_and_frontmatter_declarations(project): """Both channels are honored, and an id named in both is marked and reported once — the natural case once a planner declares it and the spec echoes it.""" - from conftest import write_ledger - - setup_stories(project, [entry("1", closes_deferred=["DW-1", "DW-2"])]) - write_ledger(project, {"DW-1": "open", "DW-2": "open", "DW-3": "open"}, commit=False) - engine, _ = make_engine(project, []) - _declare_spec(story_spec(project, "1"), "done", ["DW-2", "DW-3"]) # DW-2 in both + engine = _closing_run( + project, + manifest=["DW-1", "DW-2"], + spec=["DW-2", "DW-3"], # DW-2 in both + ledger={"DW-1": "open", "DW-2": "open", "DW-3": "open"}, + ) - engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + engine.run() entries = _entries(project) assert all(not entries[dw].open for dw in ("DW-1", "DW-2", "DW-3")) assert entries["DW-2"].body.count("resolution: resolved by story 1") == 1 # not doubled closed = _kinds(engine.journal, "story-deferred-closed") - assert closed[0]["dw_ids"] == ["DW-2", "DW-3", "DW-1"] # frontmatter first, deduped - + assert closed[0]["dw_ids"] == ["DW-1", "DW-2", "DW-3"] # manifest first, deduped -def test_stories_mode_manifest_declaration_skipped_when_story_unfinished(project): - """The manifest channel rides the same clean-close gate: a story parked short - of `done` closes nothing, however the declaration got there.""" - from conftest import write_ledger - setup_stories(project, [entry("1", closes_deferred=["DW-1"])]) - write_ledger(project, {"DW-1": "open"}, commit=False) - engine, _ = make_engine(project, []) - _declare_spec(story_spec(project, "1"), "in-progress", None) +def test_stories_mode_unknown_id_is_journaled_not_fatal(project): + """An id naming no ledger entry — a typo, or an entry renumbered since the + breakdown was written — is journaled and dropped. The annotation is + traceability, so a stale reference must never fail a story that succeeded.""" + engine = _closing_run(project, manifest=["DW-99"]) + before = project.deferred_work.read_bytes() - engine._post_dev_state_sync(StoryTask(story_key="1", epic=0), {}) + summary = engine.run() - assert _entries(project)["DW-1"].open - assert not _kinds(engine.journal, "story-deferred-closed") + assert summary.done == 1 + assert project.deferred_work.read_bytes() == before + unmatched = _kinds(engine.journal, "deferred-close-unmatched") + assert len(unmatched) == 1 and unmatched[0]["dw_ids"] == ["DW-99"] diff --git a/tests/test_worktree_flow.py b/tests/test_worktree_flow.py index 4ada5b0f..010f490d 100644 --- a/tests/test_worktree_flow.py +++ b/tests/test_worktree_flow.py @@ -86,7 +86,9 @@ def _make_flow( ): """Build a WorktreeFlow wired to recording stubs. The returned flow carries a ``.calls`` namespace tallying the injected callbacks for assertions.""" - calls = SimpleNamespace(saves=0, emits=[], gates=[], pauses=[], workspaces=[workspace]) + calls = SimpleNamespace( + saves=0, emits=[], gates=[], pauses=[], workspaces=[workspace], integrated=[] + ) def _save() -> None: calls.saves += 1 @@ -132,6 +134,7 @@ def _pause(reason, story_key="", *, cause=None): escalation_pause=_pause, workspace_get=lambda: calls.workspaces[-1], workspace_set=lambda ws: calls.workspaces.append(ws), + on_integrated=lambda task: calls.integrated.append(task), ) flow.calls = calls return flow @@ -298,6 +301,40 @@ def test_escalate_unit_marks_escalated_notifies_and_pauses(tmp_path): assert "CRITICAL escalation: 2-3" in (tmp_path / ATTENTION_FILE).read_text() +def test_integrate_unit_runs_post_integration_bookkeeping_after_a_merge(tmp_path): + """The integration chokepoint is the only safe point for bookkeeping the + unit's own commit could not carry (an out-of-repo deferred-work ledger, #234). + It fires after merge_local, so it is reached only by a unit that landed.""" + flow = _make_flow(tmp_path, state=SimpleNamespace(target_branch="main", run_id="r", tasks={})) + merged = [] + flow.merge_local = lambda task, unit: merged.append(task) + task = StoryTask(story_key="1-1", epic=1) + task.phase = Phase.DONE + + flow.integrate_unit(task, unit=None) + + assert merged == [task] + assert flow.calls.integrated == [task] + + +def test_integrate_unit_skips_bookkeeping_when_the_merge_escalates(tmp_path): + """A merge that escalates raises out of merge_local. Nothing downstream may + claim the unit's work landed — a shared ledger must still read `open`.""" + flow = _make_flow(tmp_path, state=SimpleNamespace(target_branch="main", run_id="r", tasks={})) + + def boom(task, unit): + raise _Pause("merge blocked", task.story_key) + + flow.merge_local = boom + task = StoryTask(story_key="1-1", epic=1) + task.phase = Phase.DONE + + with pytest.raises(_Pause): + flow.integrate_unit(task, unit=None) + + assert flow.calls.integrated == [] + + def test_reopen_unit_escalates_when_worktree_missing(tmp_path): flow = _make_flow(tmp_path, state=SimpleNamespace(target_branch="main", run_id="r", tasks={})) task = StoryTask(story_key="1-1", epic=1) From c0f139dc823b7a71b5ce76c7f1e123e4f0d04785 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 08:19:04 -0700 Subject: [PATCH 05/36] fix(validate,stories): one shared closes_deferred reading, warned in both modes (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same `closes_deferred:` mistake meant different things depending on which file it was made in: a wrong container was a hard schema error in stories.yaml and a silent empty declaration in a story spec, where it closed nothing and said nothing. stories.py now reads the field through deferredwork.parse_declaration, the shared primitive the engine and validate already use, and only the severity differs by caller — the manifest parser raises, the engine journals, validate warns. The preflight also ran in stories mode only, so a sprint-mode typo reached the operator as nothing but a journal line after the run. It now runs in both branches: stories mode keeps the manifest + id-resolved spec union, and sprint mode scans the story specs already sitting in the artifacts dir — written by create-story ahead of the run, which is exactly while a typo is still cheap to fix. Check ids are mode-neutral now that the check is: stories.closes-deferred- unknown becomes deferred.closes-unknown, joined by deferred.closes- malformed for a declaration nothing can read. Both stay warnings; a stale traceability reference must never block a run that would otherwise start. --- src/bmad_loop/checks.py | 3 +- src/bmad_loop/cli.py | 117 +++++++++++++++++++++++++++---------- src/bmad_loop/stories.py | 35 +++++------ tests/test_cli.py | 66 +++++++++++++++++++-- tests/test_deferredwork.py | 97 ++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 56 deletions(-) diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index 10e2a809..34c8bcee 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -71,7 +71,8 @@ "skills.stories-dispatch", "skills.stories-dispatch-missing", "skills.stories-dispatch-stale", - "stories.closes-deferred-unknown", + "deferred.closes-unknown", + "deferred.closes-malformed", } ) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 45a4e284..88fbaa95 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -230,8 +230,9 @@ def cmd_validate(args: argparse.Namespace) -> int: _validate_stories_queue( project, paths, spec_folder, [p.skill_tree for p in profiles], report ) - _validate_closes_deferred(paths, spec_folder, report) + _validate_closes_deferred(paths, report, spec_folder=spec_folder) else: + _validate_closes_deferred(paths, report) try: ss = sprintstatus.load(paths.sprint_status) actionable = [s for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] @@ -594,11 +595,25 @@ def _validate_stories_queue( report.extend(stories_probs) +def _spec_closes_deferred(path: Path) -> tuple[tuple[str, ...], str | None]: + """One story spec's ``closes_deferred:`` declaration, degrading an unreadable + spec to an empty one — other gates own spec readability, and an advisory + check must never be the thing that crashes the preflight it is advising.""" + try: + raw = frontmatter.read_frontmatter(path).get("closes_deferred") + except (OSError, UnicodeDecodeError): + return (), None + return deferredwork.parse_declaration(raw) + + def _validate_closes_deferred( - paths: bmadconfig.ProjectPaths, spec_folder: str, report: ValidationReport + paths: bmadconfig.ProjectPaths, + report: ValidationReport, + *, + spec_folder: str | None = None, ) -> None: """Warn when a story declares ``closes_deferred:`` ids the deferred-work - ledger does not carry (#234). + ledger does not carry, or declares them in a shape nothing can read (#234). At clean close the orchestrator flips each declared id to ``status: done `` + a ``resolution:`` line. An id that names no entry — a typo, or an @@ -607,52 +622,94 @@ def _validate_closes_deferred( the annotation exists to spare. Saying it at preflight is the whole point: the declaration is fixable before the run, not after. - Both declaration channels are checked, unioned per story: the ``stories.yaml`` - entry and, once the story has a spec on disk, that spec's frontmatter. The - manifest half is what makes this genuinely a *pre*-flight — those ids are - readable before the story has ever been dispatched. + Runs in **both** queue modes, because the declaration exists in both. With + ``spec_folder`` (stories mode) each manifest entry is checked together with + its id-resolved spec, unioned — the manifest half is what makes this genuinely + a *pre*-flight, since those ids are readable before the story has ever been + dispatched. Without it (sprint mode) the story specs already sitting in the + artifacts dir are scanned instead; those are written by `create-story` ahead + of the run, which is exactly while a typo is still cheap to fix. Reporting + only in stories mode left sprint-mode operators with nothing but a journal + line after the fact. Only *absent* ids warn. An id present but already ``done`` is a declaration a prior run already satisfied (a resume re-drives the same close), so it stays - silent — the same present-vs-absent classification the engine's close hook - makes, for the same reason. + silent — the same classification the engine's close hook makes, for the same + reason. A wrong-container declaration warns separately: it names real intent + that would otherwise close nothing and say nothing. Never a failure. The annotation is traceability, not a gate, so a stale reference must not be able to block a run that would otherwise start. """ - folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) ledger = paths.deferred_work try: text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" - story_set = stories_mod.load_stories(folder) + sources = ( + _stories_declarations(paths, spec_folder) + if spec_folder is not None + else _sprint_declarations(paths) + ) except (OSError, UnicodeDecodeError, stories_mod.StoriesError): # An unparseable manifest is already a `queue.stories-manifest` failure from - # the gate above — don't double-report it. And an advisory check must never - # be the thing that crashes the preflight it is advising. + # the gate above — don't double-report it. return - present = {e.id for e in deferredwork.parse_ledger(text)} - for entry in story_set.entries: - declared = list(entry.closes_deferred) - state = stories_mod.resolve_story_spec(folder, entry.id) - if state.kind == stories_mod.KIND_PRESENT and state.path is not None: - try: - raw = frontmatter.read_frontmatter(state.path).get("closes_deferred") or [] - except OSError: - raw = [] # unreadable spec: other gates own that, this one degrades - if isinstance(raw, list): - declared += [str(x).strip() for x in raw] - ids = dict.fromkeys(i for i in declared if i) # dedupe across both channels - unknown = [i for i in ids if i not in present] - if unknown: + for label, ids, error in sources: + if error: + report.warn( + "deferred.closes-malformed", + f"{label} declares closes_deferred in a shape that cannot be read: " + f"{error} — nothing will be marked resolved for it", + {"source": label, "error": error}, + ) + declared = deferredwork.classify(text, ids) + if declared.unknown: + unknown = list(declared.unknown) report.warn( - "stories.closes-deferred-unknown", - f"story {entry.id} declares closes_deferred ids that are not in " + "deferred.closes-unknown", + f"{label} declares closes_deferred ids that are not in " f"{ledger.name}: {', '.join(unknown)} — nothing will be marked " f"resolved for them (typo, or a renumbered/reworded entry?)", - {"story": entry.id, "unknown_ids": unknown}, + {"source": label, "unknown_ids": unknown}, ) +def _stories_declarations( + paths: bmadconfig.ProjectPaths, spec_folder: str +) -> list[tuple[str, tuple[str, ...], str | None]]: + """Per manifest entry: the union of its ``stories.yaml`` ids and its + id-resolved spec's, deduped across both channels.""" + folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) + out = [] + for entry in stories_mod.load_stories(folder).entries: + ids = list(entry.closes_deferred) + error = None + state = stories_mod.resolve_story_spec(folder, entry.id) + if state.kind == stories_mod.KIND_PRESENT and state.path is not None: + spec_ids, error = _spec_closes_deferred(state.path) + ids += spec_ids + out.append((f"story {entry.id}", tuple(dict.fromkeys(ids)), error)) + return out + + +def _sprint_declarations( + paths: bmadconfig.ProjectPaths, +) -> list[tuple[str, tuple[str, ...], str | None]]: + """Sprint mode has no manifest, so the declarations that exist yet are the + ones in story specs already on disk — the flat ``*.md`` layout the artifacts + dir holds.""" + impl = paths.implementation_artifacts + if not impl.is_dir(): + return [] + out = [] + for path in sorted(impl.glob("*.md")): + if path == paths.deferred_work: + continue + ids, error = _spec_closes_deferred(path) + if ids or error: + out.append((f"spec {path.name}", ids, error)) + return out + + def _warn_unknown_keys(ss: sprintstatus.SprintStatus) -> None: """Surface sprint-status keys the parser could not classify. Silently dropping one reads to the operator as "that story is done, or not mine to diff --git a/src/bmad_loop/stories.py b/src/bmad_loop/stories.py index 10db8e7f..13b99417 100644 --- a/src/bmad_loop/stories.py +++ b/src/bmad_loop/stories.py @@ -28,6 +28,7 @@ import yaml +from . import deferredwork from .frontmatter import read_frontmatter, status_of # Fixed-name discovery, like SPEC.md / .memlog.md — never listed in companions. @@ -243,28 +244,20 @@ def _text_field(raw: dict, key: str, story_id: str) -> str: def _id_list_field(raw: dict, key: str, story_id: str) -> tuple[str, ...]: """Optional list of ledger ids, defaulting empty when missing/null (#234). - Strict about the *container*: a bare ``closes_deferred: DW-1`` is a schema - error, not a silently-wrapped single id — the same fail-loud rule - :func:`_bool_field` applies, because a string is iterable and a lenient - reading would quietly turn one id into a list of characters. - - Lenient about each *item*, exactly as :func:`_parse_id` is: an LLM-authored - manifest may emit an unquoted ``DW-1`` as a string but a bare ``5`` as an - int, so items are ``str()``-normalized and stripped. Blanks are dropped and - duplicates collapse (order-preserving), since both are noise rather than a - contradiction. Whether an id names a real entry is *not* checked here: this - module never reads the ledger, and a stale reference is a `validate` warning - and a journaled close-time note — never a parse failure. + The reading itself lives in :func:`deferredwork.parse_declaration`, shared + with the engine's close hook and ``validate`` so the same mistake cannot mean + different things in the manifest and in a story spec's frontmatter. Only the + *severity* differs: a manifest is a schema the parser owns, so a wrong + container raises here, where the engine journals and ``validate`` warns. + + Whether an id names a real entry is not checked here: this module never reads + the ledger, and a stale reference is a `validate` warning and a journaled + close-time note — never a parse failure. """ - value = raw.get(key) - if value is None: - return () - if not isinstance(value, list): - raise StoriesError( - f"stories.yaml story {story_id!r} field {key!r} must be a list of " - f"deferred-work ids (got {type(value).__name__})" - ) - return tuple(dict.fromkeys(item for item in (str(x).strip() for x in value) if item)) + ids, error = deferredwork.parse_declaration(raw.get(key)) + if error: + raise StoriesError(f"stories.yaml story {story_id!r} field {key!r} {error}") + return ids def _validate_prefix_free(ids: list[str]) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 04e1e919..cbe81d24 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,7 +14,9 @@ install_dev_base_skills, machine_json, mark_ledger_done, + spec_path, write_ledger, + write_spec, write_sprint, ) @@ -3327,7 +3329,7 @@ def _declare_closes_deferred(project, declared, *, ledger_ids=("DW-1",), manifes def _closes_deferred_findings(capsys): doc = json.loads(capsys.readouterr().out) - return [f for f in doc["findings"] if f["check"] == "stories.closes-deferred-unknown"] + return [f for f in doc["findings"] if f["check"] == "deferred.closes-unknown"] def test_validate_warns_on_unknown_closes_deferred(project, capsys): @@ -3343,7 +3345,7 @@ def test_validate_warns_on_unknown_closes_deferred(project, capsys): findings = _closes_deferred_findings(capsys) assert len(findings) == 1 assert findings[0]["severity"] == "warning" # advisory: never blocks a run - assert findings[0]["detail"] == {"story": "1", "unknown_ids": ["DW-99"]} + assert findings[0]["detail"] == {"source": "story 1", "unknown_ids": ["DW-99"]} assert "DW-99" in findings[0]["message"] @@ -3374,7 +3376,7 @@ def test_validate_warns_on_unknown_closes_deferred_in_the_manifest(project, caps cli.cmd_validate(args) findings = _closes_deferred_findings(capsys) assert len(findings) == 1 - assert findings[0]["detail"] == {"story": "1", "unknown_ids": ["DW-99"]} + assert findings[0]["detail"] == {"source": "story 1", "unknown_ids": ["DW-99"]} def test_validate_closes_deferred_dedupes_across_both_channels(project, capsys): @@ -3405,7 +3407,63 @@ def test_validate_closes_deferred_warning_does_not_fail_the_run(project, capsys, doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys) # rc 0 assert doc["ok"] is True assert doc["counts"]["problem"] == 0 - assert [f for f in doc["findings"] if f["check"] == "stories.closes-deferred-unknown"] + assert [f for f in doc["findings"] if f["check"] == "deferred.closes-unknown"] + + +def test_validate_warns_on_unknown_closes_deferred_in_sprint_mode(project, capsys): + """The check runs in BOTH queue modes (#234 review, finding 6). Sprint mode + has no manifest, but `create-story` writes its story specs into the artifacts + dir before the run — so a typo there is caught at preflight too, rather than + surfacing only as a journal line nobody reads until the retro.""" + install_bmad_config(project) + _write_policy(project.project) # sprint mode: no [stories] source + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}, commit=False) + write_spec(spec_path(project, "1-1-a"), "ready-for-dev", "abc123", closes_deferred=["DW-99"]) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + findings = _closes_deferred_findings(capsys) + assert len(findings) == 1 + assert findings[0]["severity"] == "warning" + assert findings[0]["detail"] == {"source": "spec spec-1-1-a.md", "unknown_ids": ["DW-99"]} + + +def test_validate_warns_on_a_malformed_closes_deferred_declaration(project, capsys): + """`closes_deferred: DW-1` (a scalar, not a list) declares real intent that + reads as empty. Silence there was indistinguishable from having no + declaration at all, so the same mistake meant nothing in a spec and a hard + schema error in stories.yaml — preflight now names it either way.""" + install_bmad_config(project) + _write_policy(project.project) + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}, commit=False) + write_spec(spec_path(project, "1-1-a"), "ready-for-dev", "abc123", closes_deferred="DW-1") + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + doc = json.loads(capsys.readouterr().out) + findings = [f for f in doc["findings"] if f["check"] == "deferred.closes-malformed"] + assert len(findings) == 1 and findings[0]["severity"] == "warning" + assert "must be a list" in findings[0]["detail"]["error"] + + +def test_validate_sprint_mode_silent_without_declarations(project, capsys): + """The ordinary sprint project declares nothing: scanning the artifacts dir + must add no findings at all.""" + install_bmad_config(project) + _write_policy(project.project) + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}, commit=False) + write_spec(spec_path(project, "1-1-a"), "ready-for-dev", "abc123") + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + doc = json.loads(capsys.readouterr().out) + assert [f for f in doc["findings"] if f["check"].startswith("deferred.closes")] == [] OPENCODE_QUALIFIED_POLICY = '[adapter]\nname = "opencode"\nmodel = "anthropic/claude-haiku-4-5"\n' diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index ffa89906..41daaa8c 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -5,12 +5,15 @@ from bmad_loop.deferredwork import ( append_decision, append_entry, + classify, field_line_present, field_severity, has_legacy, mark_done, + mark_done_many, next_seq, open_ids, + parse_declaration, parse_ledger, parse_legacy, ) @@ -485,3 +488,97 @@ def test_field_line_present_matches_field_not_substring(): assert not field_line_present(body, "origin", "review-budget") # a value that only appears incidentally inside `reason:` is not a field line assert not field_line_present(body, "source_spec", "spec-foobar.md") + + +# ------------------------------ closes_deferred declaration primitives (#234) + + +def test_parse_declaration_normalizes_items_and_dedupes(): + """Lenient about items, exactly as the id parser is: an LLM-authored manifest + may emit an unquoted id as a string and a bare number as an int.""" + ids, error = parse_declaration(["DW-1", " DW-2 ", 5, "", "DW-1"]) + assert ids == ("DW-1", "DW-2", "5") # stripped, blanks dropped, order-preserving dedupe + assert error is None + + +def test_parse_declaration_defaults_empty_when_absent(): + assert parse_declaration(None) == ((), None) + assert parse_declaration([]) == ((), None) + + +def test_parse_declaration_rejects_a_non_list_container(): + """A string is iterable, so a lenient reading would silently turn one id into + a list of characters. Callers pick the severity; the reading is shared.""" + ids, error = parse_declaration("DW-1") + assert ids == () + assert error is not None and "must be a list" in error and "str" in error + + +def test_classify_partitions_open_done_unknown_and_malformed(): + """Four outcomes, not two: `not open` hides a satisfied declaration (a resume + re-driving a landed close, which must stay silent) and an entry whose status + line cannot be read at all (which must not).""" + text = ( + "# Deferred Work\n\n" + "### DW-1: a\nstatus: open\n\n" + "### DW-2: b\nstatus: done 2026-06-01\n\n" + "### DW-3: c\nstatus: in-progress\n\n" + "### DW-4: d\nreason: no status line at all\n" + ) + declared = classify(text, ["DW-1", "DW-2", "DW-3", "DW-4", "DW-99"]) + + assert declared.open_ids == ("DW-1",) + assert declared.already_done == ("DW-2",) + assert declared.unknown == ("DW-99",) + assert declared.malformed == ("DW-3", "DW-4") + + +def test_mark_done_many_writes_once_and_reports_what_landed(tmp_path): + p = tmp_path / "deferred-work.md" + p.write_text(LEDGER, encoding="utf-8") + + # DW-2 is already done in the fixture, DW-99 is absent — both skipped, neither + # an error: only the entries that were open get marked, in the order given. + marked = mark_done_many( + p, ["DW-1", "DW-3", "DW-2", "DW-99"], "2026-07-24", "resolved by story 1" + ) + + assert marked == ["DW-1", "DW-3"] + entries = {e.id: e for e in parse_ledger(p.read_text(encoding="utf-8"))} + for dw in ("DW-1", "DW-3"): + assert entries[dw].status == "done 2026-07-24" + assert "resolution: resolved by story 1" in entries[dw].body + + +def test_mark_done_many_is_all_or_nothing_on_a_write_failure(tmp_path, monkeypatch): + """A per-id read-modify-write loop leaves earlier marks on disk when it raises + partway through — a half-applied closure the caller never gets to journal, so + the ledger claims resolutions the run has no record of.""" + p = tmp_path / "deferred-work.md" + p.write_text(LEDGER, encoding="utf-8") + before = p.read_bytes() + monkeypatch.setattr( + "bmad_loop.deferredwork.atomic_replace", + lambda tmp, target: (_ for _ in ()).throw(OSError("disk full")), + ) + + try: + mark_done_many(p, ["DW-1", "DW-3"], "2026-07-24", "note") + except OSError: + pass + + assert p.read_bytes() == before # nothing partially applied + + +def test_mark_done_many_skips_an_already_done_entry(tmp_path): + """Idempotent for a resume that re-drives a close that already landed: no + second resolution line, and the id is not reported as newly marked.""" + p = tmp_path / "deferred-work.md" + p.write_text(LEDGER, encoding="utf-8") + mark_done_many(p, ["DW-1"], "2026-07-24", "resolved by story 1") + + again = mark_done_many(p, ["DW-1"], "2026-07-25", "resolved by story 1") + + assert again == [] + body = next(e for e in parse_ledger(p.read_text(encoding="utf-8")) if e.id == "DW-1").body + assert body.count("resolution: resolved by story 1") == 1 From e269b2c1915129e0a2888ff8c528f0f232b8f0d0 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 08:26:48 -0700 Subject: [PATCH 06/36] docs: correct the closes_deferred claims the review found overstated (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three claims did not hold: - "squashed into the story's own commit" is true only when the ledger lives inside the repo. An externally configured artifacts dir is shared between worktrees and `git add -A` can never stage it, so the docs now say where the annotation lands in each case, and that an isolated run waits for the merge. - "at clean story-close" described the old dev-sync placement. Closure is at the commit boundary; the docs now name what it sits behind (verification, review, checkpoints, pre-commit) and what that buys. - "makes this work unattended" oversold the stories.yaml channel. It avoids hand-editing each generated spec, but nothing upstream emits the field: it is human-authored at breakdown time exactly like spec_checkpoint / done_checkpoint, and re-deriving stories.yaml drops it unless the intent is logged in .memlog.md first. Filed upstream as BMAD-METHOD#2619 (schema row + the per-story Story Breakdown prompt). Also documents the two failure modes that used to be silent — an entry whose status reads as neither open nor done, and a non-list declaration — and the renamed validate checks (deferred.closes-unknown / deferred.closes-malformed), which now run in both queue modes. --- CHANGELOG.md | 44 +++++++++++++------ README.md | 12 +++-- docs/FEATURES.md | 2 +- .../bmad-loop-sweep/deferred-work-format.md | 24 +++++++--- 4 files changed, 59 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2903f5..64de09bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,20 +14,38 @@ breaking changes may land in a minor release. so a multi-epic run ended with entries satisfied epics ago still reading `open` and the retro reconstructing by hand which story closed what. A story spec can now declare the entries its work closes — `closes_deferred: [DW-5, DW-6]`, on its `stories.yaml` entry (stories mode) or in the - story spec's frontmatter, the two unioned — and at clean story-close the + story spec's frontmatter, the two unioned — and when the story commits the orchestrator writes the same annotation a sweep bundle writes: `status: done ` plus - `resolution: resolved by story `. The write happens before the commit, so it squashes into - the story's own commit, and it fires in both sprint and stories mode. Closure is declared, never - inferred from a diff; a story that fails, blocks, or stops short of its success status closes - nothing; an id already `done` is a silent no-op, so a resumed run re-driving the same close - neither doubles the `resolution:` line nor warns; and an id matching no ledger entry is journaled - (`deferred-close-unmatched`) rather than failing the story. Closes are journaled as - `story-deferred-closed`. The `stories.yaml` channel is what makes this work unattended: - `bmad-dev-auto` generates the story spec and knows nothing of the ledger, whereas the Story - Breakdown is authored while the ledger is in view. `bmad-loop validate` adds a matching - pre-flight warning - (`stories.closes-deferred-unknown`) naming any declared id absent from the ledger — a typo or a - renumbered entry — before the run starts; it is a warning, so it never changes the exit code. + `resolution: resolved by story `. It fires in both sprint and stories mode. + + The write sits at the **commit boundary** — after artifact verification, the verify commands, + every checkpoint, the review loop and the `pre_commit` workflows, and immediately before + `finalize_commit` squashes the story — so a story that fails verification, is rejected by + review, or escalates closes nothing, and an in-repo ledger still carries the annotation in the + story's own commit. An artifact dir configured _outside_ the repo is shared between worktrees + and cannot be committed at all; an isolated run holds its closure until the story's branch has + merged (journaled `deferred-close-external-ledger`), so a unit whose integration fails never + claims its work resolved. + + Closure is declared, never inferred from a diff. An id already `done` is a silent no-op, so a + resumed run re-driving the same close neither doubles the `resolution:` line nor warns. Nothing + here can fail a story: an id matching no ledger entry (`deferred-close-unmatched`), a ledger + entry whose `status:` reads as neither `open` nor `done` (`deferred-close-malformed`), and a + declaration that is not a list — a bare `closes_deferred: DW-5` — are journaled and dropped. + Closes are journaled as `story-deferred-closed`. + + The `stories.yaml` channel is the one that avoids hand-editing each generated spec: + `bmad-dev-auto` writes the story spec and knows nothing of the ledger, whereas the Story + Breakdown is authored while the ledger is in view. Like `spec_checkpoint` / `done_checkpoint` + it is human-authored — no upstream skill emits it yet (BMAD-METHOD#2619 proposes the schema row + and the breakdown prompt), and re-deriving `stories.yaml` drops it unless the intent is logged + in `.memlog.md`. + + `bmad-loop validate` adds matching pre-flight warnings in **both** queue modes — + `deferred.closes-unknown` for an id absent from the ledger (a typo or a renumbered entry) and + `deferred.closes-malformed` for an unreadable declaration. Stories mode reads the manifest plus + each id-resolved spec; sprint mode reads the story specs already on disk in the artifacts dir. + Both are warnings, so neither changes the exit code. - **`review.on_timeout` policy knob (#271).** A timeout-like review verdict (`timeout` / `stalled` / `over_budget`) previously always burned a review cycle (RETRY) until diff --git a/README.md b/README.md index eabaf580..a98639a2 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,9 @@ An entry may also carry **`closes_deferred`** — the `DW-` ledger ids that s closes_deferred: [DW-5, DW-6] ``` -Declaring it here rather than in the story spec is what makes the annotation work unattended: `bmad-dev-auto` generates the spec and knows nothing of the ledger, whereas the breakdown is authored while the ledger is in view. A spec that _does_ carry the field in frontmatter is honored too — the two are unioned. +Declaring it here rather than in the story spec is what lets the annotation happen without touching each generated spec: `bmad-dev-auto` generates the spec and knows nothing of the ledger, whereas the breakdown is authored while the ledger is in view. A spec that _does_ carry the field in frontmatter is honored too — the two are unioned. + +Like `spec_checkpoint` and `done_checkpoint`, this is a **human-authored, caller-only field**: you add it at breakdown time with the ledger open. Upstream Story Breakdown does not emit it yet ([BMAD-METHOD#2619](https://github.com/bmad-code-org/BMAD-METHOD/issues/2619)), and re-deriving `stories.yaml` rewrites the file — so record the intent in `.memlog.md` alongside the story, or the declaration is lost on the next re-derive. A story may set both checkpoints (it pauses twice); `gates.mode` pauses stack on top. A blocked story escalates exactly as in sprint mode — `bmad-loop resolve`, then re-arm + resume — now with the story's title/description and the blocking condition surfaced; a pre-planning-halt **sentinel** spec is auto-deleted (a copy preserved under the run dir) on re-arm for a clean re-dispatch. @@ -252,9 +254,13 @@ bmad-loop sweep [--no-prompt] [--decisions-only] [--max-bundles N] [--repeat] [- closes_deferred: [DW-5, DW-6] # DW- ids this story closes ``` -In stories mode the same declaration can live on the `stories.yaml` entry instead (`closes_deferred: [DW-5, DW-6]`), which is where it belongs for an unattended run — the breakdown is authored while the ledger is in view, whereas the story spec is generated later by a dev skill that knows nothing of the ledger. Both channels are unioned. +In stories mode the same declaration can live on the `stories.yaml` entry instead (`closes_deferred: [DW-5, DW-6]`), which is where it belongs for an unattended run — the breakdown is authored while the ledger is in view, whereas the story spec is generated later by a dev skill that knows nothing of the ledger. Both channels are unioned. Either way the field is human-authored — like `spec_checkpoint` / `done_checkpoint`, no upstream skill emits it yet, and re-deriving `stories.yaml` drops it unless the intent is logged in `.memlog.md`. + +The orchestrator writes the same annotation a bundle close writes — `status: done ` and `resolution: resolved by story ` — into each declared entry. Without it the ledger is one-way: entries are filed automatically but only ever marked resolved by hand, so a multi-epic run ends with entries that were satisfied epics ago still reading `open`. + +**Closure happens at the commit boundary**, after artifact verification, the verify commands, every checkpoint, the review loop and the pre-commit workflows — and just before the story's commit is squashed, so an in-repo ledger carries the annotation in that same commit. A story that fails, blocks, is rejected by review, or escalates closes nothing, and there is nothing to undo. Closure is declared, never inferred from the diff; a resume re-driving the same close is a no-op; and an id that matches no entry — or a declaration in a shape nothing can read, such as a bare `closes_deferred: DW-5` — is journaled rather than failing the story. `bmad-loop validate` surfaces both as warnings before the run starts, in sprint and stories mode alike. -At clean story-close the orchestrator writes the same annotation a bundle close writes — `status: done ` and `resolution: resolved by story ` — into each declared entry, squashed into the story's own commit. Without it the ledger is one-way: entries are filed automatically but only ever marked resolved by hand, so a multi-epic run ends with entries that were satisfied epics ago still reading `open`. Closure is declared, never inferred from the diff; a story that fails or blocks closes nothing; a resume re-driving the same close is a no-op; and an id that matches no entry is journaled rather than failing the story — `bmad-loop validate` surfaces those as a warning before the run starts. +> **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. Closure still happens, but for an isolated (`scm.isolation = "worktree"`) run it is held until the unit's branch has merged — a story whose integration fails leaves its entries `open`. The run journals `deferred-close-external-ledger` so the annotation's absence from git history is not a surprise. **Answering missed decisions later.** An unattended sweep (`--no-prompt`) skips decisions, and an interactive one can be abandoned before you answer them all — those answers would otherwise be lost, since triage re-derives the decision set from the ledger every run. `bmad-loop decisions` (or press `d` in the TUI) surfaces every decision past sweeps left unanswered, reconstructed from their triage output, and lets you answer them out of band. A `close` is applied immediately; a `build`/`keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep (build → bundle, keep-open → recorded) with no re-prompt. `--list` shows them without answering; `bmad-loop status` reports the outstanding count. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c513a8b7..1e307299 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -104,7 +104,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Deferred-work sweeps - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. -- Story-declared closure (`closes_deferred: [DW-5, DW-6]` on a `stories.yaml` entry, or in a story spec's frontmatter — the two are unioned): at clean story-close the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Declared, never inferred from a diff; skipped entirely when the story fails or blocks; idempotent across a resume; and an id matching no entry is journaled, never fatal. `bmad-loop validate` warns about such ids before the run starts. +- Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Written at the commit boundary, after verification/review/checkpoints and just before the squash, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; idempotent across a resume; an unknown id, an unreadable entry status, or a non-list declaration is journaled, never fatal. `bmad-loop validate` warns about all of those before the run starts, in both queue modes. An artifact dir configured outside the repo is shared across worktrees and cannot be committed — an isolated run holds its closure until the story's branch merges. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 3b619a2f..404c9a5f 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -70,7 +70,11 @@ or in its story spec's frontmatter. The two are unioned: closes_deferred: [DW-5, DW-6] # DW- ids this story closes ``` -At clean story-close the orchestrator annotates each declared id exactly as a +Both are written by a human, at breakdown time, with this file open — no +upstream skill emits the field yet, and re-deriving `stories.yaml` will drop it +unless the intent is recorded in `.memlog.md` first. + +When the story commits, the orchestrator annotates each declared id exactly as a bundle close does — `status: done ` plus a `resolution:` line naming the story: @@ -83,13 +87,21 @@ The rules that keep this safe: - **Declared, never inferred.** Closure comes only from this field; the orchestrator does not guess it from a diff. -- **Only on a clean close.** A story that fails, blocks, or ends short of its - success status closes nothing. +- **Only once the story actually lands.** The annotation is written at the + commit boundary — after verification, the review loop and every checkpoint, + and just before the story's commit is squashed. A story that fails, blocks, is + rejected by review, or escalates closes nothing. +- **In the story's own commit**, when this file lives inside the repo. If the + artifacts dir is configured outside it, the file is shared between worktrees + and no commit can carry it; an isolated run then waits until the story's + branch has merged before annotating. - **Idempotent.** An id already `done` is left untouched, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns. -- **Never a gate.** An id that matches no entry is journaled and dropped — - it cannot fail the story. `bmad-loop validate` reports the same mismatch as a - warning before the run starts. +- **Never a gate.** An id that matches no entry, an entry whose `status:` reads + as neither `open` nor `done`, and a declaration that is not a list (a bare + `closes_deferred: DW-5`) are each journaled and dropped — none can fail the + story. `bmad-loop validate` reports the same mismatches as warnings before the + run starts. Keep the ids stable when editing this file: a reworded title is fine, but renumbering an entry orphans any declaration that already references it. From 2c81215aa642d3fcfdcb0519ab464d5cfad7cb67 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 10:30:41 -0700 Subject: [PATCH 07/36] fix(deferredwork): preserve the ledger's mode and symlink across the write (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mark_done_many`'s tmp-write-and-replace swaps a fresh inode over the ledger, so it silently reset everything carried by the old file rather than by its name: a 0600 ledger came back 0644, extended attributes were dropped, and a symlinked ledger was replaced by a regular file, orphaning the real one. The `mark_done` this replaced wrote in place and had none of these effects. New `platform_util.atomic_write_text` beside `atomic_replace`: resolve the target first, stage through a uniquely-named `mkstemp` sibling (no fixed `.tmp` for a concurrent writer of the same file to collide with), copy the mode and — best effort, Linux — the xattrs before replacing, and clean the temp up on any failure. Ownership cannot be preserved unprivileged; the docstring says so rather than implying otherwise. --- src/bmad_loop/deferredwork.py | 13 +++-- src/bmad_loop/platform_util.py | 73 ++++++++++++++++++++++++++-- tests/test_deferredwork.py | 4 +- tests/test_platform_util.py | 88 ++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index e273e88d..a3d17592 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from pathlib import Path -from .platform_util import atomic_replace +from .platform_util import atomic_write_text HEADING_RE = re.compile(r"^### (DW-\d+): (.+?)\s*$", re.MULTILINE) ANY_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE) @@ -182,7 +182,12 @@ def mark_done_many(path: Path, dw_ids: Sequence[str], date: str, note: str) -> l disk when it raises partway through several ids — a half-applied closure the caller never gets to journal, so the ledger claims resolutions the run has no record of. Here a failure writes nothing, and the returned list is exactly - what landed.""" + what landed. + + The write goes through :func:`~bmad_loop.platform_util.atomic_write_text` + rather than a bare tmp+replace: swapping a fresh inode over the ledger + otherwise resets its mode (a ``0600`` ledger silently becoming world-readable) + and turns a symlinked ledger into a regular file.""" if not path.is_file(): return [] text = path.read_text(encoding="utf-8") @@ -195,9 +200,7 @@ def mark_done_many(path: Path, dw_ids: Sequence[str], date: str, note: str) -> l marked.append(dw_id) if not marked: return [] - tmp = path.with_name(path.name + ".tmp") - tmp.write_text(text, encoding="utf-8") - atomic_replace(tmp, path) + atomic_write_text(path, text) return marked diff --git a/src/bmad_loop/platform_util.py b/src/bmad_loop/platform_util.py index 36ae4a5b..28dc2224 100644 --- a/src/bmad_loop/platform_util.py +++ b/src/bmad_loop/platform_util.py @@ -6,8 +6,9 @@ configuration, not a process-lifecycle primitive, so it does not belong on the host. On Linux/macOS — and WSL, which *is* Linux — these preserve today's exact behavior. The file-replace and segment helpers below (``atomic_replace``, -``safe_segment``, ``safe_ref_segment``) are exercised by the platform tests; the pid -kill/liveness Windows branch degrades gracefully and is not yet exercised. +``atomic_write_text``, ``safe_segment``, ``safe_ref_segment``) are exercised by the +platform tests; the pid kill/liveness Windows branch degrades gracefully and is not +yet exercised. ``safe_segment`` and ``safe_ref_segment`` share a contract but not a rule set: the first coerces a Windows *filename* segment, the second a *git ref* component, and @@ -22,10 +23,12 @@ import os import random import re +import shutil import subprocess import sys +import tempfile import time -from contextlib import contextmanager +from contextlib import contextmanager, suppress from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Callable, Iterator @@ -143,6 +146,70 @@ def atomic_replace(tmp: Path, target: Path) -> None: _retry_on_sharing_violation(lambda: os.replace(tmp, target)) +def _copy_xattrs(src: Path, dst: Path) -> None: + """Best-effort extended-attribute copy (Linux). Absent everywhere else, and + unsupported by many filesystems even there, so every failure is ignored: an + xattr we could not carry over is not worth failing a ledger write for.""" + listxattr = getattr(os, "listxattr", None) + if listxattr is None: # portability: xattr syscalls are Linux-only + return + try: + names = listxattr(src) + except OSError: + return + for name in names: + try: + os.setxattr(dst, name, os.getxattr(src, name)) + except OSError: + continue + + +def atomic_write_text(path: Path, text: str) -> None: + """Replace ``path``'s contents with ``text`` atomically, preserving what the + replacement would otherwise silently discard. + + ``os.replace`` swaps a *new inode* into place, so a naive tmp-write-and-replace + quietly resets everything carried by the old file rather than by its name. This + restores the parts that matter: + + * **Symlinks are followed.** ``path.resolve()`` first, so a ledger symlinked + into the repo keeps being a symlink and the real file is what gets rewritten + — a replace against the link itself would turn it into a regular file and + orphan the target. + * **Permission bits survive.** A ``0600`` file stays ``0600`` instead of + becoming ``0644 & ~umask``, which on a shared artifact dir is the difference + between "the group can still write this" and a silent lockout (or a + disclosure). + * **Extended attributes survive** where the platform has them (best effort). + + Ownership is NOT preserved — an unprivileged process cannot chown — so a file + written by another user changes hands. Callers writing genuinely shared, + multi-user state need more than this helper. A target that does not exist yet + is created with ``mkstemp``'s private ``0600``, not the umask default: there is + no prior mode to carry over, and the restrictive choice is the safe one. + + The temp file is uniquely named in the target's own directory: same filesystem + (``os.replace`` cannot cross one), and no fixed ``.tmp`` sibling for a + concurrent writer of the same file to collide with. A failure anywhere leaves + the original untouched and removes the temp.""" + target = path.resolve() + fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=target.name + ".", suffix=".tmp") + tmp = Path(tmp_name) + try: + # newline default (translating) deliberately: matches the Path.write_text + # this replaced, so a ledger's line endings do not change under Windows. + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + if target.exists(): + shutil.copymode(target, tmp) + _copy_xattrs(target, tmp) + atomic_replace(tmp, target) + except BaseException: + with suppress(OSError): + tmp.unlink() + raise + + def retrying_unlink(path: Path) -> None: """``path.unlink()`` with the same win32 retry as :func:`atomic_replace`. diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 41daaa8c..8fbb9cd6 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -558,8 +558,8 @@ def test_mark_done_many_is_all_or_nothing_on_a_write_failure(tmp_path, monkeypat p.write_text(LEDGER, encoding="utf-8") before = p.read_bytes() monkeypatch.setattr( - "bmad_loop.deferredwork.atomic_replace", - lambda tmp, target: (_ for _ in ()).throw(OSError("disk full")), + "bmad_loop.deferredwork.atomic_write_text", + lambda path, text: (_ for _ in ()).throw(OSError("disk full")), ) try: diff --git a/tests/test_platform_util.py b/tests/test_platform_util.py index fbacc61a..a4fd3ba1 100644 --- a/tests/test_platform_util.py +++ b/tests/test_platform_util.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import stat import subprocess import sys @@ -131,6 +132,93 @@ def denied(src, dst): assert sleeps == [] # zero backoff — a real POSIX error surfaces at once +# ------------------------------------------------------------- atomic_write_text + + +def test_atomic_write_text_replaces_contents(tmp_path): + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + + platform_util.atomic_write_text(target, "after") + + assert target.read_text(encoding="utf-8") == "after" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX mode bits") +def test_atomic_write_text_preserves_the_target_mode(tmp_path): + """`os.replace` swaps in a NEW inode, so a naive tmp-write-and-replace resets + the file's permissions to the umask default — silently widening a 0600 ledger + to world-readable, or dropping group-write on a shared artifact dir.""" + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + target.chmod(0o600) + + platform_util.atomic_write_text(target, "after") + + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_atomic_write_text_writes_through_a_symlink(tmp_path): + """Replacing the LINK would turn it into a regular file and orphan the real + ledger, so the link is resolved first and the target is what gets rewritten.""" + real = tmp_path / "real-ledger.md" + real.write_text("before", encoding="utf-8") + link = tmp_path / "ledger.md" + link.symlink_to(real) + + platform_util.atomic_write_text(link, "after") + + assert link.is_symlink() + assert real.read_text(encoding="utf-8") == "after" + + +def test_atomic_write_text_leaves_no_temp_behind(tmp_path): + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + + platform_util.atomic_write_text(target, "after") + + assert [p.name for p in tmp_path.iterdir()] == ["ledger.md"] + + +def test_atomic_write_text_cleans_up_and_keeps_the_original_on_failure(tmp_path, monkeypatch): + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + + def boom(src, dst): + raise OSError("disk full") + + monkeypatch.setattr(platform_util.os, "replace", boom) + + with pytest.raises(OSError): + platform_util.atomic_write_text(target, "after") + + assert target.read_text(encoding="utf-8") == "before" + assert [p.name for p in tmp_path.iterdir()] == ["ledger.md"] # no orphaned temp + + +def test_atomic_write_text_temp_name_is_unique_per_call(tmp_path, monkeypatch): + """A fixed `.tmp` sibling is a collision between two writers of the same + file — the second clobbers the first's staged content and one replace lands + a half-written mix.""" + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + seen: list[str] = [] + real_replace = os.replace + + def record(src, dst): + seen.append(os.path.basename(src)) + real_replace(src, dst) + + monkeypatch.setattr(platform_util.os, "replace", record) + + platform_util.atomic_write_text(target, "a") + platform_util.atomic_write_text(target, "b") + + assert len(set(seen)) == 2 + + # --------------------------------------------------------------- retrying_unlink From 27c8142c47c3624b2d5ac1196863a7afb365c955 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 10:30:54 -0700 Subject: [PATCH 08/36] fix(engine): close deferred work only for a story that durably landed (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ways the bookkeeping still outlived, was lost by, or lost track of the story it belonged to. **A failed commit left the entry claiming done.** The close is written just before `finalize_commit` so an in-repo annotation rides the story's own commit — but that commit can still fail (a rejecting native pre-commit hook) and `_escalate` unwinds nothing. Worse, the usual recovery cements it: a resolved re-drive preserves the artifact folders' tracked content through `safe_reset`, so the false close survives the reset that would otherwise have reverted it. `_close_declared_deferred` now returns a snapshot and `_restore_deferred_closes` puts the ledger back before the escalation, on the record as `deferred-close-rolled-back`. Only the working tree is restored: every path that commits again starts with another `add -A`. **An out-of-repo ledger was written before the commit in place.** It can never ride a commit, so writing it early bought nothing and risked exactly the false claim above. Both modes now park the ids; in place they flush once `finalize_commit` returns (before the DONE advance, so a crash in the window re-drives the idempotent commit phase), under isolation at the merge as before. **A crash after the merge dropped the obligation.** `_flush_pending_deferred_closes` cleared `pending_deferred_closes` before applying the write, so a raising write unwound to the crash handler whose `finally: self._save()` persisted the emptied list. Clear after the write instead. And because the flush runs when the task is already DONE, a crash between `merge_local` and it left a terminal task holding an unsatisfied obligation that `_finish_inflight` skips: `StoryTask.unit_merged` is now recorded and persisted before that bookkeeping, and a resume reconcile pass retries the closure when it is set — or journals `deferred-close-abandoned` when the unit never merged, since `open` is then the truthful reading. **A transient spec read at the commit boundary silently dropped a declaration.** `_observed_frontmatter` degrades an unreadable spec to None, which there was indistinguishable from a spec declaring nothing — the story committed with its declared entry still open. The declaration is captured onto `StoryTask.declared_deferred` when the dev artifacts verify (the last point the spec is known good) and persisted, so the commit boundary needs no spec read at all and the COMMITTING resume arm, which never re-verifies, gets it too. `None` (never captured) is kept distinct from `[]` (captured, declares nothing) so only the former re-reads; the remaining fallback read journals `deferred-close-declaration-unreadable` rather than reading as silence. Tests drive `engine.run()` end to end: a native pre-commit hook rejecting the commit, the re-armed re-drive that follows it, an external ledger in place, a failing external write asserted against the *persisted* state, a crash after the merge resumed into the reconcile, and every spec read faulted from the commit phase onward. --- src/bmad_loop/engine.py | 231 ++++++++++++++++++++++++++------- src/bmad_loop/model.py | 39 +++++- src/bmad_loop/sweep.py | 4 +- src/bmad_loop/worktree_flow.py | 9 ++ tests/test_engine.py | 163 +++++++++++++++++++++++ tests/test_engine_worktree.py | 76 +++++++++++ 6 files changed, 470 insertions(+), 52 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 09a3c6b5..4a8f296f 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -43,7 +43,7 @@ SessionRecord, StoryTask, ) -from .platform_util import atomic_replace, retrying_unlink, safe_segment +from .platform_util import atomic_replace, atomic_write_text, retrying_unlink, safe_segment from .plugins import HookBus, HookContext, PluginRegistry from .policy import Policy from .recovery_flow import RecoveryFlow @@ -776,8 +776,34 @@ def _pause_for_manual_recovery( task, baseline, preserve_failed=preserve_failed ) + def _reconcile_pending_deferred_closes(self) -> None: + """Finish external-ledger closures a previous process left parked (#234). + + These sit outside the phase machine the rest of `_finish_inflight` walks: + the write happens after the task is already DONE (after `merge_local` + under isolation), so a crash in that window leaves a terminal task holding + an unsatisfied obligation — and terminal tasks are exactly what that loop + skips. `unit_merged` is the durable proof the work landed; without it the + unit never reached the target branch, and the ledger reading `open` is the + truthful answer, so the obligation is reported rather than discharged.""" + for task in list(self.state.tasks.values()): + if not task.pending_deferred_closes: + continue + if task.phase != Phase.DONE: + continue # still re-drivable; the commit phase applies it + if task.unit_merged or not self._isolated: + self._flush_pending_deferred_closes(task) + else: + self.journal.append( + "deferred-close-abandoned", + story_key=task.story_key, + dw_ids=list(task.pending_deferred_closes), + reason="story is done but its unit never merged; entries stay open", + ) + def _finish_inflight(self) -> None: """Complete or roll back tasks interrupted by a pause or crash.""" + self._reconcile_pending_deferred_closes() for task in list(self.state.tasks.values()): if task.terminal: continue @@ -1217,6 +1243,10 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None else: task.followup_review_recommended = self._followup_from_spec(task, rj) outcome = self._verify_dev_artifacts(task, result.result_json) + if outcome.ok: + # the spec is verified and on disk: the one moment its + # `closes_deferred:` declaration is known readable (#234). + self._capture_declared_deferred(task) if outcome.ok and self._run_verify_commands_after_dev(task, result.result_json): # deterministic gates run here too: a broken build must not # reach the (far more expensive) review loop @@ -1724,8 +1754,9 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: # verify gate, checkpoint, review cycle and pre-commit workflow is behind # us, and a pre_commit pause veto has already raised out of _escalate # above — but finalize_commit's `git add -A` is still ahead, so an in-repo - # annotation lands in this story's own commit. - self._close_declared_deferred(task) + # annotation lands in this story's own commit. It is not final until that + # commit is: the snapshot below unwinds it if the commit fails. + rollback = self._close_declared_deferred(task) try: # bmad-dev-auto commits its own work each iteration; the orchestrator # squashes that chain plus its uncommitted bookkeeping back onto the @@ -1740,7 +1771,16 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: task.resolved_redrive = False task.restore_patch = None except verify.GitError as e: + self._restore_deferred_closes(task, rollback) self._escalate(task, f"commit failed: {e}") + if not self._isolated: + # An out-of-repo ledger could not ride the commit, so it was parked; + # the commit has now landed, which in place is the whole of "durably + # landed" (there is no integration step). Deliberately BEFORE the DONE + # advance: a crash in this window leaves the task COMMITTING, which + # the resume arm re-drives — and both finalize_commit and the close + # are idempotent. After DONE it would be terminal, and unreachable. + self._flush_pending_deferred_closes(task) advance(task, Phase.DONE) self.journal.append("story-done", story_key=task.story_key, commit=task.commit_sha) self._emit("post_commit", task) @@ -2070,44 +2110,80 @@ def _manifest_closes_deferred(self, task: StoryTask) -> tuple[str, ...]: generated later by a dev skill that knows nothing of the ledger.""" return () + def _capture_declared_deferred(self, task: StoryTask) -> bool: + """Latch the spec's ``closes_deferred:`` declaration onto the task, at the + moment the dev artifacts verified (#234). + + Capturing here rather than reading at the commit boundary is the whole + point: ``_observed_frontmatter`` degrades an unreadable spec to None, and + at the close site that was indistinguishable from a spec declaring + nothing — so a transient read fault let the story commit with its declared + entry still open, leaving a journal line as the only trace. It also hands + the declaration to the COMMITTING resume arm, which finishes a commit + WITHOUT re-verifying and so has no other way to learn it. + + The path is a verified one — ``task.spec_file`` is recorded only by a + passing ``verify_dev``/``verify_dev_stories`` gate, and stories mode + resolves it by id rather than trusting the session's claim. Sprint mode's + path still came from the session, so it is held to the same + root-containment rule the frontmatter-status reconcile applies: a + surprising absolute path must not be able to steer a ledger write. + + A wrong-container declaration (``closes_deferred: DW-5``) is journaled + rather than silently read as empty: it names real intent that would + otherwise close nothing and say nothing. A failed read leaves any earlier + capture standing — a later attempt's silence is not a retraction. + + False only when there IS a spec to read and reading it failed; the caller + decides whether that costs anything. A missing or out-of-tree spec is + nothing to capture, not a failure to capture.""" + spec_path = Path(task.spec_file) if task.spec_file else None + if spec_path is None or not spec_path.is_file(): + return True + if not verify.spec_within_roots(spec_path, self.workspace.paths): + self.journal.append( + "deferred-close-skipped-out-of-tree", + story_key=task.story_key, + spec=str(spec_path), + ) + return True + fm = self._observed_frontmatter(spec_path, task.story_key, "deferred-close") + if fm is None: + return False + declared, error = deferredwork.parse_declaration(fm.get("closes_deferred")) + if error: + self.journal.append( + "deferred-close-malformed", + story_key=task.story_key, + spec=str(spec_path), + error=f"closes_deferred {error}", + ) + task.declared_deferred = list(declared) + return True + def _declared_deferred_ids(self, task: StoryTask) -> tuple[str, ...]: """The ids this story declares it closes, unioned across both channels and order-preserving (a story that names the same id in the manifest and in its spec must be marked once and reported once). - The spec read is a *verified* one: ``task.spec_file`` is recorded only by - a passing ``verify_dev``/``verify_dev_stories`` gate, and stories mode - resolves it deterministically by id rather than trusting the session's - claim. Sprint mode's path still came from the session, so it is held to - the same root-containment rule the frontmatter-status reconcile applies — - a surprising absolute path must not be able to steer a ledger write. - - A wrong-container declaration (``closes_deferred: DW-5``) is journaled - rather than silently read as empty: it names real intent that would - otherwise close nothing and say nothing (#234).""" + The spec half comes from the verify-time capture. The fallback read is for + a task that reached the commit boundary without one — a run resumed from a + DEV_VERIFY pause that never re-verified, or a state file written before + the capture existed — and it is the one place a failed read can still cost + a closure, so it says so (``deferred-close-declaration-unreadable``) + instead of reading as "declares nothing".""" ids: list[str] = list(self._manifest_closes_deferred(task)) - spec_path = Path(task.spec_file) if task.spec_file else None - if spec_path is not None and spec_path.is_file(): - if not verify.spec_within_roots(spec_path, self.workspace.paths): + if task.declared_deferred is None and task.spec_file: + if not self._capture_declared_deferred(task): self.journal.append( - "deferred-close-skipped-out-of-tree", + "deferred-close-declaration-unreadable", story_key=task.story_key, - spec=str(spec_path), + spec=task.spec_file, ) - else: - fm = self._observed_frontmatter(spec_path, task.story_key, "deferred-close") - declared, error = deferredwork.parse_declaration((fm or {}).get("closes_deferred")) - if error: - self.journal.append( - "deferred-close-malformed", - story_key=task.story_key, - spec=str(spec_path), - error=f"closes_deferred {error}", - ) - ids += declared + ids += task.declared_deferred or [] return tuple(dict.fromkeys(ids)) - def _close_declared_deferred(self, task: StoryTask) -> None: + def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str] | None: """At the commit boundary, flip every ledger entry the story declares via ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note (#234) — the regular-story counterpart of the sweep bundle close at @@ -2127,13 +2203,24 @@ def _close_declared_deferred(self, task: StoryTask) -> None: mutation and restores it over the rollback, and ``_escalate`` rolls back nothing at all. - Where the write lands depends on where the ledger lives. Inside the repo - it rides the commit. Configured *outside* it (``ProjectPaths.rebased`` - deliberately shares an external artifact dir between worktrees, and - ``add -A`` can never stage it) an isolated story stashes its ids for - ``_flush_pending_deferred_closes`` to apply once the branch has merged — - otherwise a unit whose integration fails would still have edited the - shared ledger. In-place there is no integration step, so it writes here. + Where the write lands depends on where the ledger lives. + + **Inside the repo** it is written here so it rides the story's own commit + — and the caller must hand the returned snapshot back to + ``_restore_deferred_closes`` if that commit then fails, because + ``_escalate`` rolls nothing back and the annotation would otherwise stand + in the operator's checkout claiming work that was never committed. + + **Outside the repo** (``ProjectPaths.rebased`` deliberately shares an + external artifact dir between worktrees, and ``add -A`` can never stage + it) nothing is written here at all: no pre-commit write can ride a commit + that cannot carry the file, so the ids are stashed on the task and applied + only once the work is durably landed — after ``finalize_commit`` in place, + after ``merge_local`` under isolation (see + ``_flush_pending_deferred_closes``). + + Returns the pre-close ledger text (with its path) when an in-repo write + actually marked something, else None. Never a gate: an unmatched or malformed id is journaled, never fatal. Idempotent, so the resume arm may re-drive the commit phase freely — ids @@ -2142,9 +2229,9 @@ def _close_declared_deferred(self, task: StoryTask) -> None: that landed — must stay silent) with "absent" (a typo — worth saying).""" ids = self._declared_deferred_ids(task) if not ids: - return + return None ledger = self.workspace.paths.deferred_work - if self._isolated and not ledger.is_relative_to(self.workspace.root): + if not ledger.is_relative_to(self.workspace.root): task.pending_deferred_closes = list(ids) self.journal.append( "deferred-close-pending-integration", @@ -2152,11 +2239,55 @@ def _close_declared_deferred(self, task: StoryTask) -> None: dw_ids=list(ids), ledger=str(ledger), ) + return None + before = ledger.read_text(encoding="utf-8") if ledger.is_file() else None + marked = self._apply_deferred_closes(task, ids, ledger) + return (ledger, before) if marked and before is not None else None + + def _restore_deferred_closes(self, task: StoryTask, snapshot: tuple[Path, str] | None) -> None: + """Put the ledger back the way ``_close_declared_deferred`` found it, after + the commit those closures were written for failed (#234). + + The close is written *before* ``finalize_commit`` precisely so an in-repo + annotation lands in the story's own commit — but a commit can still fail + (a rejecting native pre-commit hook, a full disk), and ``_escalate`` then + raises without unwinding anything. Left alone the entry reads ``done`` for + work that is not in any commit, and the most likely recovery makes it + permanent: a human-resolved re-drive sets ``resolved_redrive``, which has + ``safe_reset`` preserve the artifact folders' tracked content through the + rollback, so the false close survives the reset that would otherwise have + reverted it. + + Only the working tree is restored. The failed ``finalize_commit`` leaves + its ``add -A`` staged, but every path that commits again starts with + another ``add -A`` (restaging from the tree) and every rollback path goes + through ``safe_reset``, so the index is not authoritative here.""" + if snapshot is None: return - self._apply_deferred_closes(task, ids, ledger) + ledger, before = snapshot + try: + atomic_write_text(ledger, before) + except OSError as e: + # bookkeeping repair must not mask the commit failure being escalated + self.journal.append( + "deferred-close-rollback-failed", + story_key=task.story_key, + ledger=str(ledger), + error=str(e), + ) + return + self.journal.append( + "deferred-close-rolled-back", + story_key=task.story_key, + dw_ids=list(self._declared_deferred_ids(task)), + ledger=str(ledger), + ) - def _apply_deferred_closes(self, task: StoryTask, ids: Sequence[str], ledger: Path) -> None: - """Write the closure for `ids` and journal exactly what landed.""" + def _apply_deferred_closes( + self, task: StoryTask, ids: Sequence[str], ledger: Path + ) -> list[str]: + """Write the closure for `ids`, journal exactly what landed, and return + the ids actually flipped.""" text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" declared = deferredwork.classify(text, ids) marked = deferredwork.mark_done_many( @@ -2178,16 +2309,23 @@ def _apply_deferred_closes(self, task: StoryTask, ids: Sequence[str], ledger: Pa dw_ids=list(declared.malformed), error="ledger entry status is neither open nor done", ) + return marked def _flush_pending_deferred_closes(self, task: StoryTask) -> None: """Apply closures held back for an out-of-repo ledger, once the story's - unit branch has merged. Called from the integration chokepoint, so a - story whose merge escalates never reaches it and the shared ledger keeps - reading ``open``. No-op in every in-repo configuration.""" + work is durably landed: after ``finalize_commit`` in place, from the + integration chokepoint under isolation. A story whose commit escalates or + whose merge fails never reaches either call, and the shared ledger keeps + reading ``open``. No-op in every in-repo configuration. + + **Clear only after the write succeeds.** Clearing first looks harmless — + the flush is about to happen — but a raising ledger write then unwinds to + ``run()``'s crash handler, whose ``finally: self._save()`` persists the + emptied list: the obligation is destroyed by the very failure that made it + worth retrying.""" ids = tuple(task.pending_deferred_closes) if not ids: return - task.pending_deferred_closes = [] ledger = self.workspace.paths.deferred_work self.journal.append( "deferred-close-external-ledger", @@ -2197,6 +2335,7 @@ def _flush_pending_deferred_closes(self, task: StoryTask) -> None: note="ledger is outside the repo; the annotation is not part of any commit", ) self._apply_deferred_closes(task, ids, ledger) + task.pending_deferred_closes = [] self._save() def _extra_session_env( diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 51b6e989..d9668157 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -220,15 +220,38 @@ class StoryTask: # rendered intent file handed to dev sessions dw_ids: list[str] = field(default_factory=list) bundle_file: str | None = None + # the normalized `closes_deferred:` declaration read from this story's spec + # frontmatter, captured when the dev artifacts VERIFIED (the last point the + # spec is known good) rather than re-read at the commit boundary. A transient + # read failure there used to be indistinguishable from "declares nothing", so + # the story committed with the entry still open and only a journal line to + # show for it; and the COMMITTING resume arm never re-verifies, so it had no + # other way to learn the declaration. The stories.yaml manifest channel is + # read from memory and needs no capture. Survives the round-trip. + # + # None means "never captured" and is NOT the same as `[]` ("captured; the spec + # declares nothing"): only the first may re-read the spec at the commit + # boundary, and conflating them re-runs — and re-journals — the read for every + # story that declares nothing. + declared_deferred: list[str] | None = None # deferred-work ids this story declared via `closes_deferred:` whose ledger # annotation could not ride the story's own commit, because the ledger is # configured OUTSIDE the repo (an external artifact dir is shared between # worktrees, so `rebased` deliberately leaves it in place and `git add -A` - # can never stage it). Stashed at commit and applied only once the unit's - # branch has merged, so an isolated story whose integration fails never - # claims work resolved. Empty in every in-repo configuration. Survives the - # resume serialization round-trip. + # can never stage it). Stashed at the commit boundary and applied only once + # the work is durably landed — after `finalize_commit` in place, after the + # unit's branch has merged under isolation — so a story whose commit or + # integration fails never claims work resolved. Empty in every in-repo + # configuration. Survives the resume serialization round-trip. pending_deferred_closes: list[str] = field(default_factory=list) + # worktree-isolation mode only: set once `merge_local` has put this unit's + # branch on the target branch, and PERSISTED before the integration + # bookkeeping that follows it runs. That bookkeeping (the external-ledger + # flush above) is not re-driven by resume — the task is already terminal, so + # `_finish_inflight` skips it — so a crash in that window needs durable proof + # the work actually landed before a later run may apply it. Survives the + # round-trip. + unit_merged: bool = False # worktree-isolation mode only (scm.isolation = "worktree"): the unit's # mounted worktree dir and branch, recorded so a paused/crashed run can # reconstruct or discard the in-flight worktree on resume. @@ -283,7 +306,9 @@ def to_dict(self) -> dict[str, Any]: "restore_patch": self.restore_patch, "dw_ids": self.dw_ids, "bundle_file": self.bundle_file, + "declared_deferred": self.declared_deferred, "pending_deferred_closes": self.pending_deferred_closes, + "unit_merged": self.unit_merged, "worktree_path": self.worktree_path, "branch": self.branch, "sessions": [s.to_dict() for s in self.sessions], @@ -331,7 +356,13 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": restore_patch=d.get("restore_patch"), dw_ids=[str(i) for i in d.get("dw_ids", [])], bundle_file=d.get("bundle_file"), + declared_deferred=( + [str(i) for i in d["declared_deferred"]] + if d.get("declared_deferred") is not None + else None + ), pending_deferred_closes=[str(i) for i in d.get("pending_deferred_closes", [])], + unit_merged=bool(d.get("unit_merged", False)), worktree_path=str(d.get("worktree_path", "")), branch=str(d.get("branch", "")), sessions=[SessionRecord.from_dict(s) for s in d.get("sessions", [])], diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index b7924d33..4f925e99 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1225,14 +1225,14 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non success_status = "in-review" if self._dev_review_enabled() else "done" self._close_bundle_ledger_when_spec_status(task, str(spec_file), success_status) - def _close_declared_deferred(self, task: StoryTask) -> None: + def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str] | None: """No-op: a bundle's ledger closure is owned by ``_close_bundle_ledger_when_spec_status``, which runs at dev-sync time because ``verify_review_bundle`` *requires* those entries closed before it will pass. Letting the base class's commit-boundary hook (#234) also fire here would re-derive closure for a task whose ids come from ``task.dw_ids``, not from a ``closes_deferred:`` declaration.""" - return + return None def _close_bundle_ledger_when_spec_status( self, diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 900592ea..afc3fc87 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -485,6 +485,15 @@ def integrate_unit(self, task: StoryTask, unit: UnitWorkspace) -> None: # out-of-repo deferred-work ledger) may safely claim it landed. A # merge that escalates raises out of merge_local and never reaches # here, leaving the shared ledger untouched (#234). + # + # Record the merge BEFORE that bookkeeping runs, and persist it. The + # task is already DONE, so a crash in this window is not re-driven by + # resume (`_finish_inflight` skips terminal tasks) — the reconcile + # pass that finishes the leftover bookkeeping needs durable proof the + # work actually landed, and "phase is DONE" is not that proof: it is + # stamped by the commit, before any of this. + task.unit_merged = True + self._save() self._on_integrated(task) else: # DEFERRED — capture the diff, keep or drop per keep_failed patch = close_unit_workspace( diff --git a/tests/test_engine.py b/tests/test_engine.py index 6f943cf2..6fcee2c4 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2170,6 +2170,169 @@ def test_closes_deferred_refuses_an_out_of_tree_spec(project, tmp_path): assert len(events) == 1 and events[0]["story_key"] == "1-1-a" +def _reject_commits(project): + """Install a native `pre-commit` hook that rejects every commit — the + real-world shape of a `finalize_commit` failure (a lint/secret hook saying no) + that no amount of orchestrator correctness prevents.""" + hooks = project.project / ".git" / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + hook = hooks / "pre-commit" + hook.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + hook.chmod(0o755) + return hook + + +def test_closes_deferred_rolls_back_when_the_commit_fails(project): + """The close is written just BEFORE `finalize_commit` so an in-repo annotation + rides the story's own commit — but that commit can still fail, and `_escalate` + unwinds nothing. Left alone the entry claims work that is in no commit, and + the usual recovery makes it permanent: a resolved re-drive preserves the + artifact folders' tracked content through `safe_reset` (#284 review, finding 1). + """ + engine = _closes_deferred_run(project, ["DW-1"]) + before = project.deferred_work.read_bytes() + _reject_commits(project) + + summary = engine.run() + + assert summary.done == 0 and summary.paused # the commit really did fail + assert _ledger_entries(project)["DW-1"].open + assert project.deferred_work.read_bytes() == before # byte-identical, not just re-opened + kinds = [e["kind"] for e in engine.journal.entries()] + # the write happened and was undone: both are on the record, in that order + assert kinds.index("story-deferred-closed") < kinds.index("deferred-close-rolled-back") + + +def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): + """The rollback must leave the story re-drivable: once the hook is gone, the + resumed commit phase re-applies the close exactly once (no doubled + `resolution:` line) and carries it in the commit it belongs to.""" + engine = _closes_deferred_run(project, ["DW-1"]) + hook = _reject_commits(project) + engine.run() + hook.unlink() + # the resolve workflow's re-arm: a resolved re-drive, which is precisely the + # recovery that PRESERVES the artifact folders' tracked content through + # `safe_reset` — so a close left standing here would never be reverted. + rearm_escalation(engine.run_dir) + + resumed, _ = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + summary = resumed.run() + + assert summary.done == 1 + entry = _ledger_entries(project)["DW-1"] + assert not entry.open + assert entry.body.count("resolution: resolved by story 1-1-a") == 1 + committed = git(project.project, "show", "HEAD", "--", str(project.deferred_work)) + assert "status: done" in committed + + +def _external_ledger_paths(project, tmp_path): + """`project` with its artifact dir configured OUTSIDE the repo — the shared, + uncommittable ledger configuration `ProjectPaths.rebased` leaves in place.""" + external = tmp_path / "shared-artifacts" + external.mkdir(exist_ok=True) + return dataclasses.replace(project, implementation_artifacts=external) + + +def test_closes_deferred_external_ledger_waits_for_the_commit_in_place(project, tmp_path): + """An out-of-repo ledger cannot ride any commit, so writing it before one buys + nothing and risks claiming work that never committed. In place there is no + integration step, so the flush happens once `finalize_commit` returns.""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + _reject_commits(project) + + summary = engine.run() + + assert summary.done == 0 and summary.paused + entries = { + e.id: e for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) + } + assert entries["DW-1"].open # never written: the commit it waited on failed + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-pending-integration" in kinds + assert "story-deferred-closed" not in kinds + # the obligation survives for the re-drive rather than being silently dropped + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] + + +def test_closes_deferred_external_write_failure_keeps_the_obligation(project, tmp_path): + """Clearing `pending_deferred_closes` before the write looks harmless — the + flush is about to happen — but a raising write unwinds to the crash handler, + whose `finally: self._save()` then persists the emptied list. The failure that + made the retry necessary destroys the record of it (#284 review, finding 2).""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + engine._apply_deferred_closes = lambda *a, **kw: (_ for _ in ()).throw(OSError("disk full")) + + summary = engine.run() + + assert summary.crashed + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] + + +def test_closes_deferred_uses_the_verified_capture_when_the_commit_read_faults( + project, monkeypatch +): + """`_observed_frontmatter` degrades an unreadable spec to None, which at the + close site was indistinguishable from a spec declaring nothing: a transient + read fault let the story commit with its declared entry still open. The + declaration is captured when the dev artifacts verify instead, so the commit + boundary needs no spec read at all (#284 review, finding 3).""" + engine = _closes_deferred_run(project, ["DW-1"]) + finalize = engine._finalize_commit_phase + + def unreadable_from_the_commit_phase_on(task): + # the spec becomes unreadable exactly when the close needs it — the TOCTOU + # shape `_observed_frontmatter` exists to absorb + monkeypatch.setattr(engine, "_observed_frontmatter", lambda *a, **kw: None) + return finalize(task) + + monkeypatch.setattr(engine, "_finalize_commit_phase", unreadable_from_the_commit_phase_on) + + summary = engine.run() + + assert summary.done == 1 + assert not _ledger_entries(project)["DW-1"].open + assert engine.state.tasks["1-1-a"].declared_deferred == ["DW-1"] + + +def test_closes_deferred_reports_an_unreadable_declaration(project, monkeypatch): + """The fallback read — a task that reached the commit boundary with no capture + — is the one place a fault can still cost a closure, so it says so instead of + reading as "declares nothing".""" + write_ledger(project, {"DW-1": "open"}) + engine, _ = make_engine(project, []) + sp = spec_path(project, "1-1-a") + write_spec(sp, "done", "abc123", closes_deferred=["DW-1"]) + task = StoryTask(story_key="1-1-a", epic=1) + task.spec_file = str(sp) # no declared_deferred: a pre-capture state file + monkeypatch.setattr(engine, "_observed_frontmatter", lambda *a, **kw: None) + + engine._close_declared_deferred(task) + + assert _ledger_entries(project)["DW-1"].open + events = [ + e for e in engine.journal.entries() if e["kind"] == "deferred-close-declaration-unreadable" + ] + assert len(events) == 1 and events[0]["story_key"] == "1-1-a" + + def test_transient_spec_read_fault_does_not_crash_run(project, monkeypatch): """Integration capstone for #97. A single transient OSError on the spec — a TOCTOU truncation while the dev skill rewrites the file the orchestrator is diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 51ace3c5..9c15c47f 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1498,3 +1498,79 @@ def test_worktree_external_ledger_closure_waits_for_integration(project, tmp_pat kinds = journal_kinds(engine) assert "deferred-close-external-ledger" in kinds # the operator is told it is uncommitted assert "story-deferred-closed" in kinds + + +def _external_paths(project, tmp_path): + """`project` with its artifact dir configured OUTSIDE the repo. The sprint + board lives there too and is shared with every worktree rather than committed, + so it is written, not committed, for these runs.""" + import dataclasses + + external = tmp_path / "shared-artifacts" + external.mkdir(exist_ok=True) + return dataclasses.replace(project, implementation_artifacts=external) + + +def test_worktree_crash_after_merge_retries_the_closure_on_resume(project, tmp_path): + """The external-ledger flush runs AFTER the task is already DONE, so a crash + between `merge_local` and the flush leaves a terminal task holding an + unsatisfied obligation — and `_finish_inflight` skips terminal tasks, so + nothing ever retried it. `unit_merged` is the durable proof the work landed, + recorded before the bookkeeping it authorizes (#284 review, finding 2).""" + from conftest import write_ledger + + paths = _external_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [wt_dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + engine._flush_pending_deferred_closes = lambda task: (_ for _ in ()).throw(OSError("host died")) + + summary = engine.run() + + assert summary.crashed + saved = load_state(engine.run_dir).tasks["1-1-a"] + assert saved.phase == Phase.DONE and saved.unit_merged # the merge did land + assert saved.pending_deferred_closes == ["DW-1"] # the obligation survived + assert _ledger_entry(paths, "DW-1").open + + state = load_state(engine.run_dir) + state.clear_pause() + resumed = Engine( + paths=paths, + policy=engine.policy, + adapter=MockAdapter([]), + run_dir=engine.run_dir, + journal=engine.journal, + state=state, + ) + resumed.run() + + assert not _ledger_entry(paths, "DW-1").open # reconciled, not lost + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] + + +def test_worktree_pending_closure_without_a_merge_stays_open(project, tmp_path): + """A DONE task whose unit never reached the target branch has nothing to + claim. `open` is the truthful reading, so the obligation is reported rather + than discharged — silently applying it would close entries for work that is + on no branch anyone will see.""" + from conftest import write_ledger + + paths = _external_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "done"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine(paths, []) + task = StoryTask(story_key="1-1-a", epic=1) + task.phase = Phase.DONE + task.pending_deferred_closes = ["DW-1"] + task.unit_merged = False # crashed before, or during, the merge + engine.state.tasks["1-1-a"] = task + + engine._finish_inflight() + + assert _ledger_entry(paths, "DW-1").open + events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-abandoned"] + assert len(events) == 1 and events[0]["dw_ids"] == ["DW-1"] From b8415f8893d4a637788a6a28cd1bb791be6cbf00 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 10:31:21 -0700 Subject: [PATCH 09/36] docs: closure is undone if the commit fails, and retried if it never happened (#234 review) The format doc and CHANGELOG described "only once the story actually lands" as if reaching the commit boundary were landing. Both now say what happens when the commit itself fails, and that the out-of-repo case waits for durable success in BOTH modes (not only under isolation) and is retried on resume. --- CHANGELOG.md | 10 ++++++---- .../skills/bmad-loop-sweep/deferred-work-format.md | 10 +++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64de09bd..5d0d1cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,12 @@ breaking changes may land in a minor release. every checkpoint, the review loop and the `pre_commit` workflows, and immediately before `finalize_commit` squashes the story — so a story that fails verification, is rejected by review, or escalates closes nothing, and an in-repo ledger still carries the annotation in the - story's own commit. An artifact dir configured _outside_ the repo is shared between worktrees - and cannot be committed at all; an isolated run holds its closure until the story's branch has - merged (journaled `deferred-close-external-ledger`), so a unit whose integration fails never - claims its work resolved. + story's own commit. A commit that then fails (a rejecting native `pre-commit` hook) rolls the + annotation back, so it never outlives the commit it was written for. An artifact dir configured + _outside_ the repo is shared between worktrees and cannot be committed at all; that closure is + held until the work is durably landed — after the commit in place, after the branch merges under + isolation (journaled `deferred-close-external-ledger`) — and a write that never got to happen is + retried on the next resume rather than being dropped. Closure is declared, never inferred from a diff. An id already `done` is a silent no-op, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns. Nothing diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 404c9a5f..136ec72f 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -90,11 +90,15 @@ The rules that keep this safe: - **Only once the story actually lands.** The annotation is written at the commit boundary — after verification, the review loop and every checkpoint, and just before the story's commit is squashed. A story that fails, blocks, is - rejected by review, or escalates closes nothing. + rejected by review, or escalates closes nothing. If the commit itself then + fails — a rejecting native `pre-commit` hook, say — the annotation is rolled + back rather than left claiming work that is in no commit. - **In the story's own commit**, when this file lives inside the repo. If the artifacts dir is configured outside it, the file is shared between worktrees - and no commit can carry it; an isolated run then waits until the story's - branch has merged before annotating. + and no commit can carry it; the closure is then held until the work is durably + landed — after the commit in place, after the branch has merged under worktree + isolation — and retried on the next resume if that write did not get to + happen. - **Idempotent.** An id already `done` is left untouched, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns. - **Never a gate.** An id that matches no entry, an entry whose `status:` reads From e9f9ff436c86286d31e0bb0f2178d2e66ab03cef Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 10:34:20 -0700 Subject: [PATCH 10/36] test(engine): pin the in-place external-ledger closure landing after the commit (#234 review) The parking rule got a test for the half where the commit fails and the entry must stay open, but none for the half where it succeeds and the held closure must actually be applied. Deleting the post-commit flush left the suite green: a parked-and-never-flushed closure reads exactly like a story that declared nothing. --- tests/test_engine.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_engine.py b/tests/test_engine.py index 6fcee2c4..0250b26b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2239,6 +2239,35 @@ def _external_ledger_paths(project, tmp_path): return dataclasses.replace(project, implementation_artifacts=external) +def test_closes_deferred_external_ledger_lands_after_the_commit_in_place(project, tmp_path): + """The other half of the parking rule: in place there is no integration step, + so a successful commit IS the durable landing and the held closure must + actually be applied — parking it and never flushing would read exactly like a + story that declared nothing.""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + + summary = engine.run() + + assert summary.done == 1 + entry = next( + e + for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) + if e.id == "DW-1" + ) + assert not entry.open and "resolution: resolved by story 1-1-a" in entry.body + kinds = [e["kind"] for e in engine.journal.entries()] + # parked at the commit boundary, applied only once the commit returned + assert kinds.index("deferred-close-pending-integration") < kinds.index("story-deferred-closed") + assert "deferred-close-external-ledger" in kinds # the operator is told it is uncommitted + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] + + def test_closes_deferred_external_ledger_waits_for_the_commit_in_place(project, tmp_path): """An out-of-repo ledger cannot ride any commit, so writing it before one buys nothing and risks claiming work that never committed. In place there is no From 1aebc7a47fc99f900a734de0a11f53aefd28b27d Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 10:39:27 -0700 Subject: [PATCH 11/36] fix(engine): report the ids a rollback actually un-flipped (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deferred-close-rolled-back` journaled everything the story declared, which overstates what changed: an id that was already `done` before the close is still `done` after the restore. The snapshot now carries the marked list, so the event names exactly what was undone — and the restore stops re-deriving the declaration just to describe itself. --- src/bmad_loop/engine.py | 18 +++++++++++------- src/bmad_loop/sweep.py | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 4a8f296f..12647213 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2183,7 +2183,7 @@ def _declared_deferred_ids(self, task: StoryTask) -> tuple[str, ...]: ids += task.declared_deferred or [] return tuple(dict.fromkeys(ids)) - def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str] | None: + def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str]] | None: """At the commit boundary, flip every ledger entry the story declares via ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note (#234) — the regular-story counterpart of the sweep bundle close at @@ -2219,8 +2219,8 @@ def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str] | None: after ``merge_local`` under isolation (see ``_flush_pending_deferred_closes``). - Returns the pre-close ledger text (with its path) when an in-repo write - actually marked something, else None. + Returns the pre-close ledger text — with its path and the ids actually + flipped — when an in-repo write marked something, else None. Never a gate: an unmatched or malformed id is journaled, never fatal. Idempotent, so the resume arm may re-drive the commit phase freely — ids @@ -2242,9 +2242,11 @@ def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str] | None: return None before = ledger.read_text(encoding="utf-8") if ledger.is_file() else None marked = self._apply_deferred_closes(task, ids, ledger) - return (ledger, before) if marked and before is not None else None + return (ledger, before, marked) if marked and before is not None else None - def _restore_deferred_closes(self, task: StoryTask, snapshot: tuple[Path, str] | None) -> None: + def _restore_deferred_closes( + self, task: StoryTask, snapshot: tuple[Path, str, list[str]] | None + ) -> None: """Put the ledger back the way ``_close_declared_deferred`` found it, after the commit those closures were written for failed (#234). @@ -2264,7 +2266,7 @@ def _restore_deferred_closes(self, task: StoryTask, snapshot: tuple[Path, str] | through ``safe_reset``, so the index is not authoritative here.""" if snapshot is None: return - ledger, before = snapshot + ledger, before, marked = snapshot try: atomic_write_text(ledger, before) except OSError as e: @@ -2279,7 +2281,9 @@ def _restore_deferred_closes(self, task: StoryTask, snapshot: tuple[Path, str] | self.journal.append( "deferred-close-rolled-back", story_key=task.story_key, - dw_ids=list(self._declared_deferred_ids(task)), + # what was actually flipped and is now un-flipped, NOT everything the + # story declared: an id that was already done stays done either way. + dw_ids=list(marked), ledger=str(ledger), ) diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 4f925e99..b9c7b2d9 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1225,7 +1225,7 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non success_status = "in-review" if self._dev_review_enabled() else "done" self._close_bundle_ledger_when_spec_status(task, str(spec_file), success_status) - def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str] | None: + def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str]] | None: """No-op: a bundle's ledger closure is owned by ``_close_bundle_ledger_when_spec_status``, which runs at dev-sync time because ``verify_review_bundle`` *requires* those entries closed before it From 0291fe56c3853236b334c4678df05bfa8044a032 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 12:24:53 -0700 Subject: [PATCH 12/36] fix(engine): close deferred work against the spec at the commit, not a snapshot (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-model review of PR #284. Six of eight findings hold; the two that do not are answered in the docstrings rather than the code. Symlinked ledgers were misclassified as in-repo. `is_relative_to` is lexical while `atomic_write_text` resolves, so an in-repo link pointing at a shared external ledger was written here as if it could ride the commit: the external target got flipped, `add -A` staged only the unchanged link, and under isolation a merge that never landed left shared work marked done with nothing left to roll it back. Decide locality on resolved paths. A failed commit was not the only way out of the close/commit window. The signal handler `run()` installs raises RunStopped from wherever the main thread is standing, and `_run_git` translates only TimeoutExpired, so a raw OSError on spawn escapes as itself — both past `except verify.GitError`, both leaving the ledger claiming a commit that does not exist. Restore on any exception and re-raise untouched. The declaration is now re-read at the commit boundary in both channels, with the verify-time capture demoted to the fallback for a read that faults. Closing against the capture could mark an id the author had since WITHDRAWN — a false close, the one outcome this path exists to prevent — and drop one added in the same edit. The manifest half reads directly rather than through `_entry_for`, whose one-warning-per-story dedupe could be spent at dispatch and leave the lost closure silent. Not changed, and now said so in place: `unit_merged` is saved after `merge_local` returns, so a host death in that window reports a merge that did land as abandoned. The entries stay open, which a sweep re-verifies; recording intent before the merge would instead close entries for a merge that never happened, and `squash` rewrites the commit so the target branch cannot arbitrate. A wrong container in `stories.yaml` stays a schema error like every other manifest field of the wrong type — it is the docs that overpromised a warning, in four places. Suite 2991 passed / 7 skipped. Every new regression mutation-checked: neuter the fix, confirm the test fails. --- CHANGELOG.md | 40 +++-- README.md | 4 +- docs/FEATURES.md | 2 +- .../bmad-loop-sweep/deferred-work-format.md | 14 +- src/bmad_loop/engine.py | 131 ++++++++++---- src/bmad_loop/stories_engine.py | 23 ++- tests/test_engine.py | 162 +++++++++++++++++- tests/test_platform_util.py | 25 +++ tests/test_stories_engine.py | 43 +++++ 9 files changed, 381 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0d1cfc..f0a829a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,19 +22,26 @@ breaking changes may land in a minor release. every checkpoint, the review loop and the `pre_commit` workflows, and immediately before `finalize_commit` squashes the story — so a story that fails verification, is rejected by review, or escalates closes nothing, and an in-repo ledger still carries the annotation in the - story's own commit. A commit that then fails (a rejecting native `pre-commit` hook) rolls the - annotation back, so it never outlives the commit it was written for. An artifact dir configured - _outside_ the repo is shared between worktrees and cannot be committed at all; that closure is - held until the work is durably landed — after the commit in place, after the branch merges under - isolation (journaled `deferred-close-external-ledger`) — and a write that never got to happen is - retried on the next resume rather than being dropped. - - Closure is declared, never inferred from a diff. An id already `done` is a silent no-op, so a - resumed run re-driving the same close neither doubles the `resolution:` line nor warns. Nothing - here can fail a story: an id matching no ledger entry (`deferred-close-unmatched`), a ledger - entry whose `status:` reads as neither `open` nor `done` (`deferred-close-malformed`), and a - declaration that is not a list — a bare `closes_deferred: DW-5` — are journaled and dropped. - Closes are journaled as `story-deferred-closed`. + story's own commit. A commit that then fails (a rejecting native `pre-commit` hook) or never + happens at all (a SIGTERM landing mid-commit) rolls the annotation back, so it never outlives the + commit it was written for. An artifact dir configured _outside_ the repo — including one reached + through a symlink that only looks in-repo — is shared between worktrees and cannot be committed + at all; that closure is held until the work is durably landed — after the commit in place, after + the branch merges under isolation (journaled `deferred-close-external-ledger`) — and a write that + never got to happen is retried on the next resume rather than being dropped. + + Closure is declared, never inferred from a diff, and both channels are re-read at that boundary + so a declaration edited after the story was implemented is the one that counts — a withdrawn id + is not closed, and one added late is. A read that fails there falls back to the declaration + captured when the dev artifacts verified rather than to "declares nothing" + (`deferred-close-declaration-unreadable` when even that is unavailable). An id already `done` is + a silent no-op, so a resumed run re-driving the same close neither doubles the `resolution:` line + nor warns. Nothing at the commit boundary can fail a story: an id matching no ledger entry + (`deferred-close-unmatched`), a ledger entry whose `status:` reads as neither `open` nor `done` + (`deferred-close-malformed`), and a story spec declaring `closes_deferred: DW-5` where a list + belongs are journaled and dropped. The same wrong container in `stories.yaml` is a schema error + like any other manifest field of the wrong type — the manifest fails to load, before any story + runs, where `validate` has already reported it. Closes are journaled as `story-deferred-closed`. The `stories.yaml` channel is the one that avoids hand-editing each generated spec: `bmad-dev-auto` writes the story spec and knows nothing of the ledger, whereas the Story @@ -45,9 +52,10 @@ breaking changes may land in a minor release. `bmad-loop validate` adds matching pre-flight warnings in **both** queue modes — `deferred.closes-unknown` for an id absent from the ledger (a typo or a renumbered entry) and - `deferred.closes-malformed` for an unreadable declaration. Stories mode reads the manifest plus - each id-resolved spec; sprint mode reads the story specs already on disk in the artifacts dir. - Both are warnings, so neither changes the exit code. + `deferred.closes-malformed` for a spec declaration in an unreadable shape. Stories mode reads the + manifest plus each id-resolved spec; sprint mode reads the story specs already on disk in the + artifacts dir. Both are warnings, so neither changes the exit code; a malformed declaration in + `stories.yaml` itself is reported by `queue.stories-manifest`, which does. - **`review.on_timeout` policy knob (#271).** A timeout-like review verdict (`timeout` / `stalled` / `over_budget`) previously always burned a review cycle (RETRY) until diff --git a/README.md b/README.md index a98639a2..108d9021 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,9 @@ In stories mode the same declaration can live on the `stories.yaml` entry instea The orchestrator writes the same annotation a bundle close writes — `status: done ` and `resolution: resolved by story ` — into each declared entry. Without it the ledger is one-way: entries are filed automatically but only ever marked resolved by hand, so a multi-epic run ends with entries that were satisfied epics ago still reading `open`. -**Closure happens at the commit boundary**, after artifact verification, the verify commands, every checkpoint, the review loop and the pre-commit workflows — and just before the story's commit is squashed, so an in-repo ledger carries the annotation in that same commit. A story that fails, blocks, is rejected by review, or escalates closes nothing, and there is nothing to undo. Closure is declared, never inferred from the diff; a resume re-driving the same close is a no-op; and an id that matches no entry — or a declaration in a shape nothing can read, such as a bare `closes_deferred: DW-5` — is journaled rather than failing the story. `bmad-loop validate` surfaces both as warnings before the run starts, in sprint and stories mode alike. +**Closure happens at the commit boundary**, after artifact verification, the verify commands, every checkpoint, the review loop and the pre-commit workflows — and just before the story's commit is squashed, so an in-repo ledger carries the annotation in that same commit. A story that fails, blocks, is rejected by review, or escalates closes nothing, and there is nothing to undo. Closure is declared, never inferred from the diff; a resume re-driving the same close is a no-op; and an id that matches no entry is journaled rather than failing the story. The declaration is re-read at that boundary, so an edit made after the story was implemented is the one that counts. + +A declaration in a shape nothing can read — a bare `closes_deferred: DW-5` where a list belongs — depends on which channel it is in. In a **story spec** it is journaled and dropped, like an unknown id: the spec is generated mid-run by a dev skill, and a malformed field there must not be able to fail a story that succeeded. In **`stories.yaml`** it is a schema error, exactly like every other manifest field of the wrong type, and the manifest fails to load — the breakdown is hand-authored before the run, where `bmad-loop validate` reports it up front and a typo is still cheap to fix. `validate` warns about unknown ids in both queue modes and about a malformed spec declaration; a malformed manifest is the manifest's own `queue.stories-manifest` failure. > **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. Closure still happens, but for an isolated (`scm.isolation = "worktree"`) run it is held until the unit's branch has merged — a story whose integration fails leaves its entries `open`. The run journals `deferred-close-external-ledger` so the annotation's absence from git history is not a surprise. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1e307299..601c08dd 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -104,7 +104,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Deferred-work sweeps - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. -- Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Written at the commit boundary, after verification/review/checkpoints and just before the squash, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; idempotent across a resume; an unknown id, an unreadable entry status, or a non-list declaration is journaled, never fatal. `bmad-loop validate` warns about all of those before the run starts, in both queue modes. An artifact dir configured outside the repo is shared across worktrees and cannot be committed — an isolated run holds its closure until the story's branch merges. +- Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, the orchestrator flips each declared entry to `status: done ` + `resolution: resolved by story ` — the same annotation a sweep bundle writes — so the ledger stops being one-way (filed automatically, marked resolved by hand). Written at the commit boundary, after verification/review/checkpoints and just before the squash, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status, or a non-list declaration in a story spec is journaled, never fatal (a non-list declaration in `stories.yaml` is a manifest schema error like any other, caught before the run). `bmad-loop validate` warns about all of those before the run starts, in both queue modes. An artifact dir configured outside the repo is shared across worktrees and cannot be committed — an isolated run holds its closure until the story's branch merges. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 136ec72f..6599807a 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -102,10 +102,16 @@ The rules that keep this safe: - **Idempotent.** An id already `done` is left untouched, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns. - **Never a gate.** An id that matches no entry, an entry whose `status:` reads - as neither `open` nor `done`, and a declaration that is not a list (a bare - `closes_deferred: DW-5`) are each journaled and dropped — none can fail the - story. `bmad-loop validate` reports the same mismatches as warnings before the - run starts. + as neither `open` nor `done`, and a story spec declaring a bare + `closes_deferred: DW-5` where a list belongs are each journaled and dropped — + none can fail the story. `bmad-loop validate` reports the same mismatches as + warnings before the run starts. The one exception is that same wrong container + in `stories.yaml`: the manifest is a schema the parser owns, so it fails to + load there like any other field of the wrong type — before any story runs, and + reported by `validate` up front. +- **Read at the commit.** The declaration that counts is the one on disk when the + story commits, not the one it was implemented from — edit it late and the edit + is honored, in both directions. Keep the ids stable when editing this file: a reworded title is fine, but renumbering an entry orphans any declaration that already references it. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 12647213..1573d85b 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -785,7 +785,16 @@ def _reconcile_pending_deferred_closes(self) -> None: an unsatisfied obligation — and terminal tasks are exactly what that loop skips. `unit_merged` is the durable proof the work landed; without it the unit never reached the target branch, and the ledger reading `open` is the - truthful answer, so the obligation is reported rather than discharged.""" + truthful answer, so the obligation is reported rather than discharged. + + `unit_merged` is saved *after* `merge_local` returns, so a host death in + that one window reports a merge that did land as abandoned, and the entry + stays open. That direction is deliberate: the entries stay open (which a + sweep re-verifies against the codebase, and which `deferred-close-abandoned` + names for the operator), where recording the intent before the merge would + instead close entries for a merge that never happened. Proving it from the + target branch is not available as a tiebreaker either — `squash` rewrites + the commit, so the unit's sha is not on the target under every strategy.""" for task in list(self.state.tasks.values()): if not task.pending_deferred_closes: continue @@ -1246,7 +1255,9 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None if outcome.ok: # the spec is verified and on disk: the one moment its # `closes_deferred:` declaration is known readable (#234). - self._capture_declared_deferred(task) + # The commit boundary re-reads it — this capture is what that + # read falls back on when it faults. + self._capture_declared_deferred(task, site="dev-verify") if outcome.ok and self._run_verify_commands_after_dev(task, result.result_json): # deterministic gates run here too: a broken build must not # reach the (far more expensive) review loop @@ -1773,6 +1784,17 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: except verify.GitError as e: self._restore_deferred_closes(task, rollback) self._escalate(task, f"commit failed: {e}") + except BaseException: + # A failed commit is not the only way out of this window. The signal + # handler installed by `_run` raises RunStopped from wherever the main + # thread is standing — including inside finalize_commit — and `_run_git` + # translates only TimeoutExpired, so a raw OSError on spawn escapes as + # itself. Both leave the ledger flipped for a commit that does not + # exist, and nothing above unwinds it: RunStopped is caught by `run()` + # to finalize a *stopped* run, not to repair bookkeeping. Restore, then + # re-raise untouched — the caller's disposition is not ours to change. + self._restore_deferred_closes(task, rollback) + raise if not self._isolated: # An out-of-repo ledger could not ride the commit, so it was parked; # the commit has now landed, which in place is the whole of "durably @@ -2110,17 +2132,27 @@ def _manifest_closes_deferred(self, task: StoryTask) -> tuple[str, ...]: generated later by a dev skill that knows nothing of the ledger.""" return () - def _capture_declared_deferred(self, task: StoryTask) -> bool: - """Latch the spec's ``closes_deferred:`` declaration onto the task, at the - moment the dev artifacts verified (#234). + def _capture_declared_deferred(self, task: StoryTask, *, site: str) -> bool: + """Read the spec's ``closes_deferred:`` declaration onto the task (#234). + + Called from two sites, and which one is authoritative matters. - Capturing here rather than reading at the commit boundary is the whole - point: ``_observed_frontmatter`` degrades an unreadable spec to None, and - at the close site that was indistinguishable from a spec declaring - nothing — so a transient read fault let the story commit with its declared - entry still open, leaving a journal line as the only trace. It also hands - the declaration to the COMMITTING resume arm, which finishes a commit - WITHOUT re-verifying and so has no other way to learn it. + **The commit boundary is the authority.** What the spec says at the + moment of the close is what the story declares; a declaration edited + after the dev artifacts verified — a review session rewriting the + frontmatter, a human editing the spec while the review loop runs — must + not be closed against a stale snapshot, because the stale half of that + can close an entry the final spec no longer names. + + **The verify-time capture is what makes re-reading safe to attempt.** + ``_observed_frontmatter`` degrades an unreadable spec to None, and at the + close site that is indistinguishable from a spec declaring nothing — so a + transient read fault there would let the story commit with its declared + entry still open, leaving a journal line as the only trace. A failed read + leaves the last good capture standing to be closed against instead. It is + the same value the COMMITTING resume arm leans on when its own re-read + faults; that arm finishes a commit WITHOUT re-verifying, so a persisted + declaration is its only other source. The path is a verified one — ``task.spec_file`` is recorded only by a passing ``verify_dev``/``verify_dev_stories`` gate, and stories mode @@ -2131,12 +2163,21 @@ def _capture_declared_deferred(self, task: StoryTask) -> bool: A wrong-container declaration (``closes_deferred: DW-5``) is journaled rather than silently read as empty: it names real intent that would - otherwise close nothing and say nothing. A failed read leaves any earlier - capture standing — a later attempt's silence is not a retraction. - - False only when there IS a spec to read and reading it failed; the caller - decides whether that costs anything. A missing or out-of-tree spec is - nothing to capture, not a failure to capture.""" + otherwise close nothing and say nothing. Both reads report it, tagged with + the ``site`` that found it — two lines for one mistake is the honest + record of two reads, and suppressing the second would need per-read error + state to avoid also suppressing a spec that only turned malformed after + verification. + + A failed read leaves any earlier capture standing — a later attempt's + silence is not a retraction. False only when there IS a spec to read and + reading it failed; the caller decides whether that costs anything. A + missing or out-of-tree spec is nothing to capture, not a failure to + capture. + + The path is not re-derived here: ``task.spec_file`` is recorded only by a + passing verify gate, and the root-containment rule below still holds at + both sites.""" spec_path = Path(task.spec_file) if task.spec_file else None if spec_path is None or not spec_path.is_file(): return True @@ -2145,9 +2186,10 @@ def _capture_declared_deferred(self, task: StoryTask) -> bool: "deferred-close-skipped-out-of-tree", story_key=task.story_key, spec=str(spec_path), + site=site, ) return True - fm = self._observed_frontmatter(spec_path, task.story_key, "deferred-close") + fm = self._observed_frontmatter(spec_path, task.story_key, f"deferred-close-{site}") if fm is None: return False declared, error = deferredwork.parse_declaration(fm.get("closes_deferred")) @@ -2157,6 +2199,7 @@ def _capture_declared_deferred(self, task: StoryTask) -> bool: story_key=task.story_key, spec=str(spec_path), error=f"closes_deferred {error}", + site=site, ) task.declared_deferred = list(declared) return True @@ -2166,20 +2209,33 @@ def _declared_deferred_ids(self, task: StoryTask) -> tuple[str, ...]: and order-preserving (a story that names the same id in the manifest and in its spec must be marked once and reported once). - The spec half comes from the verify-time capture. The fallback read is for - a task that reached the commit boundary without one — a run resumed from a - DEV_VERIFY pause that never re-verified, or a state file written before - the capture existed — and it is the one place a failed read can still cost - a closure, so it says so (``deferred-close-declaration-unreadable``) - instead of reading as "declares nothing".""" + Both halves are re-read here, because the spec and the manifest on disk at + the commit are what the story declares — a declaration edited after the + dev artifacts verified would otherwise be closed against a snapshot the + final spec no longer agrees with, and closing an id the author took back + is the one failure this whole path exists to prevent. + + A read that FAILS falls back rather than to "declares nothing": the spec + half to the last good capture (``StoryTask.declared_deferred``, which is + also all a COMMITTING resume has when its own re-read faults), the + manifest half to nothing at all — it is not persisted, so there is nothing + to fall back to. Either way the loss is the one place a failed read can + still cost a closure, so both say so + (``deferred-close-declaration-unreadable``) rather than passing in + silence.""" ids: list[str] = list(self._manifest_closes_deferred(task)) - if task.declared_deferred is None and task.spec_file: - if not self._capture_declared_deferred(task): - self.journal.append( - "deferred-close-declaration-unreadable", - story_key=task.story_key, - spec=task.spec_file, - ) + if task.spec_file and not self._capture_declared_deferred(task, site="commit-boundary"): + self.journal.append( + "deferred-close-declaration-unreadable", + story_key=task.story_key, + source="spec", + spec=task.spec_file, + note=( + "closing against the last good capture" + if task.declared_deferred + else "no captured declaration to fall back on" + ), + ) ids += task.declared_deferred or [] return tuple(dict.fromkeys(ids)) @@ -2231,7 +2287,16 @@ def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str if not ids: return None ledger = self.workspace.paths.deferred_work - if not ledger.is_relative_to(self.workspace.root): + # Decided on RESOLVED paths. `is_relative_to` is lexical while the write + # below follows symlinks (`atomic_write_text` resolves first, so a + # symlinked ledger stays a symlink instead of being replaced by a regular + # file) — so an in-repo link pointing at a shared external ledger would + # otherwise be treated as committable: the external target gets flipped + # here, `add -A` stages only the unchanged link, and under isolation a + # merge that never lands leaves shared work marked done with nothing left + # to roll it back. Only the last path component can differ: ProjectPaths + # resolves the artifact dir at construction and again in `rebased`. + if not ledger.resolve().is_relative_to(self.workspace.root.resolve()): task.pending_deferred_closes = list(ids) self.journal.append( "deferred-close-pending-integration", diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 515611fe..38f280bf 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -431,8 +431,27 @@ def _manifest_closes_deferred(self, task: StoryTask) -> tuple[str, ...]: ``bmad-dev-auto`` writes the story spec and knows nothing of the ledger, so with the spec frontmatter alone a human would have to hand-edit every generated spec. The breakdown, by contrast, is authored while the ledger - is in view. Both channels compose — the base hook unions them.""" - entry = self._entry_for(task) # None on an unreadable manifest (journaled there) + is in view. Both channels compose — the base hook unions them. + + Read here rather than through ``_entry_for`` because the two want opposite + things from an unreadable manifest. ``_entry_for`` is a dispatch helper: it + warns once per story and falls back to a bare folder+id dispatch that still + works. Here the fallback is silence — the ids are not persisted anywhere, + unlike the spec half's capture, so a parse failure at the commit boundary + drops a declared closure with no trace beyond a generic one-time warning + that may already have been spent earlier in the run. Say what was actually + lost, at the site that lost it.""" + try: + entry = self._load_stories().get(task.story_key) + except stories.StoriesError as e: + self.journal.append( + "deferred-close-declaration-unreadable", + story_key=task.story_key, + source="stories.yaml", + error=str(e), + note="manifest-declared ids cannot be read; nothing is closed for them", + ) + return () return entry.closes_deferred if entry else () def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): diff --git a/tests/test_engine.py b/tests/test_engine.py index 0250b26b..62210f8e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2127,7 +2127,11 @@ def test_closes_deferred_reports_a_wrong_container_declaration(project): assert summary.done == 1 # still never a gate assert project.deferred_work.read_bytes() == before events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-malformed"] - assert len(events) == 1 and "must be a list" in events[0]["error"] + # once per read of the declaration — the verify-time capture and the + # commit-boundary re-read that supersedes it — each saying which site it is, + # so two lines read as two reads rather than as two closes. + assert [e["site"] for e in events] == ["dev-verify", "commit-boundary"] + assert all("must be a list" in e["error"] for e in events) def test_closes_deferred_noop_when_field_absent(project): @@ -2321,8 +2325,9 @@ def test_closes_deferred_uses_the_verified_capture_when_the_commit_read_faults( """`_observed_frontmatter` degrades an unreadable spec to None, which at the close site was indistinguishable from a spec declaring nothing: a transient read fault let the story commit with its declared entry still open. The - declaration is captured when the dev artifacts verify instead, so the commit - boundary needs no spec read at all (#284 review, finding 3).""" + declaration is captured when the dev artifacts verify, and the commit + boundary's own re-read falls back to that capture rather than to "declares + nothing" (#284 review, finding 3).""" engine = _closes_deferred_run(project, ["DW-1"]) finalize = engine._finalize_commit_phase @@ -2342,9 +2347,10 @@ def unreadable_from_the_commit_phase_on(task): def test_closes_deferred_reports_an_unreadable_declaration(project, monkeypatch): - """The fallback read — a task that reached the commit boundary with no capture - — is the one place a fault can still cost a closure, so it says so instead of - reading as "declares nothing".""" + """A commit-boundary read that faults with nothing captured to fall back on — + a state file written before the capture existed — is the one place a fault can + still cost a closure, so it says so instead of reading as "declares + nothing".""" write_ledger(project, {"DW-1": "open"}) engine, _ = make_engine(project, []) sp = spec_path(project, "1-1-a") @@ -2360,6 +2366,150 @@ def test_closes_deferred_reports_an_unreadable_declaration(project, monkeypatch) e for e in engine.journal.entries() if e["kind"] == "deferred-close-declaration-unreadable" ] assert len(events) == 1 and events[0]["story_key"] == "1-1-a" + assert events[0]["source"] == "spec" + assert "no captured declaration" in events[0]["note"] + + +def test_closes_deferred_re_reads_the_declaration_at_the_commit_boundary(project, monkeypatch): + """The spec on disk at the commit is what the story declares. Closing against + the verify-time capture instead let an id the author had since WITHDRAWN be + marked resolved — a false close, the one outcome this whole path exists to + prevent — and dropped one added in the same edit (#284 follow-up review, + finding 3). + + The declaration is re-read here and the capture demoted to the fallback for a + read that faults, so both directions follow the final spec.""" + engine = _closes_deferred_run(project, ["DW-1"], ledger={"DW-1": "open", "DW-3": "open"}) + sp = spec_path(project, "1-1-a") + finalize = engine._finalize_commit_phase + + def edited_after_verification(task): + # the shape of a review session rewriting the frontmatter, or a human + # editing the spec while the review loop runs: DW-1 withdrawn, DW-3 added + write_spec(sp, "done", task.baseline_commit, closes_deferred=["DW-3"]) + return finalize(task) + + monkeypatch.setattr(engine, "_finalize_commit_phase", edited_after_verification) + + summary = engine.run() + + assert summary.done == 1 + entries = _ledger_entries(project) + assert entries["DW-1"].open # withdrawn before the commit: never closed + assert not entries["DW-3"].open # named by the final spec: closed + closed = [e for e in engine.journal.entries() if e["kind"] == "story-deferred-closed"] + assert [e["dw_ids"] for e in closed] == [["DW-3"]] + + +def test_closes_deferred_capture_survives_a_committing_resume(project, monkeypatch): + """The COMMITTING resume arm finishes a commit WITHOUT re-verifying, so when + its own re-read faults the persisted capture is all that knows what the story + declared. Drive it through a real save/reload rather than trusting the + round-trip by inspection: `declared_deferred` distinguishes None (never + captured) from [] (captured, declares nothing), and a `to_dict`/`from_dict` + that flattened the two would close nothing here while every direct-hook test + stayed green (#284 follow-up review, finding 7).""" + write_ledger(project, {"DW-1": "open"}) + engine, _ = make_engine(project, []) + committing_crash_state(project, engine) + engine.state.tasks["1-1-a"].declared_deferred = ["DW-1"] + engine._save() + + resumed, _ = resume_engine(project, engine, []) + # survived state.json — the resumed engine re-read it from disk + assert resumed.state.tasks["1-1-a"].declared_deferred == ["DW-1"] + # ...and the spec the crash left behind carries no declaration to re-read, + # so make the re-read fault: the fallback is the only path to the close + monkeypatch.setattr(resumed, "_observed_frontmatter", lambda *a, **kw: None) + + resumed.run() + + assert not _ledger_entries(project)["DW-1"].open + committed = git(project.project, "show", "HEAD", "--", str(project.deferred_work)) + assert "status: done" in committed + + +def test_closes_deferred_rolls_back_when_a_signal_stops_the_commit(project, monkeypatch): + """A failed commit is not the only way out of the close→commit window. The + handler `run()` installs raises RunStopped from wherever the main thread is + standing, and `run()` catches it to finalize a *stopped* run — not to repair + bookkeeping. Left alone the ledger claims work that is in no commit, exactly + as a rejecting pre-commit hook did, but past `except verify.GitError` + (#284 follow-up review, finding 2).""" + monkeypatch.setattr("bmad_loop.engine.kill_session", lambda rid: None) + engine = _closes_deferred_run(project, ["DW-1"]) + before = project.deferred_work.read_bytes() + real_finalize = verify.finalize_commit + + def sigterm_mid_commit(*a, **kw): + # in-process, catchable, and routed through the REAL installed handler — + # this is the mechanism, not a stand-in for it + signal.raise_signal(signal.SIGTERM) + return real_finalize(*a, **kw) # unreachable: the handler raises first + + monkeypatch.setattr(verify, "finalize_commit", sigterm_mid_commit) + + summary = engine.run() + + assert summary.done == 0 + assert load_state(engine.run_dir).stopped is True # the stop really landed + assert _ledger_entries(project)["DW-1"].open + assert project.deferred_work.read_bytes() == before # byte-identical + kinds = [e["kind"] for e in engine.journal.entries()] + assert kinds.index("story-deferred-closed") < kinds.index("deferred-close-rolled-back") + + +def test_closes_deferred_rolls_back_when_the_commit_raises_a_non_git_error(project, monkeypatch): + """`_run_git` translates only TimeoutExpired, so a raw OSError from the spawn + itself (a fork failure, git gone from PATH) reaches the commit boundary as + itself and slips past the GitError arm.""" + engine = _closes_deferred_run(project, ["DW-1"]) + before = project.deferred_work.read_bytes() + + def cannot_spawn(*a, **kw): + raise OSError("cannot allocate memory") + + monkeypatch.setattr(verify, "finalize_commit", cannot_spawn) + + summary = engine.run() + + assert summary.crashed # re-raised untouched: the disposition is not ours + assert _ledger_entries(project)["DW-1"].open + assert project.deferred_work.read_bytes() == before + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_closes_deferred_parks_a_ledger_symlinked_out_of_the_repo(project, tmp_path): + """Locality is decided on resolved paths. `is_relative_to` is lexical while + the write follows symlinks, so an in-repo link pointing at a shared external + ledger read as committable: the external target was flipped before the commit, + `add -A` staged only the unchanged link, and under isolation a merge that + never landed left shared work marked done with nothing left to roll it back + (#284 follow-up review, finding 1).""" + write_ledger(project, {"DW-1": "open"}, commit=False) + external = tmp_path / "shared" / "deferred-work.md" + external.parent.mkdir(parents=True, exist_ok=True) + external.write_text(project.deferred_work.read_text(encoding="utf-8"), encoding="utf-8") + project.deferred_work.unlink() + project.deferred_work.symlink_to(external) # in the repo by path, not by inode + + engine, _ = make_engine(project, []) + sp = spec_path(project, "1-1-a") + write_spec(sp, "done", "abc123", closes_deferred=["DW-1"]) + task = StoryTask(story_key="1-1-a", epic=1) + task.spec_file = str(sp) + + snapshot = engine._close_declared_deferred(task) + + assert snapshot is None # nothing written here that a commit could carry + assert "status: open" in external.read_text(encoding="utf-8") + assert task.pending_deferred_closes == ["DW-1"] # held for the durable landing + assert "deferred-close-pending-integration" in [e["kind"] for e in engine.journal.entries()] + + engine._flush_pending_deferred_closes(task) + + assert "status: done" in external.read_text(encoding="utf-8") + assert project.deferred_work.is_symlink() # written through, not replaced def test_transient_spec_read_fault_does_not_crash_run(project, monkeypatch): diff --git a/tests/test_platform_util.py b/tests/test_platform_util.py index a4fd3ba1..87ce9498 100644 --- a/tests/test_platform_util.py +++ b/tests/test_platform_util.py @@ -173,6 +173,31 @@ def test_atomic_write_text_writes_through_a_symlink(tmp_path): assert real.read_text(encoding="utf-8") == "after" +def test_atomic_write_text_preserves_extended_attributes(tmp_path): + """`os.replace` swaps a fresh inode into place, so anything carried by the old + inode rather than by its name is silently reset — xattrs included, which on a + ledger is where an SELinux label or a backup tool's marker lives. Deleting + `_copy_xattrs` left every other test green (#284 follow-up review, finding 8). + + Skipped where the platform or filesystem has no user xattrs (Windows, macOS's + different API, tmpfs mounted `nouser_xattr`) — the helper is best-effort by + design and must stay silent there, which the write below also proves.""" + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + setxattr = getattr(os, "setxattr", None) + if setxattr is None: + pytest.skip("no os.setxattr on this platform") + try: + setxattr(target, "user.bmad-loop-test", b"kept") + except OSError as e: + pytest.skip(f"filesystem does not support user xattrs: {e}") + + platform_util.atomic_write_text(target, "after") + + assert target.read_text(encoding="utf-8") == "after" + assert os.getxattr(target, "user.bmad-loop-test") == b"kept" + + def test_atomic_write_text_leaves_no_temp_behind(tmp_path): target = tmp_path / "ledger.md" target.write_text("before", encoding="utf-8") diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 78cff1a3..1f3ac21e 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1360,6 +1360,49 @@ def test_stories_mode_unions_manifest_and_frontmatter_declarations(project): assert closed[0]["dw_ids"] == ["DW-1", "DW-2", "DW-3"] # manifest first, deduped +def test_stories_mode_reports_a_manifest_unreadable_at_the_commit_boundary(project, monkeypatch): + """The manifest half is re-read at the commit and, unlike the spec half, is + persisted nowhere — so a parse failure there drops a declared closure with + nothing to fall back on. Routing it through `_entry_for` reported that as a + generic `stories-manifest-unreadable` warning that is emitted at most ONCE per + story, so an earlier dispatch-time failure spent the one line and the lost + closure passed in silence (#284 follow-up review, finding 4). + + The spec half is unaffected: one unreadable channel must not take the other + down with it.""" + from bmad_loop import stories as stories_mod + + engine = _closing_run( + project, + manifest=["DW-1"], + spec=["DW-2"], + ledger={"DW-1": "open", "DW-2": "open"}, + ) + finalize = engine._finalize_commit_phase + real_load = engine._load_stories + + def unreadable(*_a, **_kw): + raise stories_mod.StoriesError("stories.yaml is not valid YAML") + + def unreadable_from_the_commit_phase_on(task): + monkeypatch.setattr(engine, "_load_stories", unreadable) + try: + return finalize(task) + finally: + monkeypatch.setattr(engine, "_load_stories", real_load) + + monkeypatch.setattr(engine, "_finalize_commit_phase", unreadable_from_the_commit_phase_on) + + summary = engine.run() + + assert summary.done == 1 # never a gate + entries = _entries(project) + assert entries["DW-1"].open # the manifest-declared id is lost... + events = _kinds(engine.journal, "deferred-close-declaration-unreadable") + assert len(events) == 1 and events[0]["source"] == "stories.yaml" # ...but not silently + assert not entries["DW-2"].open # the spec half still closed + + def test_stories_mode_unknown_id_is_journaled_not_fatal(project): """An id naming no ledger entry — a typo, or an entry renumbered since the breakdown was written — is journaled and dropped. The annotation is From a84507a586859ae88a7949a19426d88d92618317 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 12:40:32 -0700 Subject: [PATCH 13/36] fix(engine): drop a parked closure the re-drive no longer declares (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's round on the re-read, plus the preflight gap it exposed. Re-reading the declaration at the commit boundary made a withdrawal reachable, and the "declares nothing" early return did not clear what a prior attempt had parked for an out-of-repo ledger. That obligation survives a failed commit on purpose, to be retried by the re-drive — but every other path recomputes the parked list wholesale from the fresh read, so only this one could flush ids the story no longer declares, after the commit, on the one ledger with no rollback behind it. Clear it, and journal `deferred-close-withdrawn`: an unreadable spec or manifest arrives here the same way, and the drop should not be silent. `validate` also promised more than it delivered. The close hook journals three things, and the preflight reported two — an id whose ledger entry carries neither an `open` nor a `done` status went unmentioned until the journal of the run it should have preceded, though README and docs/FEATURES.md both listed it. Its remedy is in the ledger rather than in the declaration, so it warns under its own `deferred.closes-entry-unreadable` rather than folded into `closes-unknown`. Docs: breakdown time is where a declaration belongs, not a deadline — say so, now that a spec edited before the commit is honored. Suite 2993 passed / 7 skipped. Both new guards mutation-checked. --- README.md | 2 +- src/bmad_loop/checks.py | 1 + src/bmad_loop/cli.py | 23 +++++++++--- .../bmad-loop-sweep/deferred-work-format.md | 8 ++-- src/bmad_loop/engine.py | 18 +++++++++ tests/test_cli.py | 29 +++++++++++++++ tests/test_engine.py | 37 +++++++++++++++++++ 7 files changed, 109 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 108d9021..18a8b584 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ An entry may also carry **`closes_deferred`** — the `DW-` ledger ids that s Declaring it here rather than in the story spec is what lets the annotation happen without touching each generated spec: `bmad-dev-auto` generates the spec and knows nothing of the ledger, whereas the breakdown is authored while the ledger is in view. A spec that _does_ carry the field in frontmatter is honored too — the two are unioned. -Like `spec_checkpoint` and `done_checkpoint`, this is a **human-authored, caller-only field**: you add it at breakdown time with the ledger open. Upstream Story Breakdown does not emit it yet ([BMAD-METHOD#2619](https://github.com/bmad-code-org/BMAD-METHOD/issues/2619)), and re-deriving `stories.yaml` rewrites the file — so record the intent in `.memlog.md` alongside the story, or the declaration is lost on the next re-derive. +Like `spec_checkpoint` and `done_checkpoint`, this is a **human-authored, caller-only field**. Breakdown time, with the ledger open, is where it belongs — but it is not a deadline: the declaration is read when the story commits, so adding one to a story spec's frontmatter mid-run is honored, and withdrawing one before the commit means it is not closed. Upstream Story Breakdown does not emit it yet ([BMAD-METHOD#2619](https://github.com/bmad-code-org/BMAD-METHOD/issues/2619)), and re-deriving `stories.yaml` rewrites the file — so record the intent in `.memlog.md` alongside the story, or the declaration is lost on the next re-derive. A story may set both checkpoints (it pauses twice); `gates.mode` pauses stack on top. A blocked story escalates exactly as in sprint mode — `bmad-loop resolve`, then re-arm + resume — now with the story's title/description and the blocking condition surfaced; a pre-planning-halt **sentinel** spec is auto-deleted (a copy preserved under the run dir) on re-arm for a clean re-dispatch. diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index 34c8bcee..a42ae0d8 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -73,6 +73,7 @@ "skills.stories-dispatch-stale", "deferred.closes-unknown", "deferred.closes-malformed", + "deferred.closes-entry-unreadable", } ) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 88fbaa95..99e6246c 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -632,11 +632,15 @@ def _validate_closes_deferred( only in stories mode left sprint-mode operators with nothing but a journal line after the fact. - Only *absent* ids warn. An id present but already ``done`` is a declaration a - prior run already satisfied (a resume re-drives the same close), so it stays - silent — the same classification the engine's close hook makes, for the same - reason. A wrong-container declaration warns separately: it names real intent - that would otherwise close nothing and say nothing. + An id present but already ``done`` is a declaration a prior run already + satisfied (a resume re-drives the same close), so it stays silent — the same + classification the engine's close hook makes, for the same reason. Everything + else it can journal is reported here: an absent id, an id whose ledger entry + carries neither an ``open`` nor a ``done`` status (nothing will be marked, and + the remedy is in the ledger rather than in the declaration), and a + wrong-container declaration, which names real intent that would otherwise + close nothing and say nothing. Covering only two of the three left the third + to be discovered in the journal after the run it should have preceded. Never a failure. The annotation is traceability, not a gate, so a stale reference must not be able to block a run that would otherwise start. @@ -662,6 +666,15 @@ def _validate_closes_deferred( {"source": label, "error": error}, ) declared = deferredwork.classify(text, ids) + if declared.malformed: + malformed = list(declared.malformed) + report.warn( + "deferred.closes-entry-unreadable", + f"{label} declares closes_deferred ids whose {ledger.name} entries carry " + f"neither an `open` nor a `done` status: {', '.join(malformed)} — nothing " + "will be marked resolved for them until the entry status is repaired", + {"source": label, "dw_ids": malformed}, + ) if declared.unknown: unknown = list(declared.unknown) report.warn( diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 6599807a..a6f46dec 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -70,9 +70,11 @@ or in its story spec's frontmatter. The two are unioned: closes_deferred: [DW-5, DW-6] # DW- ids this story closes ``` -Both are written by a human, at breakdown time, with this file open — no -upstream skill emits the field yet, and re-deriving `stories.yaml` will drop it -unless the intent is recorded in `.memlog.md` first. +Both are written by a human, and breakdown time — with this file open — is where +it belongs, though not a deadline: the declaration is read when the story +commits, so one added to a spec's frontmatter mid-run still counts. No upstream +skill emits the field yet, and re-deriving `stories.yaml` will drop it unless the +intent is recorded in `.memlog.md` first. When the story commits, the orchestrator annotates each declared id exactly as a bundle close does — `status: done ` plus a `resolution:` line naming the diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 1573d85b..8f6f974f 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2285,6 +2285,24 @@ def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str that landed — must stay silent) with "absent" (a typo — worth saying).""" ids = self._declared_deferred_ids(task) if not ids: + # A prior attempt may have parked ids for an out-of-repo ledger and + # then failed its commit; that obligation deliberately survives, to be + # retried by the re-drive. But the re-drive re-reads the declaration, + # and if it is gone the obligation goes with it — every other path + # recomputes the parked list wholesale from the fresh read, and + # flushing a withdrawn one after the commit would be exactly the false + # close the re-read exists to prevent, on the one ledger that has no + # rollback behind it. Journaled, because an unreadable spec or + # manifest reaches here the same way (reported by + # `_declared_deferred_ids`, but indistinguishable once it returns). + if task.pending_deferred_closes: + self.journal.append( + "deferred-close-withdrawn", + story_key=task.story_key, + dw_ids=list(task.pending_deferred_closes), + reason="the story no longer declares them; the parked closure is dropped", + ) + task.pending_deferred_closes = [] return None ledger = self.workspace.paths.deferred_work # Decided on RESOLVED paths. `is_relative_to` is lexical while the write diff --git a/tests/test_cli.py b/tests/test_cli.py index cbe81d24..f53d1698 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3364,6 +3364,35 @@ def test_validate_silent_when_closes_deferred_all_present(project, capsys): assert _closes_deferred_findings(capsys) == [] +def test_validate_warns_when_a_declared_entry_status_is_unreadable(project, capsys): + """A declared id can also point at an entry the ledger carries but whose + `status:` reads as neither `open` nor `done`. Nothing gets marked for it and + the close hook journals `deferred-close-malformed`, but preflight covered only + absent ids and unreadable declarations — leaving this third case to be found in + the journal after the run it should have preceded (CodeRabbit, round 3). + + The remedy is in the ledger, not the declaration, so it is its own check id + rather than folded into `deferred.closes-unknown`.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _declare_closes_deferred(project, ["DW-1"]) + ledger = project.deferred_work + ledger.write_text( + ledger.read_text(encoding="utf-8").replace("status: open", "status: in-progress"), + encoding="utf-8", + ) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + doc = json.loads(capsys.readouterr().out) + findings = [f for f in doc["findings"] if f["check"] == "deferred.closes-entry-unreadable"] + assert len(findings) == 1 + assert findings[0]["severity"] == "warning" # advisory, like the rest + assert findings[0]["detail"] == {"source": "story 1", "dw_ids": ["DW-1"]} + # not misreported as a typo: the id IS in the ledger + assert not [f for f in doc["findings"] if f["check"] == "deferred.closes-unknown"] + + def test_validate_warns_on_unknown_closes_deferred_in_the_manifest(project, capsys): """The manifest channel is what makes this a genuine *pre*-flight: a stories.yaml entry declares its ids before the story has ever been dispatched, so a typo is diff --git a/tests/test_engine.py b/tests/test_engine.py index 62210f8e..0332c8b0 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2299,6 +2299,43 @@ def test_closes_deferred_external_ledger_waits_for_the_commit_in_place(project, assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] +def test_closes_deferred_external_obligation_drops_when_the_declaration_is_withdrawn( + project, tmp_path +): + """A parked out-of-repo obligation survives a failed commit on purpose, to be + retried by the re-drive — but the re-drive re-reads the declaration, and the + early return for "declares nothing" left the old parked ids standing while + every other path recomputes them wholesale. The re-drive then flushed a + withdrawn closure after its commit: the false close the re-read exists to + prevent, on the one ledger with no rollback (CodeRabbit, review round 3).""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])] + ) + hook = _reject_commits(project) + + engine.run() # parks DW-1, then the commit fails + + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] + + hook.unlink() + rearm_escalation(engine.run_dir) + # the re-drive's dev session writes the spec with the declaration withdrawn + resumed, _ = resume_engine(paths, engine, [dev_effect(paths, "1-1-a", followup_review=False)]) + summary = resumed.run() + + assert summary.done == 1 # the story itself lands + entries = { + e.id: e for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) + } + assert entries["DW-1"].open # never closed: nothing declares it any more + assert load_state(resumed.run_dir).tasks["1-1-a"].pending_deferred_closes == [] + dropped = [e for e in resumed.journal.entries() if e["kind"] == "deferred-close-withdrawn"] + assert len(dropped) == 1 and dropped[0]["dw_ids"] == ["DW-1"] + + def test_closes_deferred_external_write_failure_keeps_the_obligation(project, tmp_path): """Clearing `pending_deferred_closes` before the write looks harmless — the flush is about to happen — but a raising write unwinds to the crash handler, From 5e349261fa5b84ea8b25137d81b684f949a0dd02 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 12:45:09 -0700 Subject: [PATCH 14/36] fix(platform): fsync the temp file before the replace publishes it (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os.replace` is atomic against a concurrent reader, which says nothing about a machine losing power. Closing the temp hands its bytes to the page cache, so the rename can be durable while the data is not, and the ledger comes back zero-length — and an empty ledger *parses*, as no entries, so the failure reads as every hand-written entry having vanished rather than as corruption. The directory is deliberately not synced. That would make the rename durable, and losing the rename only leaves the old contents in place: stale, never torn. The regression asserts the ORDER (fsync before replace), not that fsync was called — calling it after the publish would be green under the weaker assertion and buy nothing. --- src/bmad_loop/platform_util.py | 14 +++++++++++++- tests/test_platform_util.py | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/bmad_loop/platform_util.py b/src/bmad_loop/platform_util.py index 28dc2224..ba19b38c 100644 --- a/src/bmad_loop/platform_util.py +++ b/src/bmad_loop/platform_util.py @@ -191,7 +191,17 @@ def atomic_write_text(path: Path, text: str) -> None: The temp file is uniquely named in the target's own directory: same filesystem (``os.replace`` cannot cross one), and no fixed ``.tmp`` sibling for a concurrent writer of the same file to collide with. A failure anywhere leaves - the original untouched and removes the temp.""" + the original untouched and removes the temp. + + The contents are **fsynced before the replace publishes them**. Closing a file + only hands the data to the page cache, so a machine that loses power just + after the rename can come back with the new name pointing at blocks that were + never written — a zero-length or torn ledger, which parses as *no entries* and + so reads as the whole file's worth of hand-written work having vanished. + Ordering the flush before the rename means a crash yields either the old file + or the complete new one. The directory itself is deliberately not synced: that + would make the *rename* durable, and losing the rename just leaves the old + contents in place — stale, never corrupt.""" target = path.resolve() fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=target.name + ".", suffix=".tmp") tmp = Path(tmp_name) @@ -200,6 +210,8 @@ def atomic_write_text(path: Path, text: str) -> None: # this replaced, so a ledger's line endings do not change under Windows. with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(text) + fh.flush() # userspace buffer -> kernel, so there is something to sync + os.fsync(fh.fileno()) if target.exists(): shutil.copymode(target, tmp) _copy_xattrs(target, tmp) diff --git a/tests/test_platform_util.py b/tests/test_platform_util.py index 87ce9498..07a0b5eb 100644 --- a/tests/test_platform_util.py +++ b/tests/test_platform_util.py @@ -198,6 +198,30 @@ def test_atomic_write_text_preserves_extended_attributes(tmp_path): assert os.getxattr(target, "user.bmad-loop-test") == b"kept" +def test_atomic_write_text_fsyncs_before_it_publishes(tmp_path, monkeypatch): + """`os.replace` is atomic against concurrent readers, but that says nothing + about a machine losing power: closing the temp only hands its bytes to the + page cache, so the rename can be durable while the data is not, and the new + name comes back pointing at a zero-length file. An empty ledger *parses* — as + no entries — so the failure reads as every hand-written entry having vanished + rather than as corruption (CodeRabbit, review round 3).""" + order = [] + real_fsync, real_replace = os.fsync, os.replace + monkeypatch.setattr( + platform_util.os, "fsync", lambda fd: (order.append("fsync"), real_fsync(fd))[1] + ) + monkeypatch.setattr( + platform_util.os, "replace", lambda s, d: (order.append("replace"), real_replace(s, d))[1] + ) + target = tmp_path / "ledger.md" + target.write_text("before", encoding="utf-8") + + platform_util.atomic_write_text(target, "after") + + assert order == ["fsync", "replace"] # the sync must precede the publish + assert target.read_text(encoding="utf-8") == "after" + + def test_atomic_write_text_leaves_no_temp_behind(tmp_path): target = tmp_path / "ledger.md" target.write_text("before", encoding="utf-8") From e52d310e7b6e731e0ea28a7441c16146a0f82074 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:07:56 -0700 Subject: [PATCH 15/36] test: pin the two contracts these assertions only looked like they covered (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from CodeRabbit's nitpick set, and both worth more than the label. `mark_done_many`'s all-or-nothing test caught the write error with a bare `except OSError: pass`, which passes just as happily if the function ever starts swallowing the error and returning []. The engine's flush clears `pending_deferred_closes` only after that call returns, so a swallowed write would discharge an obligation nothing performed — exactly the failure the surrounding test was written to prevent. `pytest.raises` pins both halves. `integrate_unit` persists `unit_merged` before the bookkeeping it authorizes, and the reconcile keys its post-crash retry on that flag. Asserting the flag after the call returns is true for either ordering, so the assertion is taken from inside the callback instead — where "already set, already saved" is the whole contract. Skipped the third (hoisting `_external_paths`): real duplication, but in tests from an earlier round that this change does not otherwise touch. --- tests/test_deferredwork.py | 9 ++++++--- tests/test_worktree_flow.py | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 8fbb9cd6..507147a9 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -2,6 +2,8 @@ from pathlib import Path +import pytest + from bmad_loop.deferredwork import ( append_decision, append_entry, @@ -562,10 +564,11 @@ def test_mark_done_many_is_all_or_nothing_on_a_write_failure(tmp_path, monkeypat lambda path, text: (_ for _ in ()).throw(OSError("disk full")), ) - try: + # the raise must REACH the caller, not just leave the file alone: the engine's + # flush clears `pending_deferred_closes` only after the write returns, so a + # swallowed error returning [] would discharge an obligation nothing performed + with pytest.raises(OSError): mark_done_many(p, ["DW-1", "DW-3"], "2026-07-24", "note") - except OSError: - pass assert p.read_bytes() == before # nothing partially applied diff --git a/tests/test_worktree_flow.py b/tests/test_worktree_flow.py index 010f490d..b38f80bc 100644 --- a/tests/test_worktree_flow.py +++ b/tests/test_worktree_flow.py @@ -310,11 +310,20 @@ def test_integrate_unit_runs_post_integration_bookkeeping_after_a_merge(tmp_path flow.merge_local = lambda task, unit: merged.append(task) task = StoryTask(story_key="1-1", epic=1) task.phase = Phase.DONE + # capture the durable-proof flag AS the bookkeeping runs, not after it returns: + # asserting `task.unit_merged` at the end passes for either ordering, and the + # ordering is the contract — `_reconcile_pending_deferred_closes` keys the + # post-crash retry on this flag, and phase DONE is not a substitute for it + # (the commit stamps DONE, before integration). + seen = [] + inner = flow._on_integrated + flow._on_integrated = lambda t: (seen.append((t.unit_merged, flow.calls.saves)), inner(t))[1] flow.integrate_unit(task, unit=None) assert merged == [task] assert flow.calls.integrated == [task] + assert seen == [(True, 1)] # flag set AND persisted before the callback fired def test_integrate_unit_skips_bookkeeping_when_the_merge_escalates(tmp_path): From a2e7bc92b92d48c86f9816b3adc3e75fe5c04f71 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:45:32 -0700 Subject: [PATCH 16/36] fix(engine): arm the deferred-close rollback before the ledger write (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close ran one line above the try that owns the restore, and handed its snapshot back through the return value — which a raise inside the close never produces. `mark_done_many` publishes the ledger atomically and the journal line recording that publication is written after it, so a full disk between the two, or the stop signal `run()` installs landing anywhere in that window, left the entry reading `done` with nothing armed to undo it and `finalize_commit` never reached. A resolution claimed for a commit that does not exist is the one outcome this path exists to prevent. The snapshot is now reported through a caller-owned slot, armed before the ledger is touched, and the close runs inside the try. The ids are refined into that slot the statement after the write, so the rolled-back record names what it un-flipped instead of reporting an empty rollback; an empty list now means only that a stop landed inside the write itself, and the restore is by content, so it is complete either way. SweepEngine's no-op override takes the same optional parameter. --- src/bmad_loop/engine.py | 68 +++++++++++++++++++++++++++++++++++------ src/bmad_loop/sweep.py | 4 ++- tests/test_engine.py | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 8f6f974f..fa5684e2 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1766,9 +1766,20 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: # us, and a pre_commit pause veto has already raised out of _escalate # above — but finalize_commit's `git add -A` is still ahead, so an in-repo # annotation lands in this story's own commit. It is not final until that - # commit is: the snapshot below unwinds it if the commit fails. - rollback = self._close_declared_deferred(task) + # commit is: the snapshot armed below unwinds it if the commit fails. + # + # The close runs INSIDE this try, and reports its snapshot through `armed` + # rather than through the return value, because the window needing a + # rollback opens before it returns: `mark_done_many` publishes the ledger + # atomically and the journal append recording that publication can still + # raise (a full disk), as can the SIGTERM handler `run()` installed, from + # anywhere in between. Binding the snapshot only on return would leave + # every one of those raises with nothing to undo — the entry left reading + # `done` for a commit that never happened, the one outcome this whole + # path exists to prevent. + armed: list[tuple[Path, str, list[str]]] = [] try: + self._close_declared_deferred(task, armed) # bmad-dev-auto commits its own work each iteration; the orchestrator # squashes that chain plus its uncommitted bookkeeping back onto the # pre-dev baseline as one commit carrying `message`. None means there @@ -1782,7 +1793,7 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: task.resolved_redrive = False task.restore_patch = None except verify.GitError as e: - self._restore_deferred_closes(task, rollback) + self._restore_deferred_closes(task, armed[-1] if armed else None) self._escalate(task, f"commit failed: {e}") except BaseException: # A failed commit is not the only way out of this window. The signal @@ -1793,7 +1804,7 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: # exist, and nothing above unwinds it: RunStopped is caught by `run()` # to finalize a *stopped* run, not to repair bookkeeping. Restore, then # re-raise untouched — the caller's disposition is not ours to change. - self._restore_deferred_closes(task, rollback) + self._restore_deferred_closes(task, armed[-1] if armed else None) raise if not self._isolated: # An out-of-repo ledger could not ride the commit, so it was parked; @@ -2239,7 +2250,9 @@ def _declared_deferred_ids(self, task: StoryTask) -> tuple[str, ...]: ids += task.declared_deferred or [] return tuple(dict.fromkeys(ids)) - def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str]] | None: + def _close_declared_deferred( + self, task: StoryTask, rollback: list[tuple[Path, str, list[str]]] | None = None + ) -> tuple[Path, str, list[str]] | None: """At the commit boundary, flip every ledger entry the story declares via ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note (#234) — the regular-story counterpart of the sweep bundle close at @@ -2278,6 +2291,15 @@ def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str Returns the pre-close ledger text — with its path and the ids actually flipped — when an in-repo write marked something, else None. + ``rollback`` is the same snapshot, reported through a list the CALLER + owns and **before the ledger is touched**, because the return value + cannot cover the window that needs covering: the atomic write publishes, + and then the journal append recording it can raise, as can the stop + signal, before this function ever returns. A caller that only had the + return value would meet those raises holding nothing to undo. The slot is + cleared again when nothing was flipped — ``mark_done_many`` writes only + when it marks — so an empty close never arms a pointless restore. + Never a gate: an unmatched or malformed id is journaled, never fatal. Idempotent, so the resume arm may re-drive the commit phase freely — ids are classified against a ledger snapshot rather than from ``mark_done``'s @@ -2324,8 +2346,19 @@ def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str ) return None before = ledger.read_text(encoding="utf-8") if ledger.is_file() else None - marked = self._apply_deferred_closes(task, ids, ledger) - return (ledger, before, marked) if marked and before is not None else None + if rollback is not None and before is not None: + # Armed here, not on the way out. Restoring text nothing changed is a + # no-op rewrite, so arming early costs an over-broad window nothing, + # while arming late would miss the whole publication window. + rollback.append((ledger, before, [])) + marked = self._apply_deferred_closes(task, ids, ledger, rollback) + snapshot = (ledger, before, marked) if marked and before is not None else None + if rollback is not None: + # `mark_done_many` writes only when it marks, so an empty close left + # the ledger byte-identical: disarm rather than record a restore that + # would journal a rollback of nothing. + rollback[:] = [snapshot] if snapshot is not None else [] + return snapshot def _restore_deferred_closes( self, task: StoryTask, snapshot: tuple[Path, str, list[str]] | None @@ -2366,20 +2399,37 @@ def _restore_deferred_closes( story_key=task.story_key, # what was actually flipped and is now un-flipped, NOT everything the # story declared: an id that was already done stays done either way. + # Empty means the close was interrupted before it could report what it + # had flipped (a stop signal landing inside the write). The restore is + # by content, not by id, so it is complete either way — only the record + # of which entries it touched is missing. dw_ids=list(marked), ledger=str(ledger), ) def _apply_deferred_closes( - self, task: StoryTask, ids: Sequence[str], ledger: Path + self, + task: StoryTask, + ids: Sequence[str], + ledger: Path, + rollback: list[tuple[Path, str, list[str]]] | None = None, ) -> list[str]: """Write the closure for `ids`, journal exactly what landed, and return - the ids actually flipped.""" + the ids actually flipped. + + ``rollback`` is the caller's already-armed restore slot (see + ``_close_declared_deferred``); the ids are written into it the moment they + are known, which is the statement after the write and the one before the + first thing here that can raise. Refining it costs a line and buys the + operator a rolled-back record that names entries instead of an empty + list.""" text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" declared = deferredwork.classify(text, ids) marked = deferredwork.mark_done_many( ledger, declared.open_ids, self._today(), f"resolved by story {task.story_key}" ) + if rollback and marked: + rollback[0] = (ledger, text, marked) if marked: self.journal.append("story-deferred-closed", story_key=task.story_key, dw_ids=marked) if declared.unknown: diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index b9c7b2d9..1335381d 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1225,7 +1225,9 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non success_status = "in-review" if self._dev_review_enabled() else "done" self._close_bundle_ledger_when_spec_status(task, str(spec_file), success_status) - def _close_declared_deferred(self, task: StoryTask) -> tuple[Path, str, list[str]] | None: + def _close_declared_deferred( + self, task: StoryTask, rollback: list[tuple[Path, str, list[str]]] | None = None + ) -> tuple[Path, str, list[str]] | None: """No-op: a bundle's ledger closure is owned by ``_close_bundle_ledger_when_spec_status``, which runs at dev-sync time because ``verify_review_bundle`` *requires* those entries closed before it diff --git a/tests/test_engine.py b/tests/test_engine.py index 0332c8b0..69179467 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2515,6 +2515,72 @@ def cannot_spawn(*a, **kw): assert project.deferred_work.read_bytes() == before +def test_closes_deferred_rolls_back_when_the_journal_fails_after_the_ledger_write( + project, monkeypatch +): + """The ledger publishes atomically and the journal line recording it is + written *after*. A full disk between the two used to leave the entry reading + `done` with the rollback un-armed: the snapshot was bound from the close's + RETURN value, which a raise inside it never produces, so the restore had + nothing to work from and `finalize_commit` never ran either — a resolution + claimed for a commit that does not exist (#284 round-5 review, finding 1). + + The snapshot is armed before the write now, so the failure unwinds whether it + lands in the commit or inside the close itself.""" + engine = _closes_deferred_run(project, ["DW-1"]) + before = project.deferred_work.read_bytes() + real_append = engine.journal.append + + def full_disk_recording_the_close(kind, **fields): + # fault ONLY the close record: the rollback's own journal line has to + # survive, or the test could not tell a restore from a crash + if kind == "story-deferred-closed": + raise OSError("No space left on device") + return real_append(kind, **fields) + + monkeypatch.setattr(engine.journal, "append", full_disk_recording_the_close) + + summary = engine.run() + + assert summary.crashed + assert _ledger_entries(project)["DW-1"].open + assert project.deferred_work.read_bytes() == before # byte-identical + rolled = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-rolled-back"] + # the ids are refined into the armed slot right after the write, so the record + # names what it un-flipped rather than reporting an empty rollback + assert len(rolled) == 1 and rolled[0]["dw_ids"] == ["DW-1"] + + +def test_closes_deferred_rolls_back_when_a_signal_stops_the_close_itself(project, monkeypatch): + """The stop signal can land INSIDE the close, not only inside the commit: the + handler raises from wherever the main thread stands, and the ledger is already + published by the time `mark_done_many` returns. Nothing downstream runs — no + close record, no commit — so without an early-armed snapshot the entry stayed + `done` for work that never landed (#284 round-5 review, finding 1).""" + monkeypatch.setattr("bmad_loop.engine.kill_session", lambda rid: None) + engine = _closes_deferred_run(project, ["DW-1"]) + before = project.deferred_work.read_bytes() + real_mark = deferredwork.mark_done_many + + def sigterm_after_publication(*a, **kw): + marked = real_mark(*a, **kw) # the flip is on disk now + signal.raise_signal(signal.SIGTERM) + return marked # unreachable: the handler raises first + + monkeypatch.setattr(deferredwork, "mark_done_many", sigterm_after_publication) + + summary = engine.run() + + assert summary.done == 0 + assert load_state(engine.run_dir).stopped is True # the stop really landed + assert _ledger_entries(project)["DW-1"].open + assert project.deferred_work.read_bytes() == before + kinds = {e["kind"] for e in engine.journal.entries()} + # the close never got to report itself, and the restore still happened + assert "story-deferred-closed" not in kinds + assert "deferred-close-rolled-back" in kinds + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_closes_deferred_parks_a_ledger_symlinked_out_of_the_repo(project, tmp_path): """Locality is decided on resolved paths. `is_relative_to` is lexical while From 8fb0aa775a2d384906224865138ccf48cf5de1a1 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:46:46 -0700 Subject: [PATCH 17/36] fix(engine): a spec that is gone or out of the roots declares nothing (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both early returns in the capture reported success without touching `declared_deferred`, and the commit-boundary read unions that capture in unconditionally. So a `DW-1` captured when the dev artifacts verified was still marked resolved after a pre_commit_gate workflow archived, renamed or deleted the spec, and after one was redirected outside the approved roots — the root-containment guard journaling a skip it did not actually perform. That is the stale half the commit-boundary re-read exists to eliminate: the close ran against a declaration no readable spec makes. A spec that is gone or out of tree is a different answer from one that could not be read, so both now clear the capture and journal the withdrawal. Only a genuine read fault still leaves the last good capture standing, which is what the COMMITTING resume arm leans on. Withdrawal by deletion is still withdrawal: a missed close beats a false one. --- src/bmad_loop/engine.py | 24 ++++++++++++++-- tests/test_engine.py | 61 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index fa5684e2..3b905d01 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2182,15 +2182,31 @@ def _capture_declared_deferred(self, task: StoryTask, *, site: str) -> bool: A failed read leaves any earlier capture standing — a later attempt's silence is not a retraction. False only when there IS a spec to read and - reading it failed; the caller decides whether that costs anything. A - missing or out-of-tree spec is nothing to capture, not a failure to - capture. + reading it failed; the caller decides whether that costs anything. + + A spec that is **gone** or has moved **out of the roots** is a different + answer from a spec that could not be read, and it clears the capture. Both + used to return success without touching it, so a `DW-1` captured at + dev-verify was still closed after a pre-commit workflow deleted, renamed + or redirected the spec — closing against a declaration no readable spec + makes, which is the stale half this re-read exists to eliminate. Only a + genuine read fault keeps the fallback standing, and the withdrawal is + journaled rather than inferred silently (#284 round-5 review, finding 2). The path is not re-derived here: ``task.spec_file`` is recorded only by a passing verify gate, and the root-containment rule below still holds at both sites.""" spec_path = Path(task.spec_file) if task.spec_file else None if spec_path is None or not spec_path.is_file(): + if task.declared_deferred: + self.journal.append( + "deferred-close-declaration-absent", + story_key=task.story_key, + spec=str(spec_path) if spec_path else None, + site=site, + dw_ids=list(task.declared_deferred), + ) + task.declared_deferred = [] return True if not verify.spec_within_roots(spec_path, self.workspace.paths): self.journal.append( @@ -2198,7 +2214,9 @@ def _capture_declared_deferred(self, task: StoryTask, *, site: str) -> bool: story_key=task.story_key, spec=str(spec_path), site=site, + dw_ids=list(task.declared_deferred or []), ) + task.declared_deferred = [] return True fm = self._observed_frontmatter(spec_path, task.story_key, f"deferred-close-{site}") if fm is None: diff --git a/tests/test_engine.py b/tests/test_engine.py index 69179467..99c359f8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2438,6 +2438,67 @@ def edited_after_verification(task): assert [e["dw_ids"] for e in closed] == [["DW-3"]] +def test_closes_deferred_drops_the_capture_when_the_final_spec_is_gone(project, monkeypatch): + """A spec that is GONE at the commit is not a spec that could not be read. The + absent branch returned success without clearing the verify-time capture, and + the close then unioned it in regardless — so a pre-commit workflow that + archived, renamed or deleted the spec still had its ids marked resolved, + against a declaration no readable spec makes (#284 round-5 review, finding 2). + + Withdrawal by deletion is still withdrawal: a missed close beats a false + one.""" + engine = _closes_deferred_run(project, ["DW-1"]) + sp = spec_path(project, "1-1-a") + finalize = engine._finalize_commit_phase + + def spec_removed_before_the_commit(task): + assert engine.state.tasks["1-1-a"].declared_deferred == ["DW-1"] # captured at verify + sp.unlink() # the shape of a pre_commit_gate workflow archiving the spec + return finalize(task) + + monkeypatch.setattr(engine, "_finalize_commit_phase", spec_removed_before_the_commit) + + summary = engine.run() + + assert summary.done == 1 # the story still commits; only the closure is dropped + assert _ledger_entries(project)["DW-1"].open + events = [ + e for e in engine.journal.entries() if e["kind"] == "deferred-close-declaration-absent" + ] + assert len(events) == 1 and events[0]["dw_ids"] == ["DW-1"] + assert "story-deferred-closed" not in {e["kind"] for e in engine.journal.entries()} + + +def test_closes_deferred_drops_the_capture_when_the_final_spec_leaves_the_roots( + project, tmp_path, monkeypatch +): + """Same hole through the other early return. The root-containment guard + journaled and returned success while leaving the capture standing, so a spec + redirected out of the approved roots between verification and the commit was + refused a read and closed from the stale snapshot anyway — the guard reporting + a skip it did not perform.""" + engine = _closes_deferred_run(project, ["DW-1"]) + finalize = engine._finalize_commit_phase + outside = tmp_path / "elsewhere" / "story.md" + outside.parent.mkdir(parents=True, exist_ok=True) + outside.write_text("---\nstatus: done\ncloses_deferred: [DW-1]\n---\n", encoding="utf-8") + + def spec_redirected_before_the_commit(task): + task.spec_file = str(outside) + return finalize(task) + + monkeypatch.setattr(engine, "_finalize_commit_phase", spec_redirected_before_the_commit) + + summary = engine.run() + + assert summary.done == 1 + assert _ledger_entries(project)["DW-1"].open # not closed from the stale capture + skipped = [ + e for e in engine.journal.entries() if e["kind"] == "deferred-close-skipped-out-of-tree" + ] + assert skipped and skipped[-1]["dw_ids"] == ["DW-1"] + + def test_closes_deferred_capture_survives_a_committing_resume(project, monkeypatch): """The COMMITTING resume arm finishes a commit WITHOUT re-verifying, so when its own re-read faults the persisted capture is all that knows what the story From 3decb0895937eddbd5e98202ff449bf6202e5ff8 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:48:22 -0700 Subject: [PATCH 18/36] fix(worktree): stamp the merge proof at the merge, not after its tail (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unit_merged` is the durable proof `_reconcile_pending_deferred_closes` keys its post-crash retry on, but it was stamped only once `merge_local` returned — after a journal append, the `post_merge` emit and the worktree teardown, none of which is evidence about whether the merge happened. Any of them raising left the code durably merged with persisted state claiming it was not, and resume then took the abandoned path and left the external ledger open. The stamp now lands the statement after `verify.merge_branch` returns, so the window is that save alone. The residual direction is still deliberate: a death in it reports a merge that landed as abandoned and the entries stay open, where recording the intent before the merge would close entries for a merge that never happened. `integrate_unit` no longer duplicates the stamp; the unit test that stubs merge_local out now carries it, next to a new test pinning the ordering at its new owner. --- src/bmad_loop/engine.py | 10 ++++++--- src/bmad_loop/worktree_flow.py | 27 ++++++++++++++++-------- tests/test_worktree_flow.py | 38 +++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 3b905d01..c76463b9 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -787,9 +787,13 @@ def _reconcile_pending_deferred_closes(self) -> None: unit never reached the target branch, and the ledger reading `open` is the truthful answer, so the obligation is reported rather than discharged. - `unit_merged` is saved *after* `merge_local` returns, so a host death in - that one window reports a merge that did land as abandoned, and the entry - stays open. That direction is deliberate: the entries stay open (which a + `unit_merged` is saved the statement after the merge itself returns, so + the window in which a host death reports a merge that did land as + abandoned is that save alone. It used to span `merge_local`'s whole tail — + a journal append, the `post_merge` emit, the worktree teardown — none of + which is evidence about whether the merge happened. + + That residual direction is deliberate: the entries stay open (which a sweep re-verifies against the codebase, and which `deferred-close-abandoned` names for the operator), where recording the intent before the merge would instead close entries for a merge that never happened. Proving it from the diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index afc3fc87..69d02b2f 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -479,21 +479,19 @@ def integrate_unit(self, task: StoryTask, unit: UnitWorkspace) -> None: # Merge the unit branch into the target branch locally. We open PRs # ourselves by hand once the branch has landed; the orchestrator only # commits the worktree onto the selected target. - self.merge_local(task, unit) # The unit's work is now on the target branch — the only point at # which bookkeeping that could NOT ride the unit's own commit (an # out-of-repo deferred-work ledger) may safely claim it landed. A # merge that escalates raises out of merge_local and never reaches # here, leaving the shared ledger untouched (#234). # - # Record the merge BEFORE that bookkeeping runs, and persist it. The - # task is already DONE, so a crash in this window is not re-driven by - # resume (`_finish_inflight` skips terminal tasks) — the reconcile - # pass that finishes the leftover bookkeeping needs durable proof the - # work actually landed, and "phase is DONE" is not that proof: it is - # stamped by the commit, before any of this. - task.unit_merged = True - self._save() + # `merge_local` stamps and persists `task.unit_merged` the moment the + # merge itself returns — durable proof the work landed, which the + # reconcile pass keys its retry on. The task is already DONE, so a + # crash in this window is not re-driven by resume (`_finish_inflight` + # skips terminal tasks), and "phase is DONE" is not a substitute for + # that proof: it is stamped by the commit, before any of this. + self.merge_local(task, unit) self._on_integrated(task) else: # DEFERRED — capture the diff, keep or drop per keep_failed patch = close_unit_workspace( @@ -563,6 +561,17 @@ def merge_local(self, task: StoryTask, unit: UnitWorkspace) -> None: ) self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused return # defensive: never fall through to the success teardown below + # The merge has landed. Stamp and persist that BEFORE the tail below, + # every line of which can still raise — a journal write on a full disk, a + # post_merge plugin, a teardown callback — while saying nothing about + # whether the merge happened. Stamping after the tail left the code + # durably merged with persisted state claiming it was not, so the resume + # reconcile abandoned the external-ledger closure it was owed + # (#284 round-5 review, finding 3). The residual window is now the merge + # and this save, which is the narrowest it can be: nothing but the merge + # itself is evidence about the merge (#234). + task.unit_merged = True + self._save() self.journal.append( "unit-merged", story_key=task.story_key, diff --git a/tests/test_worktree_flow.py b/tests/test_worktree_flow.py index b38f80bc..998e55ea 100644 --- a/tests/test_worktree_flow.py +++ b/tests/test_worktree_flow.py @@ -307,7 +307,15 @@ def test_integrate_unit_runs_post_integration_bookkeeping_after_a_merge(tmp_path It fires after merge_local, so it is reached only by a unit that landed.""" flow = _make_flow(tmp_path, state=SimpleNamespace(target_branch="main", run_id="r", tasks={})) merged = [] - flow.merge_local = lambda task, unit: merged.append(task) + + def merge(task, unit): + # the stamp lives inside merge_local, next to the merge it is proof of + # (see test_merge_local_persists_unit_merged_before_its_journal_tail) + merged.append(task) + task.unit_merged = True + flow._save() + + flow.merge_local = merge task = StoryTask(story_key="1-1", epic=1) task.phase = Phase.DONE # capture the durable-proof flag AS the bookkeeping runs, not after it returns: @@ -326,6 +334,34 @@ def test_integrate_unit_runs_post_integration_bookkeeping_after_a_merge(tmp_path assert seen == [(True, 1)] # flag set AND persisted before the callback fired +def test_merge_local_persists_unit_merged_before_its_journal_tail(tmp_path, monkeypatch): + """`unit_merged` is the durable proof `_reconcile_pending_deferred_closes` + keys its post-crash retry on, so the merge itself must stamp it — not + everything that follows the merge. + + The tail after `verify.merge_branch` returns (a journal append, the post_merge + emit, the worktree teardown) can each still raise while saying nothing about + whether the merge happened. Stamping after that tail left the code durably + merged with persisted state claiming it was not, and resume then abandoned the + external-ledger closure it was owed (#284 round-5 review, finding 3).""" + flow = _make_flow(tmp_path, state=SimpleNamespace(target_branch="main", run_id="r", tasks={})) + monkeypatch.setattr(verify, "clean_incoming_collisions", lambda *a, **kw: []) + monkeypatch.setattr(verify, "merge_branch", lambda *a, **kw: None) + + def full_disk(event, **fields): + raise OSError("No space left on device") + + flow.journal.append = full_disk # the first fallible step after the merge + task = StoryTask(story_key="1-1", epic=1) + task.phase = Phase.DONE + + with pytest.raises(OSError): + flow.merge_local(task, unit=SimpleNamespace(branch="unit/1-1")) + + assert task.unit_merged # the merge landed, and the proof of it survived + assert flow.calls.saves == 1 # persisted, not just set in memory + + def test_integrate_unit_skips_bookkeeping_when_the_merge_escalates(tmp_path): """A merge that escalates raises out of merge_local. Nothing downstream may claim the unit's work landed — a shared ledger must still read `open`.""" From 54bff9fe703bf23e6dc47c333ec0128a6cf81013 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:50:02 -0700 Subject: [PATCH 19/36] fix(engine): an unavailable ledger location is not an answer about the entries (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read that raises kept the parked obligation by unwinding, but an absent ledger does not raise: it reads as empty text, every id classifies as unknown, and the flush then cleared the ids on the strength of a ledger nobody could see. The risk is specific to this path — it exists because the ledger is out of the repo, so it can sit on a mount that is temporarily gone. The artifact directory tells the two cases apart. Gone means the location is unavailable: journal it and keep the obligation for the reconcile. Present with no ledger in it means there is genuinely nothing to close — the same answer the in-repo path gives — and stays a discharge. Retaining on any missing ledger, as the review proposed, would instead create an obligation nothing could ever satisfy for a project that has never filed deferred work, re-journaled by every later resume. Both directions have a test; the over-broad rule fails the second one. --- src/bmad_loop/engine.py | 24 ++++++++++++- tests/test_engine.py | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index c76463b9..35d24c0f 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2481,11 +2481,33 @@ def _flush_pending_deferred_closes(self, task: StoryTask) -> None: the flush is about to happen — but a raising ledger write then unwinds to ``run()``'s crash handler, whose ``finally: self._save()`` persists the emptied list: the obligation is destroyed by the very failure that made it - worth retrying.""" + worth retrying. + + **An unavailable location is not an answer about the entries.** A read + that raises keeps the obligation by unwinding, but an absent file does + not raise: ``_apply_deferred_closes`` reads a missing ledger as empty + text, every id classifies as unknown, and the ids were then cleared on + the strength of a ledger nobody could see. That is a real risk here and + only here — this path exists *because* the ledger is out of the repo, so + it can live on a mount that is temporarily gone. The artifact directory + is what distinguishes the two: gone means the location is unavailable and + the obligation is retryable; present-but-no-ledger means there is + genuinely nothing to close, which is the same answer the in-repo path + gives and stays a discharge rather than an obligation nothing could ever + satisfy (#284 round-5 review, finding 4).""" ids = tuple(task.pending_deferred_closes) if not ids: return ledger = self.workspace.paths.deferred_work + if not ledger.parent.is_dir(): + self.journal.append( + "deferred-close-ledger-unavailable", + story_key=task.story_key, + dw_ids=list(ids), + ledger=str(ledger), + note="the artifact directory is not present; the closure stays owed and is retried", + ) + return self.journal.append( "deferred-close-external-ledger", story_key=task.story_key, diff --git a/tests/test_engine.py b/tests/test_engine.py index 99c359f8..f23fac1e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2356,6 +2356,84 @@ def test_closes_deferred_external_write_failure_keeps_the_obligation(project, tm assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] +def test_closes_deferred_external_ledger_unavailable_keeps_the_obligation(project, tmp_path): + """A read that raises keeps the obligation by unwinding, but an ABSENT ledger + does not raise: it reads as empty text, every id classifies as unknown, and + the flush cleared the parked ids on the strength of a ledger nobody could see. + + That risk is specific to this path — it exists *because* the ledger is out of + the repo, so it can sit on a mount that is temporarily gone. The artifact + directory tells the two apart: gone is an unavailable location, not an answer + about the entries (#284 round-5 review, finding 4).""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + external = paths.implementation_artifacts + unmounted = tmp_path / "unmounted" + real_flush = engine._flush_pending_deferred_closes + + def flush_with_the_mount_away(task): + external.rename(unmounted) # the shape of a shared mount dropping out + try: + return real_flush(task) + finally: + unmounted.rename(external) # ...and back, so the rest of the run is normal + + engine._flush_pending_deferred_closes = flush_with_the_mount_away + + summary = engine.run() + + assert summary.done == 1 + entries = { + e.id: e for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) + } + assert entries["DW-1"].open # nothing was written against an invisible ledger + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-ledger-unavailable" in kinds + assert "story-deferred-closed" not in kinds + # owed, not discharged — and the id is not silently reported as a typo + assert "deferred-close-unmatched" not in kinds + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] + + # the mount is back: the reconcile pass a later run makes finishes the job + engine._flush_pending_deferred_closes = real_flush + engine._reconcile_pending_deferred_closes() + + entries = { + e.id: e for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) + } + assert not entries["DW-1"].open + assert engine.state.tasks["1-1-a"].pending_deferred_closes == [] + + +def test_closes_deferred_external_ledger_absent_from_a_live_dir_still_discharges( + project, tmp_path +): + """The other side of that discriminator. An artifact dir that IS there and + simply has no ledger is not an outage — there is genuinely nothing to close, + which is the same answer the in-repo path gives. Retaining here would leave an + obligation nothing could ever satisfy, re-journaled by every later resume.""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-9"])], + ) + assert not paths.deferred_work.exists() # no ledger was ever filed + + summary = engine.run() + + assert summary.done == 1 + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-unmatched" in kinds # reported as the stale reference it is + assert "deferred-close-ledger-unavailable" not in kinds + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] + + def test_closes_deferred_uses_the_verified_capture_when_the_commit_read_faults( project, monkeypatch ): From eec9857f08e35e8ce84c1d9bf5a28f3846770e7e Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:51:37 -0700 Subject: [PATCH 20/36] fix(stories): an unreadable manifest is a StoriesError like every other fault (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load_stories` translated UnicodeDecodeError but let an OSError from the read escape raw — `is_file()` only rules out absence, not permissions, an I/O error or a dead mount. `_manifest_closes_deferred` catches StoriesError, so its advertised fallback (journal the unreadable manifest, carry on with the spec channel) never fired: a commit-time PermissionError crashed the whole run instead of costing one declaration channel. Translating it at the read rather than at that one call site fixes the same hole for every caller — the scheduler, dispatch, preflight, status and validate all already catch StoriesError and turn it into a clean "stories mode:" message. --- src/bmad_loop/stories.py | 9 ++++++++ tests/test_stories.py | 14 +++++++++++++ tests/test_stories_engine.py | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/bmad_loop/stories.py b/src/bmad_loop/stories.py index 13b99417..84d898cd 100644 --- a/src/bmad_loop/stories.py +++ b/src/bmad_loop/stories.py @@ -133,6 +133,15 @@ def load_stories(spec_folder: Path | str) -> Stories: # fault — every caller already catches that and prints a clean "stories mode:" # error instead of crashing preflight/dry-run/status with a traceback. raise StoriesError(f"stories.yaml is not valid UTF-8: {path}: {e}") from e + except OSError as e: + # Same rule for a manifest that is present but cannot be read (permissions, + # an I/O error, a dead mount). `is_file()` above only rules out absence, so + # this escaped as a bare OSError and crashed the run from whichever caller + # happened to hit it first — including the commit-boundary read of + # `closes_deferred`, whose whole contract is to journal an unreadable + # manifest and carry on with the spec channel (#284 round-5 review, + # finding 5). + raise StoriesError(f"stories.yaml could not be read: {path}: {e}") from e try: doc = yaml.safe_load(raw) except yaml.YAMLError as e: diff --git a/tests/test_stories.py b/tests/test_stories.py index 809cb56f..b943f352 100644 --- a/tests/test_stories.py +++ b/tests/test_stories.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from conftest import fault_read_text from bmad_loop import stories @@ -67,6 +68,19 @@ def test_load_non_utf8_raises_stories_error(tmp_path): stories.load_stories(tmp_path) +def test_load_unreadable_raises_stories_error(tmp_path, monkeypatch): + """A manifest that is present but unreadable — permissions, an I/O error, a + dead mount — must be a StoriesError like every other manifest fault. `is_file` + only rules out absence, so the OSError escaped raw and crashed the run from + whichever caller reached it first, including the commit-boundary read of + `closes_deferred`, whose contract is to journal and carry on + (#284 round-5 review, finding 5).""" + write_stories(tmp_path, "- id: 1\n title: t\n description: d\n") + fault_read_text(monkeypatch, tmp_path / stories.STORIES_FILENAME) + with pytest.raises(stories.StoriesError, match="could not be read"): + stories.load_stories(tmp_path) + + def test_id_unquoted_int_normalized(tmp_path): # An LLM-authored file may emit `id: 1` unquoted (PyYAML -> int); we str()-normalize. write_stories(tmp_path, "- id: 1\n title: t\n description: d\n") diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 1f3ac21e..0efb0769 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1341,6 +1341,46 @@ def test_stories_mode_closes_ids_declared_in_the_manifest(project): assert _kinds(engine.journal, "story-deferred-closed")[0]["dw_ids"] == ["DW-1"] +def test_stories_mode_unreadable_manifest_at_commit_does_not_crash_the_run(project, monkeypatch): + """`_manifest_closes_deferred` advertises a fallback — journal the unreadable + manifest, carry on with the spec channel — but it catches only StoriesError, + and `load_stories` let an OSError from the read escape raw. A commit-time + permission fault therefore crashed the whole run instead of costing one + channel (#284 round-5 review, finding 5). + + The fault is transient and scoped to the close, which is the shape that + matters: a manifest unreadable for the whole run is a preflight failure, not + this.""" + engine = _closing_run(project, spec=["DW-1"]) + manifest = project.project / SPEC_FOLDER / "stories.yaml" + real_read = Path.read_text + faulted = {"on": False} + + def maybe_fault(self, *a, **kw): + if faulted["on"] and self == manifest: + raise PermissionError(13, "Permission denied") + return real_read(self, *a, **kw) + + monkeypatch.setattr(Path, "read_text", maybe_fault) + finalize = engine._finalize_commit_phase + + def unreadable_across_the_close(task): + faulted["on"] = True + try: + return finalize(task) + finally: + faulted["on"] = False + + monkeypatch.setattr(engine, "_finalize_commit_phase", unreadable_across_the_close) + + summary = engine.run() + + assert summary.done == 1 # the run survives; only the manifest channel is lost + assert not _entries(project)["DW-1"].open # the spec channel still closed it + lost = _kinds(engine.journal, "deferred-close-declaration-unreadable") + assert lost and lost[-1]["source"] == "stories.yaml" + + def test_stories_mode_unions_manifest_and_frontmatter_declarations(project): """Both channels are honored, and an id named in both is marked and reported once — the natural case once a planner declares it and the spec echoes it.""" From 2fe4066eb1593c3c22fbd9505830906e52d97885 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:53:35 -0700 Subject: [PATCH 21/36] fix(validate): report a ledger it cannot read instead of returning silently (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ledger read shared a try with the declaration scan, whose silent return is correct for its own reason: an unparseable manifest is already reported by `queue.stories-manifest` and must not be double-counted. Nothing else in `validate` reads the deferred-work ledger, though, so that silence meant an unreadable one produced no finding at all — preflight reporting success for a check that examined nothing, against the very file the run's own closure will fail on. Split the two. A ledger read fault now warns `deferred.ledger-unreadable` (registered in VALIDATE_CHECKS) and the declaration checks it could not run stay quiet rather than guessing; manifest faults keep their single report. Still a warning, never a gate. --- src/bmad_loop/checks.py | 1 + src/bmad_loop/cli.py | 20 ++++++++++++++++++++ tests/test_cli.py | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index a42ae0d8..7ffea0f3 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -74,6 +74,7 @@ "deferred.closes-unknown", "deferred.closes-malformed", "deferred.closes-entry-unreadable", + "deferred.ledger-unreadable", } ) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 99e6246c..55558cc7 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -642,12 +642,32 @@ def _validate_closes_deferred( close nothing and say nothing. Covering only two of the three left the third to be discovered in the journal after the run it should have preceded. + A ledger that cannot be read at all is reported as well. Staying quiet there + is not the same trade as staying quiet about an unparseable manifest: the + manifest is already reported by ``queue.stories-manifest``, while nothing + else in ``validate`` reads the ledger, so silence meant reporting success for + a preflight that checked nothing. + Never a failure. The annotation is traceability, not a gate, so a stale reference must not be able to block a run that would otherwise start. """ ledger = paths.deferred_work try: text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + except (OSError, UnicodeDecodeError) as e: + # Split from the manifest read below, which is silent for a good reason + # that does not apply here: nothing else in `validate` reads the ledger, so + # returning quietly reported success for a preflight that checked nothing, + # against the very file the run's closure will fail on + # (#284 round-5 review, finding 6). + report.warn( + "deferred.ledger-unreadable", + f"{ledger} cannot be read ({e}) — closes_deferred declarations were not " + "checked against it, and the run's own closure will fail the same way", + {"ledger": str(ledger), "error": str(e)}, + ) + return + try: sources = ( _stories_declarations(paths, spec_folder) if spec_folder is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index f53d1698..fa952341 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ import yaml from conftest import ( escalated_run, + fault_read_text, git, install_bmad_config, install_dev_base_skills, @@ -3459,6 +3460,32 @@ def test_validate_warns_on_unknown_closes_deferred_in_sprint_mode(project, capsy assert findings[0]["detail"] == {"source": "spec spec-1-1-a.md", "unknown_ids": ["DW-99"]} +def test_validate_warns_when_the_ledger_itself_is_unreadable(project, capsys, monkeypatch): + """The ledger read shared a `try` with the manifest read, and that arm returns + silently — correctly for the manifest, which `queue.stories-manifest` already + reports, but nothing else in `validate` reads the ledger. So an unreadable one + produced no finding at all: preflight reported success for a check that + examined nothing, against the very file the run's closure will fail on + (#284 round-5 review, finding 6).""" + install_bmad_config(project) + _write_policy(project.project) + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_ledger(project, {"DW-1": "open"}, commit=False) + write_spec(spec_path(project, "1-1-a"), "ready-for-dev", "abc123", closes_deferred=["DW-1"]) + fault_read_text(monkeypatch, project.deferred_work) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + doc = json.loads(capsys.readouterr().out) + findings = [f for f in doc["findings"] if f["check"] == "deferred.ledger-unreadable"] + assert len(findings) == 1 + assert findings[0]["severity"] == "warning" # advisory: still never a gate + assert findings[0]["detail"]["ledger"] == str(project.deferred_work) + # and the declaration checks it could not run stay quiet rather than guessing + assert not [f for f in doc["findings"] if f["check"] == "deferred.closes-unknown"] + + def test_validate_warns_on_a_malformed_closes_deferred_declaration(project, capsys): """`closes_deferred: DW-1` (a scalar, not a list) declares real intent that reads as empty. Silence there was indistinguishable from having no From 087fd3300a906ab0cd59c0e55b5e633a5e48c6ba Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 13:57:15 -0700 Subject: [PATCH 22/36] style: black formatting on the new external-ledger test (#234 review) --- tests/test_engine.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_engine.py b/tests/test_engine.py index f23fac1e..c754f38c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2410,9 +2410,7 @@ def flush_with_the_mount_away(task): assert engine.state.tasks["1-1-a"].pending_deferred_closes == [] -def test_closes_deferred_external_ledger_absent_from_a_live_dir_still_discharges( - project, tmp_path -): +def test_closes_deferred_external_ledger_absent_from_a_live_dir_still_discharges(project, tmp_path): """The other side of that discriminator. An artifact dir that IS there and simply has no ledger is not an outage — there is genuinely nothing to close, which is the same answer the in-repo path gives. Retaining here would leave an From d6cba04600e9ef7b5635ed4786d20bfc7ae138e1 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:41:35 -0700 Subject: [PATCH 23/36] =?UTF-8?q?fix(deferredwork):=20one=20duplicate=20id?= =?UTF-8?q?,=20one=20answer=20=E2=80=94=20classify=20what=20the=20write=20?= =?UTF-8?q?marks=20(#234=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classify` indexed the LAST entry of a duplicated `DW-*` id while `_find_entry` — and so every mutation in the module — takes the FIRST. Both orders then closed nothing and said nothing: a done-first ledger classified the id `open`, sent it to `mark_done_many`, and had `_apply_done` refuse the done copy it found first, marking nothing at all (not even an unmatched warning); an open-first ledger classified it `already_done` and never attempted the write. Index first-wins so the classification names the entry the write acts on, and report the duplicate itself through `Declared.duplicates` / `deferred-close-duplicate-id`: one id naming two entries is a fault about the ledger (#286), not an answer about the work, and the second entry is neither read nor written either way. Duplicate reporting in `validate` stays with #286, which owns ledger corruption. --- src/bmad_loop/deferredwork.py | 29 +++++++++++++-- src/bmad_loop/engine.py | 11 ++++++ tests/test_deferredwork.py | 67 +++++++++++++++++++++++++++++++++++ tests/test_engine.py | 38 ++++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index a3d17592..9c104fab 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -107,12 +107,18 @@ class Declared: already landed — and must stay silent. ``malformed`` is an entry that exists but carries neither an ``open`` nor a ``done`` status: nothing can be marked, and saying nothing would leave the operator believing it was. + + ``duplicates`` cross-cuts the other four: it names the declared ids the ledger + carries more than once, whichever bucket they landed in. A duplicate id is a + corrupt ledger (#286), and the entry this classification describes is only one + of them — so the close is reported, never silent. """ open_ids: tuple[str, ...] = () already_done: tuple[str, ...] = () unknown: tuple[str, ...] = () malformed: tuple[str, ...] = () + duplicates: tuple[str, ...] = () def classify(text: str, ids: Sequence[str]) -> Declared: @@ -120,8 +126,26 @@ def classify(text: str, ids: Sequence[str]) -> Declared: Classifying from a snapshot rather than from :func:`mark_done`'s return value is deliberate: that return conflates "already done" with "absent from the - ledger", and those need opposite treatment (silence vs. a warning).""" - by_id = {e.id: e for e in parse_ledger(text)} + ledger", and those need opposite treatment (silence vs. a warning). + + **The FIRST entry of a duplicated id wins**, because that is the one + :func:`_find_entry` — and so every mutation in this module — acts on. Indexing + last-wins instead made the two disagree, and a ledger carrying one `DW-1` open + and another done then closed nothing while saying nothing, in either order: a + done-first ledger classified the id `open`, sent it to + :func:`mark_done_many`, and had :func:`_apply_done` refuse the done copy it + found first (marked nothing, so not even an unmatched warning); an open-first + ledger classified it `already_done` and never attempted the write at all + (#284 round-6 review, finding 4). The duplicate itself is reported through + ``duplicates`` rather than swallowed — one id naming two entries is a fault + about the ledger, not an answer about the work.""" + by_id: dict[str, DWEntry] = {} + duplicated: set[str] = set() + for e in parse_ledger(text): + if e.id in by_id: + duplicated.add(e.id) + continue # first wins: `_find_entry` mutates that one + by_id[e.id] = e buckets: dict[str, list[str]] = {"open": [], "done": [], "unknown": [], "malformed": []} for dw_id in ids: entry = by_id.get(dw_id) @@ -135,6 +159,7 @@ def classify(text: str, ids: Sequence[str]) -> Declared: already_done=tuple(buckets["done"]), unknown=tuple(buckets["unknown"]), malformed=tuple(buckets["malformed"]), + duplicates=tuple(dw_id for dw_id in dict.fromkeys(ids) if dw_id in duplicated), ) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 35d24c0f..be9b25ea 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2468,6 +2468,17 @@ def _apply_deferred_closes( dw_ids=list(declared.malformed), error="ledger entry status is neither open nor done", ) + if declared.duplicates: + # One id, two entries: only the first was classified and only the + # first can be marked, so whatever the second says about this work is + # neither read nor written. A corrupt ledger (#286) must not close + # quietly — the operator has to know which id to go look at. + self.journal.append( + "deferred-close-duplicate-id", + story_key=task.story_key, + dw_ids=list(declared.duplicates), + error="the ledger carries more than one entry for this id; only the first was read", + ) return marked def _flush_pending_deferred_closes(self, task: StoryTask) -> None: diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 507147a9..c7fac9c4 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -535,6 +535,73 @@ def test_classify_partitions_open_done_unknown_and_malformed(): assert declared.malformed == ("DW-3", "DW-4") +DUPLICATE_OPEN_FIRST = ( + "# Deferred Work\n\n" + "### DW-1: the first copy\nstatus: open\n\n" + "### DW-1: the second copy\nstatus: done 2026-06-01\n" +) +DUPLICATE_DONE_FIRST = ( + "# Deferred Work\n\n" + "### DW-1: the first copy\nstatus: done 2026-06-01\n\n" + "### DW-1: the second copy\nstatus: open\n" +) + + +@pytest.mark.parametrize( + ("text", "expected_open", "expected_done"), + [ + (DUPLICATE_OPEN_FIRST, ("DW-1",), ()), + (DUPLICATE_DONE_FIRST, (), ("DW-1",)), + ], + ids=["open-first", "done-first"], +) +def test_classify_reads_the_same_duplicate_the_mutation_writes(text, expected_open, expected_done): + """`classify` must name the entry `_find_entry` acts on. Indexing last-wins + while the mutation takes the first made them disagree, and BOTH orders then + closed nothing while saying nothing: done-first classified `open`, then + `_apply_done` refused the done copy it found first (so nothing was marked and + not even an unmatched warning followed); open-first classified `already_done` + and never attempted the write (#284 round-6 review, finding 4).""" + declared = classify(text, ["DW-1"]) + + assert declared.open_ids == expected_open + assert declared.already_done == expected_done + assert declared.unknown == () + assert declared.duplicates == ("DW-1",) # reported either way — the ledger is corrupt + + +@pytest.mark.parametrize( + ("text", "expected", "after"), + [ + (DUPLICATE_OPEN_FIRST, ["DW-1"], ["done", "done"]), + (DUPLICATE_DONE_FIRST, [], ["done", "open"]), + ], + ids=["open-first", "done-first"], +) +def test_mark_done_many_agrees_with_classify_on_a_duplicated_id(tmp_path, text, expected, after): + """The other half of the same contract: what `classify` calls open is exactly + what the write marks, so an id is never reported closed without being closed — + nor, as before, classified open and then silently left open. + + The done-first ledger still ends with an open copy nobody touched. That is the + honest outcome of a corrupt ledger (#286) and it is why the close is journaled + as a duplicate rather than passed over.""" + p = tmp_path / "deferred-work.md" + p.write_text(text, encoding="utf-8") + declared = classify(text, ["DW-1"]) + + assert list(mark_done_many(p, declared.open_ids, "2026-07-24", "note")) == expected + + entries = parse_ledger(p.read_text(encoding="utf-8")) + assert [e.status.split()[0] for e in entries] == after + + +def test_classify_reports_no_duplicates_for_a_well_formed_ledger(): + declared = classify(LEDGER, ["DW-1", "DW-2"]) + + assert declared.duplicates == () + + def test_mark_done_many_writes_once_and_reports_what_landed(tmp_path): p = tmp_path / "deferred-work.md" p.write_text(LEDGER, encoding="utf-8") diff --git a/tests/test_engine.py b/tests/test_engine.py index c754f38c..33a82328 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2114,6 +2114,44 @@ def test_closes_deferred_reports_a_malformed_ledger_entry(project): assert len(events) == 1 and events[0]["dw_ids"] == ["DW-1"] +@pytest.mark.parametrize( + ("first", "second", "closed"), + [("open", "done 2026-06-01", True), ("done 2026-06-01", "open", False)], + ids=["open-first", "done-first"], +) +def test_closes_deferred_reports_a_duplicated_ledger_id(project, first, second, closed): + """One id, two entries: only the first is read and only the first can be + written, so the second is neither. Both orders used to pass in total silence — + `classify` indexed the LAST entry while the mutation took the first, so a + done-first ledger classified the id open and then marked nothing at all + (#284 round-6 review, finding 4). + + The close itself still behaves as the first entry dictates. What changes is + that the operator is told the ledger names one id twice (#286).""" + engine = _closes_deferred_run(project, ["DW-1"]) + project.deferred_work.write_text( + "# Deferred Work\n\n" + f"### DW-1: the first copy\nstatus: {first}\n\n" + f"### DW-1: the second copy\nstatus: {second}\n", + encoding="utf-8", + ) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "duplicate id") + + summary = engine.run() + + assert summary.done == 1 # never a gate + kinds = [e["kind"] for e in engine.journal.entries()] + events = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-duplicate-id"] + assert len(events) == 1 and events[0]["dw_ids"] == ["DW-1"] + # the first entry decides, and the close is reported only when it happened + assert ("story-deferred-closed" in kinds) is closed + parsed = deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8")) + assert [e.status.split()[0] for e in parsed] == ( + ["done", "done"] if closed else ["done", "open"] + ) + + def test_closes_deferred_reports_a_wrong_container_declaration(project): """`closes_deferred: DW-1` (a scalar, not a list) is a real declaration of intent. Reading it as an empty list closed nothing and said nothing, while From 181f7b5f5e7efd2e827189b66f0ba4f8b332cd73 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:43:54 -0700 Subject: [PATCH 24/36] fix(frontmatter,engine): an unparseable spec is not a spec declaring nothing (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read_frontmatter` flattens invalid YAML and non-UTF-8 to `{}`. Every status gate wants that — an unparseable spec reads as status "" and retries or repairs — but the deferred-work close is the one caller for which `{}` is not an absence, it is a RETRACTION: the story declares nothing. So a spec that turned unparseable between the dev-verify capture and the commit boundary cleared `declared_deferred` and the story committed with its declared entry still open, which is the exact fault class the capture exists to survive. `read_frontmatter_or_none` keeps the distinction (None = present but unparseable, {} = nothing to read) and `read_frontmatter` becomes it `or {}`, so every existing caller is unchanged. `_observed_frontmatter` grows a `strict` flag that extends its degrade-to-None to unreadable content; only `_capture_declared_deferred` passes it, and the fallback then journals like any other failed read. --- src/bmad_loop/engine.py | 39 +++++++++++++++++++++++++++++++++--- src/bmad_loop/frontmatter.py | 36 ++++++++++++++++++++++++--------- src/bmad_loop/verify.py | 1 + tests/test_engine.py | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index be9b25ea..b39cf764 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1856,7 +1856,9 @@ def _dev_review_enabled(self) -> bool: def _today(self) -> str: return time.strftime("%Y-%m-%d") - def _observed_frontmatter(self, spec_path: Path, story_key: str, site: str) -> dict | None: + def _observed_frontmatter( + self, spec_path: Path, story_key: str, site: str, *, strict: bool = False + ) -> dict | None: """Read a spec's frontmatter on a *bookkeeping* path, degrading an unreadable spec to ``None`` (journaled) instead of a whole-run crash. @@ -1874,12 +1876,34 @@ def _observed_frontmatter(self, spec_path: Path, story_key: str, site: str) -> d opposite and let OSError raise: silently skipping a rewrite would leave the spec in a state the caller believes it fixed. Observation degrades, repair raises. + + ``strict`` extends the same degrade-to-None to a spec whose *content* is + unreadable — invalid YAML, non-UTF-8 — which ``read_frontmatter`` + otherwise flattens to ``{}``. Every status gate wants that flattening (an + unparseable spec reads as status "" and retries or repairs), so it stays + the default. The deferred-work close needs the opposite: there ``{}`` says + the story declares nothing, which is a retraction, and reading an + unparseable spec as one destroyed the verified capture that exists to + survive precisely this (#284 round-6 review, finding 5). """ try: - return verify.read_frontmatter(spec_path) + fm = ( + verify.read_frontmatter_or_none(spec_path) + if strict + else verify.read_frontmatter(spec_path) + ) except OSError as e: self._journal_spec_read_failed(spec_path, story_key, site, e) return None + if fm is None: # strict only: present but unparseable + self.journal.append( + "spec-read-failed", + story_key=story_key, + spec=str(spec_path), + site=site, + error="frontmatter is not parseable (invalid YAML or non-UTF-8)", + ) + return fm def _journal_spec_read_failed( self, spec_path: Path, story_key: str, site: str, e: OSError @@ -2169,6 +2193,13 @@ def _capture_declared_deferred(self, task: StoryTask, *, site: str) -> bool: faults; that arm finishes a commit WITHOUT re-verifying, so a persisted declaration is its only other source. + The read is therefore ``strict``: unreadable here means the *content* too, + not only an OSError. ``read_frontmatter`` flattens invalid YAML and + non-UTF-8 to ``{}``, which every status gate wants and which this one site + must not have — ``{}`` is a spec declaring nothing, so the flattening + turned a spec nobody could parse into a retraction and cleared the very + capture it should have fallen back to (#284 round-6 review, finding 5). + The path is a verified one — ``task.spec_file`` is recorded only by a passing ``verify_dev``/``verify_dev_stories`` gate, and stories mode resolves it by id rather than trusting the session's claim. Sprint mode's @@ -2222,7 +2253,9 @@ def _capture_declared_deferred(self, task: StoryTask, *, site: str) -> bool: ) task.declared_deferred = [] return True - fm = self._observed_frontmatter(spec_path, task.story_key, f"deferred-close-{site}") + fm = self._observed_frontmatter( + spec_path, task.story_key, f"deferred-close-{site}", strict=True + ) if fm is None: return False declared, error = deferredwork.parse_declaration(fm.get("closes_deferred")) diff --git a/src/bmad_loop/frontmatter.py b/src/bmad_loop/frontmatter.py index e478d476..c40eb9b1 100644 --- a/src/bmad_loop/frontmatter.py +++ b/src/bmad_loop/frontmatter.py @@ -38,28 +38,46 @@ def _split_frontmatter(text: str) -> tuple[str, str, str] | None: return None -def read_frontmatter(path: Path) -> dict[str, Any]: +def read_frontmatter_or_none(path: Path) -> dict[str, Any] | None: + """Like :func:`read_frontmatter`, but distinguishes *unreadable* from *absent*: + ``None`` when the content exists and could not be parsed, ``{}`` when there is + genuinely no frontmatter (or no file) to read. + + Every status gate wants the flattening — a spec that cannot be parsed reads as + status ``""`` and retries or repairs — so :func:`read_frontmatter` keeps it. + One caller needs the difference: the deferred-work close reads a declaration + it will otherwise fall back to a verified capture for, and ``{}`` there says + "this spec declares nothing", which is a *retraction*. Collapsing an + unparseable spec into that retraction destroyed the fallback for exactly the + fault class it exists to survive (#284 round-6 review, finding 5).""" if not path.is_file(): return {} try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: - # A non-UTF-8 file carries no readable frontmatter — degrade exactly like - # unparseable YAML below. Every status gate then reads status "" and - # returns a clean retry/repair outcome instead of crashing mid-verify - # (UnicodeDecodeError is a ValueError, so it slipped past callers' - # except-OSError guards). - return {} + # A non-UTF-8 file carries no readable frontmatter (UnicodeDecodeError is + # a ValueError, so it slips past callers' except-OSError guards). + return None split = _split_frontmatter(text) if split is None: - return {} + return {} # no frontmatter block is an answer, not a failure to read one try: doc = yaml.safe_load(split[1]) except yaml.YAMLError: - return {} + return None return doc if isinstance(doc, dict) else {} +def read_frontmatter(path: Path) -> dict[str, Any]: + """A spec's frontmatter as a dict, with anything unreadable flattened to ``{}``. + + Every status gate then reads status "" and returns a clean retry/repair + outcome instead of crashing mid-verify. A caller that must tell an unparseable + spec from one carrying no such field wants + :func:`read_frontmatter_or_none` instead.""" + return read_frontmatter_or_none(path) or {} + + def status_of(fm: dict[str, Any]) -> str: """Normalized spec status from a frontmatter dict: stripped + lowercased. diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index e36adf68..432d78b0 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -17,6 +17,7 @@ from . import deferredwork from .bmadconfig import ProjectPaths +from .frontmatter import read_frontmatter_or_none # noqa: F401 — re-export from .frontmatter import set_frontmatter_status # noqa: F401 — re-export from .frontmatter import _split_frontmatter, read_frontmatter, status_of from .model import StoryTask, VerifyOutcome diff --git a/tests/test_engine.py b/tests/test_engine.py index 33a82328..6434b084 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2497,6 +2497,45 @@ def unreadable_from_the_commit_phase_on(task): assert engine.state.tasks["1-1-a"].declared_deferred == ["DW-1"] +def test_closes_deferred_keeps_the_capture_when_the_final_spec_is_unparseable(project): + """The same fallback, reached through the real read rather than a patched one. + + `read_frontmatter` flattens invalid YAML and non-UTF-8 to `{}`, so the close + site saw a spec that *parsed* and declared nothing — a retraction — and + cleared the verified capture for the one fault class the capture exists to + survive. Unreadable content is now unreadable, not empty + (#284 round-6 review, finding 5).""" + engine = _closes_deferred_run(project, ["DW-1"]) + finalize = engine._finalize_commit_phase + sp = spec_path(project, "1-1-a") + + def corrupt_the_spec_then_finalize(task): + # an unterminated flow sequence: real YAML the loader rejects, of exactly + # the shape a mid-run frontmatter rewrite can leave behind + sp.write_text( + "---\ntitle: t\nstatus: done\ncloses_deferred: [DW-1\n---\n\nbody\n", encoding="utf-8" + ) + return finalize(task) + + engine._finalize_commit_phase = corrupt_the_spec_then_finalize + + summary = engine.run() + + assert summary.done == 1 + assert not _ledger_entries(project)["DW-1"].open # closed against the verified capture + assert engine.state.tasks["1-1-a"].declared_deferred == ["DW-1"] # capture NOT retracted + read_failed = [ + e + for e in engine.journal.entries() + if e["kind"] == "spec-read-failed" and e["site"] == "deferred-close-commit-boundary" + ] + assert len(read_failed) == 1 and "not parseable" in read_failed[0]["error"] + fell_back = [ + e for e in engine.journal.entries() if e["kind"] == "deferred-close-declaration-unreadable" + ] + assert len(fell_back) == 1 and "last good capture" in fell_back[0]["note"] + + def test_closes_deferred_reports_an_unreadable_declaration(project, monkeypatch): """A commit-boundary read that faults with nothing captured to fall back on — a state file written before the capture existed — is the one place a fault can From 86844f681570cee53535ee5e67e8921313ffd838 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:46:29 -0700 Subject: [PATCH 25/36] fix(engine): a bookkeeping write must not be able to skip a checkpoint (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WorktreeFlow.integrate_unit` calls the external-ledger flush after the unit has merged and the task is already terminal. A raise from there escaped `run_unit` and the story loop, so `_after_story` never fired — in stories mode that is the `done_checkpoint`, a mandatory human review silently skipped with the next story free to dispatch. Resume did not repair it either: `_finish_inflight` reconciles the parked closure and then skips terminal tasks by design. Every fault in that flush is an OSError on the out-of-repo mount that is the only reason it runs at all — the ledger read and write, the journal append, the state save. Trading a human review for a failed annotation is backwards, and the annotation is never a gate anywhere else in this feature. `_flush_after_integration` journals `deferred-close-flush-failed`, leaves `pending_deferred_closes` standing for the reconcile pass, and lets the story finish. Only the integration call site is guarded: the in-place flush runs before the DONE advance precisely so a raise leaves the task COMMITTING for the resume arm to re-drive idempotently. The generic window this shares with `merge_local`'s own tail (a raising post_merge plugin, a failed journal write) is pre-existing and not closed here. --- src/bmad_loop/engine.py | 34 ++++++++++++++++++++++++++- tests/test_engine_worktree.py | 6 ++++- tests/test_stories_engine.py | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index b39cf764..2a0b443b 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -281,7 +281,7 @@ def __init__( escalation_pause=self._escalation_pause, workspace_get=lambda: self.workspace, workspace_set=lambda ws: setattr(self, "workspace", ws), - on_integrated=lambda t: self._flush_pending_deferred_closes(t), + on_integrated=lambda t: self._flush_after_integration(t), ) # Attempt rollback + recovery-ref preservation flow (issue #244 PR 2/2). # Same narrow-deps + engine-callbacks pattern as _worktree_flow: `emit` is @@ -2514,6 +2514,38 @@ def _apply_deferred_closes( ) return marked + def _flush_after_integration(self, task: StoryTask) -> None: + """The integration chokepoint's flush, which may not raise (#234). + + ``WorktreeFlow.integrate_unit`` calls this after the unit has merged and + the task is already terminal, and a raise from here escapes ``run_unit`` + and the whole story loop — so ``_after_story`` never fires. In stories + mode that is the ``done_checkpoint``: a mandatory human review silently + skipped, with the next story free to dispatch. Resume does not repair it + either, because ``_finish_inflight`` skips terminal tasks by design. + + Everything this flush touches can raise OSError — the ledger read and + write, the journal append, the state save — and all of it lives on the + out-of-repo mount that is the only reason the flush does anything at all. + Trading a checkpoint for a failed annotation is exactly backwards, and the + annotation is never a gate anywhere else in this feature: journal the + failure, leave ``pending_deferred_closes`` standing for the reconcile pass + to retry, and let the story finish (#284 round-6 review, finding 2). + + Only the integration call site is guarded. The in-place flush runs before + the DONE advance precisely so a raise there leaves the task COMMITTING for + the resume arm to re-drive idempotently; guarding it would discard that.""" + try: + self._flush_pending_deferred_closes(task) + except OSError as e: + self.journal.append( + "deferred-close-flush-failed", + story_key=task.story_key, + dw_ids=list(task.pending_deferred_closes), + error=f"{e.__class__.__name__}: {e}", + note="the closure stays owed; the story's own bookkeeping continues", + ) + def _flush_pending_deferred_closes(self, task: StoryTask) -> None: """Apply closures held back for an out-of-repo ledger, once the story's work is durably landed: after ``finalize_commit`` in place, from the diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 9c15c47f..e102086d 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1526,7 +1526,11 @@ def test_worktree_crash_after_merge_retries_the_closure_on_resume(project, tmp_p paths, [wt_dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], ) - engine._flush_pending_deferred_closes = lambda task: (_ for _ in ()).throw(OSError("host died")) + # patched at the guarded outer seam, which is where the process would have + # died: a *fault* inside the flush is deliberately caught now and no longer + # ends the run (see the stories-mode done_checkpoint regression), so raising + # from the inner method would model something else entirely. + engine._flush_after_integration = lambda task: (_ for _ in ()).throw(OSError("host died")) summary = engine.run() diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 0efb0769..5e4470a2 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -839,6 +839,50 @@ def test_story_checkpoint_pause_after_commit(project): assert load_state(resumed.run_dir).tasks["2"].phase == Phase.DONE +def test_a_failed_integration_flush_cannot_skip_the_done_checkpoint(project, tmp_path, monkeypatch): + """The external-ledger flush runs from `integrate_unit`, after the task is + terminal. A raise there escaped `run_unit` and the whole story loop, so + `_after_story` never fired — a mandatory `done_checkpoint` silently dropped, + with the next story free to dispatch — and resume did not repair it either, + because `_finish_inflight` skips terminal tasks by design. + + Everything that flush touches (the ledger read and write, the journal append, + the state save) lives on the shared mount that is the only reason it runs at + all. Trading a human review for a failed annotation is backwards, and the + annotation is never a gate anywhere else here (#284 round-6 review, finding 2).""" + import dataclasses + + from conftest import write_ledger + + from bmad_loop import deferredwork + + paths = dataclasses.replace(project, implementation_artifacts=tmp_path / "shared-artifacts") + paths.implementation_artifacts.mkdir(exist_ok=True) + setup_stories(paths, [entry("1", closes_deferred=["DW-1"], done_checkpoint=True), entry("2")]) + write_ledger(paths, {"DW-1": "open"}, commit=False) # shared: never committable + engine, _ = make_engine( + paths, + [stories_dev_effect()], + policy=_stories_policy(scm=ScmPolicy(isolation="worktree")), + ) + + def the_mount_goes_away(*a, **kw): + raise OSError("shared artifact mount went away") + + monkeypatch.setattr(deferredwork, "mark_done_many", the_mount_goes_away) + + summary = engine.run() + + assert summary.paused and summary.done == 1 # the checkpoint still fired + persisted = load_state(engine.run_dir) + assert persisted.paused_stage == PAUSE_STORY_CHECKPOINT + assert "2" not in persisted.tasks # no story leapfrogged the human review + # owed, not lost: the reconcile pass retries it on the next resume + assert persisted.tasks["1"].pending_deferred_closes == ["DW-1"] + failed = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-flush-failed"] + assert len(failed) == 1 and failed[0]["dw_ids"] == ["DW-1"] + + def test_story_checkpoint_still_fires_when_manifest_unreadable_after_commit(project): """A manifest that goes unreadable between the commit and the after-story check makes the done_checkpoint flag unknowable — the conservative default is From ab0568bb9067148e482888b94e0865a727b5dbb6 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:47:34 -0700 Subject: [PATCH 26/36] fix(engine,verify): restore the index with the tree when a commit rolls a close back (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `finalize_commit` stages with `git add -A` before the commit a native pre-commit hook can still reject, so `_restore_deferred_closes` rewriting only the working tree left the index holding `status: done` for a commit that never happened. The loop never reads that back — every path that commits again re-adds from the tree, every rollback goes through `safe_reset` — but an escalation is exactly where a human takes over, and fixing the hook then running `git commit` by hand would publish the close the rollback existed to remove. `verify.stage_path` re-stages the restored file. Advisory by design: it runs on a path already escalating a commit failure, so a refusal is journaled (`deferred-close-rollback-unstaged`) and never raised, where a repair that raises would mask the failure being reported. --- src/bmad_loop/engine.py | 21 +++++++++++++++++---- src/bmad_loop/verify.py | 19 +++++++++++++++++++ tests/test_engine.py | 21 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 2a0b443b..9b18e213 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2431,10 +2431,16 @@ def _restore_deferred_closes( rollback, so the false close survives the reset that would otherwise have reverted it. - Only the working tree is restored. The failed ``finalize_commit`` leaves - its ``add -A`` staged, but every path that commits again starts with - another ``add -A`` (restaging from the tree) and every rollback path goes - through ``safe_reset``, so the index is not authoritative here.""" + The index is restored with the tree. Every path this orchestrator takes to + commit again begins with another ``add -A`` (restaging from the tree) and + every rollback path goes through ``safe_reset``, so for the loop itself + the index is not authoritative — but the failed ``finalize_commit`` left + its ``add -A`` staged, and an escalation is precisely where a human takes + over. Fixing the rejecting hook and running ``git commit`` by hand would + otherwise publish the false ``done`` this restore exists to remove + (#284 round-6 review, finding 3). Advisory: a refused re-stage is + journaled, never raised, because it must not mask the commit failure being + escalated.""" if snapshot is None: return ledger, before, marked = snapshot @@ -2449,6 +2455,13 @@ def _restore_deferred_closes( error=str(e), ) return + if not verify.stage_path(self.workspace.root, ledger): + self.journal.append( + "deferred-close-rollback-unstaged", + story_key=task.story_key, + ledger=str(ledger), + note="the working tree is restored; the index still holds the rolled-back close", + ) self.journal.append( "deferred-close-rolled-back", story_key=task.story_key, diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 432d78b0..0698b20a 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -1664,6 +1664,25 @@ def patch_new_files(patch_path: Path) -> set[str]: return new_files +def stage_path(repo: Path, path: Path) -> bool: + """Stage one path so the index matches what is on disk. Returns False when the + path is outside `repo` or git refused; never raises. + + For repairing an index a failed commit left behind. `finalize_commit` stages + with `add -A` *before* the commit a native hook can still reject, so undoing + that commit's bookkeeping in the working tree alone leaves the index holding + the version that was rolled back — which an operator who fixes the hook and + runs `git commit` by hand would then publish. Advisory by design: this runs on + a path that is already escalating a commit failure, and a repair that raises + would mask it.""" + try: + rel = str(Path(path).resolve().relative_to(repo.resolve())) + except ValueError: + return False + rc, _ = _git(repo, "add", "--", rel) + return rc == 0 + + def commit_paths(repo: Path, message: str, paths: list[Path]) -> str | None: """Commit exactly `paths` (and nothing else), leaving any unrelated working or staged changes untouched. Unlike commit_story's `add -A`, this is safe to diff --git a/tests/test_engine.py b/tests/test_engine.py index 6434b084..fe0ab38b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2245,6 +2245,27 @@ def test_closes_deferred_rolls_back_when_the_commit_fails(project): assert kinds.index("story-deferred-closed") < kinds.index("deferred-close-rolled-back") +def test_closes_deferred_rollback_restores_the_index_too(project): + """`finalize_commit` stages with `add -A` before the hook can reject, so + restoring only the working tree left the INDEX holding `status: done`. The + loop itself never reads it back — every re-commit re-adds and every rollback + goes through `safe_reset` — but an escalation is exactly where a human takes + over, and fixing the hook then running `git commit` by hand would publish the + close the rollback removed (#284 round-6 review, finding 3).""" + engine = _closes_deferred_run(project, ["DW-1"]) + _reject_commits(project) + + engine.run() + + # neither view of the repo carries the annotation the commit never took + assert "status: done" not in git(project.project, "diff", "--", str(project.deferred_work)) + staged = git(project.project, "diff", "--cached", "--", str(project.deferred_work)) + assert "status: done" not in staged + assert "deferred-close-rollback-unstaged" not in [ + e["kind"] for e in engine.journal.entries() + ] + + def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): """The rollback must leave the story re-drivable: once the hook is gone, the resumed commit phase re-applies the close exactly once (no doubled From ca0d68e751009337887e0eb89a80c1686813ee8f Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:53:50 -0700 Subject: [PATCH 27/36] fix(engine): an unavailable ledger is owed only while the run can still pay (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the external-ledger obligation lied about what would happen to it. A **dangling ledger symlink** is an outage wearing a shape the discriminator did not recognize: the artifact dir is present so the directory test passed, `is_file()` followed the link and said no, so every id classified as unknown and the obligation was discharged with a `deferred-close-unmatched` line — a typo warning for a mount that went away. The link existing at all is the evidence a ledger is expected there. And a **retained obligation outlived the only thing that could satisfy it**: the caller advances the task to DONE, the run finishes, and `resume` refuses a finished run — so `the closure stays owed and is retried` promised a retry the CLI cannot reach. `_reconcile_pending_deferred_closes(final=True)` now runs once more just before the run is marked finished: it retries what the outage held back (a mount that came back inside the run still closes), and releases anything still owed with `deferred-close-abandoned`. The entries stay `open`, which is the truthful reading and the one a sweep re-verifies. Deliberately not the other repair the review proposed — refusing to finish a run while a closure is owed. An annotation is never a gate in this feature, and a mount that stays gone would make the run unfinishable. The regression that missed all this asserted the pending list and then called the reconcile pass directly, which the CLI never would; it now drives `engine.run()` to the boundary, and `resume_engine` refuses a finished run like the CLI does. --- src/bmad_loop/engine.py | 56 ++++++++++++++++++++++++++++++----- tests/test_engine.py | 65 ++++++++++++++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 9b18e213..aecd7949 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -366,6 +366,11 @@ def _run_inner(self) -> RunSummary: self._ensure_target_branch() self._prune_preserve_refs() self._loop() + # Last chance to settle an external-ledger closure held back by an + # unavailable location: past the line below the run is finished, + # and `resume` refuses a finished run, so a retained obligation + # would promise a retry nothing can reach (#234). + self._reconcile_pending_deferred_closes(final=True) self.state.finished = True self._gc_run_worktrees() self._emit("post_run") @@ -776,7 +781,7 @@ def _pause_for_manual_recovery( task, baseline, preserve_failed=preserve_failed ) - def _reconcile_pending_deferred_closes(self) -> None: + def _reconcile_pending_deferred_closes(self, *, final: bool = False) -> None: """Finish external-ledger closures a previous process left parked (#234). These sit outside the phase machine the rest of `_finish_inflight` walks: @@ -798,21 +803,45 @@ def _reconcile_pending_deferred_closes(self) -> None: names for the operator), where recording the intent before the merge would instead close entries for a merge that never happened. Proving it from the target branch is not available as a tiebreaker either — `squash` rewrites - the commit, so the unit's sha is not on the target under every strategy.""" + the commit, so the unit's sha is not on the target under every strategy. + + ``final`` is the run's last pass, immediately before it is marked finished. + An obligation is only worth retaining while some later process can act on + it, and ``resume`` refuses a finished run — so one carried past this point + promises a retry that can never be reached. Anything still owed here is + reported and released: the entries stay ``open``, which is the truthful + reading and the one a sweep re-verifies against the codebase. Deliberately + NOT a reason to hold the run open — an annotation is never a gate in this + feature, and a mount that stays gone would make the run unfinishable + (#284 round-6 review, finding 1).""" for task in list(self.state.tasks.values()): if not task.pending_deferred_closes: continue if task.phase != Phase.DONE: continue # still re-drivable; the commit phase applies it - if task.unit_merged or not self._isolated: - self._flush_pending_deferred_closes(task) - else: + if not (task.unit_merged or not self._isolated): self.journal.append( "deferred-close-abandoned", story_key=task.story_key, dw_ids=list(task.pending_deferred_closes), reason="story is done but its unit never merged; entries stay open", ) + continue + self._flush_pending_deferred_closes(task) + if final and task.pending_deferred_closes: + # the flush kept it: the location was unavailable, and this run + # has no later pass — nor any resume — left to try again in. + self.journal.append( + "deferred-close-abandoned", + story_key=task.story_key, + dw_ids=list(task.pending_deferred_closes), + reason=( + "the ledger location was still unavailable at run end; the entries " + "stay open for a sweep to re-verify" + ), + ) + task.pending_deferred_closes = [] + self._save() def _finish_inflight(self) -> None: """Complete or roll back tasks interrupted by a pause or crash.""" @@ -2583,18 +2612,29 @@ def _flush_pending_deferred_closes(self, task: StoryTask) -> None: the obligation is retryable; present-but-no-ledger means there is genuinely nothing to close, which is the same answer the in-repo path gives and stays a discharge rather than an obligation nothing could ever - satisfy (#284 round-5 review, finding 4).""" + satisfy (#284 round-5 review, finding 4). + + A **dangling symlink** is the same outage wearing the other shape: the + artifact dir is present, so the directory test passes, but the ledger the + link names is on the mount that went away. ``is_file()`` follows the link + and says no, so every id classified as unknown and the obligation was + discharged with an ``unmatched`` line blaming a typo for an outage. The + link existing at all is the evidence a ledger is expected there + (#284 round-6 review, finding 1).""" ids = tuple(task.pending_deferred_closes) if not ids: return ledger = self.workspace.paths.deferred_work - if not ledger.parent.is_dir(): + if not ledger.parent.is_dir() or (ledger.is_symlink() and not ledger.exists()): self.journal.append( "deferred-close-ledger-unavailable", story_key=task.story_key, dw_ids=list(ids), ledger=str(ledger), - note="the artifact directory is not present; the closure stays owed and is retried", + note=( + "the ledger location is not present; the closure stays owed and is retried " + "while the run is resumable" + ), ) return self.journal.append( diff --git a/tests/test_engine.py b/tests/test_engine.py index fe0ab38b..a747ad67 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -91,6 +91,10 @@ def make_engine(project, script, policy=None, **kwargs) -> tuple[Engine, MockAda def resume_engine(project, engine, script, policy=None) -> tuple[Engine, MockAdapter]: state = load_state(engine.run_dir) + # `cli._resume_paused_run` refuses a finished run outright. Without the same + # refusal here a test can "resume" what the CLI never would, and prove a + # recovery path that does not exist (#284 round-6 review, finding 1). + assert not state.finished, "cli._resume_paused_run refuses a finished run" state.clear_pause() adapter = MockAdapter(script) new_engine = Engine( @@ -2423,7 +2427,10 @@ def test_closes_deferred_external_ledger_unavailable_keeps_the_obligation(projec That risk is specific to this path — it exists *because* the ledger is out of the repo, so it can sit on a mount that is temporarily gone. The artifact directory tells the two apart: gone is an unavailable location, not an answer - about the entries (#284 round-5 review, finding 4).""" + about the entries (#284 round-5 review, finding 4). + + Kept, and then paid: the run-end pass retries what the outage held back, so a + mount that comes back within the run still closes the entry.""" paths = _external_ledger_paths(project, tmp_path) write_sprint(paths, {"1-1-a": "ready-for-dev"}) write_ledger(paths, {"DW-1": "open"}, commit=False) @@ -2434,8 +2441,12 @@ def test_closes_deferred_external_ledger_unavailable_keeps_the_obligation(projec external = paths.implementation_artifacts unmounted = tmp_path / "unmounted" real_flush = engine._flush_pending_deferred_closes + attempts: list[int] = [] def flush_with_the_mount_away(task): + attempts.append(1) + if len(attempts) > 1: + return real_flush(task) # the mount is back for the run-end retry external.rename(unmounted) # the shape of a shared mount dropping out try: return real_flush(task) @@ -2447,26 +2458,52 @@ def flush_with_the_mount_away(task): summary = engine.run() assert summary.done == 1 - entries = { - e.id: e for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) - } - assert entries["DW-1"].open # nothing was written against an invisible ledger kinds = [e["kind"] for e in engine.journal.entries()] assert "deferred-close-ledger-unavailable" in kinds - assert "story-deferred-closed" not in kinds - # owed, not discharged — and the id is not silently reported as a typo + # the id is not reported as a typo against a ledger nobody could see assert "deferred-close-unmatched" not in kinds - assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] - - # the mount is back: the reconcile pass a later run makes finishes the job - engine._flush_pending_deferred_closes = real_flush - engine._reconcile_pending_deferred_closes() - + # the mount came back, so the run-end pass finished the job it kept owed entries = { e.id: e for e in deferredwork.parse_ledger(paths.deferred_work.read_text(encoding="utf-8")) } assert not entries["DW-1"].open - assert engine.state.tasks["1-1-a"].pending_deferred_closes == [] + assert kinds.index("deferred-close-ledger-unavailable") < kinds.index("story-deferred-closed") + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] + + +def test_closes_deferred_owes_nothing_a_finished_run_can_no_longer_pay(project, tmp_path): + """The other end of that promise. `resume` refuses a finished run, so an + obligation carried past the end of a run that completed promises a retry + nothing can reach — the regression that missed this asserted the pending list + and then called the reconcile pass directly, which the CLI never would. + + A dangling ledger symlink is the second shape of the same outage: the artifact + dir is present, so the directory test passed, `is_file()` followed the link and + said no, and every id was discharged as `unmatched` — a typo warning for a + mount that went away (#284 round-6 review, finding 1).""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + # the ledger is a link onto a mount that is not there; the link existing at all + # is the evidence a ledger is expected + paths.deferred_work.symlink_to(tmp_path / "gone-mount" / "deferred-work.md") + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + + summary = engine.run() + + assert summary.done == 1 # never a gate: the story still lands + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-ledger-unavailable" in kinds + assert "deferred-close-unmatched" not in kinds # not a typo — an outage + assert "story-deferred-closed" not in kinds + abandoned = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-abandoned"] + assert len(abandoned) == 1 and abandoned[0]["dw_ids"] == ["DW-1"] + assert "sweep" in abandoned[0]["reason"] # says what will re-verify the open entry + persisted = load_state(engine.run_dir) + assert persisted.finished # ...which is exactly why the obligation is not kept + assert persisted.tasks["1-1-a"].pending_deferred_closes == [] def test_closes_deferred_external_ledger_absent_from_a_live_dir_still_discharges(project, tmp_path): From 1346617814ea97a2d19f43521348ea2ca9e5b59a Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:53:50 -0700 Subject: [PATCH 28/36] docs: the closure is retried while the run is resumable, not forever (#234 review) The skill doc promised the external-ledger closure was "retried on the next resume if that write did not get to happen", with no mention that a finished run cannot be resumed. Say what actually happens: retried before the run ends and on any later resume, and if the location is still unreachable when the run finishes, the entries stay `open` and are journaled for a sweep to re-verify. Same clause added to the README's ledger-outside-the-repo note. --- README.md | 2 +- .../data/skills/bmad-loop-sweep/deferred-work-format.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 18a8b584..31c13235 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ The orchestrator writes the same annotation a bundle close writes — `status: d A declaration in a shape nothing can read — a bare `closes_deferred: DW-5` where a list belongs — depends on which channel it is in. In a **story spec** it is journaled and dropped, like an unknown id: the spec is generated mid-run by a dev skill, and a malformed field there must not be able to fail a story that succeeded. In **`stories.yaml`** it is a schema error, exactly like every other manifest field of the wrong type, and the manifest fails to load — the breakdown is hand-authored before the run, where `bmad-loop validate` reports it up front and a typo is still cheap to fix. `validate` warns about unknown ids in both queue modes and about a malformed spec declaration; a malformed manifest is the manifest's own `queue.stories-manifest` failure. -> **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. Closure still happens, but for an isolated (`scm.isolation = "worktree"`) run it is held until the unit's branch has merged — a story whose integration fails leaves its entries `open`. The run journals `deferred-close-external-ledger` so the annotation's absence from git history is not a surprise. +> **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. Closure still happens, but for an isolated (`scm.isolation = "worktree"`) run it is held until the unit's branch has merged — a story whose integration fails leaves its entries `open`. The run journals `deferred-close-external-ledger` so the annotation's absence from git history is not a surprise. If the location itself is unavailable when the write comes due (a shared mount that has gone away), the closure is retried before the run ends and on any later resume; a run that finishes with it still unreachable leaves those entries `open` and journals `deferred-close-abandoned` for a sweep to re-verify. **Answering missed decisions later.** An unattended sweep (`--no-prompt`) skips decisions, and an interactive one can be abandoned before you answer them all — those answers would otherwise be lost, since triage re-derives the decision set from the ledger every run. `bmad-loop decisions` (or press `d` in the TUI) surfaces every decision past sweeps left unanswered, reconstructed from their triage output, and lets you answer them out of band. A `close` is applied immediately; a `build`/`keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep (build → bundle, keep-open → recorded) with no re-prompt. `--list` shows them without answering; `bmad-loop status` reports the outstanding count. diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index a6f46dec..96aeb4fe 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -99,8 +99,12 @@ The rules that keep this safe: artifacts dir is configured outside it, the file is shared between worktrees and no commit can carry it; the closure is then held until the work is durably landed — after the commit in place, after the branch has merged under worktree - isolation — and retried on the next resume if that write did not get to - happen. + isolation. A write that could not happen — the location is on a mount that is + gone — is retried once more before the run ends, and on the next resume while + the run is still resumable. A run that *finishes* with the location still + unavailable leaves those entries `open` and says so + (`deferred-close-abandoned`); a sweep re-verifies them against the codebase. + The closure never holds a completed run open. - **Idempotent.** An id already `done` is left untouched, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns. - **Never a gate.** An id that matches no entry, an entry whose `status:` reads From 9dd7bbc4f8fa0f23c572ea2dca368d9dade1e509 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 17:58:03 -0700 Subject: [PATCH 29/36] style: black + prettier on the round-6 review fixes (#234 review) --- .../data/skills/bmad-loop-sweep/deferred-work-format.md | 2 +- tests/test_engine.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 96aeb4fe..a20eb5a5 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -101,7 +101,7 @@ The rules that keep this safe: landed — after the commit in place, after the branch has merged under worktree isolation. A write that could not happen — the location is on a mount that is gone — is retried once more before the run ends, and on the next resume while - the run is still resumable. A run that *finishes* with the location still + the run is still resumable. A run that _finishes_ with the location still unavailable leaves those entries `open` and says so (`deferred-close-abandoned`); a sweep re-verifies them against the codebase. The closure never holds a completed run open. diff --git a/tests/test_engine.py b/tests/test_engine.py index a747ad67..2e416448 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2265,9 +2265,7 @@ def test_closes_deferred_rollback_restores_the_index_too(project): assert "status: done" not in git(project.project, "diff", "--", str(project.deferred_work)) staged = git(project.project, "diff", "--cached", "--", str(project.deferred_work)) assert "status: done" not in staged - assert "deferred-close-rollback-unstaged" not in [ - e["kind"] for e in engine.journal.entries() - ] + assert "deferred-close-rollback-unstaged" not in [e["kind"] for e in engine.journal.entries()] def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): From 20677c4a307fb1e13ef107aabc69b314bb252178 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 18:03:38 -0700 Subject: [PATCH 30/36] test: cover the run-end release on Windows too (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end reachability test for an unavailable ledger needs a dangling symlink, so it skips on Windows like every other symlink test here — which left the release semantics themselves unverified there, and a test that skips in CI is not a gate. Pin them directly instead, both ways round: mid-run the obligation is kept because a later pass can still pay it, at run end it is released because none can. (A permanently-missing artifact dir is not an alternative vehicle: `_pick_next` raises SprintStatusError on the missing board, so the run crashes rather than finishing, and the finished-run boundary is exactly what is under test.) --- tests/test_engine.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_engine.py b/tests/test_engine.py index 2e416448..c4a88325 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2469,6 +2469,7 @@ def flush_with_the_mount_away(task): assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_closes_deferred_owes_nothing_a_finished_run_can_no_longer_pay(project, tmp_path): """The other end of that promise. `resume` refuses a finished run, so an obligation carried past the end of a run that completed promises a retry @@ -2504,6 +2505,32 @@ def test_closes_deferred_owes_nothing_a_finished_run_can_no_longer_pay(project, assert persisted.tasks["1-1-a"].pending_deferred_closes == [] +@pytest.mark.parametrize("final", [False, True], ids=["mid-run", "run-end"]) +def test_closes_deferred_obligation_is_released_only_by_the_final_pass(project, tmp_path, final): + """The release semantics on their own, on every platform (the end-to-end + reachability test above needs a symlink, so it is POSIX-only). + + Mid-run the obligation is kept: a later pass — this run's own retry, or a + resume — can still pay it. At run end nothing can, because `resume` refuses a + finished run, so keeping it would promise a retry that never comes. Released + means the entries stay `open` for a sweep, never that they are marked done.""" + paths = _external_ledger_paths(project, tmp_path) + engine, _ = make_engine(paths, []) + task = StoryTask(story_key="1-1-a", epic=1) + task.phase = Phase.DONE + task.pending_deferred_closes = ["DW-1"] + engine.state.tasks["1-1-a"] = task + paths.implementation_artifacts.rename(tmp_path / "unmounted") # the location is gone + + engine._reconcile_pending_deferred_closes(final=final) + + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-ledger-unavailable" in kinds # never read as "no entries" + abandoned = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-abandoned"] + assert bool(abandoned) is final + assert task.pending_deferred_closes == ([] if final else ["DW-1"]) + + def test_closes_deferred_external_ledger_absent_from_a_live_dir_still_discharges(project, tmp_path): """The other side of that discriminator. An artifact dir that IS there and simply has no ledger is not an outage — there is genuinely nothing to close, From 6f27142a1de2b0db62b385982180327e26ea4315 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 21:33:14 -0700 Subject: [PATCH 31/36] fix(verify): an advisory repair must not raise past the failure it is repairing (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stage_path` documents "never raises" and caught only `ValueError`. Three escapes, all landing where `_restore_deferred_closes` is repairing an escalating commit failure — so the escalation the operator needs is replaced by a generic crash: - `resolve()` raises `RuntimeError` on a symlink loop under Python 3.11/3.12 (3.13+ returns the unresolved path, so the exposure is version-dependent and the requires-python floor is the affected one) - `resolve()` raises `OSError` when a path component is inaccessible - `_git` raises `GitError` on a timeout and lets a raw `OSError` from the spawn through — `_run_git` translates only `TimeoutExpired` The regression injects the resolve fault so it gates on every interpreter; the real symlink loop is covered separately, asserting only that nothing raises, because the return value legitimately differs by version. --- src/bmad_loop/verify.py | 19 +++++++++-- tests/test_verify.py | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 0698b20a..d46758dd 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -1674,12 +1674,25 @@ def stage_path(repo: Path, path: Path) -> bool: the version that was rolled back — which an operator who fixes the hook and runs `git commit` by hand would then publish. Advisory by design: this runs on a path that is already escalating a commit failure, and a repair that raises - would mask it.""" + would mask it — so "never raises" has to hold against every step, not just the + containment check that motivated the guard. + + Both halves can fail in ways that are not `ValueError`. `resolve()` raises + `RuntimeError` on a symlink loop under Python 3.11 and 3.12 (3.13 returns the + unresolved path instead, so the exposure is version-dependent and the floor is + the affected one), and `OSError` when a path component is inaccessible. `_git` + raises `GitError` on a timeout and lets a raw `OSError` from the spawn itself + through — `_run_git` translates only `TimeoutExpired`. Any of the three would + replace the commit escalation being reported with a generic crash + (#284 round-7 review, finding 4).""" try: rel = str(Path(path).resolve().relative_to(repo.resolve())) - except ValueError: + except (OSError, RuntimeError, ValueError): + return False + try: + rc, _ = _git(repo, "add", "--", rel) + except (GitError, OSError): return False - rc, _ = _git(repo, "add", "--", rel) return rc == 0 diff --git a/tests/test_verify.py b/tests/test_verify.py index c49e9869..516a348e 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -132,6 +132,79 @@ def spying_run(cmd, **kwargs): assert seen["timeout"] == 7 +def test_stage_path_stages_a_repo_file(project): + """The happy path the advisory repair exists for: a working-tree edit reaches + the index so a hand-run `git commit` publishes what is on disk.""" + target = project.project / "src.txt" + target.write_text("staged by the repair\n") + assert verify.stage_path(project.project, target) is True + rc, out = verify._git(project.project, "diff", "--cached", "--name-only") + assert rc == 0 and "src.txt" in out + + +def test_stage_path_refuses_a_path_outside_the_repo(project, tmp_path): + """Containment: a path that is not under `repo` is refused rather than staged.""" + outside = tmp_path / "elsewhere.md" + outside.write_text("not ours\n") + assert verify.stage_path(project.project, outside) is False + + +@pytest.mark.parametrize("exc", [RuntimeError("symlink loop"), OSError("stale handle")]) +def test_stage_path_returns_false_when_resolve_raises(project, monkeypatch, exc): + """`resolve()` is not total. It raises RuntimeError on a symlink loop and + OSError when a component is inaccessible, and neither is the ValueError the + containment check was written for — so on the paths that hit them the advisory + repair replaced the commit escalation it is supposed to be assisting with a + generic crash (#284 round-7 review, finding 4). + + Injected rather than built from a real symlink loop, because the real trigger + is version-dependent: `resolve()` raises on Python 3.11/3.12 (the + requires-python floor) and returns the unresolved path on 3.13+, so a test + driven by an actual loop would assert a different outcome per interpreter.""" + target = project.project / "src.txt" + target.write_text("changed\n") + monkeypatch.setattr( + verify.Path, "resolve", lambda self, *a, **kw: (_ for _ in ()).throw(exc) + ) + + assert verify.stage_path(project.project, target) is False + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_stage_path_survives_a_real_symlink_loop(project): + """The mechanism above with nothing injected. The return value is deliberately + not asserted — on 3.11/3.12 `resolve()` raises and this refuses, while on 3.13+ + it resolves and git happily stages the link itself — but "never raises" holds + on every interpreter, and it is the half CI's 3.11 job would otherwise not + exercise at all.""" + a, b = project.project / "loop-a", project.project / "loop-b" + a.symlink_to(b) + b.symlink_to(a) + + assert isinstance(verify.stage_path(project.project, a), bool) + + +def test_stage_path_returns_false_when_git_times_out(project, monkeypatch): + """`_run_git` translates TimeoutExpired to GitError, which is still an + exception escaping an advisory repair. Same for a raw OSError on the spawn, + which `_run_git` does not translate at all.""" + target = project.project / "src.txt" + target.write_text("changed\n") + monkeypatch.setattr(verify.subprocess, "run", _timing_out_run) + assert verify.stage_path(project.project, target) is False + + +def test_stage_path_returns_false_when_the_spawn_fails(project, monkeypatch): + """The untranslated half: `_run_git` re-raises anything that is not a + TimeoutExpired, so a fork failure or a missing git binary arrives as itself.""" + target = project.project / "src.txt" + target.write_text("changed\n") + monkeypatch.setattr( + verify.subprocess, "run", lambda *a, **kw: (_ for _ in ()).throw(OSError("fork failed")) + ) + assert verify.stage_path(project.project, target) is False + + def test_run_git_forces_c_locale(project, monkeypatch): """Every git child runs with LC_ALL=C so message text stays stable English — the chokepoint fix for #236 (safe_rollback's "did not match" tolerance must not From c7a26fa0ff96ec3ed6dd429aa4f3a2dca71a96a7 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 21:35:24 -0700 Subject: [PATCH 32/36] fix(engine): a spec nobody could stat is not a spec that was withdrawn (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_capture_declared_deferred` decided "the spec is gone" with `Path.is_file()`, which answers False for exactly ENOENT / ENOTDIR / EBADF / ELOOP and propagates every other OSError. That got the site wrong in both directions: - a symlink loop or an unreadable path component reported a confirmed withdrawal and cleared the verified capture - an EACCES / EIO / ESTALE rose through `_declared_deferred_ids` and `_close_declared_deferred` into `_finalize_commit_phase`'s `except BaseException`, which restores and re-raises — so the story crashed at the commit boundary with every gate already passed Reachable in the same mount outage the external-ledger path exists for: `ProjectPaths.rebased` can place `implementation_artifacts`, and so the spec, outside the project. Absence is now an explicit `stat`. FileNotFoundError, NotADirectoryError and a successful stat of a non-regular file are a confirmed withdrawal and clear; every other OSError keeps the capture and is reported as unreadable, matching how an OSError from *reading* the spec is already handled. The direction matters: clearing costs a closure the story did declare (a miss the ledger reports truthfully by staying open), while falling back is a false close if the declaration really was withdrawn. --- src/bmad_loop/engine.py | 34 ++++++++++++++++++++++++++- tests/test_engine.py | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index aecd7949..95ff9d96 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -14,6 +14,7 @@ import hashlib import shutil import signal +import stat as stat_mod import sys import time import traceback @@ -2257,11 +2258,42 @@ def _capture_declared_deferred(self, task: StoryTask, *, site: str) -> bool: genuine read fault keeps the fallback standing, and the withdrawal is journaled rather than inferred silently (#284 round-5 review, finding 2). + Absence is therefore decided by an explicit ``stat``, not by + ``Path.is_file()``. That helper answers False for exactly four errnos — + ENOENT, ENOTDIR, EBADF, ELOOP — and *propagates* every other OSError, so it + gets this site wrong in both directions at once: a symlink loop or an + unreadable path component was reported as a confirmed withdrawal and + cleared the capture, while an EACCES, EIO or ESTALE — the shape of the + very mount outage this feature's external-ledger path exists for, and + `spec_file` can live on that mount — raised straight through + ``_declared_deferred_ids`` and ``_close_declared_deferred`` into + ``_finalize_commit_phase``'s ``except BaseException``, crashing the story + at the commit boundary with all of its work already passed + (#284 round-7 review, finding 5). + + The split is not cosmetic, because the two answers move in opposite + directions. Clearing costs a closure the story really did declare — a miss, + which the ledger reports truthfully by staying ``open``. Falling back + closes against the last good capture, which is a FALSE close if the + declaration was genuinely withdrawn. So only a fault that says nothing + about whether the spec is there falls back, and anything that positively + establishes there is no file to read clears. + The path is not re-derived here: ``task.spec_file`` is recorded only by a passing verify gate, and the root-containment rule below still holds at both sites.""" spec_path = Path(task.spec_file) if task.spec_file else None - if spec_path is None or not spec_path.is_file(): + absent = True + if spec_path is not None: + try: + absent = not stat_mod.S_ISREG(spec_path.stat().st_mode) + except (FileNotFoundError, NotADirectoryError): + absent = True # nothing is at that path: a confirmed withdrawal + except OSError: + # the location said nothing about the declaration. Keep the + # verified capture; `_declared_deferred_ids` reports the fault. + return False + if spec_path is None or absent: if task.declared_deferred: self.journal.append( "deferred-close-declaration-absent", diff --git a/tests/test_engine.py b/tests/test_engine.py index c4a88325..fadb1f09 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2705,6 +2705,57 @@ def spec_removed_before_the_commit(task): assert "story-deferred-closed" not in {e["kind"] for e in engine.journal.entries()} +def test_closes_deferred_keeps_the_capture_when_the_spec_stat_faults(project, monkeypatch): + """The other side of the deletion discriminator, and the reason it cannot be + `Path.is_file()`. + + That helper answers False for exactly ENOENT / ENOTDIR / EBADF / ELOOP and + PROPAGATES every other OSError, so it was wrong in both directions at this + site. An EACCES / EIO / ESTALE — the shape of the same mount outage the + external-ledger path exists for, and `spec_file` can live on that mount — rose + through `_declared_deferred_ids` and `_close_declared_deferred` into + `_finalize_commit_phase`'s `except BaseException`, which restores and + re-raises: the story crashed at the commit boundary with every gate already + passed (#284 round-7 review, finding 5). + + A fault that says nothing about whether the spec is there is not a withdrawal, + so the verified capture stands and the closure still lands. Paired with + `..._when_the_final_spec_is_gone` above, which pins the opposite direction: a + confirmed absence must still clear.""" + engine = _closes_deferred_run(project, ["DW-1"]) + sp = spec_path(project, "1-1-a") + finalize = engine._finalize_commit_phase + real_stat = Path.stat + faulting: list[int] = [] + + def stat_denied_on_the_spec(self, *a, **kw): + if faulting and self == sp: + raise PermissionError(13, "Permission denied") + return real_stat(self, *a, **kw) + + def commit_with_the_spec_unreadable(task): + assert engine.state.tasks["1-1-a"].declared_deferred == ["DW-1"] # captured at verify + faulting.append(1) # scoped to the commit boundary: the capture must stay good + try: + return finalize(task) + finally: + faulting.clear() + + monkeypatch.setattr(Path, "stat", stat_denied_on_the_spec) + monkeypatch.setattr(engine, "_finalize_commit_phase", commit_with_the_spec_unreadable) + + summary = engine.run() + + assert summary.done == 1 and not summary.crashed # never a gate, and never a crash + assert not _ledger_entries(project)["DW-1"].open # closed from the verified capture + events = [ + e for e in engine.journal.entries() if e["kind"] == "deferred-close-declaration-unreadable" + ] + assert len(events) == 1 and "last good capture" in events[0]["note"] + # an unreadable spec is not a deletion, so it must not report one + assert "deferred-close-declaration-absent" not in {e["kind"] for e in engine.journal.entries()} + + def test_closes_deferred_drops_the_capture_when_the_final_spec_leaves_the_roots( project, tmp_path, monkeypatch ): From 03add18c605038ecd0c09ae11dc1e0ada34b74cf Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 21:38:15 -0700 Subject: [PATCH 33/36] fix(deferredwork,engine): undo the close, not the whole ledger (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_restore_deferred_closes` rewrote the entire document from the pre-close snapshot, which cannot tell this story's `status: done` flip from anybody else's work. The writer that can land inside that window is the very thing that fails the commit: a native `pre-commit` hook that edits `deferred-work.md` and then rejects. Its entry was silently deleted along with the close being undone. Synchronous, inside this story's own commit, so it is not the concurrent-writer problem tracked by #286. New `deferredwork.restore_entries(current, before, ids)` substitutes just the named entries' bodies inside whatever is on disk now, applying the splices last-first so each one leaves the earlier offsets valid. Entry-wide rather than line-wide because that is exactly as wide as `_apply_done`'s write: it rewrites the status line and inserts a `resolution:` line, both inside the entry. The whole-document rewrite stays as the fallback for the one case with nothing to target — `marked` is empty when a stop signal landed inside the write, before the close could report which ids it had flipped. There the restore is by content or not at all. An id that cannot be put back from either side is journaled as `deferred-close-rollback-partial` rather than guessed at. --- src/bmad_loop/deferredwork.py | 37 +++++++++++++++++++++ src/bmad_loop/engine.py | 40 +++++++++++++++++++++-- tests/test_deferredwork.py | 61 +++++++++++++++++++++++++++++++++++ tests/test_engine.py | 37 +++++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 9c104fab..6e4e8ddc 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -229,6 +229,43 @@ def mark_done_many(path: Path, dw_ids: Sequence[str], date: str, note: str) -> l return marked +def restore_entries(current: str, before: str, dw_ids: Sequence[str]) -> tuple[str, list[str]]: + """Put just the named entries back the way `before` had them, inside whatever + `current` says now. Returns the rewritten text plus the ids that could not be + restored, in the order given. + + This is the undo half of :func:`mark_done_many`, and it is deliberately + narrower than "write `before` back". A rollback runs after a commit failed, + and the thing that failed the commit can be a native `pre-commit` hook that + edited this same ledger before rejecting — appending a fresh entry, say. A + whole-document restore silently takes that edit with it, because it cannot + tell the story's own `status: done` flip from anybody else's work + (#284 round-7 review, finding 3). + + Substituting whole entry bodies rather than just the two lines the close wrote + keeps the undo exactly as wide as the do: :func:`_apply_done` rewrites the + status line and inserts a `resolution:` line, both inside the entry, so + replacing the entry restores precisely that and nothing else. An id missing + from either side is reported rather than guessed at — the caller decides + whether an entry the hook deleted outright is worth complaining about. + + Spans are applied last-first so each splice leaves the earlier offsets valid. + """ + before_by_id = {e.id: e for e in parse_ledger(before)} + current_by_id = {e.id: e for e in parse_ledger(current)} + edits: list[tuple[tuple[int, int], str]] = [] + missing: list[str] = [] + for dw_id in dict.fromkeys(dw_ids): + was, now = before_by_id.get(dw_id), current_by_id.get(dw_id) + if was is None or now is None: + missing.append(dw_id) + continue + edits.append((now.span, was.body)) + for (start, end), body in sorted(edits, key=lambda e: e[0][0], reverse=True): + current = current[:start] + body + current[end:] + return current, missing + + def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: """Flip one entry to `status: done ` and record a resolution note. Returns False (no write) when the entry is missing or already done.""" diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 95ff9d96..058b7261 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2501,12 +2501,37 @@ def _restore_deferred_closes( otherwise publish the false ``done`` this restore exists to remove (#284 round-6 review, finding 3). Advisory: a refused re-stage is journaled, never raised, because it must not mask the commit failure being - escalated.""" + escalated. + + **Only the entries this story flipped are put back.** What failed the + commit can be a native ``pre-commit`` hook that edited this same ledger + before rejecting it, and rewriting the whole document takes that edit with + it: the restore cannot tell the story's own ``status: done`` from anybody + else's work, and the entry the hook appended simply disappears + (#284 round-7 review, finding 3). ``restore_entries`` substitutes the named + entries inside whatever is on disk now, which is exactly as wide as the + write it undoes. + + The whole-document rewrite stays as the fallback for the one case with + nothing to target: ``marked`` is empty when a stop signal landed *inside* + the write, before the close could report which ids it had flipped. There + the restore is by content or not at all, and giving it up would hand that + window back its false close.""" if snapshot is None: return ledger, before, marked = snapshot + text, unrestored = before, [] + if marked: + try: + text, unrestored = deferredwork.restore_entries( + ledger.read_text(encoding="utf-8"), before, marked + ) + except OSError: + # unreadable now; the snapshot is still a better answer than a + # ledger left claiming work that was never committed. + text, unrestored = before, [] try: - atomic_write_text(ledger, before) + atomic_write_text(ledger, text) except OSError as e: # bookkeeping repair must not mask the commit failure being escalated self.journal.append( @@ -2516,6 +2541,17 @@ def _restore_deferred_closes( error=str(e), ) return + if unrestored: + # the entry is not in the ledger any more, so there is no `status: done` + # left standing — but the operator should know the rollback found the + # document rearranged underneath it. + self.journal.append( + "deferred-close-rollback-partial", + story_key=task.story_key, + dw_ids=list(unrestored), + ledger=str(ledger), + note="these entries were gone from the ledger by the time the close was undone", + ) if not verify.stage_path(self.workspace.root, ledger): self.journal.append( "deferred-close-rollback-unstaged", diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index c7fac9c4..77e1a817 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -18,6 +18,7 @@ parse_declaration, parse_ledger, parse_legacy, + restore_entries, ) LEDGER = """\ @@ -652,3 +653,63 @@ def test_mark_done_many_skips_an_already_done_entry(tmp_path): assert again == [] body = next(e for e in parse_ledger(p.read_text(encoding="utf-8")) if e.id == "DW-1").body assert body.count("resolution: resolved by story 1") == 1 + + +def test_restore_entries_reverts_only_the_named_entries(): + """The undo half of `mark_done_many`, and deliberately no wider than the do. + + A rollback runs after a commit failed, and what failed it can be a native + `pre-commit` hook that edited this same ledger before rejecting. Rewriting the + whole document from the pre-close snapshot takes that edit with it, because it + cannot tell the story's own flip from anybody else's work + (#284 round-7 review, finding 3).""" + before = "# Deferred Work\n\n### DW-1: one\n\nstatus: open\n\n### DW-2: two\n\nstatus: open\n" + current = ( + "# Deferred Work\n\n" + "### DW-1: one\n\nstatus: done 2026-07-25\nresolution: resolved by story 1-1-a\n\n" + "### DW-2: two\n\nstatus: done 2026-07-25\nresolution: closed by hand\n\n" + "### DW-9: appended later\n\nstatus: open\n" + ) + + text, missing = restore_entries(current, before, ["DW-1"]) + + assert missing == [] + entries = {e.id: e for e in parse_ledger(text)} + assert entries["DW-1"].open # reverted + assert not entries["DW-2"].open # somebody else's close, untouched + assert "closed by hand" in text + assert "DW-9" in entries # an entry that did not exist at snapshot time survives + + +def test_restore_entries_multiple_entries_keep_their_neighbours(tmp_path): + """Several ids at once. Each splice shifts every offset after it, so the edits + are applied last-first; front-first would rewrite the second entry using spans + measured against the pre-splice text and corrupt the document.""" + p = tmp_path / "deferred-work.md" + p.write_text(LEDGER, encoding="utf-8") + before = LEDGER + marked = mark_done_many(p, ["DW-1", "DW-3"], "2026-07-25", "resolved by story 1-1-a") + assert marked == ["DW-1", "DW-3"] # the fixture's DW-2 is already done + + text, missing = restore_entries(p.read_text(encoding="utf-8"), before, marked) + + assert missing == [] + assert text == before # nothing else changed, so a full revert is byte-identical + entries = {e.id: e for e in parse_ledger(text)} + assert entries["DW-1"].open and entries["DW-3"].open + assert "resolved by story 1-1-a" not in text + + +def test_restore_entries_reports_an_id_it_cannot_put_back(): + """Unrestorable in either direction is reported, never guessed at: an entry the + hook deleted outright is gone from `current`, and one absent from the snapshot + has no prior state to restore. Neither is resurrected — the rollback undoes a + status flip, it does not re-author the ledger.""" + before = "# Deferred Work\n\n### DW-1: one\n\nstatus: open\n" + current = "# Deferred Work\n\n### DW-2: two\n\nstatus: open\n" + + text, missing = restore_entries(current, before, ["DW-1", "DW-2"]) + + assert missing == ["DW-1", "DW-2"] # gone from `current` / absent from `before` + assert text == current # nothing to splice either way + assert "DW-1" not in text diff --git a/tests/test_engine.py b/tests/test_engine.py index fadb1f09..fd604689 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2268,6 +2268,43 @@ def test_closes_deferred_rollback_restores_the_index_too(project): assert "deferred-close-rollback-unstaged" not in [e["kind"] for e in engine.journal.entries()] +def test_closes_deferred_rollback_keeps_an_edit_the_rejecting_hook_made(project): + """The rollback used to rewrite the whole ledger from the pre-close snapshot, + which cannot tell this story's `status: done` from anybody else's work. + + The writer that can land inside that window is the very thing that fails the + commit: a native `pre-commit` hook that edits `deferred-work.md` and then + rejects. Its entry was silently deleted along with the close being undone — + synchronously, inside this story's own commit, so it is not the concurrent + -writer problem tracked by #286 (#284 round-7 review, finding 3).""" + engine = _closes_deferred_run(project, ["DW-1"]) + ledger = project.deferred_work + hooks = project.project / ".git" / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + hook = hooks / "pre-commit" + # files an entry of its own (the shape of a lint/secret hook recording what it + # objected to) and only then refuses the commit + hook.write_text( + "#!/bin/sh\n" + f"cat >> '{ledger}' <<'EOF'\n\n" + "### DW-9: filed by the hook\n\n" + "origin: hook, 2026-06-01\nlocation: src.txt:1\nreason: hook entry.\nstatus: open\n" + "EOF\n" + "exit 1\n", + encoding="utf-8", + ) + hook.chmod(0o755) + + summary = engine.run() + + assert summary.done == 0 and summary.paused # the commit really did fail + entries = _ledger_entries(project) + assert entries["DW-1"].open # the story's close is undone... + assert "DW-9" in entries and entries["DW-9"].open # ...and the hook's entry survives + kinds = [e["kind"] for e in engine.journal.entries()] + assert kinds.index("story-deferred-closed") < kinds.index("deferred-close-rolled-back") + + def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): """The rollback must leave the story re-drivable: once the hook is gone, the resumed commit phase re-applies the close exactly once (no doubled From 09a2fe3f8aed6a6c0a011c3d6c74cb03404d150f Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 21:43:14 -0700 Subject: [PATCH 34/36] fix(engine): one read decides both whether the ledger is there and what it says (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Availability was probed in `_flush_pending_deferred_closes` and the contents read again in `_apply_deferred_closes`, each stat'ing the path afresh. A mount that dropped between them passed the probe, read as empty text, classified every declared id `unknown` and discharged the obligation with an `unmatched` line — blaming a typo for an outage, permanently. New `_read_ledger` returns (available, text) and is the only place that decides. The ORDER is the fix: the read is attempted first and the location probed only when it comes back absent, so "there is nothing to close" is never concluded from a probe that succeeded a moment earlier. Anything that drops in between falls out as unavailable, which is the retryable answer. It also widens what counts as unavailable to match reality: a ledger present but undecodable is an outage, not an empty ledger — and an empty ledger PARSES, so reading it as text would report every hand-written entry as vanished. Two in-repo shapes that were never routed here: - a dangling ledger symlink read as empty, so the story reported a typo - a looping one crashed the commit boundary: `resolve()` raises RuntimeError on Python 3.11/3.12 An unresolvable ledger path now closes nothing and says so. Neither guess is safe: writing could flip a shared external ledger no commit will carry, and parking would write an in-repo ledger after the commit and leave the tree dirty for story N+1's step-01 to halt on. --- src/bmad_loop/engine.py | 117 +++++++++++++++++++++++++++++++++----- tests/test_engine.py | 121 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 14 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 058b7261..aa6843e7 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2452,7 +2452,27 @@ def _close_declared_deferred( # merge that never lands leaves shared work marked done with nothing left # to roll it back. Only the last path component can differ: ProjectPaths # resolves the artifact dir at construction and again in `rebased`. - if not ledger.resolve().is_relative_to(self.workspace.root.resolve()): + try: + in_repo = ledger.resolve().is_relative_to(self.workspace.root.resolve()) + except (OSError, RuntimeError): + # A symlink loop (`resolve()` raises RuntimeError for one under Python + # 3.11/3.12, the requires-python floor) or an unreadable path + # component. Nothing can be decided about where this ledger lives, and + # both answers are wrong to guess: writing it here could flip a shared + # external ledger no commit will carry, while parking it would write an + # IN-repo ledger after the commit and leave the tree dirty for story + # N+1's step-01 to halt on. So close nothing and say so — the entries + # stay open for a sweep, which is what an unreadable ledger location + # honestly amounts to (#284 round-7 review, finding 6). + self.journal.append( + "deferred-close-ledger-unavailable", + story_key=task.story_key, + dw_ids=list(ids), + ledger=str(ledger), + note="the ledger path could not be resolved; nothing was closed", + ) + return None + if not in_repo: task.pending_deferred_closes = list(ids) self.journal.append( "deferred-close-pending-integration", @@ -2461,13 +2481,26 @@ def _close_declared_deferred( ledger=str(ledger), ) return None - before = ledger.read_text(encoding="utf-8") if ledger.is_file() else None + available, before = self._read_ledger(ledger) + if not available: + # In repo and yet not readable — a dangling or looping link, a + # permission fault. There is no park to fall back on here (an in-repo + # annotation exists to ride this commit), so say so and close nothing + # rather than read the outage as "no such entries". + self.journal.append( + "deferred-close-ledger-unavailable", + story_key=task.story_key, + dw_ids=list(ids), + ledger=str(ledger), + note="the in-repo ledger could not be read; nothing was closed", + ) + return None if rollback is not None and before is not None: # Armed here, not on the way out. Restoring text nothing changed is a # no-op rewrite, so arming early costs an over-broad window nothing, # while arming late would miss the whole publication window. rollback.append((ledger, before, [])) - marked = self._apply_deferred_closes(task, ids, ledger, rollback) + marked = self._apply_deferred_closes(task, ids, ledger, before or "", rollback) snapshot = (ledger, before, marked) if marked and before is not None else None if rollback is not None: # `mark_done_many` writes only when it marks, so an empty close left @@ -2572,23 +2605,73 @@ def _restore_deferred_closes( ledger=str(ledger), ) + def _read_ledger(self, ledger: Path) -> tuple[bool, str | None]: + """The one place that decides whether a ledger location is answering. + + Returns ``(available, text)``. ``available`` False means the location said + nothing — the artifact directory is gone, the link dangles, a component is + unreadable, the bytes are not decodable — and a caller holding an + obligation must keep it. ``(True, None)`` is a live location with no ledger + in it, which genuinely has nothing to close and discharges. ``(True, text)`` + is the ledger. + + Collapsing the probe and the read into one function is the point. They used + to sit in different methods — the availability test in + ``_flush_pending_deferred_closes``, the read in ``_apply_deferred_closes`` + — each stat'ing the path again, so a mount that dropped between them passed + the test, read as empty text, classified every declared id as ``unknown`` + and cleared the obligation with an ``unmatched`` line blaming a typo for an + outage (#284 round-7 review, finding 6). Here any fault raised by any step + lands in the same handler and answers "unavailable", which is the truthful + reading and the retryable one. + + A ledger that is present but not decodable is unavailable too, not empty: + an empty ledger PARSES, so reading it as text would say every hand-written + entry had vanished. + + Note the ORDER. The read is attempted first and the location is probed only + when it comes back absent, so "there is nothing to close" is never + concluded from a probe that succeeded a moment before the read — the very + ordering that made the race. Anything that drops in between now falls out + as unavailable, which is the retryable answer and the safe direction.""" + try: + return True, ledger.read_text(encoding="utf-8") + except FileNotFoundError: + pass # nothing at that path — but why is decided below + except (OSError, UnicodeDecodeError, RuntimeError): + return False, None + try: + # A live artifact directory with no ledger in it has genuinely nothing + # to close. A directory that is gone, or a link left pointing at a + # mount that is, is an outage — and the link existing at all is the + # evidence a ledger is expected there. + present = ledger.parent.is_dir() and not ledger.is_symlink() + except OSError: + return False, None + return (True, None) if present else (False, None) + def _apply_deferred_closes( self, task: StoryTask, ids: Sequence[str], ledger: Path, + text: str, rollback: list[tuple[Path, str, list[str]]] | None = None, ) -> list[str]: """Write the closure for `ids`, journal exactly what landed, and return the ids actually flipped. + ``text`` is the ledger snapshot the caller already read, never re-read + here: classification and the write have to describe the same document, and + a second read is a second chance for the location to have gone away + underneath them. + ``rollback`` is the caller's already-armed restore slot (see ``_close_declared_deferred``); the ids are written into it the moment they are known, which is the statement after the write and the one before the first thing here that can raise. Refining it costs a line and buys the operator a rolled-back record that names entries instead of an empty list.""" - text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" declared = deferredwork.classify(text, ids) marked = deferredwork.mark_done_many( ledger, declared.open_ids, self._today(), f"resolved by story {task.story_key}" @@ -2669,13 +2752,12 @@ def _flush_pending_deferred_closes(self, task: StoryTask) -> None: emptied list: the obligation is destroyed by the very failure that made it worth retrying. - **An unavailable location is not an answer about the entries.** A read - that raises keeps the obligation by unwinding, but an absent file does - not raise: ``_apply_deferred_closes`` reads a missing ledger as empty - text, every id classifies as unknown, and the ids were then cleared on - the strength of a ledger nobody could see. That is a real risk here and - only here — this path exists *because* the ledger is out of the repo, so - it can live on a mount that is temporarily gone. The artifact directory + **An unavailable location is not an answer about the entries.** An absent + file does not raise: it reads as empty text, every id classifies as + unknown, and the ids were then cleared on the strength of a ledger nobody + could see. That is a real risk here and only here — this path exists + *because* the ledger is out of the repo, so it can live on a mount that is + temporarily gone. The artifact directory is what distinguishes the two: gone means the location is unavailable and the obligation is retryable; present-but-no-ledger means there is genuinely nothing to close, which is the same answer the in-repo path @@ -2688,12 +2770,19 @@ def _flush_pending_deferred_closes(self, task: StoryTask) -> None: and says no, so every id classified as unknown and the obligation was discharged with an ``unmatched`` line blaming a typo for an outage. The link existing at all is the evidence a ledger is expected there - (#284 round-6 review, finding 1).""" + (#284 round-6 review, finding 1). + + Availability and content come from ONE ``_read_ledger`` call. Probing here + and reading again inside ``_apply_deferred_closes`` left a window for the + mount to drop in between: the probe passed, the read saw nothing, and the + obligation was discharged against a ledger that was no longer there + (#284 round-7 review, finding 6).""" ids = tuple(task.pending_deferred_closes) if not ids: return ledger = self.workspace.paths.deferred_work - if not ledger.parent.is_dir() or (ledger.is_symlink() and not ledger.exists()): + available, text = self._read_ledger(ledger) + if not available: self.journal.append( "deferred-close-ledger-unavailable", story_key=task.story_key, @@ -2712,7 +2801,7 @@ def _flush_pending_deferred_closes(self, task: StoryTask) -> None: ledger=str(ledger), note="ledger is outside the repo; the annotation is not part of any commit", ) - self._apply_deferred_closes(task, ids, ledger) + self._apply_deferred_closes(task, ids, ledger, text or "") task.pending_deferred_closes = [] self._save() diff --git a/tests/test_engine.py b/tests/test_engine.py index fd604689..c3fc370b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2506,6 +2506,127 @@ def flush_with_the_mount_away(task): assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] +@pytest.mark.parametrize( + "ledger_bytes, label", + [(b"\xff\xfe not utf-8\n", "undecodable"), (None, "unreadable")], + ids=["undecodable", "unreadable"], +) +def test_closes_deferred_external_ledger_that_cannot_be_read_stays_owed( + project, tmp_path, monkeypatch, ledger_bytes, label +): + """A ledger that is THERE but cannot be turned into text is an outage, not an + empty ledger — and emphatically not a crash. + + Two shapes, one answer. Undecodable bytes raise `UnicodeDecodeError`, which is + a `ValueError` and so slipped past every `except OSError` on this path; an + unreadable file raises `PermissionError` from inside the close, where the + read used to live. Reading either as empty text would classify every declared + id `unknown` and discharge the obligation with a typo warning — and an empty + ledger PARSES, so there is nothing to make that look wrong + (#284 round-7 review, findings 2 and 6).""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + if ledger_bytes is not None: + paths.deferred_work.write_bytes(ledger_bytes) + else: + real_read = Path.read_text + target = paths.deferred_work + + def denied(self, *a, **kw): + if self == target: + raise PermissionError(13, "Permission denied") + return real_read(self, *a, **kw) + + monkeypatch.setattr(Path, "read_text", denied) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + + summary = engine.run() + + assert summary.done == 1 and not summary.crashed # never a gate, never a crash + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-ledger-unavailable" in kinds + assert "deferred-close-unmatched" not in kinds # an outage is not a typo + assert "story-deferred-closed" not in kinds + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +@pytest.mark.parametrize("shape", ["dangling", "looping"]) +def test_closes_deferred_in_repo_broken_ledger_link_is_an_outage_not_a_typo(project, shape): + """The in-repo half of the round-6 dangling-symlink fix, which only ever + covered the external path. + + An in-repo link pointing at a mount that went away read as an empty ledger, so + every declared id came back `unknown` and the story reported a typo for an + outage. A looping link was worse: `resolve()` raises RuntimeError on Python + 3.11/3.12 — the requires-python floor — and that rose through the commit + boundary (#284 round-7 review, finding 6). + + Both shapes reach the same answer on every interpreter, by different routes: + the loop is refused at the containment check on 3.11/3.12 and at the read on + 3.13+, where `resolve()` returns the unresolved path instead of raising.""" + engine = _closes_deferred_run(project, ["DW-1"]) + ledger = project.deferred_work + ledger.unlink() + if shape == "dangling": + ledger.symlink_to(project.project / "gone-mount" / "deferred-work.md") + else: + other = project.project / "ledger-loop" + ledger.symlink_to(other) + other.symlink_to(ledger) + + summary = engine.run() + + assert summary.done == 1 and not summary.crashed # the story still lands + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-ledger-unavailable" in kinds + assert "deferred-close-unmatched" not in kinds + assert "story-deferred-closed" not in kinds + + +def test_closes_deferred_survives_a_ledger_path_it_cannot_resolve(project, monkeypatch): + """The containment check resolves both paths, and `resolve()` is not total — + RuntimeError on a symlink loop, OSError on an unreadable component. Injected + rather than built from a real loop because the real trigger only raises on + Python 3.11/3.12, so the platform decides whether the guard is exercised at + all; here every interpreter runs it. + + Unresolvable takes the safe side of the guard it broke — not provably in the + repo — so the ids are parked and retried instead of crashing the commit.""" + engine = _closes_deferred_run(project, ["DW-1"]) + finalize = engine._finalize_commit_phase + real_resolve = Path.resolve + ledger = project.deferred_work + faulting: list[int] = [] + + def resolve_fails(self, *a, **kw): + # only the ledger: `finalize_commit` and the worktree machinery resolve + # paths of their own, and faulting those would prove something else + if faulting and self == ledger: + raise RuntimeError("Symlink loop") + return real_resolve(self, *a, **kw) + + def commit_with_an_unresolvable_ledger(task): + faulting.append(1) + try: + return finalize(task) + finally: + faulting.clear() + + monkeypatch.setattr(Path, "resolve", resolve_fails) + monkeypatch.setattr(engine, "_finalize_commit_phase", commit_with_an_unresolvable_ledger) + + summary = engine.run() + + assert summary.done == 1 and not summary.crashed + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-ledger-unavailable" in kinds + assert "story-deferred-closed" not in kinds + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_closes_deferred_owes_nothing_a_finished_run_can_no_longer_pay(project, tmp_path): """The other end of that promise. `resume` refuses a finished run, so an From e10aae8abd06a006f17d948a70d5a810b3774ac2 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 21:51:59 -0700 Subject: [PATCH 35/36] fix(engine): every retry site goes through the guard, not just one of them (#234 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 6 made the post-merge flush non-throwing and left the other three callers bare: both reconcile passes (`_finish_inflight` and the run-end pass) and the in-place commit-boundary flush. A persistent failure — a read-only mount, an EACCES — therefore crashed the run through an unguarded site. The crash handler leaves `finished` False, `resume` accepts a crashed run, `_finish_inflight` retries at the same bare call and crashes again: an unbounded loop over an advisory annotation, in a feature whose stated contract is that closure is never a gate. Guarding the in-place call reverses round 6's reasoning deliberately. Leaving it bare bought an idempotent COMMITTING re-drive, which is a real retry for a transient fault and no retry at all for a persistent one — just the same crash every attempt. The reconcile passes already retry, and more cheaply than re-running a commit phase. The catch now names UnicodeDecodeError and RuntimeError alongside OSError, because neither is one: an undecodable ledger raises a ValueError subclass, and `atomic_write_text` resolves the path first — `resolve()` raises RuntimeError on a symlink loop under Python 3.11/3.12. It stays an explicit tuple rather than Exception so a RunStopped or RunPaused still travels. The diagnostic append is itself suppressed: a journal write that fails while reporting a failed write must not become the crash the guard exists to prevent. Finding 7, same loop: a DONE task whose unit never merged journaled its abandonment and fell through without clearing and without consulting `final`, so a resumed run recorded it twice and a finished run kept the ids serialized forever. Now reported once per process and released on the final pass, like every other obligation nothing can service. `test_closes_deferred_external_write_failure_keeps_the_obligation` asserted the crash this removes. Its real contract — clear only after the write succeeds — is now driven with a one-shot fault, so what proves the obligation survived is that the run-end pass pays it. --- src/bmad_loop/engine.py | 121 +++++++++++++++++++--------- tests/test_engine.py | 173 ++++++++++++++++++++++++++++++++++++++-- tests/test_verify.py | 4 +- 3 files changed, 252 insertions(+), 46 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index aa6843e7..3543f551 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -261,6 +261,11 @@ def __init__( # best-effort hint (None when the estimate could not be computed). self._graceful_stopped = False self._graceful_remaining: int | None = None + # Story keys whose parked closure has already been reported unservicable + # (DONE, but the unit never merged). In-memory on purpose: the point is one + # line per process, not one line ever — a later run that re-derives the same + # standing fact should say so again. + self._unmerged_closes_reported: set[str] = set() # Per-unit worktree isolation + integration flow (issue #244 F-3/F-9a). # Built from narrow deps + engine callbacks; the same-name Engine._* worktree # methods below delegate to it. `emit` is late-bound (a lambda, not the bound @@ -821,14 +826,26 @@ def _reconcile_pending_deferred_closes(self, *, final: bool = False) -> None: if task.phase != Phase.DONE: continue # still re-drivable; the commit phase applies it if not (task.unit_merged or not self._isolated): - self.journal.append( - "deferred-close-abandoned", - story_key=task.story_key, - dw_ids=list(task.pending_deferred_closes), - reason="story is done but its unit never merged; entries stay open", - ) + # Reported once per process, not once per pass: a resumed run walks + # this loop from `_finish_inflight` and again at the end, and the + # condition is the same standing fact both times. + if task.story_key not in self._unmerged_closes_reported: + self._unmerged_closes_reported.add(task.story_key) + self.journal.append( + "deferred-close-abandoned", + story_key=task.story_key, + dw_ids=list(task.pending_deferred_closes), + reason="story is done but its unit never merged; entries stay open", + ) + if final: + # Nothing after this point can service it either — same reason + # as the release below — so it is let go rather than left + # serialized on a finished run as an obligation with no owner + # (#284 round-7 review, finding 7). + task.pending_deferred_closes = [] + self._save() continue - self._flush_pending_deferred_closes(task) + self._flush_after_integration(task) if final and task.pending_deferred_closes: # the flush kept it: the location was unavailable, and this run # has no later pass — nor any resume — left to try again in. @@ -1847,7 +1864,13 @@ def _finalize_commit_phase(self, task: StoryTask) -> None: # advance: a crash in this window leaves the task COMMITTING, which # the resume arm re-drives — and both finalize_commit and the close # are idempotent. After DONE it would be terminal, and unreachable. - self._flush_pending_deferred_closes(task) + # + # Guarded like every other flush: a persistent ledger failure re-drives + # into the same failure on every attempt, so leaving it bare traded an + # advisory annotation for a crash loop (#284 round-7 review, finding 1). + # A transient one is still retried, now by the reconcile pass rather + # than by re-running the commit phase. + self._flush_after_integration(task) advance(task, Phase.DONE) self.journal.append("story-done", story_key=task.story_key, commit=task.commit_sha) self._emit("post_commit", task) @@ -2708,36 +2731,62 @@ def _apply_deferred_closes( return marked def _flush_after_integration(self, task: StoryTask) -> None: - """The integration chokepoint's flush, which may not raise (#234). - - ``WorktreeFlow.integrate_unit`` calls this after the unit has merged and - the task is already terminal, and a raise from here escapes ``run_unit`` - and the whole story loop — so ``_after_story`` never fires. In stories - mode that is the ``done_checkpoint``: a mandatory human review silently - skipped, with the next story free to dispatch. Resume does not repair it - either, because ``_finish_inflight`` skips terminal tasks by design. - - Everything this flush touches can raise OSError — the ledger read and - write, the journal append, the state save — and all of it lives on the - out-of-repo mount that is the only reason the flush does anything at all. - Trading a checkpoint for a failed annotation is exactly backwards, and the - annotation is never a gate anywhere else in this feature: journal the - failure, leave ``pending_deferred_closes`` standing for the reconcile pass - to retry, and let the story finish (#284 round-6 review, finding 2). - - Only the integration call site is guarded. The in-place flush runs before - the DONE advance precisely so a raise there leaves the task COMMITTING for - the resume arm to re-drive idempotently; guarding it would discard that.""" + """The flush every caller goes through, and which may not raise (#234). + + ``WorktreeFlow.integrate_unit`` calls it after the unit has merged and the + task is already terminal, and a raise from there escapes ``run_unit`` and + the whole story loop — so ``_after_story`` never fires. In stories mode + that is the ``done_checkpoint``: a mandatory human review silently skipped, + with the next story free to dispatch. Resume does not repair it either, + because ``_finish_inflight`` skips terminal tasks by design. + + Everything this flush touches can fail — the ledger read and write, the + journal append, the state save — and all of it lives on the out-of-repo + mount that is the only reason the flush does anything at all. Trading a + checkpoint for a failed annotation is exactly backwards, and the annotation + is never a gate anywhere else in this feature: journal the failure, leave + ``pending_deferred_closes`` standing for a later pass to retry, and let the + story finish (#284 round-6 review, finding 2). + + **Every retry site goes through here, not just integration.** Round 6 + guarded this one call and left the reconcile passes + (``_reconcile_pending_deferred_closes``, from ``_finish_inflight`` and from + the end of ``run()``) calling the flush bare, and the in-place + commit-boundary call bare as well. A *persistent* failure — a read-only + mount, an EACCES — then crashed the run through the unguarded site; the + crash handler leaves ``finished`` False, ``resume`` accepts a crashed run, + ``_finish_inflight`` retries at the same bare call and crashes again. An + unbounded crash loop over an advisory annotation, in a feature whose + stated contract is that closure is never a gate + (#284 round-7 review, finding 1). + + Guarding the in-place call reverses round 6's reasoning deliberately. + Leaving it bare bought an idempotent COMMITTING re-drive, which is a real + retry for a *transient* fault and no retry at all for a persistent one — + just the same crash on every attempt. The reconcile passes already provide + the retry, and more cheaply than re-driving a whole commit phase, so the + story now advances to DONE holding its obligation and the run-end pass + retries and then releases it. + + The catch names ``UnicodeDecodeError`` and ``RuntimeError`` alongside + ``OSError`` because neither is one: an undecodable ledger raises a + ``ValueError`` subclass and ``Path.resolve()`` raises ``RuntimeError`` on a + symlink loop under Python 3.11/3.12. It stays an explicit tuple rather than + ``Exception`` so a ``RunStopped`` or ``RunPaused`` still travels. And the + diagnostic append is itself suppressed — a journal write that fails while + reporting a failed write must not become the crash the guard exists to + prevent (#284 round-7 review, finding 2).""" try: self._flush_pending_deferred_closes(task) - except OSError as e: - self.journal.append( - "deferred-close-flush-failed", - story_key=task.story_key, - dw_ids=list(task.pending_deferred_closes), - error=f"{e.__class__.__name__}: {e}", - note="the closure stays owed; the story's own bookkeeping continues", - ) + except (OSError, UnicodeDecodeError, RuntimeError) as e: + with contextlib.suppress(Exception): + self.journal.append( + "deferred-close-flush-failed", + story_key=task.story_key, + dw_ids=list(task.pending_deferred_closes), + error=f"{e.__class__.__name__}: {e}", + note="the closure stays owed; the story's own bookkeeping continues", + ) def _flush_pending_deferred_closes(self, task: StoryTask) -> None: """Apply closures held back for an out-of-repo ledger, once the story's diff --git a/tests/test_engine.py b/tests/test_engine.py index c3fc370b..01b60215 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2436,9 +2436,122 @@ def test_closes_deferred_external_obligation_drops_when_the_declaration_is_withd def test_closes_deferred_external_write_failure_keeps_the_obligation(project, tmp_path): """Clearing `pending_deferred_closes` before the write looks harmless — the - flush is about to happen — but a raising write unwinds to the crash handler, - whose `finally: self._save()` then persists the emptied list. The failure that - made the retry necessary destroys the record of it (#284 review, finding 2).""" + flush is about to happen — but a failing write would then have destroyed the + record of the retry it made necessary (#284 review, finding 2). + + Driven with a ONE-SHOT fault, because what proves the obligation survived is + that a later pass can still pay it: the run-end reconcile retries and DW-1 + closes. Asserting a crash instead (as this did) pinned the wrong half — the + crash was itself the round-7 defect, not the contract.""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + real_apply = engine._apply_deferred_closes + attempts: list[int] = [] + + def fails_once(*a, **kw): + attempts.append(1) + if len(attempts) == 1: + raise OSError("disk full") + return real_apply(*a, **kw) + + engine._apply_deferred_closes = fails_once + + summary = engine.run() + + assert summary.done == 1 and not summary.crashed + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-flush-failed" in kinds # the first attempt is on the record + # ...and the obligation it kept was paid by the run-end pass + assert not _ledger_entries(paths)["DW-1"].open + assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == [] + + +@pytest.mark.parametrize( + "exc", + [ + PermissionError(13, "read-only file system"), + RuntimeError("Symlink loop"), + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), + ], + ids=["oserror", "runtimeerror", "unicodedecodeerror"], +) +def test_closes_deferred_persistent_write_failure_never_crashes_the_run(project, tmp_path, exc): + """The other side of that fault, and the round-7 defect itself. + + Parametrized over all three failure types the guard names, because two of them + are not `OSError` and that is exactly how they got past it: `atomic_write_text` + resolves the path first and `resolve()` raises `RuntimeError` on a symlink loop + under Python 3.11/3.12, while an undecodable ledger raises `UnicodeDecodeError` + — a `ValueError`. Whichever step inside the flush produces one, the story must + still land. + + Round 6 guarded only the post-merge flush; the reconcile passes and the + in-place commit-boundary call stayed bare. A PERSISTENT failure — a read-only + mount, an EACCES — therefore crashed the run through an unguarded site, and + `resume` accepts a crashed run, so `_finish_inflight` retried at the same bare + call and crashed again: an unbounded loop over an advisory annotation, in a + feature whose stated contract is that closure is never a gate + (#284 round-7 review, finding 1). + + The story lands, the failure is journaled, and the run-end pass releases what + it cannot pay — the entry stays `open` for a sweep to re-verify.""" + paths = _external_ledger_paths(project, tmp_path) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + write_ledger(paths, {"DW-1": "open"}, commit=False) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], + ) + engine._apply_deferred_closes = lambda *a, **kw: (_ for _ in ()).throw(exc) + + summary = engine.run() + + assert summary.done == 1 and not summary.crashed + persisted = load_state(engine.run_dir) + assert persisted.finished # the run completes rather than looping on the retry + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-flush-failed" in kinds + abandoned = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-abandoned"] + assert len(abandoned) == 1 and abandoned[0]["dw_ids"] == ["DW-1"] + assert _ledger_entries(paths)["DW-1"].open # truthful: nothing was closed + assert persisted.tasks["1-1-a"].pending_deferred_closes == [] + + +def test_closes_deferred_resume_reconcile_does_not_crash_on_a_dead_ledger(project, tmp_path): + """The half that made finding 1 unbounded. `_finish_inflight` opens with the + reconcile pass, and that call was bare — so a resume met the same persistent + failure the first run crashed on and crashed identically, before it could + touch anything else. + + Driven through the reconcile pass directly with a task already DONE and owing, + which is exactly the state a resume loads.""" + paths = _external_ledger_paths(project, tmp_path) + engine, _ = make_engine(paths, []) + task = StoryTask(story_key="1-1-a", epic=1) + task.phase = Phase.DONE + task.pending_deferred_closes = ["DW-1"] + engine.state.tasks["1-1-a"] = task + engine._apply_deferred_closes = lambda *a, **kw: (_ for _ in ()).throw( + PermissionError(13, "read-only file system") + ) + write_ledger(paths, {"DW-1": "open"}, commit=False) + + engine._reconcile_pending_deferred_closes() # must not raise + + assert task.pending_deferred_closes == ["DW-1"] # kept: a later pass may pay it + kinds = [e["kind"] for e in engine.journal.entries()] + assert "deferred-close-flush-failed" in kinds + + +def test_closes_deferred_flush_guard_survives_a_failing_diagnostic(project, tmp_path): + """The guard's own journal append can fail — it writes to the same host that + just refused the ledger. Reporting a failed write must not become the crash + the guard exists to prevent (#284 round-7 review, finding 2).""" paths = _external_ledger_paths(project, tmp_path) write_sprint(paths, {"1-1-a": "ready-for-dev"}) write_ledger(paths, {"DW-1": "open"}, commit=False) @@ -2447,11 +2560,18 @@ def test_closes_deferred_external_write_failure_keeps_the_obligation(project, tm [dev_effect(paths, "1-1-a", followup_review=False, closes_deferred=["DW-1"])], ) engine._apply_deferred_closes = lambda *a, **kw: (_ for _ in ()).throw(OSError("disk full")) + real_append = engine.journal.append + + def append_fails_for_the_diagnostic(kind, **fields): + if kind == "deferred-close-flush-failed": + raise OSError("journal is on the same dead mount") + return real_append(kind, **fields) + + engine.journal.append = append_fails_for_the_diagnostic summary = engine.run() - assert summary.crashed - assert load_state(engine.run_dir).tasks["1-1-a"].pending_deferred_closes == ["DW-1"] + assert summary.done == 1 and not summary.crashed def test_closes_deferred_external_ledger_unavailable_keeps_the_obligation(project, tmp_path): @@ -2594,8 +2714,9 @@ def test_closes_deferred_survives_a_ledger_path_it_cannot_resolve(project, monke Python 3.11/3.12, so the platform decides whether the guard is exercised at all; here every interpreter runs it. - Unresolvable takes the safe side of the guard it broke — not provably in the - repo — so the ids are parked and retried instead of crashing the commit.""" + Unresolvable closes nothing and says so: neither guess is safe, since writing + could flip a shared external ledger no commit will carry and parking would + write an in-repo ledger after the commit and leave the tree dirty.""" engine = _closes_deferred_run(project, ["DW-1"]) finalize = engine._finalize_commit_phase real_resolve = Path.resolve @@ -2689,6 +2810,44 @@ def test_closes_deferred_obligation_is_released_only_by_the_final_pass(project, assert task.pending_deferred_closes == ([] if final else ["DW-1"]) +@pytest.mark.parametrize("final", [False, True], ids=["mid-run", "run-end"]) +def test_closes_deferred_unmerged_unit_is_reported_once_and_released_at_the_end( + project, tmp_path, final +): + """A DONE task whose unit never merged holds an obligation nothing will ever + service — `_finish_inflight` skips terminal tasks by design, so no later pass + re-integrates it. + + That branch journaled the abandonment and then fell through without clearing + and without consulting `final`, so a resumed run recorded it twice (once from + `_finish_inflight`, once from the run-end pass) and a finished run kept the + pending ids serialized forever (#284 round-7 review, finding 7).""" + paths = _external_ledger_paths(project, tmp_path) + # worktree isolation: the merge, not the commit, is what makes the work durable + engine, _ = make_engine( + paths, + [], + policy=Policy( + gates=GatesPolicy(mode="none"), notify=QUIET, scm=ScmPolicy(isolation="worktree") + ), + ) + task = StoryTask(story_key="1-1-a", epic=1) + task.phase = Phase.DONE + task.unit_merged = False # committed, never integrated + task.pending_deferred_closes = ["DW-1"] + engine.state.tasks["1-1-a"] = task + + engine._reconcile_pending_deferred_closes() # the `_finish_inflight` pass + engine._reconcile_pending_deferred_closes(final=final) + + abandoned = [e for e in engine.journal.entries() if e["kind"] == "deferred-close-abandoned"] + assert len(abandoned) == 1 # one standing fact, one line — not one per pass + assert abandoned[0]["dw_ids"] == ["DW-1"] + # kept while some later pass could still act, let go once none can + assert task.pending_deferred_closes == ([] if final else ["DW-1"]) + assert "story-deferred-closed" not in {e["kind"] for e in engine.journal.entries()} + + def test_closes_deferred_external_ledger_absent_from_a_live_dir_still_discharges(project, tmp_path): """The other side of that discriminator. An artifact dir that IS there and simply has no ledger is not an outage — there is genuinely nothing to close, diff --git a/tests/test_verify.py b/tests/test_verify.py index 516a348e..ed96c1a7 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -163,9 +163,7 @@ def test_stage_path_returns_false_when_resolve_raises(project, monkeypatch, exc) driven by an actual loop would assert a different outcome per interpreter.""" target = project.project / "src.txt" target.write_text("changed\n") - monkeypatch.setattr( - verify.Path, "resolve", lambda self, *a, **kw: (_ for _ in ()).throw(exc) - ) + monkeypatch.setattr(verify.Path, "resolve", lambda self, *a, **kw: (_ for _ in ()).throw(exc)) assert verify.stage_path(project.project, target) is False From f00048a1ce50408e10600fd9c4566da2f65cb265 Mon Sep 17 00:00:00 2001 From: pbean Date: Fri, 24 Jul 2026 21:52:57 -0700 Subject: [PATCH 36/36] docs: the rollback is targeted, and an unreadable ledger cannot crash the run (#234 review) Two claims the round-7 fixes changed, in the sweep skill's format doc and the CHANGELOG entry for the unreleased feature: - the rollback reverts only the entries the story flipped, so a native pre-commit hook that edited the ledger before refusing the commit keeps its own edit - a ledger location that cannot be read or written is journaled and retried, never read as "no such entries" and never able to fail the story or crash the run that owns it --- CHANGELOG.md | 8 ++++++-- .../data/skills/bmad-loop-sweep/deferred-work-format.md | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a829a4..24be5a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +24,15 @@ breaking changes may land in a minor release. review, or escalates closes nothing, and an in-repo ledger still carries the annotation in the story's own commit. A commit that then fails (a rejecting native `pre-commit` hook) or never happens at all (a SIGTERM landing mid-commit) rolls the annotation back, so it never outlives the - commit it was written for. An artifact dir configured _outside_ the repo — including one reached + commit it was written for; the rollback reverts only the entries that story flipped, so a hook + that edited the ledger before refusing the commit keeps its own edit. An artifact dir configured _outside_ the repo — including one reached through a symlink that only looks in-repo — is shared between worktrees and cannot be committed at all; that closure is held until the work is durably landed — after the commit in place, after the branch merges under isolation (journaled `deferred-close-external-ledger`) — and a write that - never got to happen is retried on the next resume rather than being dropped. + never got to happen is retried on the next resume rather than being dropped. A ledger location + that cannot be read or written at all — a dropped mount, a broken symlink, undecodable bytes — is + journaled and retried, never read as "no such entries" and never able to fail the story or crash + the run that owns it. Closure is declared, never inferred from a diff, and both channels are re-read at that boundary so a declaration edited after the story was implemented is the one that counts — a withdrawn id diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index a20eb5a5..916966d3 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -94,7 +94,9 @@ The rules that keep this safe: and just before the story's commit is squashed. A story that fails, blocks, is rejected by review, or escalates closes nothing. If the commit itself then fails — a rejecting native `pre-commit` hook, say — the annotation is rolled - back rather than left claiming work that is in no commit. + back rather than left claiming work that is in no commit. The rollback reverts + only the entries that story flipped, so a hook that edited this file before + refusing the commit keeps its own edit. - **In the story's own commit**, when this file lives inside the repo. If the artifacts dir is configured outside it, the file is shared between worktrees and no commit can carry it; the closure is then held until the work is durably @@ -104,7 +106,9 @@ The rules that keep this safe: the run is still resumable. A run that _finishes_ with the location still unavailable leaves those entries `open` and says so (`deferred-close-abandoned`); a sweep re-verifies them against the codebase. - The closure never holds a completed run open. + The closure never holds a completed run open — nor crashes one: a ledger that + cannot be read or written is journaled and retried, never allowed to fail the + story or the run it belongs to. - **Idempotent.** An id already `done` is left untouched, so a resumed run re-driving the same close neither doubles the `resolution:` line nor warns. - **Never a gate.** An id that matches no entry, an entry whose `status:` reads