Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,30 @@ decisions` and the TUI decision modal now also catch the state-root failure that
created inside the window went with it. A tracked ledger absent at snapshot time is
still never deleted, and is now answered before any lock is taken. A write or lock fault
is journaled as `ledger-restore-failed` and preserves an in-flight pause, as before.
- **A read-dependent no-op is answered before its lock is taken** (#736). Taking a lock for
work that turns out to write nothing could fail a call that used to succeed — a replayed
`bmad-loop confirm` against a story the board already records as `done`, a replayed
rollback, `sweep --archive` over a ledger holding nothing closed — either on contention or
on deriving a sidecar path where no state root exists. `sprintstatus.advance` and the five
read-dependent `deferred-work.md` mutators now take one advisory read first, running the
same pure decision helper the locked pass runs; only a writes-nothing answer skips the lock,
and every other answer, plus any probe fault, falls through to the hold and decides there.
`sweep --archive` with nothing eligible now exits 0 rather than 1; an eligible archive under
a dead lock still fails as before. `append_entries_published` deliberately still locks on a
missing ledger, where absence means create.
- **A ledger restore that spans `git reset --hard` anchors on the committed baseline blob**
(#735). The three restores no lock may cover — the rejected attempt's retraction, a rolled
back defer's, and the sweep's failed-migration rewrite — compared the ledger against an
observation read after the reset returned. A rival writing a tracked ledger inside that
window became the observation, so the compare held and the restore overwrote its entries.
Each write arm now takes its expected text from the ledger's blob at the baseline commit,
probed before the lock, since a lock may never span a subprocess. Where the reset
republishes no text at all — an untracked ledger, one configured outside the repo tree, or
one symlinked into it, whose blob is a target pathname rather than ledger text — the anchor
is the rewrite the attempt actually graded. Where no anchor can be derived, the retraction
skips, the defer restore merges what disk has lost, and the sweep escalates; those
doubly-uncertain cases previously still wrote. A rival whose text is byte-equal to the
anchor stays indistinguishable from the reset's own work.

### Security

Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Automated per-story pipeline: `dev → verify → review → verify → commit`, end-to-end, no human in the loop.
- Deterministic control flow in plain Python — story selection, retry budgets, gate checks, and completion checks are code, not an LLM session.
- Owns `sprint-status.yaml`, the single source of truth: `bmad-sprint-planning` generates it, and while a run is in flight the orchestrator is its sole writer (`sprintstatus.advance` — idempotent, never-regress) while the dev and review sessions it dispatches are told never to write or revert it; your own BMAD skill runs still edit the board outside a run. Selects the next `ready-for-dev` story; advances by epic/story.
- Two orchestrator processes can no longer interleave a board advance (#286/#469): `sprintstatus.advance` holds an advisory cross-process lock across all three of its reads and its write, so the never-regress decision and the bytes that decision is applied to can no longer be separated by somebody else's write. Being the board's sole write path was never mutual exclusion on its own — a second `bmad-loop` process runs that same sole writer. Readers stay lock-free, a board that does not exist is still reported missing without a lock being created at all, and a lock that cannot be taken fails the advance on the channel that already carries its errors rather than rewriting the board unserialized. Same sidecar mechanism as the deferred-work ledger, described under _Deferred-work sweeps_.
- Two orchestrator processes can no longer interleave a board advance (#286/#469): `sprintstatus.advance` holds an advisory cross-process lock across every read that decides the **published bytes** and across the write itself, so the never-regress decision and the bytes that decision is applied to can no longer be separated by somebody else's write. Being the board's sole write path was never mutual exclusion on its own — a second `bmad-loop` process runs that same sole writer. Readers stay lock-free, a board that does not exist is still reported missing without a lock being created at all, and a lock that cannot be taken fails the advance on the channel that already carries its errors rather than rewriting the board unserialized. That scope is deliberately narrower than every read the call makes: an advance that would write nothing — an absent row, or a row already at or past target — is answered from one advisory pre-lock read and takes no lock at all (#736), so an idempotent replay such as `bmad-loop confirm` against a story the board already records as done cannot fail on contention, or on a state root it never needed. The probe runs the same never-regress predicate the locked body applies, so it cannot answer "no write" where the writer would write; every other answer, and any fault while probing, falls through to the hold, which re-reads and decides authoritatively. Same sidecar mechanism as the deferred-work ledger, described under _Deferred-work sweeps_.
- Scoping flags: `--epic N`, `--story KEY`, `--max-stories N`, `--dry-run` (prints the plan, spawns nothing).

### Spec + implementation (dev stage)
Expand Down Expand Up @@ -136,7 +136,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Repeat mode (`--repeat` / `[sweep] repeat`): re-triages after each cycle to absorb newly generated deferred work, stopping when a cycle does nothing addressable or hits `max_cycles`.
- Ledger archiving (`sweep --archive`, optionally `--before DATE` to bound by close date; `--dry-run` previews): moves closed (`status: done <ISO date>`) entries to sibling `deferred-work-archive.md` (body preserved, an `archived: <date>` marker appended), leaving an id-preserving stub (`status: done <date>` + `archived: <date>`) so grep and `closes_deferred` cross-references keep resolving and the live ledger stays proportional to open work rather than all history. The stub keeps load-bearing field lines — `gate:` (validate's closed-gate report keeps speaking), `origin:`/`source_spec:` (the harvest-replay dedupe stays status-agnostic), and the reopenable-close undo tail (a paused sweep's bundle rollback still works). Reopening an archived stub — what that rollback does — demotes its `archived:` stamp to `archived-body:` rather than dropping it: the entry is live work again, so the stamp would be a lie and its shape would strand the entry outside every future archive, but the body its close moved out is still in the archive file and that line is what a later triage follows to it. Deterministic, no LLM, atomic writes with crash-safe ordering (archive before ledger; a retry keyed on id + close date completes the move without duplicate bodies). Refuses while any engine run is live or its liveness is unverifiable — it is the one out-of-band ledger writer. When the ledger is tracked, the move is durable only once both files are committed; a gitignored ledger — the default shape — or an artifact dir outside the repo has nothing to commit. Unrelated to `bmad-loop archive <run-id>` (run-tarball archiving).
- Sweeps are their own resumable runs (`bmad-loop resume <id>`). An escalated bundle resolves like a story escalation, including intent-gap patch-restore: `bmad-loop resolve <id> --restore-patch <path>` re-arms the bundle spec to `in-review` and the re-driven bundle session resumes review on the re-applied patch instead of re-implementing.
- Ledger writes serialize across processes (#286/#469). Every orchestrator mutation of `deferred-work.md` — an append, a close, a reopen, a recorded decision, `sweep --archive`'s two-file rewrite — holds an advisory lock for its whole read-modify-write, so a second `bmad-loop run`, a run plus a sweep, or a run plus the TUI decision modal can no longer both read, both edit, and let the last atomic write win (lost entries, silently reverted closures, two appenders minting the same `DW-<n>`); multi-row work is batched into one locked pass rather than one per row. The lock is a sidecar under the state root (`<state root>/locks/<digest>-<basename>.lock`), never beside the ledger, because the ledger is tracked by design and the engine stages with `git add -A`; it is keyed on the resolved path, so every spelling of one file contends on one lock while two worktrees' in-tree ledgers correctly get their own. Readers stay lock-free — every writer already replaces the file atomically, so a reader sees one whole version or another. The wait is platform-asymmetric: POSIX blocks, while Windows bounds it at roughly ten seconds and then surfaces contention as an error rather than proceeding unlocked. A dev or review session's own ledger writes are deliberately outside this — the orchestrator sequences its writes against the sessions it dispatches. The rollback paths that span a `git reset --hard` cannot be covered by a lock at all, so each is instead compare-and-set against the ledger as observed the instant the rollback returned, degrading to a journaled `defer-ledger-restore-diverged`, `ledger-restore-skipped-diverged` or `sweep-migration-restore-diverged` rather than writing over a concurrent writer; and a failed commit reopens exactly the entries the story itself closed, journaling `deferred-close-rolled-back` with their ids and `deferred-close-reopen-unmatched` for an entry whose undo marker a foreign edit has displaced.
- Ledger writes serialize across processes (#286/#469). Every orchestrator mutation of `deferred-work.md` — an append, a close, a reopen, a recorded decision, `sweep --archive`'s two-file rewrite — holds an advisory lock for its whole read-modify-write, so a second `bmad-loop run`, a run plus a sweep, or a run plus the TUI decision modal can no longer both read, both edit, and let the last atomic write win (lost entries, silently reverted closures, two appenders minting the same `DW-<n>`); multi-row work is batched into one locked pass rather than one per row. The lock is a sidecar under the state root (`<state root>/locks/<digest>-<basename>.lock`), never beside the ledger, because the ledger is tracked by design and the engine stages with `git add -A`; it is keyed on the resolved path, so every spelling of one file contends on one lock while two worktrees' in-tree ledgers correctly get their own. Readers stay lock-free — every writer already replaces the file atomically, so a reader sees one whole version or another. The wait is platform-asymmetric: POSIX blocks, while Windows bounds it at roughly ten seconds and then surfaces contention as an error rather than proceeding unlocked. A dev or review session's own ledger writes are deliberately outside this — the orchestrator sequences its writes against the sessions it dispatches. Here too the hold covers every read that decides the **published bytes** rather than every read: a mutator whose work turns out to be a no-op — ids already done, a decision on an entry that is not there, specs that all dedupe, nothing eligible to archive, or no ledger at all — is answered from one advisory pre-lock read running the same pure decision helper the locked pass runs, and takes no lock (#736), so a replayed rollback or a `sweep --archive` over a ledger holding nothing closed cannot fail on a lock it had no write to serialize. Only a would-write-nothing answer is acted on; anything else, and any fault while probing, falls through to the hold. `append_entries_published` deliberately keeps no missing-ledger answer — an absent ledger there means create, which is a write. The rollback paths that span a `git reset --hard` cannot be covered by a lock at all, so each is instead compare-and-set — and the **write** is anchored on the ledger's committed blob at the run's baseline commit, the text that reset republished, read out of git before the lock rather than off a working tree a rival could have written inside the reset window (#735); a ledger the reset republishes no text for — untracked, configured outside the repo tree, or symlinked into it, where the committed blob is a target pathname rather than ledger text — has no baseline text to anchor on, so the sweep anchors instead on the rejected rewrite the attempt itself graded, while the engine's two restores decline to read a missing file there as the reset's own work. A post-reset observation still authorizes the **skips** — declining to act is safe whoever wrote those bytes — and never a write. Divergence, and an anchor no probe could derive — an unreadable blob or a failed path resolution, both journaled `ledger-baseline-probe-failed`, or a run with no baseline commit, which stands down silently rather than filing a row an operator would have to triage — degrade each site in its own direction rather than writing over a concurrent writer: `defer-ledger-restore-diverged` merges by appending the entries disk has since lost, `ledger-restore-skipped-diverged` skips, and `sweep-migration-restore-diverged` escalates for a human to re-run the sweep. And a failed commit reopens exactly the entries the story itself closed, journaling `deferred-close-rolled-back` with their ids and `deferred-close-reopen-unmatched` for an entry whose undo marker a foreign edit has displaced.

### Stories mode (folder+id dispatch)

Expand Down
Loading