Skip to content

feat: auto-resolve deferred-work entries a story declares (closes #234)#284

Closed
pbean wants to merge 36 commits into
mainfrom
feat/closes-deferred-auto-resolve-234
Closed

feat: auto-resolve deferred-work entries a story declares (closes #234)#284
pbean wants to merge 36 commits into
mainfrom
feat/closes-deferred-auto-resolve-234

Conversation

@pbean

@pbean pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #234.

The deferred-work ledger was one-way: the loop reliably files entries but never marked one resolved when a later story closed it. Across a multi-epic unattended run that leaves entries satisfied epics ago still reading open, and the retro reconstructing by hand which story closed what (#234 observed +6 filed / 0 marked / 8 silently satisfied in a single epic).

A story can now declare the entries its work closes:

closes_deferred: [DW-5, DW-6]

on its stories.yaml entry (stories mode) or in its story spec's frontmatter — the two unioned. When the story commits, the orchestrator writes the same annotation a sweep bundle already writes: status: done <date> plus resolution: resolved by story <id>.

Where the write happens

At the commit boundary, from _finalize_commit_phase between the pre_commit emit and finalize_commit — behind artifact verification, the verify commands, every checkpoint, the review loop and the pre_commit_gate workflows, and still ahead of git add -A so an in-repo annotation lands in the story's own commit. A story that fails, blocks, is rejected by review or escalates closes nothing.

It is not final until the commit is:

  • The commit fails or never happens → the annotation is rolled back (deferred-close-rolled-back). A rejecting native pre-commit hook raises GitError; a SIGTERM landing mid-commit raises RunStopped from the handler run() installed, wherever the main thread was standing; _run_git translates only TimeoutExpired, so a raw OSError on spawn arrives as itself. All three unwind through the restore, and anything that is not a commit failure is re-raised untouched. The snapshot is armed before the ledger is touched and reported through a caller-owned slot rather than the close's return value, so a raise inside the close unwinds too — the atomic write publishes and the journal line recording it is written after, and a stop signal can land between them. _escalate unwinds nothing on its own, and the usual recovery would have cemented the false close: a resolved re-drive preserves the artifact folders' tracked content through safe_reset.
  • The ledger lives outside the repo (an external artifact dir is shared between worktrees and add -A can never stage it) — decided on resolved paths, so an in-repo symlink to a shared ledger counts as outside; is_relative_to is lexical while the write follows the link → the ids are parked on the task and applied only once the work is durably landed: after finalize_commit in place, after merge_local under worktree isolation (deferred-close-external-ledger). StoryTask.unit_merged is the durable proof the merge happened — stamped and saved the statement after verify.merge_branch returns, ahead of the journal/emit/teardown tail that says nothing about whether the merge landed — so a crash between the merge and the flush is retried by a resume reconcile pass instead of being lost. A flush that finds the artifact directory absent keeps the obligation (deferred-close-ledger-unavailable) rather than reading an invisible ledger as empty; a directory that is present with no ledger in it has genuinely nothing to close and discharges, as in-repo does.
  • The declaration is re-read at the commit boundary, in both channels — the spec on disk when the story commits is what it declares, so an id withdrawn after implementation is not closed and one added late is. A spec that is gone or has moved out of the approved roots by then declares nothing at all: both used to leave the verify-time capture standing to be closed against, so deleting or redirecting the spec still marked its ids resolved. The capture taken when the dev artifacts verified is the fallback for a read that faults there: _observed_frontmatter degrades an unreadable spec to None, which at the close site is indistinguishable from a spec declaring nothing, and the COMMITTING resume arm never re-verifies.

Validate

Pre-flight warnings in both queue modes: deferred.closes-unknown (an id absent from the ledger — a typo, or an entry renumbered since the spec was written) and deferred.closes-malformed (a story spec declaration that is not a list). Stories mode reads the manifest plus each id-resolved spec, and flags manifest ids even when the story has no spec on disk yet; sprint mode reads the story specs already in the artifacts dir. A ledger that cannot be read at all is deferred.ledger-unreadable — nothing else in validate reads it, so staying quiet reported success for a check that examined nothing. All are warnings, so none changes the exit code.

The same wrong container in stories.yaml is not a warning: the manifest is a schema its parser owns, so it fails to load like any other field of the wrong type, reported by queue.stories-manifest before any story runs. That split is deliberate — the manifest is hand-authored before the run, where a typo is still cheap to fix, while a story spec is generated mid-run by a skill and a malformed field there must not be able to fail a story that succeeded.

Design notes

  • Closure is declared, never inferred from a diff.
  • The record format is not new. Closure is status: done <date> + resolution: via the existing mark_done primitive. The issue text sketches a resolved: block, but this repo has no such field — sweep, verify and the TUI all recognize closure through status:, so adding one would have forked the format.
  • Idempotent on resume. 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 from the ledger" (worth a warning).
  • Never a gate. An unmatched id (deferred-close-unmatched), a ledger entry whose status: is neither open nor done (deferred-close-malformed), and a wrong-container declaration in a story spec are each journaled and dropped. Closes journal as story-deferred-closed.
  • A missed close beats a false one. unit_merged is persisted the statement after the merge itself returns, so a host death in that one save reports a merge that did land as abandoned and leaves the entries open — which a sweep re-verifies against the codebase. Recording the intent before the merge would trade that for closing entries a merge never landed, and squash rewrites the commit, so the target branch cannot arbitrate after the fact.
  • The field is human-authored at breakdown time, like spec_checkpoint / done_checkpoint. 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.

Verification

Lifecycle coverage drives engine.run() rather than the hook: the ledger stays open through verify-defer, escalation, a pre_commit pause veto and a rejected commit; the annotation is asserted present in git show HEAD -- <ledger>; the external-ledger path is checked on both sides of the commit and of integration; a crash after the merge is resumed into the reconcile; and the primitives have their own units (wrong container, malformed status, all-or-nothing write, mode/symlink preservation, resume idempotency).

Every regression added for a review finding is mutation-checked: neuter the fix, confirm the test fails (11 mutations across the round-5 fixes, 11 caught). The interruption path fires a real in-process SIGTERM from inside finalize_commit rather than raising RunStopped directly, so it exercises the handler that actually causes it.

Full suite: 3004 passed, 7 skipped, 0 failed. trunk check --no-fix clean.

Summary by CodeRabbit

  • New Features
    • Stories can now declare deferred-work entries to close via closes_deferred in manifests and/or story specs (unioned/deduped).
    • On successful story commits, declared entries are marked done with story-based resolution notes; external-ledger closures are deferred until integration and retried on resume when needed.
  • Bug Fixes
    • validate now emits warn-only diagnostics for unknown, malformed, or unreadable closes_deferred declarations (no exit-code change).
  • Documentation
    • Expanded “Deferred-work sweeps” docs with story-declared closure semantics, idempotency, and timing/constraints.
  • Tests
    • Added CLI and end-to-end coverage for parsing, warnings, rollback, and worktree/external-ledger behavior.

pbean added 2 commits July 23, 2026 22:53
…ds + document the field (#234)

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.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c026aca-ba71-42ad-924a-cf7ecb20b155

📥 Commits

Reviewing files that changed from the base of the PR and between 9dd7bbc and f00048a.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • 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/verify.py
  • tests/test_deferredwork.py
  • tests/test_engine.py
  • tests/test_verify.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/bmad_loop/verify.py
  • CHANGELOG.md
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • tests/test_deferredwork.py
  • src/bmad_loop/deferredwork.py
  • tests/test_engine.py

Walkthrough

Stories can declare closes_deferred IDs in manifests or frontmatter. The engine validates, journals, and closes matching ledger entries at commit or integration boundaries, with atomic writes, rollback, persistence, resume handling, and non-fatal validation warnings.

Changes

Deferred-work story closure

Layer / File(s) Summary
Declaration and ledger contracts
src/bmad_loop/deferredwork.py, src/bmad_loop/stories.py, src/bmad_loop/model.py, src/bmad_loop/platform_util.py, src/bmad_loop/frontmatter.py, src/bmad_loop/verify.py, tests/*
Declarations are normalized and classified, ledger entries are atomically updated in batches, and closure state is persisted and tested.
Commit-boundary closure lifecycle
src/bmad_loop/engine.py, src/bmad_loop/stories_engine.py, tests/test_engine.py, tests/test_stories_engine.py
The engine captures declarations, closes entries around commits, rolls back failed finalizations, journals outcomes, and resumes pending external-ledger work.
Deferred-close validation
src/bmad_loop/checks.py, src/bmad_loop/cli.py, tests/test_cli.py
validate checks manifest and spec declarations in both queue modes and emits non-fatal warnings for malformed, unreadable, or unknown IDs.
Worktree integration handling
src/bmad_loop/worktree_flow.py, src/bmad_loop/sweep.py, tests/test_engine_worktree.py, tests/test_worktree_flow.py
Successful merges trigger integration bookkeeping and pending closure flushing; failed or unmerged units leave entries open.
Feature documentation
README.md, docs/FEATURES.md, CHANGELOG.md, src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
Documentation describes declaration channels, closure timing, rollback, external ledgers, validation, and resume behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant Story
  participant StoriesEngine
  participant Engine
  participant Ledger
  participant Git
  Story->>StoriesEngine: declare closes_deferred IDs
  StoriesEngine->>Engine: capture manifest and spec declarations
  Engine->>Ledger: classify and mark open entries done
  Engine->>Git: finalize story commit
  Git-->>Engine: commit result
  Engine->>Ledger: restore on failure or flush after integration
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: dracic, otiunov

Poem

A bunny tags the ledger bright,
Then hops through commit’s careful gate.
If branches merge, the marks take flight;
If not, they patiently await.
Atomic paws keep records right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.74% 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 accurately summarizes the main change: automatic deferred-work closure for story-declared entries.
Linked Issues check ✅ Passed The PR implements declarative story-close annotations, idempotent updates, warnings for missing entries, and preserves the ledger format as requested.
Out of Scope Changes check ✅ Passed The changes are largely scoped to deferred-work closure, validation, persistence, and supporting tests/docs, with no obvious unrelated additions.
✨ 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-auto-resolve-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.

… just the spec (#234)

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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Added the missing producer. Review caught that the first two commits were the consumer half only: nothing writes closes_deferred into a story spec. Verified against the installed skill — bmad-dev-auto/spec-template.md frontmatter carries title, type, created, status, review_loop_iteration, followup_review_recommended, context, warnings and nothing else, and the ledger appears there only as deferred_work_file, used by step-04 (review) to append entries. Step-02 (plan) never reads it. So closing an entry would have required hand-editing every generated spec — precisely what doesn't happen in the unattended multi-epic run this feature is for.

b8ae6c0 adds the declaration channel the orchestrator actually owns: the stories.yaml entry, authored at breakdown time with the ledger in view.

- id: "3-2"
  title: Export digests
  description: 
  closes_deferred: [DW-5, DW-6]
  • StoryEntry.closes_deferred — optional, strict about the container (a bare closes_deferred: DW-1 is a schema error, never silently iterated into characters), lenient and de-duplicated per item exactly as ids are. A manifest written before the field existed parses unchanged.
  • Engine._close_declared_deferred(..., extra_ids=…) unions the two channels order-preservingly, so an id declared in both the manifest and the spec is marked once and journaled once.
  • StoriesEngine passes its manifest entry in; the spec frontmatter still works and the two compose.
  • The validate check now reads both, and flags manifest ids even when the story has no spec on disk yet — which is what makes it a genuine pre-flight rather than one that only sees a story after its first dispatch.

Sprint mode has no manifest, so there the frontmatter remains the only channel — a human or a customized skill. The loop-native version (the dev session declaring what it closed) still needs an upstream bmad-dev-auto change.

Suite: 2950 passed / 7 skipped / 0 failed (+9 tests). trunk check --no-fix clean.

@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive review: request changes

I recommend holding merge until the two high-severity lifecycle/storage issues below are resolved. Four independent passes (adversarial, edge-case, verification-gap, and acceptance) converged on the primary defect.

Findings

  1. High — Entries close before the story actually succeeds.

    Engine._post_dev_state_sync closes entries before artifact verification, verify commands, checkpoints, follow-up review, pre-commit workflows, and commit. Stories mode does the same in StoriesEngine._post_dev_state_sync.

    The in-place defer path then snapshots and restores the already-mutated ledger. Reproduction against the PR head:

    after post-dev sync: done 2026-07-24
    after story deferred: done 2026-07-24
    resolution retained: True
    

    A failed/rejected story can therefore permanently claim that deferred work is resolved. The current direct-hook tests do not exercise this downstream failure path.

  2. High — External artifact directories break isolation and commit semantics.

    ProjectPaths.rebased deliberately leaves externally configured artifact directories shared between worktrees. The new mutation can therefore edit the shared ledger from an isolated story that later fails, and the external file cannot be included in the story's Git commit despite the implementation/docs claiming the annotation is squashed into it.

  3. Medium — The advertised unattended stories.yaml channel has no canonical producer integration.

    This PR teaches the loop reader to accept closes_deferred, but the current upstream bmad-spec Story Breakdown schema/workflow neither defines nor collects it. Normal Story Breakdown output cannot produce the field, and re-derivation may remove a manual edit. That leaves the workflow manual despite the unattended claim.

  4. Medium — Sprint mode trusts an unverified, out-of-root spec path.

    The sprint sync resolves the session-supplied spec_file and acts on its frontmatter without the adjacent verify.spec_within_roots guard. A stale or malicious absolute path containing status: done plus closes_deferred can mutate the project ledger before verification.

  5. Medium — Malformed frontmatter declarations disappear silently.

    A scalar such as closes_deferred: DW-5 becomes an empty declaration at runtime and in validation, while the equivalent stories.yaml mistake is rejected. The entry stays open without a warning or journal evidence.

  6. Medium — Unknown-ID warnings are operator-visible only in stories mode.

    _validate_closes_deferred runs only in the stories-mode validation branch. Sprint-mode typos produce only an internal deferred-close-unmatched journal event, so the promised warning may never reach the operator.

  7. Medium — Multi-entry closure can silently skip or partially apply.

    The writer classifies only present/absent and calls mark_done once per ID. A matching heading with a malformed status is neither closed nor reported, and an I/O failure midway through multiple IDs can leave a partial update without the corresponding journal record.

Recommended remediation plan

  1. Move closure to the success/commit boundary.

    • Keep sprint-board synchronization in _post_dev_state_sync, but remove deferred closure from it.
    • Add a success-bookkeeping hook inside commit finalization, after verification, review, checkpoints, and pre-commit workflows.
    • Read declarations from the verified task.spec_file, enforce root containment, and restore the ledger snapshot if finalization fails.
    • Ensure SweepEngine does not accidentally inherit the regular-story hook.
  2. Handle ledger location explicitly.

    • For an in-repository ledger, include one atomic closure update in the story commit.
    • For shared/external ledgers, apply closure only after successful commit/worktree integration and document that it cannot be part of the Git commit; alternatively reject that configuration for this feature until post-integration bookkeeping exists.
  3. Create one declaration/update primitive.

    • Share parsing between manifest, frontmatter, runtime, and validation.
    • Surface wrong container types.
    • Classify ledger entries as open, done, unknown, or malformed.
    • Apply all requested closures from one snapshot via atomic replacement, then journal the committed set.
  4. Complete the producer and warning contracts.

    • Update upstream bmad-spec Story Breakdown schema/authoring instructions and add a capability probe, or describe manifest declarations as manual until that dependency ships.
    • Surface unknown/malformed declarations to operators in both sprint and stories modes while keeping them non-fatal.
  5. Add lifecycle-level regression coverage.

    • Both sprint and stories modes; success, verify failure, review defer/rewrite, checkpoint pause, pre-commit failure, commit failure, and crash/resume.
    • In-place and worktree isolation, including external artifact directories.
    • Assert the ledger remains open until the story reaches successful finalization, and that successful in-repo closure is present in the resulting commit.

Verification

  • Exact PR-head suite: 2,950 passed, 7 skipped.
  • Focused changed-area suite: 581 passed.
  • trunk check --no-fix: clean.
  • GitHub Python/Windows/lint/typecheck/version checks are green.

The green checks establish baseline compatibility, but they do not cover the complete verify/review/commit lifecycle where the primary defect occurs.

pbean added 2 commits July 24, 2026 08:12
… at dev sync (#234 review)

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 -- <ledger>`; and the
isolated external-ledger path is covered on both sides of integration.
…both modes (#234 review)

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.
…234 review)

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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Review remediation: all 7 findings addressed

Three commits on this branch. I traced every finding against the code before acting — all 7 are real, with two corrections to the review noted at the bottom.

1 (High) — entries closed before the story succeeded → f1c13fd

Confirmed, and the exposure was wider than the repro showed. Closure now runs from _finalize_commit_phase, between the pre_commit emit and finalize_commit: behind artifact verification, the verify commands, checkpoints, the review loop and the pre_commit_gate workflows, but still ahead of git add -A — so an in-repo annotation stays part of the story's own commit. Closing after the commit was not an option: the ledger edit would be left dirty and story N+1's step-01 HALTs on a dirty tree.

The in-place DEFER repro is now a test (test_closes_deferred_stays_open_when_verify_defers_the_story) — _defer's snapshot/restore has nothing left to resurrect. Beyond it, _escalate rolls back nothing at all, so an escalated story leaked a false close into the operator's checkout where the next story's add -A would sweep it into an unrelated commit; covered by test_closes_deferred_stays_open_when_the_story_escalates.

2 (High) — external artifact dirs → f1c13fd

Confirmed both halves. Ledger location now decides where the write lands: in-repo it rides the commit; out-of-repo an isolated unit parks the ids on task.pending_deferred_closes (persisted, so it survives resume) and WorktreeFlow.integrate_unit applies them only after merge_local returns. A merge that escalates raises before that point and the shared ledger keeps reading open. deferred-close-external-ledger is journaled so the annotation's absence from git history isn't a surprise. In-place runs have no integration step, so they write at commit as before.

3 (Medium) — no canonical producer → e269b2c + BMAD-METHOD#2619

Real, but the framing needed correcting in both directions. assets/stories-schema.md indeed has no closes_deferred row and update semantics re-derive the file — but it already carries three human-authored caller-only fields (spec_checkpoint, done_checkpoint, invoke_dev_with) that SKILL.md tells the skill to ask about per story. So this is a docs + upstream-schema gap, not a design error. The docs now say the field is human-authored at breakdown time, that no upstream skill emits it, and that a re-derive drops it unless the intent is logged in .memlog.md. Upstream issue filed proposing the schema row and the breakdown prompt.

4 (Medium) — unverified out-of-root spec path → f1c13fd

Confirmed: _reconcile_generic_terminal_status has the verify.spec_within_roots guard, the sync did not. The close site now applies the same rule and journals deferred-close-skipped-out-of-tree.

5 + 7 (Medium) — silent malformed declarations and entries → f1c13fd, c0f139d

Confirmed. deferredwork gains one shared reading (parse_declaration) used by the manifest parser, the engine and validate, so the same mistake can't be a hard schema error in stories.yaml and a silent empty list in frontmatter — only the severity differs by caller. classify replaces present/absent with four outcomes: DWEntry.open is status.split()[0] == "open", so an entry with a garbled status was previously neither closed nor reported; it's now deferred-close-malformed.

mark_done_many does one read, applies every flip in memory and writes once via atomic_replace — the N-read/N-write loop could leave marks on disk when it raised partway through, a half-applied closure the caller never got to journal. mark_done is now a wrapper over it, so SweepEngine and decisions are untouched.

6 (Medium) — warnings only in stories mode → c0f139d

Confirmed. _validate_closes_deferred runs in both branches now. Stories mode keeps the manifest + id-resolved-spec union; sprint mode scans the story specs already in the artifacts dir (written by create-story ahead of the run, which is when a typo is still cheap). Check ids are mode-neutral: stories.closes-deferred-unknowndeferred.closes-unknown, plus deferred.closes-malformed. Both stay warnings.


Two corrections to the review

  • The SweepEngine remediation item was already satisfied — it fully overrides _post_dev_state_sync, so it never reached the regular-story hook. It only becomes a live concern once closure moves to the commit boundary, which Sweep does inherit; SweepEngine._close_declared_deferred is now an explicit no-op, since bundle closure is owned by _close_bundle_ledger_when_spec_status and verify_review_bundle depends on that running at dev time.
  • StoriesEngine._post_dev_state_sync is a pure no-op again. Its whole added body existed to run the close; the manifest channel is now declared through a _manifest_closes_deferred seam, restoring "the orchestrator writes nothing at dev time" in stories mode.

Coverage

The old tests called _post_dev_state_sync directly, which is exactly why none of this surfaced. They now drive engine.run(): the ledger stays open through verify-defer, escalation and a pre_commit pause veto in both modes; the annotation is asserted present in git show HEAD -- <ledger>; the isolated external-ledger path is covered on both sides of integration; integrate_unit is pinned to skip bookkeeping when the merge escalates; and the primitives have their own units (wrong container, malformed status, all-or-nothing write, resume idempotency).

Suite: 2969 passed, 7 skipped, 0 failed (2950 before). trunk check --no-fix clean.

@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive code review

Recommendation: request changes before merge. Two high-severity correctness issues violate the core guarantee that deferred work is closed only after a story succeeds, and that a resumable run eventually reconciles all durable work.

Findings

  1. High — A failed Git commit leaves deferred work falsely resolved

    Engine._finalize_commit_phase calls _close_declared_deferred() before finalize_commit(). If git add or git commit fails, the error escalates without undoing the ledger mutation.

    Reproduced with a rejecting native pre-commit hook: the story ended escalated with done == 0, while DW-1 remained status: done. This directly violates Auto-annotate deferred-work.md entries as resolved at story-close #234's successful-story-close requirement.

  2. High — External-ledger closure is not recoverable across a failure or crash after merge

    _flush_pending_deferred_closes clears pending_deferred_closes before applying the ledger write. If the write fails, the crash save persists an empty retry obligation.

    There is also a crash window after merge_local() succeeds but before _on_integrated(). Resume then skips the terminal DONE task, so the pending closure is never retried.

    Reproduced the write-failure half: an exception from the external ledger write leaves pending_deferred_closes empty.

  3. Medium — A transient spec-read failure silently drops a declared closure

    _declared_deferred_ids converts a failed frontmatter read into {}, treating it as no declaration. The story can then commit successfully while the declared ledger entry remains open.

    Reproduced by faulting only the deferred-close read: the story finished DONE, DW-1 stayed open, and only a journal event recorded the loss.

  4. Medium — Atomic replacement discards ledger permissions and filesystem metadata

    mark_done_many creates a fresh fixed-name temporary file and replaces the ledger without preserving its mode, ACLs, or extended attributes.

    Reproduced on Linux: a 0600 ledger became 0644. This can expose project information or remove group-write access from a shared external ledger.

  5. Low — The PR description is stale

    The description still documents the earlier dev-sync placement, names the superseded stories.closes-deferred-unknown check, and says “8 new tests” while enumerating 13. Exact head currently collects 2,976 tests, so the suite count is stale as well.

Deferred observation

Concurrent ledger writers remain unsafe: the update is an unlocked read-modify-write and all writers use the same .tmp name. This conflict class predates the PR, and the UI explicitly warns before launching concurrent engines, so I would track full concurrent-run safety separately. Using a unique temp file while addressing finding 4 would still remove the newly introduced temp-name collision.

The mode-neutral rename to deferred.closes-unknown is reasonable; the code should not be reverted to the earlier ID. The PR description should instead be refreshed.

Remediation plan

  1. Persist the normalized closes_deferred declaration after its final successful verification. Do not reinterpret an I/O failure as an empty declaration.
  2. Make in-repository closure transactional with the story commit. If finalize_commit() fails, restore both ledger contents and Git index state before escalating.
  3. Delay out-of-repository closure until success is durable: after commit in in-place mode and after integration in worktree mode.
  4. Retain pending_deferred_closes until the ledger update and state save both succeed. Add durable integration state or resume reconciliation so a DONE task with a pending closure is retried safely.
  5. Use a unique sibling temporary file and preserve the ledger's security metadata before replacement.
  6. Add regression coverage for:
    • native Git hook rejection with closes_deferred;
    • external-ledger write failure followed by resume;
    • crash after merge but before the integration callback;
    • commit-boundary spec-read failure;
    • permission preservation.
  7. Refresh the PR description and verification counts after the fixes.

Verification context

All GitHub checks are currently green, trunk check --no-fix is clean, and the existing targeted closure tests passed 19/19. The four reproductions above exercise paths not covered by the current tests.

pbean added 3 commits July 24, 2026 10:30
…write (#234 review)

`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.
…#234 review)

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.
…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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Review remediation: all 5 findings addressed

Three commits on this branch. I traced each finding against the code before acting — all 5 are real, with two reclassifications noted at the bottom.

1 (High) — a failed commit left deferred work falsely resolved → 2c81215..27c8142

Confirmed, and it hits the default configuration: scm.isolation defaults to none, so the mutation lands in the operator's own checkout. It also survives the most likely recovery — a commit failure escalates, and 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 is not reverted by the reset that would otherwise have caught it.

_close_declared_deferred now returns the pre-close ledger snapshot and _restore_deferred_closes puts it back before _escalate raises, journaled deferred-close-rolled-back after the story-deferred-closed it undoes. Only the working tree is restored, deliberately: finalize_commit leaves its add -A staged, but every path that commits again begins with another add -A (restaging from the tree) and every rollback path goes through safe_reset — pinned by a test that re-arms the escalation and asserts the re-driven commit carries the close exactly once.

2 (High) — external-ledger closure unrecoverable across failure or crash → 27c8142

Both halves confirmed, and a third: in place, the external ledger was written before the commit at all. It can never ride a commit, so writing it early bought nothing and risked precisely the false claim in finding 1. Both modes now park the ids; the flush happens after finalize_commit returns in place (before the DONE advance, so a crash in that window re-drives the idempotent commit phase) and at the merge under isolation.

_flush_pending_deferred_closes clears pending_deferred_closes after the write, not before — the clear-first ordering meant a raising write unwound to run()'s crash handler, whose finally: self._save() persisted the emptied obligation. For the post-merge crash window: StoryTask.unit_merged is set and persisted inside integrate_unit before the bookkeeping it authorizes, and a new resume reconcile pass over terminal tasks retries the closure when it is set. When it is not — the unit never reached the target branch — the entry stays open and the pass journals deferred-close-abandoned, because open is then the truthful reading.

3 (Medium) — transient spec-read failure dropped a declaration → 27c8142

Confirmed. The declaration is now captured onto StoryTask.declared_deferred when the dev artifacts verify — the last point the spec is known good — and persisted, so the commit boundary performs no spec I/O at all and the COMMITTING resume arm (which never re-verifies) gets the declaration too. None (never captured) is kept distinct from [] (captured; declares nothing), so only the former can trigger the remaining fallback read; that read journals deferred-close-declaration-unreadable instead of reading as "declares nothing".

Note the blast radius was narrower than stated: the stories.yaml manifest channel is read from memory and was never affected — only the frontmatter channel, which is sprint mode's only channel.

4 (Medium) — atomic replacement discarded permissions → 2c81215

Confirmed, and worth flagging that it is a regression this PR introduced: mark_done previously wrote in place with write_text, so mode and inode survived; mark_done_many swapped in a fresh inode. Two consequences beyond the reproduced 0600 → 0644: extended attributes were dropped, and a symlinked ledger was replaced by a regular file, orphaning the real one.

New platform_util.atomic_write_text beside atomic_replace, since three other modules repeat the same fixed-.tmp pattern and now have a correct primitive to move to: resolve the target first, stage through a uniquely-named mkstemp sibling, copymode plus best-effort xattrs before replacing, and clean up the temp on any failure. The unique name also removes the temp-name collision you flagged under the deferred observation. Ownership is not preserved — unprivileged os.replace cannot — and the docstring says so rather than implying more.

5 (Low) — stale PR description → updated

Rewritten: commit-boundary placement, both declaration channels, deferred.closes-unknown / deferred.closes-malformed, and current counts.


Two reclassifications

  • Finding 2 is two bugs with different severities. The clear-before-write ordering is a true High. The post-merge crash window is a Medium: its failure direction is a missed close (the entry stays open), which degrades to the pre-PR status quo rather than making a false claim. Both are fixed regardless.
  • The enclosing gap in 2b predates this PR. A DONE task that crashed before or during integration is not re-integrated by resume at all — its branch stays unmerged. The parked closure was one casualty; the fix is scoped to the closure (a durable merge marker plus a reconcile pass), not to re-integration in general.

Out of scope, as you suggested

Concurrent-run ledger safety — the unlocked read-modify-write across engines. Agreed it is a pre-existing condition tracked separately; the unique temp name lands here either way. Happy to file the follow-up issue.

Coverage

+15 tests, all lifecycle ones driving engine.run(): a rejecting native pre-commit hook (rollback, then the re-armed re-drive landing the close once and in the commit), the external ledger in place on both sides of the commit, a failing external write asserted against the persisted state, a crash after merge_local resumed into the reconcile, an unmerged pending closure staying open, every spec read faulted from the commit phase onward, an in-place external closure landing after a successful commit, and the primitives' units for mode preservation, symlink write-through, temp uniqueness and cleanup-on-failure.

Suite: 2984 passed, 7 skipped, 0 failed (2969 before). trunk check --no-fix clean.

pbean added 2 commits July 24, 2026 10:34
…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.
`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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Filed the follow-up: #286 — deferred-work.md: concurrent writers can lose entries, duplicate DW ids, and revert closures.

Scoped to the ledger, as you suggested, and explicitly noting nothing in it was introduced here — this PR only added one more writer, and the unique temp name from 2c81215 already removed the temp-name collision you flagged in the same paragraph.

Two things the write-up turned up that are worth surfacing:

  • The primitive is already in the tree and unused. platform_util.file_lock is a cross-platform advisory lock whose docstring states the exact rule this needs — lock a dedicated sibling, never data swapped via atomic_replace. It has unit tests and zero production call sites: it was added by d946a2a "fix(mux): lock herdr sidecar read-modify-write cycles", the identical bug class, and went unused when the herdr backend moved out-of-tree.
  • The exposure is wider than the unlocked flip. Two whole-file restores round-trip the entire ledger — _defer snapshots at engine.py:2977 and writes back at 2989, across _rollback_or_pause, so a concurrent append inside that window is reverted rather than merely raced. And append_entry / append_decision still use path.write_text, so a reader can observe a truncated file mid-write, which undercuts the "gates re-read from disk" guarantee at verify.py:1482.

@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up comprehensive review: request changes

I recommend holding merge. The current head is strong on covered paths, but three high-severity lifecycle defects can still falsely close deferred work, and three medium defects can silently miss closure or violate the documented warning-only contract.

Findings

  1. High — Symlinked external ledgers are misclassified as in-repo.

    ledger.is_relative_to(...) is lexical, while atomic_write_text() follows symlinks. An in-repo symlink targeting a shared external ledger is therefore treated as committable: the external target is updated before integration, while Git can only stage the unchanged symlink. A failed merge can leave external work falsely marked done.

  2. High — Stops and non-GitError failures bypass rollback.

    _close_declared_deferred() runs outside the try, and only GitError is caught. SIGTERM's RunStopped, KeyboardInterrupt, or an OSError spawning Git can leave the ledger closed while no story commit exists. The current regression covers a rejecting native Git hook, not these paths.

  3. High — The captured declaration can become stale before commit.

    Spec IDs are captured immediately after dev verification, and _declared_deferred_ids() never rereads once the field is a list. If a human changes closes_deferred during the spec-approval pause, or a follow-up review changes it, commit closes the pre-pause IDs rather than the final approved declaration. That can close an entry the final spec no longer names.

  4. Medium — Manifest-only declarations can silently disappear.

    StoriesEngine rereads stories.yaml at commit time, while _entry_for converts a manifest read/parse failure into None. The story then commits without closing its manifest-only IDs. Unlike spec IDs, the manifest half is not captured on StoryTask for commit/resume.

  5. Medium — A crash after merge but before saving unit_merged loses closure.

    The Git merge completes before unit_merged=True is persisted. A host death in that window leaves the story on the target branch but the saved flag false. Resume then treats the pending close as abandoned at _reconcile_pending_deferred_closes. The added crash test starts after the flag was saved, so it misses this side of the window.

  6. Medium — Malformed manifest declarations violate the warning-only contract.

    closes_deferred: DW-5 raises StoriesError, which makes the manifest/run fail and prevents _validate_closes_deferred from emitting its advisory warning. The README, CHANGELOG, canonical format doc, and PR description instead promise a nonfatal deferred.closes-malformed warning plus a dropped declaration.

  7. Medium verification gap — Persisted declarations lack a real round-trip/resume test.

    StoryTask.to_dict/from_dict correctly preserves the meaningful None versus [] distinction, but no test saves and reloads a COMMITTING task with declared_deferred=["DW-1"], faults the spec read, and proves the captured declaration still closes. Existing commit-failure recovery launches a fresh dev effect that can recapture the field.

  8. Low verification gap — Extended-attribute preservation is untested.

    The implementation worked in a Linux smoke test, but the suite does not assert the newly documented xattr behavior. Removing _copy_xattrs would leave all tests green.

Deferred observation

Concurrent ledger writers can still lose updates; a synchronized two-process reproduction left one worker's entry reopened. This predates PR #284 and is already tracked as #286, so it is not a newly introduced blocker here.

Remediation plan

  1. Classify ledger locality by resolved targets. Resolve both the ledger and workspace root before choosing commit-local versus pending/post-integration closure. Add the in-repo-symlink-to-external-target failure case.
  2. Persist the final declaration. Capture the union of manifest and spec IDs after the final successful gate, immediately before COMMITTING, and save it atomically with that phase. Preserve the last known-good declaration on transient reads rather than replacing it with empty.
  3. Make in-repo closure interruption-safe. Land the story content first, apply the closure only after that succeeds, then amend it into the same story commit. Keep COMMITTING until the amend is durable; restore cleanly if the amend fails. This removes the interval where the ledger claims success before any story commit exists.
  4. Make integration recovery evidence-based. Persist a merge intent/receipt and reconcile actual target-branch state after a crash; do not rely solely on a flag written after merge_local() returns. It must work for ff, merge, and squash strategies.
  5. Honor the malformed-declaration contract. Parse a wrong-container manifest field as empty plus retained error metadata, so validate warns and runtime journals/drops it without failing the story.
  6. Add lifecycle coverage. Cover symlink locality, SIGTERM/KeyboardInterrupt/OSError at commit, declaration changes during approval/review, manifest read failure, the merge-before-save crash, COMMITTING state round-trip, and platform-gated/mocked xattrs.

Verification

Reviewed exact head 1aebc7a47fc99f900a734de0a11f53aefd28b27d:

  • Full suite: 2,984 passed, 7 skipped
  • Changed-area suite: 951 passed
  • trunk check --no-fix: clean
  • git diff --check: clean
  • All GitHub Python, Windows, lint, typecheck, and version checks: green

The green checks establish compatibility on covered behavior; the findings above are lifecycle/recovery gaps the current suite does not exercise.

…a snapshot (#234 review)

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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Remediation: 6 of 8 fixed in code, 2 answered in place (0291fe5)

Traced all eight against the head you reviewed before touching anything. Six hold and are fixed; two are real observations whose proposed remedy costs more than it buys, and both are now stated in the code rather than left implicit. Findings 1 and 2 reproduced exactly as described.

Fixed

1 — Symlinked ledgers. Confirmed by construction: an in-repo symlink to an external ledger reports is_relative_to(root) == True, and mark_done_many writes status: done straight through to the external target while the symlink git stages is unchanged. Locality is now decided on resolved paths (only the last component can differ — ProjectPaths resolves the artifact dir at construction and again in rebased). New POSIX-gated regression asserts the external target is untouched at the close and the link is written through, not replaced, at the flush.

2 — Non-GitError exits. Stronger than stated: _run installs a SIGTERM/SIGINT handler that raises RunStopped from wherever the main thread is standing, so it can land inside finalize_commit, and run() catches it to finalize a stopped run, not to repair bookkeeping. except BaseException: restore; raise — re-raised untouched, the disposition stays the caller's. The regression fires a real in-process signal.raise_signal(SIGTERM) from inside finalize_commit rather than raising RunStopped directly, so it exercises the mechanism and not a stand-in for it. OSError on spawn covered separately.

3 — Stale declaration. The gap is real, though not by the route described: _capture_declared_deferred runs on every successful dev verify, so an edit at the spec_checkpoint plan pause is re-captured on the leg-2 verify, and done_checkpoint fires after the commit. What is uncovered is an edit landing between the final dev verify and the commit. Fixed by inverting the relationship rather than moving the capture: the commit boundary re-reads, and the capture becomes the fallback for a read that faults. That keeps the fault-tolerance the capture was built for — and keeps the COMMITTING resume arm working — while a withdrawn id is no longer closed. Regression drives engine.run() with the spec rewritten between verify and commit, asserting both directions (withdrawn not closed, added late closed).

4 — Manifest-only declarations. _entry_for does journal stories-manifest-unreadable, but it is deduped per story and generic, so an earlier dispatch-time failure spends the one line and the lost closure passes in silence. _manifest_closes_deferred now reads directly and journals deferred-close-declaration-unreadable with source: stories.yaml at the site that lost it. Regression proves one unreadable channel does not take the other down.

6 — Malformed manifest declarations. The contradiction is real; the docs are the wrong half. Every other malformed manifest field raises, and _id_list_field documents the severity split deliberately — parsing this one field as empty-with-metadata would make it the only manifest field that silently degrades, and would hide a typo in the file that is authored with the ledger in view, before the run, where validate reports it and a fix is cheap. Corrected the four places that overpromised a warning (README, CHANGELOG, docs/FEATURES.md, deferred-work-format.md) plus the PR body.

7, 8 — Verification gaps. Both added. The COMMITTING round-trip saves and reloads through state.json, faults the spec read, and proves the persisted capture is what closes. The xattr test is os.setxattr-gated and skips where the filesystem has no user xattrs, which is also the best-effort contract.

Not changed

5 — Merge-then-save window. Real, and the current ordering is the one I want. A death there reports a landed merge as abandoned and leaves the entries open, which a sweep re-verifies against the codebase; recording intent before the merge trades that miss for closing entries a merge never landed — the direction this whole path exists to prevent. Proving it from the target branch afterwards is not available as a tiebreaker either: squash rewrites the commit, so the unit's sha is not on the target under every strategy. Named explicitly in _reconcile_pending_deferred_closes now, so the next reader does not have to re-derive it.

Also declined the amend-after-commit redesign for finding 2: finalize_commit's re-drive contract is squash-to-baseline and idempotent across both crash states, and an amend step adds a new crash window between the commit and the amend to close one the restore already covers.

Deferred observation. Agreed and already tracked as #286.

Verification

Every regression added here is mutation-checked — neuter the fix, confirm the test fails — because a passing new test proves nothing about the guard it was written for. All six caught their mutation.

Full suite 2991 passed / 7 skipped / 0 failed, trunk check --no-fix clean.

@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: 4

🧹 Nitpick comments (3)
tests/test_engine_worktree.py (1)

1473-1480: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse _external_paths instead of re-inlining it.

Lines 1477-1479 rebuild exactly what _external_paths constructs 25 lines below (same shared-artifacts dir, same dataclasses.replace), and the helper is defined after its first would-be use site. Hoisting the helper above this test and calling it keeps the two from drifting if the external-paths shape changes.

♻️ Proposed refactor (move `_external_paths` above this test, then)
-    import dataclasses
-
     from conftest import write_ledger
 
-    external = tmp_path / "shared-artifacts"
-    external.mkdir()
-    paths = dataclasses.replace(project, implementation_artifacts=external)
+    paths = _external_paths(project, tmp_path)
+    external = paths.implementation_artifacts
     write_ledger(paths, {"DW-1": "open"}, commit=False)

Also applies to: 1503-1511

🤖 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 `@tests/test_engine_worktree.py` around lines 1473 - 1480, Hoist the
`_external_paths` helper above the affected test cases and replace the
duplicated `external.mkdir()`/`dataclasses.replace(project,
implementation_artifacts=external)` setup in both locations with calls to
`_external_paths`. Preserve the existing shared-artifacts path and returned
project-path behavior.
tests/test_deferredwork.py (1)

565-570: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the failure propagates instead of swallowing it.

try/except OSError: pass passes just as happily if mark_done_many ever starts swallowing the write error and returning [] — but callers rely on the raise reaching them (tests/test_engine.py:2302-2319 keeps the pending obligation precisely because the write unwinds). pytest.raises pins both halves.

♻️ Proposed refactor
-    try:
-        mark_done_many(p, ["DW-1", "DW-3"], "2026-07-24", "note")
-    except OSError:
-        pass
+    with pytest.raises(OSError):
+        mark_done_many(p, ["DW-1", "DW-3"], "2026-07-24", "note")
 
     assert p.read_bytes() == before  # nothing partially applied

Requires pytest to be imported in this module — verify:

#!/bin/bash
rg -nP --type=py '^\s*import pytest' tests/test_deferredwork.py
🤖 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 `@tests/test_deferredwork.py` around lines 565 - 570, Update the test around
mark_done_many to use pytest.raises(OSError), ensuring the write failure must
propagate rather than be swallowed or converted into a normal return. Keep the
existing assertion that p remains unchanged, and add the pytest import if
tests/test_deferredwork.py does not already import it.
tests/test_worktree_flow.py (1)

304-317: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Also assert the durable-proof flag this seam now sets.

integrate_unit sets task.unit_merged and saves before invoking the callback, and _reconcile_pending_deferred_closes keys the retry on exactly that flag. Asserting it here pins the ordering contract at the unit level rather than only in the end-to-end worktree test.

💚 Proposed test addition
     assert merged == [task]
     assert flow.calls.integrated == [task]
+    assert task.unit_merged  # persisted before the bookkeeping it authorizes
+    assert flow.calls.saves == 1
🤖 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 `@tests/test_worktree_flow.py` around lines 304 - 317, Extend
test_integrate_unit_runs_post_integration_bookkeeping_after_a_merge to assert
that task.unit_merged is true after flow.integrate_unit completes, preserving
the existing merge and callback assertions to pin the durable-proof flag set by
integrate_unit before its callback.
🤖 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/cli.py`:
- Around line 609-674: Update _validate_closes_deferred to inspect
declared.malformed after deferredwork.classify and emit a
deferred.closes-malformed warning for those referenced ids, including the source
and malformed ids in the warning details. Perform this before the existing
declared.unknown check, while preserving the current unknown-id warning
behavior.

In `@src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md`:
- Around line 73-75: Update the authoring guidance in
src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md:73-75 and
README.md:185-187 to describe breakdown-time declaration authoring as
recommended rather than mandatory, and explicitly state that frontmatter edits
made before commit are read and remain honored.

In `@src/bmad_loop/engine.py`:
- Around line 2286-2288: Update the empty-result branch in the deferred-close
flow around _declared_deferred_ids to clear pending_deferred_closes before
returning when no declarations remain. Preserve the existing return behavior
while ensuring a fresh read with withdrawn declarations cannot flush stale
externally parked obligations.

In `@src/bmad_loop/platform_util.py`:
- Around line 195-210: Update atomic_write_text around the existing os.fdopen
and atomic_replace calls to flush the buffered file handle and invoke
os.fsync(fh.fileno()) before publishing the temporary file. Preserve the current
cleanup and atomic replacement behavior, with directory fsync remaining optional
and POSIX-only.

---

Nitpick comments:
In `@tests/test_deferredwork.py`:
- Around line 565-570: Update the test around mark_done_many to use
pytest.raises(OSError), ensuring the write failure must propagate rather than be
swallowed or converted into a normal return. Keep the existing assertion that p
remains unchanged, and add the pytest import if tests/test_deferredwork.py does
not already import it.

In `@tests/test_engine_worktree.py`:
- Around line 1473-1480: Hoist the `_external_paths` helper above the affected
test cases and replace the duplicated
`external.mkdir()`/`dataclasses.replace(project,
implementation_artifacts=external)` setup in both locations with calls to
`_external_paths`. Preserve the existing shared-artifacts path and returned
project-path behavior.

In `@tests/test_worktree_flow.py`:
- Around line 304-317: Extend
test_integrate_unit_runs_post_integration_bookkeeping_after_a_merge to assert
that task.unit_merged is true after flow.integrate_unit completes, preserving
the existing merge and callback assertions to pin the durable-proof flag set by
integrate_unit before its callback.
🪄 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: 2b002e0b-1bf7-4a22-9529-568c82390786

📥 Commits

Reviewing files that changed from the base of the PR and between cb87176 and 0291fe5.

📒 Files selected for processing (23)
  • 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/model.py
  • src/bmad_loop/platform_util.py
  • src/bmad_loop/stories.py
  • src/bmad_loop/stories_engine.py
  • src/bmad_loop/sweep.py
  • src/bmad_loop/worktree_flow.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
  • tests/test_worktree_flow.py

Comment thread src/bmad_loop/cli.py
Comment thread src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md Outdated
Comment thread src/bmad_loop/engine.py
Comment thread src/bmad_loop/platform_util.py
pbean added 2 commits July 24, 2026 12:40
 review)

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.
 review)

`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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CodeRabbit round: 4 actionable, all 4 fixed (a84507a, 5e34926)

Two of these are Major and both are real. One of them is a regression the re-read in 0291fe5 introduced, which is the more useful half of the finding.

engine.py — a withdrawn declaration left a parked external obligation standing. Correct, and it is mine. Re-reading at the commit boundary is what made a withdrawal reachable in the first place, and the if not ids: return None early return predates it: every other path recomputes pending_deferred_closes wholesale from the fresh read, so only that one could carry ids forward that the story no longer declares. With an out-of-repo ledger the parked list deliberately survives a failed commit (to be retried by the re-drive), so the re-drive would have flushed a withdrawn closure after its commit — a false close on the one ledger with no rollback behind it.

Cleared, and journaled as deferred-close-withdrawn rather than dropped in silence: an unreadable spec or manifest reaches that same early return, and by then it is indistinguishable from a genuine withdrawal. New lifecycle regression drives the whole thing — park, fail the commit, re-drive with the declaration removed, assert the entry is still open and the obligation is gone.

platform_util.py — fsync before the replace. Right, and worth more here than the usual durability argument: an empty ledger parses, as zero entries, so a torn write does not surface as corruption — it reads as every hand-written entry having vanished. Fixed. Not syncing the directory as well is deliberate and now stated: that would make the rename durable, and losing the rename leaves the old contents in place, which is stale rather than torn. The test asserts the order (fsync before replace), since calling it afterwards would pass a weaker "was fsync called" assertion and buy nothing.

cli.py — preflight the malformed bucket. Agreed, and it was a documented promise: docs/FEATURES.md and the README both list an unreadable entry status among the things validate warns about, while the check covered only absent ids and unreadable declarations. Added under its own deferred.closes-entry-unreadable (registered in checks.VALIDATE_CHECKS) rather than folded into closes-unknown — the id is not a typo, it is in the ledger, and the remedy is to repair the entry's status.

Docs — breakdown-time authoring is a recommendation, not a deadline. Fixed in both places you flagged, now that a declaration edited before the commit is honored in both directions.

Skipping the three nitpicks: the _external_paths hoist is a fair point about duplication but touches tests from an earlier round that are not otherwise in this change, and you marked it low-value yourself.

Full suite 2994 passed / 7 skipped / 0 failed, trunk check --no-fix clean. Both new guards mutation-checked.

…vered (#234 review)

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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Correction: two of the three nitpicks were worth taking (e52d310)

I said I was skipping all three, having only read one of them properly. Two hold up, and both are stronger than the "Trivial" label:

tests/test_deferredwork.pypytest.raises instead of except OSError: pass. You're right that the weaker form passes if mark_done_many ever starts swallowing the error and returning [], and that matters more than a style point here: _flush_pending_deferred_closes clears pending_deferred_closes only after the write returns, precisely so a raising write leaves the obligation to be retried. A swallowed error would discharge an obligation nothing performed — the exact failure the surrounding test exists to prevent.

tests/test_worktree_flow.py — assert unit_merged. Taken, but not as proposed: asserting task.unit_merged after integrate_unit returns is true for either ordering, so it pins nothing. The contract is that the flag is set and persisted before the callback fires, since _reconcile_pending_deferred_closes keys its post-crash retry on it. The assertion is now taken from inside the callback, and a mutation swapping the two orderings fails it.

Skipping the _external_paths hoist: real duplication, but in tests from an earlier round this change doesn't otherwise touch.

While checking these I found my mutation harness could report a stale result — it rewrote a source file twice inside one second, and CPython keys its bytecode cache on source mtime in whole seconds, so a restored file can be served from the mutated compile. Re-ran all eleven mutations with -B and a cleared __pycache__: all eleven still fail their test when the fix is neutered, and the unmutated suite is 2994 passed / 7 skipped / 0 failed. The stale cache only ever produced a spurious failure, never a spurious pass, but the harness is only worth anything if that is checked rather than assumed.

pbean added 2 commits July 24, 2026 13:53
…lently (#234 review)

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.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Remediation: all 6 findings fixed, with 2 deviations from the plan

Six commits. I traced each finding against the head you reviewed before touching anything — all six are real, and the two High ones are the same class the previous three rounds were about: bookkeeping that outlives the story it was written for.

1 (High) — a raise inside the close bypasses rollback → a2e7bc9

Confirmed. rollback = self._close_declared_deferred(task) sat one line above the try that owns the restore, and bound its snapshot from the return value — which a raise inside the close never produces. The window is real in both directions you named: mark_done_many publishes atomically and _apply_deferred_closes journals story-deferred-closed after it, which is an open(…, "a") + write; and the handler run() installs raises from anywhere in between.

The snapshot is now reported through a caller-owned slot armed before the ledger is touched, and the close runs inside the try. Arming early is free: mark_done_many writes only when it marks, so the slot is disarmed again when nothing flipped, and restoring text nothing changed is a no-op rewrite.

One refinement your plan didn't ask for but the record needed: the ids are written into that slot the statement after the write, so deferred-close-rolled-back still names what it un-flipped rather than reporting an empty rollback. An empty list now means specifically "a stop landed inside the write itself" — the restore is by content, so it is complete either way, and only the record of which entries it touched is missing. That distinction is documented at the journal site.

2 (High) — missing/out-of-tree final specs reuse stale declarations → 8fb0aa7

Confirmed, through both early returns. A spec that is gone and a spec that is out of the roots each returned success without touching declared_deferred, and _declared_deferred_ids unions that capture in unconditionally. So a DW-1 captured at dev-verify was still closed after a workflow archived, renamed or deleted the spec — and the root-containment guard journaled a skip it did not actually perform.

Both branches now clear the capture and journal the withdrawal (deferred-close-declaration-absent for the missing case; the existing out-of-tree event grows a dw_ids field). Only a genuine _observed_frontmatter fault still leaves the last good capture standing, which is the case the fallback was built for.

Deviation — I did not persist the commit-boundary declaration before side effects. Your item 2 asks for it so "COMMITTING resume cannot fall back to an older attempt", but the resume arm re-drives _finalize_commit_phase, which re-reads both channels from disk — it re-derives the same answer with or without an extra save, including the new clearing behavior. I could not construct a failure mode the extra persistence prevents, so I left it out rather than add a write nothing reads. Happy to be shown the case I'm missing.

Also declined the four-state return type: valid / withdrawn-missing / out-of-tree / unreadable is the right distinction, and it is what the code now makes — but it is expressible as "clear here, keep there" without a new enum threading through one caller.

3 (Medium) — merge proof recorded after fallible post-merge work → 3decb08

Confirmed. The stamp landed only once merge_local returned — after the unit-merged journal append, the post_merge emit and the worktree teardown, none of which is evidence about whether the merge happened.

task.unit_merged = True; self._save() now runs the statement after verify.merge_branch returns, and integrate_unit no longer duplicates it. The residual window is that save alone.

Worth stating plainly since it changes how the finding should be read: this one fails safe. Entries stay open, a sweep re-verifies them, and deferred-close-abandoned names it for the operator. It was worth fixing because the window was wider than it needed to be, not because it could produce a false close.

The unit test that stubs merge_local out now carries the stamp itself, and a new test pins the ordering at its new owner: a journal that raises on unit-merged still leaves the flag set and saved.

4 (Medium) — a missing external ledger silently discharges the obligation → 54bff9f

Confirmed. 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 ids were cleared on the strength of a ledger nobody could see.

Deviation — narrowed from "missing or unreadable" to "the artifact directory is gone". Retaining on any missing ledger creates an obligation nothing can ever satisfy: a project that has never filed deferred work has no ledger at all, so a stale declared id would be re-journaled by every later resume forever. The artifact directory separates the two cleanly, and paths.deferred_work is by construction implementation_artifacts / "deferred-work.md":

  • directory gone → the location is unavailable. deferred-close-ledger-unavailable, obligation kept.
  • directory there, no ledger in it → genuinely nothing to close. unmatched, cleared — the same answer the in-repo path already gives.

Both directions have a test, and a mutation applying your version of the rule fails the second one. If you'd rather have the blanket rule, say so and I'll take it — but I'd want the undischargeable-obligation case answered first.

5 (Medium) — a real manifest read error escapes the fallback → eec9857

Confirmed exactly as described: load_stories translates UnicodeDecodeError but let OSError escape raw, and is_file() only rules out absence.

Fixed in load_stories rather than at the call site. _manifest_closes_deferred was the site that exposed it, but the hole is the same for the scheduler, dispatch, preflight, status and validate — all of which already catch StoriesError and turn it into a clean stories mode: message. One translation closes it everywhere; catching OSError in the one hook would have left the others crashing.

6 (Medium) — validation silently skips an unreadable ledger → 2fe4066

Confirmed, and I checked the double-report concern that justified the silence: nothing else in validate reads the ledger (the only other read is _sweep_dry_run, which is not a check). So the shared except was reporting success for a preflight that examined nothing.

The try is split. A ledger read fault warns deferred.ledger-unreadable and the declaration checks it could not run stay quiet rather than guessing; manifest faults keep their single report via queue.stories-manifest. Still a warning, never a gate. The new id is registered in checks.VALIDATE_CHECKSchecks.py asserts that at emit time, so an unregistered id is a hard failure rather than a silent one.

Coverage

Ten new regressions, all driving engine.run() or the real collaborator rather than the hook under test: journal-fails-after-publication and a real in-process SIGTERM inside mark_done_many; a deleted and a redirected final spec; merge_local's journal tail; an artifact dir renamed away across the flush and restored for the reconcile; the live-dir-no-ledger mirror; a transient commit-time manifest PermissionError; and an unreadable ledger at preflight.

Every one is mutation-checked — 11 mutations, 11 caught, including one that applies the review's own over-broad rule for finding 4. Run with python -B and a cleared __pycache__, and the harness now rejects an exit-5 "no tests collected" as a bad selector rather than scoring it as a catch (it briefly scored one that way; the corrected run is the number above).

Suite: 3004 passed, 7 skipped, 0 failed (2994 before). trunk check --no-fix clean, git diff --check clean.

@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up review at 087fd33 — request changes

I ran four independent review passes (adversarial, edge-case, verification-gap, and acceptance) over the full 23-file diff, then verified the candidates against the PR head. The implementation is well tested, but two lifecycle defects remain release-blocking, with three additional correctness gaps worth closing in the same repair round.

Findings

  1. High — an external-ledger outage can leave a closure permanently stranded or incorrectly discharged

    In _flush_pending_deferred_closes, an unavailable artifact directory journals and returns while retaining pending_deferred_closes. The caller nevertheless advances the task to DONE; the run can then become finished, and resume rejects finished runs. The promised next-resume retry is therefore unreachable.

    The added regression currently hides that boundary: it asserts a completed run and then calls _reconcile_pending_deferred_closes() directly rather than exercising the supported CLI resume path. A dangling deferred-work.md symlink is another variant: its parent remains present, is_file() is false, all ids become unmatched, and the pending obligation is cleared.

  2. High — an integration-bookkeeping failure can silently bypass done_checkpoint

    WorktreeFlow.integrate_unit invokes the new _on_integrated callback after the task is durably DONE and unit_merged. If the external-ledger read/write, journal append, or state save raises, resume reconciles the pending closure and then _finish_inflight skips the terminal task. StoriesEngine._after_story is never called, so a mandatory human checkpoint can disappear and a later story can dispatch.

  3. Medium — commit rollback restores the working tree but leaves the false closure staged

    finalize_commit stages with git add -A before a native hook can reject the commit. _restore_deferred_closes repairs only the working-tree file. The index still contains status: done, so an operator who fixes the hook and runs git commit can publish the closure that rollback was intended to remove.

  4. Medium — duplicate ledger ids make classification and mutation disagree

    classify keeps the last duplicate through a dict comprehension, while _find_entry mutates the first. With one duplicate open and another done, the close can silently leave the open entry untouched without an unmatched/malformed warning.

  5. Medium — invalid final frontmatter erases the last-good declaration capture

    read_frontmatter maps invalid YAML and non-UTF-8 content to {}. At the commit boundary, _capture_declared_deferred interprets that as a valid empty declaration and clears the verified fallback instead of treating it as unreadable and journaling/falling back.

Remediation plan

  1. Make external-ledger unavailability resumable: do not permit finished=True while a closure obligation remains; treat an expected-but-missing ledger, including a dangling symlink, as retryable. Replace the private-method test with an end-to-end resume test.
  2. Persist a post-story/checkpoint obligation before integration. On resume, reconcile integration bookkeeping and then run/discharge _after_story; add a stories-mode done_checkpoint regression with an injected external-ledger callback failure.
  3. When rolling back an in-repo close, restore both the working tree and the index. Assert both git diff and git diff --cached contain no false done annotation after a rejecting hook.
  4. Treat duplicate DW-* ids as malformed at classification time, surface them consistently from runtime and validate, and add both open/done ordering permutations.
  5. Add a close-specific strict frontmatter read result that distinguishes a valid absent field from YAML/encoding failure; preserve the last-good capture and journal the fallback on the latter.
  6. Run the focused fault-injection tests, the full suite, and the automatic merge result against current main.

Verification performed

  • Exact PR head: 3,004 passed, 7 skipped
  • Automatic merge with current main: 3,035 passed, 7 skipped
  • The automatic merge is conflict-free; current CI, lint, and type-check jobs are green.

The unlocked ledger read-modify-write / whole-snapshot rollback concurrency risk is real, but it is already explicitly split into #286 and documented as out of scope for this PR, so I did not treat it as an additional blocker here.

pbean added 7 commits July 24, 2026 17:41
…rite marks (#234 review)

`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.
…nothing (#234 review)

`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.
#234 review)

`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.
…ls a close back (#234 review)

`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.
…ll pay (#234 review)

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.
…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.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Remediation: all 5 findings fixed, 2 of the 6 plan steps declined

Seven commits (d6cba04..9dd7bbc). I traced every finding against the code before acting — all 5 are real, and finding 4 is worse than described. Two severities I read differently, and two of the six remediation steps I did not take as written; reasons at the bottom.

1 (High → Low/Medium) — external-ledger outage → ca0d68e, 1346617

Both halves confirmed. _flush_pending_deferred_closes retains the obligation, the caller advances to DONE, run() sets finished = True, and cli._resume_paused_run refuses a finished run — so "the closure stays owed and is retried" was a promise the CLI cannot keep. The dangling-symlink variant is the same outage in a shape the discriminator missed: the artifact dir is present, is_file() follows the link and says no, every id classifies unknown, and the obligation was discharged with a deferred-close-unmatched line — a typo warning for a mount that went away.

_reconcile_pending_deferred_closes(final=True) now runs once more immediately before the run is marked finished. It retries what the outage held back — a mount that comes back inside the run still closes the entry — and releases anything still owed with deferred-close-abandoned. A dangling symlink is treated as unavailable, because the link existing at all is the evidence a ledger is expected there.

Severity, for the record. The residual here is that entries stay open — the PR's own documented safe direction, re-verified by a sweep. No false close, no data loss. What was broken was the claim, in the journal note and in deferred-work-format.md; both now say retried while the run is resumable, and what happens when it isn't.

The test criticism was fair and is fixed: the regression 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 a second test covers the dangling symlink end to end. resume_engine also refuses a finished run like the CLI does, so no future test can prove a recovery path that doesn't exist.

2 (High) — an integration-bookkeeping failure could skip done_checkpoint86844f6

Confirmed exactly as described: a raise from the _on_integrated callback escapes run_unit and the story loop, _after_story never fires, and resume does not repair it because _finish_inflight skips terminal tasks by design.

_flush_after_integration wraps the flush: deferred-close-flush-failed is journaled, pending_deferred_closes is left standing for the reconcile pass, and the story finishes. Every fault in that flush is an OSError on the out-of-repo mount that is the only reason it runs at all — trading a mandatory human review for a failed annotation is backwards, and the annotation is never a gate anywhere else in this feature.

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.

One thing the review did not say: that window is pre-existing. merge_local's own tail — a journal append, a post_merge plugin — can raise identically and cost the same checkpoint. This PR added one more raiser to it, and that one is now non-fatal. The generic case is not closed here; I'll file it separately.

3 (Medium) — rollback restored the tree, left the index → ab0568b

Confirmed. finalize_commit stages with add -A before the hook can reject, so the index kept status: done for a commit that never happened. The loop never reads that back, 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. verify.stage_path re-stages the restored file; advisory by design (journaled as deferred-close-rollback-unstaged, never raised) because a repair that raises would mask the commit failure being escalated.

4 (Medium) — duplicate ledger ids → d6cba04

Confirmed, and both permutations were silent, not just one:

ledger classify write journal
DW-1 open, then done already_done not attempted silent
DW-1 done, then open open_ids _apply_done refuses the done copy it finds first, marks nothing silent — no unmatched, no malformed

Root cause is one line: classify indexed last-wins while _find_entry takes the first. It now indexes first-wins, so the classification names the entry the write acts on, and Declared.duplicates / deferred-close-duplicate-id reports the duplicate itself.

5 (Medium) — invalid final frontmatter erased the capture → 181f7b5

Confirmed. read_frontmatter flattens invalid YAML and non-UTF-8 to {}, and _observed_frontmatter degrades only on OSError — so the close site saw a spec that parsed and declared nothing, i.e. a retraction, and cleared the verified fallback for exactly the fault class the fallback exists to survive. read_frontmatter_or_none keeps the distinction and read_frontmatter is now it or {}, so every existing caller is unchanged; only _capture_declared_deferred reads strictly, and the fallback journals like any other failed read.

Two plan steps I did not take

Step 1's "do not permit finished=True while a closure obligation remains." That lets a failed annotation hold a completed run open, and a permanently-gone mount would make the run unfinishable. It contradicts this PR's own "Never a gate" design note. Reporting the truth costs nothing and keeps the run's lifecycle out of the ledger's hands.

Step 2's "persist a post-story/checkpoint obligation + a new resume pass to run/discharge _after_story." A new durable state field and a second reconcile pass to defend a checkpoint against a bookkeeping write. Making the write non-fatal is four lines and removes the failure rather than recovering from it.

Step 4, trimmed. Duplicate ids are journaled at runtime; the validate surface stays with #286, which owns ledger corruption and is already out of scope here.

Verification

  • Full suite: 3017 passed, 7 skipped, 0 failed (+13 tests).
  • Every regression mutation-checked: neuter the fix, confirm the paired test fails, restore — 12 mutations across the five fixes, 12 caught. The resume_engine finished-run guard is the one addition with nothing to mutate: it is a safety net against a future test, not a behaviour change.
  • trunk check --no-fix clean; all 10 CI checks green, including both Windows jobs.

One coverage note: the end-to-end test for an unavailable ledger needs a dangling symlink, so it skips on Windows like every other symlink test here. Since a test that skips in CI is not a gate, the release semantics themselves are pinned separately by a platform-neutral test — kept mid-run, released at run end. (A permanently-missing artifact dir is not an alternative vehicle: sprintstatus.load raises on the missing board, so the run crashes rather than finishing, and the finished-run boundary is exactly what is under test.)

@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/verify.py`:
- Around line 1678-1683: Update stage_path’s path-resolution exception handling
to also catch OSError and RuntimeError, returning False when resolving either
path fails. Preserve the existing ValueError handling and subsequent _git
staging behavior for successfully resolved paths.
🪄 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: 902cd8d0-d6c5-45ce-bef9-f74b5b68ff49

📥 Commits

Reviewing files that changed from the base of the PR and between 087fd33 and 9dd7bbc.

📒 Files selected for processing (10)
  • README.md
  • 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/frontmatter.py
  • src/bmad_loop/verify.py
  • tests/test_deferredwork.py
  • tests/test_engine.py
  • tests/test_engine_worktree.py
  • tests/test_stories_engine.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • tests/test_engine_worktree.py
  • src/bmad_loop/deferredwork.py
  • README.md
  • src/bmad_loop/engine.py
  • tests/test_engine.py

Comment thread src/bmad_loop/verify.py
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.)
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive review — request changes

Reviewed against issue #234 at head 20677c4a307fb1e13ef107aabc69b314bb252178 using adversarial, edge-case, verification-gap, and acceptance-criteria passes.

I found 7 actionable issues: 3 high, 3 medium, 1 low. The current CI is green, but two failure paths below were independently reproduced and are not covered by the suite.

Findings

  1. High — Persistent external-ledger failures crash an otherwise completed run

    The guarded post-merge write retains the obligation after an OSError, but resume and run-end reconciliation retry it without a guard (engine.py:817-844, called from lines 373 and 848). A persistent PermissionError leaves a merged task DONE while the overall run repeatedly crashes instead of abandoning the advisory annotation as documented.

  2. High — The post-merge “must not raise” boundary still throws

    _flush_after_integration() catches only OSError (engine.py:2559-2589). A non-UTF-8 ledger raises UnicodeDecodeError, crashing after merge and again on resume. Additionally, if the fallback journal.append() fails, the exception handler itself rethrows and can skip a mandatory done_checkpoint.

    Reproduced: the task merged and reached DONE, but the run crashed without pausing; resume crashed identically.

  3. High — Whole-file rollback can delete edits made by a rejecting native pre-commit hook

    The rollback restores the complete pre-close ledger snapshot (engine.py:2432-2478). If a native hook appends or edits deferred work and then rejects the commit, those hook changes are silently overwritten along with the intended close rollback. This is distinct from concurrent-writer issue deferred-work.md: concurrent writers can lose entries, duplicate DW ids, and revert closures #286 because it can happen synchronously inside this story’s own commit.

  4. Medium — Advisory index repair can mask the original commit failure

    stage_path() promises never to raise, but path resolution can raise RuntimeError/OSError, and _git() can raise GitError or raw OSError outside the current try (verify.py:1667-1683). That replaces the intended commit escalation with a generic crash. Confirmed on supported Python 3.11 with a symlink loop.

  5. Medium — A transient spec stat failure erases the verified fallback

    _capture_declared_deferred() treats not spec_path.is_file() as confirmed deletion and clears the previously verified declaration (engine.py:2263-2289). Path.is_file() can fold filesystem errors into False, so an inaccessible spec can be misread as withdrawal instead of falling back to the verified capture.

  6. Medium — Ledger availability checks contain a lost-obligation race

    The external ledger’s parent/symlink is checked first, but the later close independently tests and reads the file again (engine.py:2523-2527, 2624-2649). If the mount disappears between those operations, the ledger is classified as empty, the IDs become “unknown,” and pending_deferred_closes is permanently cleared. Looping or dangling in-repo symlinks are also not routed through unavailable-ledger handling.

  7. Low — Final abandonment retains an impossible pending obligation

    For a DONE isolated task with unit_merged == false, final reconciliation journals abandonment but leaves pending_deferred_closes serialized (engine.py:822-844), even though a finished run cannot service it. This can also duplicate abandonment records.

Remediation plan

  1. Centralize post-merge and reconciliation writes behind a result-returning, non-throwing external-ledger boundary. Handle filesystem and decoding failures, make diagnostic journaling best-effort, retain obligations on non-final passes, and abandon/clear them on the final pass.
  2. Read ledger availability/content once per attempt. Distinguish “no ledger was ever present” from “an expected ledger became unavailable,” including symlink-resolution failures.
  3. Replace whole-document rollback with a targeted reversal of only the status/resolution lines inserted for this story, preserving unrelated hook edits.
  4. Make stage_path() genuinely non-throwing around both path resolution and Git execution.
  5. Replace is_file() inference for specs with explicit stat/read handling: clear only on confirmed absence and retain the verified capture on other filesystem failures.
  6. Add regressions for persistent permission failures, invalid UTF-8, checkpoint resume, journal failure, mount disappearance, hook-edited rollback, Python 3.11 symlink loops, and final obligation cleanup. Add capability-gated Windows symlink coverage.
  7. Re-run the full suite, Pyright, lint, and the Windows matrix.

Verification

Separate pre-existing issue

A crash after a worktree task’s DONE save but before integration can strand the committed unit branch: resume skips terminal tasks and never merges it (engine.py:1842-1853, 846-851). PR #284 exposes this through unit_merged, but did not introduce the underlying state-machine gap; I recommend tracking it separately rather than expanding this PR further.

pbean added 6 commits July 24, 2026 21:33
… repairing (#234 review)

`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.
#234 review)

`_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.
…review)

`_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.
…at it says (#234 review)

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.
… them (#234 review)

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.
… 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
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Remediation: all 7 findings fixed, 2 severities re-read, 2 plan steps declined

Six commits (6f27142..f00048a). I traced every finding against 20677c4 before touching anything — all 7 are real. Two I read at a different severity than filed, and one of those is worse than described, not better. Reasons for the deviations at the bottom.

Round 7's theme, since the earlier rounds each had one: the guard exists but only on one of its call sites. All three highs are protections round 6 built correctly and then left off a second path reaching the same code.

1 (High) — persistent external-ledger failures crash a completed run → e10aae8

Confirmed, and the loop is unbounded. _reconcile_pending_deferred_closes called the flush bare while the post-merge site went through _flush_after_integration. An OSError there lands in run()'s except Exception, which sets finished = False and crashed = True — and cli._resume_paused_run refuses only finished runs, so a resume re-enters _finish_inflight, hits the same bare call, and crashes identically. A read-only mount makes that permanent, over an annotation this feature's own design note says is never a gate.

Worth naming: that run-end call site is round 6's own fix for the mirror-image defect. Same shape as round 3's CodeRabbit finding — a fix made new state reachable, and the early paths around it were written before that state existed.

I also guarded the in-place commit-boundary flush, which your finding did not name and which had the same defect. Round 6 deliberately left it bare so a raise leaves the task COMMITTING for an idempotent re-drive. That 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, more cheaply than re-running a whole commit phase, so the task now advances to DONE holding its obligation and the run-end pass retries and then releases it.

2 (High) — the post-merge boundary still throws → e10aae8

Both halves confirmed. UnicodeDecodeError is a ValueError, so an undecodable ledger walked straight past except OSError; and the except arm's own journal.append was unguarded, so a diagnostic failing on the same dead mount became the crash the guard exists to prevent.

Two additions from tracing it. Path.resolve() raises RuntimeError on a symlink loop, and atomic_write_text resolves before writing — so that is a third non-OSError escape on the same path. And the catch stays an explicit tuple rather than Exception, because a RunStopped or RunPaused must still travel through.

The regression is parametrized over all three types, since two of them are exactly what got past the old guard.

3 (High → Low) — whole-file rollback deletes a hook's edits → 03add18

The mechanism is real and I fixed it. The severity I read lower: the only writer that can land in the window (close → finalize_commit → rollback) is a native pre-commit hook that both mutates deferred-work.md and rejects the commit, and the loss is one edit to a human-readable, git-tracked file. That is a narrow trigger and a recoverable outcome — not the "false close" class the highs in rounds 1–5 were about.

Fixed anyway, because it is small and strictly narrower: new deferredwork.restore_entries substitutes just the entries the story flipped, spliced last-first so each edit leaves the earlier offsets valid. Entry-wide rather than line-wide because that is exactly as wide as _apply_done's write.

I kept the whole-document rewrite as the fallback for one casemarked is empty when a stop signal lands inside the write, before the close can report what it flipped. There the restore is by content or not at all, and giving it up would hand that window back its false close. That is the round-5 guarantee, so it stays.

4 (Medium) — advisory index repair can mask the commit failure → 6f27142

Confirmed, and I checked the version claim because it decides whether CI exercises it: resolve() raises RuntimeError on a symlink loop under 3.11 and 3.12 and returns the unresolved path under 3.13 and 3.14. So the requires-python floor is the affected one and the exposure is real on two matrix rows.

Fixed both halves — the resolve guard widened to (OSError, RuntimeError, ValueError), and the _git call wrapped, since _run_git translates only TimeoutExpired and lets a raw OSError from the spawn through. The gate is a fault-injected test so it holds on every interpreter; the real symlink loop is covered separately, asserting only that nothing raises, because the return value legitimately differs by version. This also covers CodeRabbit's inline comment, which was the resolve half of the same finding.

5 (Medium → worse than filed) — a transient spec stat failure → c7a26fa

Your mechanism is right but understates it. Path.is_file() folds exactly ENOENT / ENOTDIR / EBADF / ELOOP into False and propagates every other OSError. So the site was wrong in both directions at once:

  • a symlink loop or unreadable component read as a confirmed withdrawal and cleared the capture — the miss you describe
  • an EACCES / EIO / ESTALE raised, through _declared_deferred_ids and _close_declared_deferred into _finalize_commit_phase's except BaseException, which restores and re-raises: the story crashes 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. The direction matters and the two answers move opposite ways: 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. So FileNotFoundError / NotADirectoryError / a non-regular file clear, and only a fault that says nothing about whether the spec is there keeps the capture — which is how an OSError from reading the spec was already handled.

6 (Medium) — the availability race → 09a2fe3

Confirmed. The fix is the order, not just the count: _read_ledger attempts the read first and probes the location only when it comes back absent, so "there is nothing to close" is never concluded from a probe that succeeded a moment earlier. Merging the two into one function without inverting them would have moved the race rather than closed it — the probe would still pass and the is_file() right after it still answer False.

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 nothing downstream would make that look wrong.

Both in-repo symlink shapes are now routed: dangling read as empty (so the story reported a typo for an outage), and looping crashed the commit boundary on 3.11/3.12. An unresolvable ledger path now closes nothing and says so — I did not park it, because 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.

7 (Low) — final abandonment retains an impossible obligation → e10aae8

Confirmed, both halves. Reported once per process now, and released on the final pass like every other obligation nothing can service.


Declined, and why

Plan step 6's "capability-gated Windows symlink coverage." Round 6's own lesson here was that a test which skips in CI is not a gate, and it was resolved by adding a platform-neutral parametrized test rather than by gating on Windows symlink capability — which needs Developer Mode and would skip on the runners anyway. Every new contract in this round is gated by fault injection for the same reason; the real-symlink tests are the POSIX-only supplement, not the gate.

Plan step 1's "make diagnostic journaling best-effort" — taken; "centralize behind a new boundary" — narrowed. One shared guard and one read-per-attempt, reusing the methods already there. A result-returning ledger boundary as a new abstraction would be a layer to defend a four-line guard.

One test's contract changed

test_closes_deferred_external_write_failure_keeps_the_obligation asserted summary.crashed. That crash was finding 1, so the assertion was pinning the defect. Its real contract — clear only after the write succeeds, or the crash handler's finally: self._save() persists the emptied list — is now driven with a one-shot fault, so what proves the obligation survived is that the run-end pass pays it. A sibling test covers the persistent fault: run finishes, failure journaled, obligation released, entry still open.

Verification

  • Full suite: 3041 passed, 7 skipped, 0 failed (3017 before, +24 tests)
  • 14/14 mutations caught — every new regression neutered and confirmed failing, run with python -B, PYTHONDONTWRITEBYTECODE=1, -p no:cacheprovider and a purged __pycache__, with pytest rc 5 treated as a bad selector rather than a catch (both harness traps from rounds 3 and 5)
  • uvx pyright@1.1.411: 0 errors
  • trunk check --no-fix, no path filter: clean

One mutation initially came back NOT CAUGHT — narrowing the guard back to except OSError — because the finding-6 fix absorbs decode faults at the read, so nothing was reaching the guard with a non-OSError any more. That is a real coverage gap in my own test, not a false alarm from the harness: fixed by parametrizing the persistent-failure test over all three types the guard names.

Separate pre-existing issue

Filed as #299 — a crash between a worktree story's DONE save and its merge strands the committed unit branch, because _finish_inflight skips terminal tasks by design. Agreed it is not this PR's to fix; #284 only made it observable, by adding the unit_merged flag that names the state. Distinct from #296 (the post-merge_local tail) and #286 (concurrent writers).

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #300, which re-scopes this by subtraction.

#234's own guard rail is "surface as a warning rather than failing the story" — an advisory annotation. This PR instead grew two-phase-commit semantics between the ledger and git (rollback arming, parked closures, a reconcile pass, a durable merge marker), and the eight review rounds were not manufacturing findings: transactional ledger↔git coupling has an unbounded adversarial surface, so each round found real defects of the approach. #300 ships the advisory core — the same declaration channels, the same commit-boundary write, the same validate warnings — with clean history and docstrings that claim only the advisory contract.

The findings from these reviews that outlived the re-scope are tracked in #286 (concurrent ledger writers), #296 (post-merge tail can skip done_checkpoint) and #299 (DONE-but-unmerged stranded unit branch); all three are pre-existing defects this PR only made visible.

The branch stays up until #300 merges, for reference.

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