Skip to content

fix: serialize deferred-work ledger and sprint board writers cross-process (#286, #469) - #726

Merged
pbean merged 12 commits into
mainfrom
pbean/ledger-lock-286-469
Aug 26, 2026
Merged

fix: serialize deferred-work ledger and sprint board writers cross-process (#286, #469)#726
pbean merged 12 commits into
mainfrom
pbean/ledger-lock-286-469

Conversation

@pbean

@pbean pbean commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every orchestrator mutation of the deferred-work ledger (deferred-work.md) and the sprint
board (sprint-status.yaml) was an unlocked read-modify-write of the whole file. Two
cross-process writers — a second bmad-loop run, a run plus a sweep, a run plus the TUI
decision modal, a run plus sweep --archive — both read, both edit, and the last atomic write
wins. The observable damage: ledger entries lost, two appenders minting the same DW-<n>
because each read next_seq from text it had just read, closures silently reverted, and board
flips vanishing — the last surfacing as spurious verify_dev retries ("sprint-status for X is
'ready-for-dev', expected 'review'").

The truncation half of this family (#379/#328) is already fixed: every writer is atomic today.
What was missing is mutual exclusion, batching, and interleave-safe restores.

Design

  • State-root sidecar locks. runs.lock_path_for keys an advisory lock at
    <state root>/locks/<sha256(resolved path)[:16]>-<basename>.lock. Deliberately not the
    <file>.lock sibling deferred-work.md: concurrent writers can lose entries, duplicate DW ids, and revert closures #286 proposes: the ledger and board are tracked by design and both
    verify.commit_story and verify.finalize_commit stage with git add -A, so a sidecar
    beside the data would be swept into the engine's own commits. Keyed on the resolved path,
    so every spelling of one file contends on one lock. Readers stay lock-free — atomic replace
    already hands a reader one whole version or another.
  • Batched primitives. append_entries, mark_open_many, record_decision and
    mark_done_many(notes=) each collapse a one-write-per-row (or per-half) sequence into a
    single locked read-modify-write, byte-identical to the serial sequence they replace. A pure
    applier stratum (_apply_done/_apply_open/_apply_decision/_apply_append, str in and
    str out) makes lock nesting structurally impossible: leaves acquire once and call only
    appliers; public wrappers delegate to exactly one leaf.
  • Entry-scoped story-close rollback. A failed commit used to rewrite the whole ledger from
    the pre-close text and call the collateral an accepted advisory trade-off. Story closes are
    now written with the durable undo markers sweep bundles have used since feat: auto-resolve deferred-work entries a story declares (closes #234) #284, and the
    rollback reopens exactly the ids that close armed — leaving a concurrent append, an unrelated
    close and a recorded human decision standing.
  • CAS/merge restores. The three remaining ledger-restore windows span git reset --hard
    and its preflight spawns, and one also spans an operator-blocking pause, so a lock must not
    cover them (deferred-work.md: concurrent writers can lose entries, duplicate DW ids, and revert closures #286's own acceptance criterion). Each is instead compare-and-set against the
    ledger as observed the instant the rollback returned: the defer restore merges a
    concurrent append back in, while the migration and pre-harvest restores degrade to a
    journaled skip
    (a retraction cannot be expressed as an append).
  • Board advance serialization. sprintstatus.advance holds the board's sidecar lock across
    all three of its reads and its write, which also closes the intra-call gap between the
    never-regress decision and the bytes it was applied to. A missing board is still reported
    without creating a lock file at all; the atomic, symlink-following,
    read-only-refusing write is unchanged.

Acceptance criteria (#286) → proof points

  • Two processes appending concurrently produce two entries with distinct ids —
    test_two_processes_append_concurrently_produce_distinct_ids
  • A close interleaved with an append preserves both —
    test_scripted_interleave_loses_no_update (rival commits in full between the call and
    the acquisition) and test_deferred_close_rollback_preserves_a_concurrent_append
  • A _defer restore interleaved with an append does not revert it —
    test_defer_restore_merges_a_concurrent_append; the pre-harvest twin is
    test_rejected_attempt_restore_skips_over_a_concurrent_append
  • A board advance interleaved with a rival advance does not lose it —
    test_a_racing_writers_flip_survives_a_concurrent_advance
  • No reader ever observes a truncated ledger — pre-existing property (every writer is
    atomic); the regression guard is the existing fault-injection battery
  • Locks are held only around file I/O, never across a subprocess, session or pause —
    test_every_mutator_holds_the_ledger_lock (exactly one acquisition per public entry
    point) and test_advance_holds_the_lock_across_every_read_and_the_write, plus a
    call-graph audit run for this PR: of the 11 lock-holding with blocks in
    src/bmad_loop, none transitively reaches a subprocess spawn. The audit is
    sensitivity-checked — it does report _ledger_is_gits_to_restore -> verify.path_tracked -> _run_git -> subprocess.run, which is exactly why that probe sits outside the lock.
  • Nested acquisition raises rather than self-deadlocking (file_lock is per-open-fd) —
    test_ledger_lock_is_not_reentrant
  • Exclusion asserted with blocking=False, never sleep-based —
    test_the_board_lock_excludes_a_second_acquirer,
    test_lock_acquisition_failure_raises_and_writes_nothing
  • A lock that cannot be taken fails the write rather than proceeding unlocked —
    test_advance_lock_failure_raises_oserror, test_sweep_archive_names_the_lock_on_failure
  • Windows CI covers the locked paths — the msvcrt branch runs on the windows legs of the
    PR matrix; no test sleeps or blocks

Verification

  • uv run pytest -q -n logical6946 passed, 49 skipped
  • uv run pyright0 errors, 0 warnings
  • trunk check --all --no-fix — clean
  • Three highest-value ablations re-run end-to-end for this PR, each red on exactly its
    expected row: hoisting _mark_done_many's read above its lock loses the rival append
    (KeyError: 'DW-4'); restoring the whole pre-close document loses the foreign entry
    (DW-2) while correctly leaving this story's own row undone; deleting the defer restore's
    CAS-and-merge loses the rival (KeyError: 'DW-2').

Scope

Closes #286
Closes #469

Non-goals, left open deliberately: the dev/review LLM session writes the ledger directly
without the lock (#286 names this a non-goal — orchestrator writes are sequenced against the
sessions they dispatch). Related: #715 (archive-move durability, out of scope), #686 (the board
carry's staging TOCTOU against a human editor, which no lock can cover, out of scope), #735 (the reset_owned restore anchor trusts a
post-reset observation, so a rival's tracked-ledger write inside that window can still be
overwritten — raised in review on this PR, verified, and tracked separately because every
remedy changes a design decision reviewed here), #736 (advance takes the board lock before
discovering a no-op, so an idempotent confirm replay can fail on contention — the remedy
would relax the pinned all-reads-under-the-lock ordering, so it is tracked separately).

Summary by CodeRabbit

  • New Features
    • Added safer concurrent updates for deferred-work ledgers and sprint boards.
    • Added batched ledger operations with sequential IDs and per-entry notes.
    • Added durable undo markers and protections against overwriting concurrent changes.
  • Bug Fixes
    • Improved recovery from interrupted archives, migrations, story closures, and deferred-work updates.
    • Added clearer error handling for lock and state-root failures while allowing decision workflows to continue.
  • Documentation
    • Documented locking behavior, atomic updates, contention handling, and recovery safeguards.

t added 8 commits August 25, 2026 21:20
…286, #469)

Every deferred-work ledger mutator was an unlocked read-modify-write of the
whole file, so two orchestrator processes both read, both edited, and the last
atomic write won — losing entries, reverting closures, and letting two
appenders mint the same DW-<n> from the same next_seq read.

Add runs.lock_path_for (a state-root sidecar keyed on the resolved path) and
deferredwork.ledger_lock (public, lazy runs import, thread-local reentrancy
guard that raises rather than self-deadlocking on flock's per-fd semantics),
and wrap the five leaf mutators: _mark_done_many, mark_open, append_decision,
append_entry (closing the next_seq mint race) and archive_closed (one
acquisition across both writes). Validation stays above the lock; readers stay
lock-free on atomic snapshots.

The lock lives out of the repository because the ledger is tracked by design
and verify.commit_story/finalize_commit stage with `git add -A`, so a sidecar
beside the file would ride into the engine's own commits.
…ves (#286, #469)

Collapse the remaining per-id loops and append+mark pairs in the sweep and the
out-of-band decision writers onto S2's batched primitives, so each is ONE locked
read->edit->write instead of one acquisition per id or per half:

- sweep._close_resolved -> mark_done_many(notes=), carrying the per-entry
  evidence the loop passed positionally
- sweep._reopen_ledger_after_defer -> mark_open_many
- sweep._apply_decision_effect and decisions.apply_pre_answer ->
  record_decision(close_note=), byte-identical to the append_decision +
  mark_done pair. apply_pre_answer's best-effort commit stays after the call,
  outside any lock (#286: locks never span a subprocess).

Error channels widen for the lock's two failure modes. `sweep --archive` names
the lock it could not take rather than surfacing a bare errno through main's
backstop; `bmad-loop decisions` and the TUI decision modal add
runs.StateRootError to their catch tuples -- it is not an OSError, and in the
TUI an uncaught one escapes into the Textual event loop and takes the dashboard
down mid-walk. The --archive pid-liveness gate stays and gains a comment saying
why: the lock beneath it makes the refusal belt-and-braces, but the gate is
deliberately coarser (no archive rewrite at all while a run is live).

7 new test cases, each with its ablation run and red on exactly the expected
rows. Suite 6919 -> 6926 passed / 49 skipped; pyright 0; trunk clean over 258
files.
…286)

The window between a story's declared `closes_deferred:` closure and its commit
spans git spawns and, on the escalation leg, a pause for a human, so another
writer can reach the same ledger inside it. The rollback rewrote the whole
document from the pre-close text and took whatever had arrived with it: a
concurrently filed entry vanished (its `DW-<n>` then handed out again), and a
concurrently verified closure silently reverted to `open`.

A story close now writes the operation-owned undo marker a sweep bundle close has
written since #284, and the rollback reopens exactly the armed entries through
`mark_open_many` in one locked read-modify-write. The arm is taken BEFORE the
write with the intended set, so a raise inside the close is still undoable, and
narrowed to the marked set afterwards; only that exact form treats a marker that
will not reopen as a foreign edit, journaling `deferred-close-reopen-unmatched`
and leaving the entry done rather than overwriting around the content that
displaced it. `deferred-close-rolled-back` now names the ids it reopened.

The isolation carry follows, under the same note and operation id, so a carried
row stays byte-identical to one the merge delivered. Ledger format: a story close
leaves a permanent `resolution-undo:` line in the committed ledger — the sweep
bundle close's format, one more writer using it.

The restore stays advisory and never raises out of the failure arm it runs
inside. Its seam moved, so two existing fault injections re-point from
`engine.atomic_write_text` and `deferredwork.mark_done_many` onto the primitives
now on the path.
… unlink (#286)

The rejected-attempt restore put the pre-harvest snapshot back across
`_rollback_or_pause`'s git spawns, so a lock cannot cover the window.
Compare-and-set stands in, against two anchors: a new persisted
`StoryTask.post_engine_ledger_digest` naming the bytes this engine last
published, and the post-rollback observation on a ledger git owns, whose
`reset --hard` republished it. Matching neither means the text belongs to
somebody else, and the restore journals `ledger-restore-skipped-diverged`
rather than writing. No merge fallback: a retraction cannot be expressed
as an append, and the skip is the conservative direction anyway.

The `snapshot is None` unlink takes the same digest gate, closing a latent
data loss — it deleted whatever sat at that path, so a ledger a concurrent
writer created inside the window went with the harvest. A tracked ledger
absent at snapshot time is still never deleted, and is now answered before
any lock is taken, since deleting is the only thing that arm could do.

The git-ownership probe stays outside the hold (it spawns git and journals
its own degrades); only pure text logic runs under the lock. The call
site's net gains `StateRootError` beside `OSError` so a lock that could not
be taken is handled like a write that could not land, and still preserves
an in-flight pause.
…onsolidate the entries (#286, #469)

FEATURES.md gains a concurrency bullet in each of the two places the
serialized files are described: the board's under `### Core orchestration
loop`, the ledger's under `### Deferred-work sweeps`, the latter also naming
the restore families' new journal kinds (the file has no journal-kind table —
kinds are named inline in the bullet describing the behavior that emits them).

AGENTS.md's sole-write-path invariant notes the serialization; it stays true
as written, `advance` is still the only writer.

CHANGELOG: the four phases' accumulated lines are consolidated — the batched
primitives move to `Added` as their own entry, the per-site inventory and the
twice-stated sidecar rationale come out, and the shared "these windows span a
`git reset --hard` so a lock must not cover them" mechanism is stated once.
@pbean

pbean commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds cross-process locking for deferred-work ledgers and sprint-board advances. It introduces batched ledger mutations, durable close undo markers, digest-based restoration guards, divergence handling, and expanded CLI, TUI, engine, and concurrency tests.

Changes

Concurrent state safety

Layer / File(s) Summary
Ledger locking and batch mutation primitives
src/bmad_loop/deferredwork.py, src/bmad_loop/runs.py, tests/test_deferredwork.py
Ledger mutations now use sidecar locks and batched read-modify-write operations. Appends allocate sequential IDs, validate batches before I/O, preserve serial output, and support combined decision-and-close updates. Archive writes use the same lock.
Engine ledger batching and rollback
src/bmad_loop/engine.py, src/bmad_loop/model.py, tests/test_engine.py, tests/test_engine_worktree.py
Engine harvest and carry paths use batch writers. Story-close rollback reopens only marked entries through undo markers. Ledger restoration uses persisted digests and compare-and-set checks to preserve divergent content.
Sprint board serialization
src/bmad_loop/sprintstatus.py, src/bmad_loop/runs.py, tests/test_sprintstatus_advance.py, AGENTS.md
sprintstatus.advance() holds a shared sidecar lock across board reads, updates, and atomic publication. Missing boards remain lock-free, and lock failures propagate.
Workflow integrations and failure handling
src/bmad_loop/cli.py, src/bmad_loop/decisions.py, src/bmad_loop/sweep.py, src/bmad_loop/tui/app.py, tests/test_cli.py, tests/test_decisions.py, tests/test_sweep.py, tests/test_tui_app.py, CHANGELOG.md, docs/FEATURES.md
Decision, sweep, archive, and TUI flows use the new atomic operations and report lock or state-root failures through existing error paths. Documentation covers locking, batching, rollback, and divergence behavior.

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

Merge Risk: 🟡 Moderate · up to 8768c

The PR serializes deferred-work and sprint-board writes, but an exception during ledger restoration can mask the original failure and skip cleanup; no-op batches may also fail during lock setup, and archive errors can be reported as lock contention. These are bounded but concrete merge-readiness issues requiring follow-up or explicit acceptance.

Suggested reviewers: dracic

Poem

A rabbit checks the ledger lock,
Then batches carrots in one block.
No lost IDs hop away,
Undo markers guard the day.
Boards advance in ordered light,
Divergent pages stay in sight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.47% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 144 functions across 17 files. (4 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation satisfies the linked objectives. It adds cross-process locking, atomic and batched ledger mutations, distinct sequential IDs, concurrent-update protection, entry-scoped rollback, co…
Out of Scope Changes check ✅ Passed The changes remain within scope. Production updates, documentation, and tests support ledger serialization, sprint board serialization, rollback safety, error handling, and concurrency coverage requir…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: cross-process serialization for deferred-work ledger and sprint board writers.
Full details: Linked Issues check

Explanation

The implementation satisfies the linked objectives. It adds cross-process locking, atomic and batched ledger mutations, distinct sequential IDs, concurrent-update protection, entry-scoped rollback, compare-and-set restore handling, and serialized sprint board advancement. The tests cover concurrent appends, closures, restores, lock failures, symlink identity, and board updates [#286] [#469].

Full details: Out of Scope Changes check

Explanation

The changes remain within scope. Production updates, documentation, and tests support ledger serialization, sprint board serialization, rollback safety, error handling, and concurrency coverage required by issues #286 and #469. No unrelated functional changes are evident.

Full details: Docstring Coverage

Explanation

Docstring coverage is 78.47% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 144 functions across 17 files. (4 skipped: 3 unsupported, 1 too large.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pbean/ledger-lock-286-469

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7ff9822f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/engine.py Outdated
# re-runs the harvest, which either writes again (refreshing this)
# or dedupes to no write at all, leaving the dead attempt's bytes
# exactly as this digest recorded them.
task.post_engine_ledger_digest = self._ledger_digest()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Capture the harvest digest while holding the ledger lock

When another ledger mutator writes after append_entries() releases its lock but before this unlocked read, its bytes are included in post_engine_ledger_digest. If the dev attempt is subsequently rejected, _restore_ledger() treats that combined state as engine-owned and restores the pre-harvest snapshot, silently deleting the rival mutation—the exact concurrent-writer loss this change is intended to prevent. Have the locked append return the digest/content it actually published rather than reading it back after releasing the lock.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 7ab473bd.

The anchor was read back after append_entries had already released its hold, so a mutator landing in that window was folded into a digest whose whole job is to say "these bytes are ours" — and the rejected-attempt restore would then retract it as our own harvest. Narrowing the window was not worth doing when it can be removed: the batch writer now returns the text it published (append_entries_published), captured inside the lock, and the harvest sets post_engine_ledger_digest from that instead of from a read-back. append_entries stays as the thin wrapper for callers that only want the ids and takes no lock of its own, so the one-acquisition-per-entry-point invariant is unchanged.

Digest equivalence holds across the change: atomic_write_text translates newlines on the way out and read_text normalizes them back, so a digest of the returned text equals a digest of a later read of the file.

Pinned by test_harvest_anchor_names_what_was_published_not_a_later_rival, with a rival appending the instant the writer releases. Ablated to confirm it bites — restoring the read-back anchor reddens the digest row. Re-pointing the harvest also stranded the two crash-injection seams that patch this symbol; both were moved onto append_entries_published, since left on the wrapper they would have injected nothing and failed on their assertions rather than on the crash they stage.

Comment on lines +337 to +338
with _board_lock(path):
return _advance_locked(path, story_key, target, now=now)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid creating locks for throwaway board copies

When advanced_bytes() calls advance() on its randomly named temporary copy, this acquisition creates a state-root sidecar keyed by that unique temporary path. file_lock() never removes sidecars, while TemporaryDirectory only removes the shadow itself, so every ownership computation permanently adds another unusable lock file under <state root>/locks. Repeated isolated-run recovery checks therefore grow that directory without bound; the private shadow can invoke the already-split _advance_locked() implementation directly because no other process can access it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 7ab473bd.

The leak is real: file_lock never reaps a sidecar, and the TemporaryDirectory removes only the shadow, so every ownership computation stranded another dead lock file under <state root>/locks keyed on a path that no longer exists. Taking your suggested route — advanced_bytes now calls _advance_locked directly. The shadow is this function private copy inside a TemporaryDirectory that no other process can name, so there is no second writer to exclude and the acquisition was pure cost.

advances docstring claimed the opposite ("advanced_bytes goes through here against a throwaway copy"), so that paragraph and advanced_bytes own were corrected rather than left to contradict the code.

The existing test asserted the weaker property — that the shadow sidecar was merely disjoint from the boards — and now asserts the stronger one: no acquisition at all, and the state-root locks directory unchanged across the call. Ablated to confirm it bites: routing back through advance reddens it on a stranded sidecar.

t added 2 commits August 25, 2026 23:22
…en with (#286, #469)

`test_advanced_bytes_never_touches_the_real_boards_lock` asserted the real board
still held `SPRINT.encode("utf-8")`. The fixture writes it with `write_text` in
text mode, so on Windows the newlines land as CRLF while the literal is LF, and
the assertion failed on both windows legs for a reason with nothing to do with
the lock it grades.

Snapshot the bytes as they actually landed and compare against that: it is the
same "untouched" claim, line-ending agnostic. The test still bites — under its
documented ablation (`lock_path_for` ignoring its argument) it reds on the
sidecar-disjointness row as before.
…ound (#286, #469)

**The harvest anchor was captured after the lock was released.** `append_entries`
returns once its hold is over, so reading the ledger back to set
`post_engine_ledger_digest` could fold a concurrent mutator's bytes into an
anchor whose entire job is to say "these bytes are ours" — and on a rejected
attempt `_restore_ledger` would then retract the rival's entry as if it were our
own harvest. That is the loss this branch exists to prevent, reintroduced by the
anchor itself. The batch writer now hands back the text it published
(`append_entries_published`), so the anchor comes from inside the hold; the
window is removed rather than narrowed. `append_entries` stays as the thin
wrapper for the callers that only want the ids, and takes no lock of its own.

**The ownership shadow minted a lock file per call.** `advanced_bytes` recomputes
an advance against a private throwaway copy; routing that through `advance` took
a lock keyed on the temporary path. Lock files are never reaped and the
TemporaryDirectory removes only the shadow, so every ownership check stranded
another dead sidecar under the state root, without bound. It calls the locked
body directly now — there is no second writer to exclude, the copy being one
nobody else can name.

Both re-pointed the two harvest crash-injection seams, which patch the symbol the
harvest actually calls; left on `append_entries` they would have injected nothing
and failed on their assertions instead of on the crash they stage.
@pbean

pbean commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bmad_loop/engine.py (1)

2289-2312: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Widen the restore guard to include RuntimeError.

_restore_persisted_ledger reaches deferredwork.ledger_lock, which calls runs.lock_path_for and Path.resolve(). On supported Python 3.11 and 3.12, a symlink loop can raise RuntimeError.

If this occurs while RunPaused or a reset fault is unwinding, the RuntimeError replaces the original exception and skips _disarm_ledger_snapshot() and _save(). Catch RuntimeError with the existing restore errors.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@src/bmad_loop/engine.py` around lines 2289 - 2312, Update the restore
exception handler around _restore_persisted_ledger to also catch RuntimeError
alongside OSError and StateRootError, assigning it to restore_error so the
original RunPaused or reset exception remains primary and subsequent stale-arm
cleanup and _save() still execute.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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 2327-2342: Update the exception handling around archive_closed so
non-lock OSError failures are not reported as likely competing processes. Split
the OSError and runs.StateRootError handling, or use wording that covers lock,
ledger read, archive write, and atomic replace failures without asserting lock
contention, while preserving ExitCode.FAILURE.

Apply the same fix in `@src/bmad_loop/sweep.py` around lines 1190 - 1204: The
empty-batch lock acquisition regression is covered explicitly.

---

Outside diff comments:
In `@src/bmad_loop/engine.py`:
- Around line 2289-2312: Update the restore exception handler around
_restore_persisted_ledger to also catch RuntimeError alongside OSError and
StateRootError, assigning it to restore_error so the original RunPaused or reset
exception remains primary and subsequent stale-arm cleanup and _save() still
execute.
🪄 Autofix

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: 8828d260-7a5a-4f17-97b4-ee3425425362

📥 Commits

Reviewing files that changed from the base of the PR and between a4ca93f and 8768cb5.

📒 Files selected for processing (21)
  • AGENTS.md
  • CHANGELOG.md
  • docs/FEATURES.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/decisions.py
  • src/bmad_loop/deferredwork.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/model.py
  • src/bmad_loop/runs.py
  • src/bmad_loop/sprintstatus.py
  • src/bmad_loop/sweep.py
  • src/bmad_loop/tui/app.py
  • tests/test_cli.py
  • tests/test_decisions.py
  • tests/test_deferredwork.py
  • tests/test_engine.py
  • tests/test_engine_worktree.py
  • tests/test_runs.py
  • tests/test_sprintstatus_advance.py
  • tests/test_sweep.py
  • tests/test_tui_app.py

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.

Comment thread src/bmad_loop/cli.py

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ab473bd29

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/engine.py
if current == snapshot:
return
ours = _digest_of(current) == task.post_engine_ledger_digest
reset_owned = current == observed and gits

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not treat a post-reset observation as reset-owned

When another process updates a tracked ledger after _rollback_or_pause() finishes its reset but before the unlocked observed read, that foreign state later satisfies current == observed and gits. This branch therefore labels the concurrent write reset_owned and overwrites it with snapshot, silently deleting the other process's entries; reading immediately after the reset only narrows this cross-process window. The restore needs an expected state derived independently of the post-reset read, or it must treat this state as divergent and fail loudly.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified as CORRECT, and deliberately not fixed in this PR. Flagging it for the maintainer rather than acting on it, with reasoning.

The claim holds. observed is read immediately after _rollback_or_pause returns, but "immediately" is still a window: a rival writing a tracked ledger between git reset --hard completing and that read makes observed the rival's text, so current == observed and gits is later satisfied and the snapshot is written over their entries. Reading sooner narrows it and cannot close it. The plan this branch implements chose the post-reset observation as the second CAS anchor and characterised the window as file-I/O-only; your point is that a narrow window is still a window, and that is fair.

Why it is not being fixed here. The two remedies you name are both design changes rather than repairs: deriving the expected state independently means reading the committed blob through git, adding a spawn and its own failure modes to a path that currently spawns nothing under the lock; and treating the state as divergent instead would disable the restore for tracked ledgers, which is its principal case. Either is a redesign of a reviewed design decision, arriving in the finalization phase of an eight-phase program. This repo's AGENTS.md is explicit that review non-convergence is evidence about the approach rather than a queue to grind down, and that the response is to escalate — so I am escalating instead of redesigning unprompted.

Worth stating plainly for whoever picks this up: the branch is still a large net improvement on this exact path. What it replaces is an unconditional overwrite that retracted whatever it found, with no anchor at all; what remains is a strictly narrower race. So this is a residual, not a regression.

I have not opened a follow-up issue, because that is the maintainer's call and filing one is not mine to make unprompted. If it should be tracked the way #715 and #686 already are, say so and it will be filed and cross-linked without a closing keyword. This thread is deliberately left unresolved so it is not lost.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and accepted as correct — this is not a false positive. Tracked as #735 rather
than fixed here, on a maintainer decision.

The reasoning: every remedy you name changes a design decision this PR reviewed and pinned,
rather than repairing a slip. Deriving the expected state from git adds a subprocess spawn
(and its failure modes) inside a restore path, and ledger_lock deliberately covers file
I/O only, so that probe would have to sit outside the lock. Failing loudly instead gives up
the restore in the cases where it would have been correct. Both are defensible; neither is
a repair, and this is the finalization phase of an eight-phase program.

What makes it deferrable rather than blocking: at this site the pre-PR behaviour was an
unconditional overwrite with no anchor at all. The guarded version is strictly narrower —
a rival now has to land inside the post-reset window rather than anywhere in the restore. So
the change is a large net improvement here, and #735 tracks the part it does not close,
carrying your two candidate remedies and the constraints a fix has to respect.

Filed alongside #715 and #686, which are handled the same way in the PR body's scope section.

Comment thread src/bmad_loop/engine.py Outdated
Comment on lines +6350 to +6351
present = {entry.id for entry in deferredwork.parse_ledger(current)}
missing = [e for e in deferredwork.parse_ledger(snapshot) if e.id not in present]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect conflicting IDs while merging the ledger snapshot

When reset removes a snapshot entry such as DW-5 and a concurrent writer then mints a different DW-5 before the restore acquires the lock, this ID-only membership test treats the lost snapshot entry as present. _merge_snapshot_entries() consequently writes nothing and reports no remainder, permanently dropping the original entry that this repair exists to preserve. Compare the entry bodies as well and surface an ID collision instead of silently accepting it.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 148cbd6c.

This one is worse than the summary suggests, and the scenario is not exotic: next_seq mints against the text it reads, so after the reset removed our uncommitted DW-5 the ledger ends at DW-4 and a rival appending into that window mints DW-5 by the ordinary path. The id-only membership test then classified our entry as already-present, dropped it, and defer-ledger-restore-diverged reported dw_ids: [] — a silent loss under a journal line claiming the repair had done its job, which is precisely the failure mode the 3-tuple return was introduced to avoid.

Entries are now matched by id AND body. Re-appending was rejected as the remedy for the reason you would expect: it publishes a duplicate DW-<n>, which both next_seq and the sweep's duplicate-id refusal treat as corruption. So a same-id-different-body pair is reported as id_collisions on the existing journal line and left alone — the same call the flat remainder already makes, telling a human rather than guessing at a boundary.

Two tests, deliberately paired: test_merge_reports_an_id_collision_instead_of_dropping_the_entry, and test_merge_still_carries_an_entry_whose_id_is_simply_absent beside it, because keying on (id, body) pairs could otherwise report every ordinary re-append as a collision and still pass the first. Ablated: reverting to the id-only test reddens the collision row and leaves the ordinary-merge row green, which is the split that shows each grades its own thing.

…ming the lock for disk errors (#286, #469)

**An empty batch acquired a lock the loop it replaced never took.** The per-id
loops did nothing when handed an empty set, because there was no call to make;
`mark_done_many` and `mark_open_many` acquired anyway, so a no-op could newly
fail on an `OSError` from the acquisition or a `StateRootError` from deriving a
sidecar path in an environment naming no state root. The sweep reaches both
routinely — a triage plan with nothing already-resolved, a discarded bundle with
no closes to undo. Both now return early, below the validation (so an empty batch
still reports a bad date or operation id) and above the lock. `append_entries`
already did this; its early return was simply never graded, and now all three
rows are.

**A full disk was reported as lock contention.** `--archive`'s single
`except (OSError, runs.StateRootError)` arm covers three causes, not the two its
comment claimed: the acquisition, deriving the sidecar path, and the archive's
own I/O — the ledger read and both atomic writes. Asserting "another bmad-loop
process may hold it" sent an operator hunting a rival that never existed. The
message now names the lock as a possibility rather than a verdict and lets the
carried cause decide between them.
@pbean

pbean commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b82d998360

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

sep = ""
elif existing.endswith("\n"):
sep = "\n"
with ledger_lock(path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip the lock when there is no ledger

When the ledger is absent and the state root is unavailable or unwritable, this unconditional acquisition raises before the following existence check, so bmad-loop sweep --archive now exits with failure instead of its established successful “no deferred-work ledger” result. Validate the date first as today, but check for a missing file before acquiring (and recheck under the lock), as sprintstatus.advance() already does for the same no-write case.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 148cbd6c.

You are right that this is a released-behavior change, which is what makes it worth fixing rather than waiving: bmad-loop sweep --archive against a project with no ledger prints "no deferred-work ledger at ..." and exits OK, and the unconditional acquisition turned that into a FAILURE wherever no state root can be derived — a lock taken for a file that is not there, changing the answer for the one case that was never going to write anything.

Fixed exactly as suggested: the dates are still validated first, the is_file check moves above the acquisition, and the recheck under the hold stays, since deletion can race the pre-lock answer. That is the order sprintstatus.advance already keeps for its own missing-board case, so the two now agree rather than each having its own rule.

Pinned by test_archive_closed_takes_no_lock_for_a_missing_ledger, which counts acquisitions rather than asserting on the return value — the empty list comes back either way, so the return is a vacuous oracle here. Ablated: moving the guard back under the lock reddens it.

…ip the lock for a missing ledger (#286, #469)

**The snapshot merge matched entries by id alone.** `git reset --hard` can remove
an uncommitted `DW-5` and leave the ledger ending at `DW-4`, so a rival appending
into the restore's window mints `DW-5` for an entry of its own — `next_seq` reads
the shortened text. Keyed by id, the merge then read OUR lost entry as already
present, dropped it, and reported nothing moved: a silent loss of exactly what
the merge exists to carry, under a journal line claiming it had done its job.
Re-appending is no better, since that publishes a duplicate id the writer and the
sweep both treat as corruption. Entries are now matched by id AND body, and a
same-id-different-body pair is reported as `id_collisions` — the same call the
flat remainder already makes: tell a human rather than guess.

**`archive_closed` took a lock for a ledger that was not there.** The acquisition
sat above the existence check, so a project with no ledger — which
`bmad-loop sweep --archive` reports as SUCCESS — failed instead wherever no state
root can be derived. That is a released behavior changed by a lock taken for a
missing file. The check moves above the acquisition, keeping the recheck under
the hold, which is the order `sprintstatus.advance` already keeps for its own
missing-board case.
@pbean

pbean commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 148cbd6c95

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +337 to +338
with _board_lock(path):
return _advance_locked(path, story_key, target, now=now)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bypass the lock for advances that are already no-ops

When an existing board already has the story at or beyond target (notably an idempotent bmad-loop confirm replay), this acquires the sidecar before _advance_locked discovers that no write is needed. An unavailable state root, unwritable locks directory, or Windows lock timeout therefore turns a previously successful read-only return into a command failure. Probe for the already-at-target or absent-story cases before acquiring, then retain the current locked recheck for calls that may write.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Assessed, and not fixed here — this one needs a maintainer decision, because the remedy contradicts a deliberately pinned invariant of this PR.

The defect is real and I agree with the class: it is the third instance of "a lock taken for an operation that will not write turns a previously-successful no-op into a failure". I fixed the other two on exactly that reasoning (archive_closed over a missing ledger, and empty mark_done_many/mark_open_many batches), so consistency would argue for fixing this too. An idempotent bmad-loop confirm replay against a story already at or beyond target does now acquire before _advance_locked discovers there is nothing to write.

Where it differs. Your suggested remedy — probe for the already-at-target and absent-story cases before acquiring — puts a board read above the lock. story_status goes through load, and test_advance_holds_the_lock_across_every_read_and_the_write asserts events[0] == "lock-enter" with events[1:-1] == ["load", "load", "write"]. That assertion is not incidental: it encodes this PR's design decision that all three reads move under the hold, which is what closes the intra-call TOCTOU between the never-regress decision and the bytes that decision is applied to. A pre-lock probe reopens a read outside the lock and reddens that test by construction.

So the choice is a genuine trade-off rather than a repair: keep every read under the hold and accept that a no-op advance can fail when the lock is unavailable, or admit an advisory pre-lock probe and relax the pinned ordering to "the reads that decide the published bytes are inside". The second is defensible — the probe would be an early-out only, with the authoritative decision still under the lock — but it changes a reviewed decision, and this is the finalization phase of an eight-phase program.

Per this repo's AGENTS.md ("review non-convergence is evidence about the approach, not just a defect queue — escalate rather than grind"), I am grouping this with the still-open reset_owned P1 on discussion_r3860227492 for the maintainer to settle, rather than quietly rewriting an invariant a test exists to protect. Left unresolved on purpose. CI is green 10/10 on 148cbd6c.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Settled by the maintainer: tracked as #736 rather than fixed here.

The assessment above stands — the defect is real, and it is the third instance of the class
"a lock taken for work that will not write turns a successful no-op into a failure", the
other two of which were fixed in this PR. What keeps it out of #726 is that the remedy puts
a board read above the lock and so reddens
test_advance_holds_the_lock_across_every_read_and_the_write, which exists to pin this PR's
decision that all three reads sit under the hold. Relaxing that to "the reads that decide
the published bytes are inside" is defensible, but it is a change to a reviewed decision
rather than a repair.

#736 carries the trade-off in full, along with the note that a fix should sweep the class
rather than patch this one site — the same shape turned up at two sites across consecutive
review rounds.

@pbean
pbean merged commit c308ae5 into main Aug 26, 2026
11 checks passed
pbean pushed a commit that referenced this pull request Aug 26, 2026
…ger lock (#736)

Third instance of the class #726 closed twice here already: a lock taken for an
operation that will not write turns a previously successful no-op into a
failure. The acquisition can raise `OSError`, and deriving the sidecar path
raises `runs.StateRootError` wherever no state root is nameable — so a replayed
rollback, a re-run defer and `sweep --archive` over a ledger holding nothing
closed could all fail at a lock they had no write to serialize.

Two guards and five advisory probes, all above the lock and below every
validation:

- Missing-ledger `is_file` guards on `_mark_done_many`, `mark_open_many` and
  `record_decision`, the pattern `archive_closed` already kept. The rechecks
  under the hold stay — creation can race the answer.
  `append_entries_published` deliberately gets none: an absent ledger there
  means CREATE, which is a write.
- One advisory pre-lock read per mutator, running the same pure decision helper
  the locked pass runs. Only a "would write nothing" answer is acted on, and
  such a call linearizes at the probe read — it publishes no bytes. Every other
  answer, and any fault during the probe, falls through to the hold, which
  re-reads and decides authoritatively. `archive_closed`'s probe sits above the
  `dry_run` branch, so a nothing-eligible dry run skips the lock too; an
  eligible one still runs under the hold, where the one code path is.

Tests: NOOP_MUTATORS, the deliberate inverse of LOCKED_MUTATORS, drives
`test_a_read_dependent_noop_takes_no_lock` and
`test_a_noop_mutation_succeeds_when_no_state_root_is_derivable` over all ten
public entry points, plus `test_mutators_take_no_lock_for_a_missing_ledger`.
`test_a_failing_probe_read_falls_through_to_the_locked_path` faults only the
probe read, which is what keeps the under-lock no-write guards ablation-provable
now that the probe answers those same inputs first — the two rewritten tests say
so and point at it. 18 ablations run singly; every one reddened its intended
oracle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant