Skip to content

feat: story-declared deferred-work closure — the advisory contract (#234)#300

Merged
pbean merged 4 commits into
mainfrom
feat/closes-deferred-advisory-234
Jul 25, 2026
Merged

feat: story-declared deferred-work closure — the advisory contract (#234)#300
pbean merged 4 commits into
mainfrom
feat/closes-deferred-advisory-234

Conversation

@pbean

@pbean pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #234. Supersedes #284 (re-scoped by subtraction; rationale in its closing comment).

What

A story declares the ledger entries its work closes — closes_deferred: [DW-5, DW-6] on its stories.yaml entry (stories mode), in its spec frontmatter, or both, the two unioned — and at the commit boundary the orchestrator flips each declared open entry to status: done <date> + resolution: resolved by story <id>, the same annotation a sweep bundle writes. The ledger stops being one-way: filed automatically, resolved only by hand. bmad-loop validate warns up front, in both queue modes, on unknown ids, malformed declarations, unreadable entry statuses and an unreadable ledger.

The contract (advisory — stated verbatim in the docstrings)

  • Written at the commit boundary — behind every verify gate, checkpoint and review cycle, immediately before finalize_commit's git add -A — so an in-repo ledger rides the story's own commit (worktree isolation included: the unit's ledger reaches the target branch with the ordinary merge) and a story that fails, is rejected by review, or escalates closes nothing.
  • The declaration is re-read live at that boundary, so a late edit counts in both directions: a withdrawn id is not closed, one added late is.
  • Idempotent: an already-done id is a silent no-op, so a resume re-driving the commit phase neither doubles the annotation nor warns.
  • Never a gate: unknown ids, duplicate ids, malformed entry statuses and an unavailable or undecodable ledger location are journaled warnings; no reading of the spec, the manifest or the ledger can fail the story. Every degraded reading closes nothing — entries stay open, the direction a sweep re-verifies.
  • A commit that fails after the write — or a signal landing anywhere inside the commit window — restores the ledger from a whole-document pre-close snapshot; the restore is itself advisory (journaled, never raised).
  • An out-of-repo ledger (ProjectPaths.rebased deliberately shares an external artifact dir across worktrees) is written at the same moment; the annotation is part of no commit, which is journaled (deferred-close-external-ledger). A merge that later fails leaves a visible, journaled, human-attended stale annotation — the accepted advisory trade-off.

Why this shape, and not #284's

#234 asked for an advisory annotation ("surface as a warning rather than failing the story"). #284 grew two-phase-commit semantics between the ledger and git — rollback arming, parked closures, a reconcile pass, a durable merge marker — and eight review rounds kept finding real defects of that approach: transactional ledger↔git coupling has an unbounded adversarial surface. This PR ships the same feature with the contract the issue actually asked for, as fresh history; because the docstrings claim advisory semantics only, review has a stated contract to check against instead of an implied stronger one.

The crash-window and transactional concerns that outlived the re-scope are tracked on their own: #286 (concurrent ledger writers), #296 (post-merge tail can skip done_checkpoint), #299 (DONE-but-unmerged stranded unit branch).

Verification

  • Full suite on the rebased tree: 3022 passed / 7 skipped; trunk check --no-fix clean; pyright 0 errors.
  • Ablation checks: deleting the commit-boundary close call fails the positive tests (the call is the sole producer); re-introducing the close at dev-sync — the historical defect — fails stays_open_when_verify_defers_the_story, stays_open_when_the_story_escalates and dev_sync_writes_nothing; misreading an absent declaration as a declaration fails close_is_silent_without_declaration.

Summary by CodeRabbit

  • New Features

    • Stories can now automatically close specified deferred-work entries when their commit succeeds.
    • Deferred entries are completed with a resolution note, driven by declarations in story metadata/spec frontmatter (and unified with unattended stories config).
  • Bug Fixes

    • Closure behavior is idempotent on resume and is safely rolled back if the story commit fails, is rejected, or is interrupted.
  • Documentation

    • Updated CHANGELOG, README, and feature docs with the new closes_deferred declaration and “story-driven closure” semantics.
  • Validation

    • validate now emits non-blocking warnings for unknown/malformed/unreadable deferred-closure declarations.

pbean added 3 commits July 24, 2026 23:59
…red-work entries (#234)

A story declares the DW-<n> entries its work closes — `closes_deferred:` on
its stories.yaml entry (stories mode), in its spec frontmatter, or both, the
two unioned — and at the commit boundary the orchestrator flips each declared
open entry to `status: done <date>` + `resolution: resolved by story <id>`,
the same annotation a sweep bundle writes. The ledger stops being one-way:
filed automatically, resolved only by hand.

Advisory by contract: the write sits behind every verify gate, checkpoint and
review cycle, immediately before finalize_commit's `git add -A`, so an
in-repo ledger rides the story's own commit (worktree isolation included) and
a story that fails, is rejected by review, or escalates closes nothing. The
declaration is re-read live at that boundary, so a late edit counts in both
directions; an already-done id is a silent no-op across a resume; unknown,
duplicate and malformed ids and an unavailable or undecodable ledger location
are journaled warnings, never failures. A commit that fails after the write
restores the ledger from a whole-document pre-close snapshot. An out-of-repo
ledger (shared across worktrees, part of no commit) is written at the same
moment and journaled `deferred-close-external-ledger`.

The ledger write path gains `platform_util.atomic_write_text` (resolve first,
unique temp sibling, mode/xattr copy, fsync-before-replace) so a close cannot
tear the ledger or swap a symlink for a regular file.
…234)

`bmad-loop validate` warns — never fails — in both queue modes:
`deferred.closes-unknown` for an id absent from the ledger,
`deferred.closes-malformed` for a spec declaration in a non-list shape,
`deferred.closes-entry-unreadable` for a ledger entry whose status parses as
neither open nor done, and `deferred.ledger-unreadable` for a ledger that
cannot be read at all. A non-list declaration in stories.yaml itself stays a
manifest schema error, reported by `queue.stories-manifest`.
README (the stories.yaml field + the sweep section), the FEATURES bullet, the
sweep skill's deferred-work-format.md, and a CHANGELOG entry — each claiming
the advisory contract only: declared, written at the commit boundary, riding
the story's own commit when the ledger is in-repo, journaled and never fatal
everywhere else.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Stories can declare deferred-work IDs through stories.yaml or spec frontmatter. The engine validates declarations, closes matching ledger entries at the story commit boundary, journals mismatches, performs atomic writes, and restores ledger state when commit finalization fails.

Changes

Deferred-work story closure

Layer / File(s) Summary
Declaration contracts and validation
src/bmad_loop/deferredwork.py, src/bmad_loop/stories.py, src/bmad_loop/cli.py, src/bmad_loop/checks.py, tests/test_deferredwork.py, tests/test_stories.py, tests/test_cli.py
Declarations are normalized and classified against ledger entries; stories-mode and sprint-mode validation emits non-failing warnings for malformed, unknown, or unreadable data.
Atomic ledger mutation
src/bmad_loop/deferredwork.py, src/bmad_loop/platform_util.py, tests/test_platform_util.py
Multiple open entries can be marked done in one atomic write, with metadata handling, failure cleanup, and idempotency coverage.
Commit-boundary orchestration
src/bmad_loop/engine.py, src/bmad_loop/stories_engine.py, src/bmad_loop/sweep.py, tests/test_engine.py, tests/test_stories_engine.py, tests/test_engine_worktree.py
Manifest and frontmatter declarations are unioned and applied at commit time, with journaling, external-ledger handling, rollback, resume behavior, and sweep ownership.
Documentation and behavior reference
README.md, CHANGELOG.md, docs/FEATURES.md, src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md, tests/conftest.py
Documentation and fixtures describe and exercise declaration sources, commit timing, annotations, validation, rollback, idempotency, and external-ledger behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Poem

A rabbit marks IDs with a hop,
Ledger entries turn done at the stop.
If commits go wrong,
I roll them along,
And journal each burrowed breadcrumb.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and directly describes the main change: story-declared deferred-work closure with advisory semantics.
Linked Issues check ✅ Passed The PR implements declarative story-close annotations, idempotent updates, and warning-only handling for missing or malformed deferred-work entries.
Out of Scope Changes check ✅ Passed The added docs, tests, and atomic write helper all support the deferred-work closure feature; no unrelated changes stand out.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/closes-deferred-advisory-234

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/bmad_loop/engine.py (1)

2257-2267: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider catching Exception for the restore, not just OSError. The docstring promises a failed restore is "journaled, never raised", but this runs inside except arms — any non-OSError escaping atomic_write_text would replace the original exception the caller must propagate unchanged.

♻️ Broaden the guard
         try:
             atomic_write_text(ledger, before)
-        except OSError as e:
+        except Exception as e:
             with contextlib.suppress(Exception):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/engine.py` around lines 2257 - 2267, Broaden the restore guard
around atomic_write_text in the deferred-close rollback path to catch Exception
rather than only OSError, while preserving the suppressed journal append and
unchanged return behavior. Ensure any restore failure is journaled without
escaping and replacing the original exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/bmad_loop/engine.py`:
- Around line 2257-2267: Broaden the restore guard around atomic_write_text in
the deferred-close rollback path to catch Exception rather than only OSError,
while preserving the suppressed journal append and unchanged return behavior.
Ensure any restore failure is journaled without escaping and replacing the
original exception.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 18f1a280-b708-448f-909c-384a36926444

📥 Commits

Reviewing files that changed from the base of the PR and between 1e6ec9f and c8b8775.

📒 Files selected for processing (20)
  • CHANGELOG.md
  • README.md
  • docs/FEATURES.md
  • src/bmad_loop/checks.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/deferredwork.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/platform_util.py
  • src/bmad_loop/stories.py
  • src/bmad_loop/stories_engine.py
  • src/bmad_loop/sweep.py
  • tests/conftest.py
  • tests/test_cli.py
  • tests/test_deferredwork.py
  • tests/test_engine.py
  • tests/test_engine_worktree.py
  • tests/test_platform_util.py
  • tests/test_stories.py
  • tests/test_stories_engine.py

… failure (#300 review)

`_restore_deferred_closes` runs inside the commit window's except arms, and
its docstring promises a failed restore is "journaled, never raised — this
runs inside except arms whose exception must travel unchanged". `except
OSError` honored that for one exception type.

`atomic_write_text` resolves the path before its own try, and below 3.13
`Path.resolve` reports a symlink loop as `RuntimeError` — for a ledger the
helper explicitly supports being a symlink, and whose other resolve
(`_ledger_in_repo`) already catches that type. 3.11 and 3.12 are both CI legs.

Escaping, it displaced the exception each arm exists to carry: the `GitError`
arm skipped `_escalate`, stranding the story in COMMITTING with the commit
diagnosis nowhere on the record, and the `BaseException` arm skipped its bare
`raise`, swapping a graceful `RunStopped` for a write complaint. The ablation
is unambiguous — reverting the width returns `escalated=0, paused=False,
crashed=True, crash_error="RuntimeError: Symlink loop..."`.

`Exception`, not `BaseException`: `RunStopped` is an `Exception`, so a second
stop landing inside the restore is absorbed while the first still travels,
and a genuine KeyboardInterrupt still gets out. The journal line now names the
type the way `_journal_ledger_unavailable` does — with every type admitted, a
bare message cannot say whether the disk or the path was at fault.

`deferred-close-rollback-failed` had no coverage at any width; the new test is
the branch's first. The fault is injected rather than built from a real symlink
loop, which 3.13+ resolves without raising — that version would pass here and
fail only on the 3.11/3.12 legs.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Resolution — nitpick applied (6bfeb6d)

Valid finding, and reachable rather than theoretical. Applied.

Why it bites. atomic_write_text opens with target = path.resolve() — outside its own try (platform_util.py:205). Below 3.13, Path.resolve(strict=False) raises RuntimeError on a symlink loop ("Changed in version 3.13: Symlink loops are treated like other errors… In previous versions, RuntimeError is raised no matter the value of strict"). 3.11 and 3.12 are both CI legs, Linux and Windows, and the helper explicitly supports the ledger being a symlink (platform_util.py:175-178). The same non-OSError was already caught one method away — _ledger_in_repo guards (OSError, RuntimeError) around ledger.resolve() for this very path.

What escaping cost. Each arm differently, and both mattered:

  • except verify.GitError — skipped _escalate, stranding the story in COMMITTING with the commit diagnosis nowhere on the record.
  • except BaseException: … raise — skipped the bare raise, swapping a graceful RunStopped for a write complaint.

Ablation is unambiguous: reverting the width returns escalated=0, paused=False, crashed=True, crash_error="RuntimeError: Symlink loop...". The commit failure disappears from the summary entirely.

Two deltas from the suggested patch:

  1. Exception, not BaseException — as suggested, and worth stating why: RunStopped is an Exception, so a second stop landing inside the restore is absorbed while the first still travels; BaseException would additionally swallow a genuine KeyboardInterrupt.
  2. The journal line now records error=f"{e.__class__.__name__}: {e}", matching _journal_ledger_unavailable. With every type admitted, a bare str(e) can't say whether the disk or the path was at fault.

Coverage. deferred-close-rollback-failed had appeared exactly once in the repo — its own emit site. The new test is the branch's first at any width. The fault is injected rather than built from a real symlink loop: 3.13+ resolves loops without raising, so a loop-based test would be green on the interpreter this suite usually runs and red only on the 3.11/3.12 legs.

Deliberately out of scope: the forward write (mark_done_manyatomic_write_text) is correct as-is — a raise there unwinds through the caller's BaseException arm, and the contract promises only that the restore never raises.

3023 passed / 7 skipped · trunk check --no-fix clean · pyright 0 errors.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/engine.py`:
- Line 2270: Add a targeted Ruff BLE001 suppression to the broad `except
Exception as e` in the rollback handling, preserving the intentional rollback
behavior without suppressing the rule more broadly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 781fa730-c172-4c62-a1e4-19d2b9de9095

📥 Commits

Reviewing files that changed from the base of the PR and between c8b8775 and 6bfeb6d.

📒 Files selected for processing (2)
  • src/bmad_loop/engine.py
  • tests/test_engine.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_engine.py

Comment thread src/bmad_loop/engine.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-annotate deferred-work.md entries as resolved at story-close

1 participant