Skip to content

fix: degrade spec read faults instead of crashing the run#113

Merged
pbean merged 3 commits into
mainfrom
fix/spec-read-oserror-gates-97
Jul 10, 2026
Merged

fix: degrade spec read faults instead of crashing the run#113
pbean merged 3 commits into
mainfrom
fix/spec-read-oserror-gates-97

Conversation

@pbean

@pbean pbean commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #97

Problem

Nine spec read-back sites raced the dev skill's own writes. A transient OSError at any of them — a TOCTOU truncation while bmad-dev-auto rewrites the spec, a lock, an EACCES — escaped to engine.run()'s except Exception, wrote crash.txt, and abandoned every remaining story. All nine are observation reads, so the crash was never proportionate to the fault.

The doctrine already exists in the codebase, stated at devcontract._read_text_or_empty: observation degrades, repair raises. These sites just never followed it.

Change (9 sites → 3 chokepoints + 1 deletion)

Sites Policy
The four verify gates (verify.py) new verify._gate_frontmatterVerifyOutcome.retry("spec unreadable (…)")
The adjacent deferred-work ledger read (verify_review_bundle) inline except OSError → retry (same TOCTOU class — the orchestrator's own mark_done rewrites it between the dev and review gates)
reconcile, reconcile-prose, post-dev-sync, bundle-ledger-close new Engine._observed_frontmatter → journal new event spec-read-failed (story_key, spec, site, error) + skip the bookkeeping pass
the followup re-read after reset_spec_status deleted
devcontract.synthesize_result (Stop read-back) inline except OSErrorSynthResult(None, True) (not-terminal)

Two details worth review:

  • The verify-gate reason is deliberately distinct. Degrading to {} would read as status "" and let a read fault masquerade as "the skill forgot to set the status" — sending a repair session after a bug that is not there. The reason names the fault instead.
  • Skipping a bookkeeping pass is safe because everything a skipped pass would have derived is re-supplied later: the spec status by the deterministic verify gate's own re-read, and the review-routing flag by _followup_from_spec at consumption (see the final-review section below — the original "always safe via the verify gate" claim had one hole). Repair writes (reset_spec_status, set_frontmatter_status, mark_done) still raise, and read_frontmatter's own contract is unchanged — now pinned by test_read_frontmatter_oserror_still_raises, since widening its except would erase the observe/repair distinction everywhere at once.

The deletion: reset_spec_status rewrites only the frontmatter status line, so the re-read after it could only ever return the followup_review_recommended the first read already carried — at the cost of a second racy read that can now fail. Folded from the existing fm; test_reconcile_folds_followup_without_reread counts the reads.

Retry, not escalate

VerifyOutcome.escalate at the verify gates would be dead code: the review-loop consumers ignore severity. The GitError → escalate precedent beside it is about broken verify machinery, not a read with a designed transient producer.

Retry is not silent. Reasons journal via dev-decision / review-verify-failed; escalation.decide_dev / decide_review_session bound a persistent fault into DEFER (or PAUSE when resolved_redrive is latched); and under the default scm.rollback_on_failure = False a dirty-tree dev retry already routes through _pause_for_manual_recovery — a loud pause. The devcontract degrade feeds the designed #95/#61 ladder: grace-bounded poll → stall/timeout verdict → _post_kill_reconcile rescue.

Final review: the replay-fold hole

Adversarial final review found one path where "the verify gate re-supplies it" was false — the gate re-supplies status, not review routing.

A resumed dev result is the pre-reconcile snapshot (synthesize_result only writes followup_review_recommended on a done synth; the durable record persists before reconcile mutates the live dict). On replay, the reconcile already-finalized branch's re-fold is the sole carrier of the flag back onto the result — and this PR's fm is None early return dropped it on a read fault. If the fault cleared before the verify gate (which then passes on status alone), task.followup_review_recommended defaulted False and trigger = "recommended" silently skipped the recommended follow-up review. The live path was never exposed: there the repair also didn't run, so the status gate fails and the retry re-drives reconcile.

Fix, in this PR: the post-dev assignment in _dev_phase treats a present key as authoritative and, only when absent, re-derives from the finalized spec frontmatter via new Engine._followup_from_spec (through _observed_frontmatter, site followup-routing — so a persistent fault degrades to the pre-existing absent-key False, journaled, and the verify gate's own faulted read still bounds the story into DEFER). Gates on the frontmatter's status, not rj["status"] — a faulted replay leaves the result status at the stale snapshot value.

Tests

13 new tests (15 items). Every one was mutate-verified — checked to fail against the unpatched source, not merely to pass against the patched one:

  • 4 in test_verify.py — dev gate, all three review gates (parametrized), the ledger read, plus the read_frontmatter still-raises pin.
  • 1 in test_devcontract.py, 1 in test_generic_tmux.py — the read-back poll, which is where the issue's headline crash lived (that path guarded only UnicodeDecodeError).
  • 4 in test_engine.py / test_sweep.py — each bookkeeping site skips, journals the right site, and leaves its artifact byte-identical.
  • Integration capstone test_transient_spec_read_fault_does_not_crash_run: raise-once-then-delegate wrapper on the spec. Pre-fix it reproduces the issue exactly — RunSummary(done=0, crashed=True, crash_error='PermissionError: [Errno 13] Permission denied'). Post-fix the run completes, story DONE, exactly one spec-read-failed journaled.
  • Replay-fold pair in test_engine.py (from the final-review finding): a shared _crash_replay_setup kills the host in the post-session window with the pre-reconcile snapshot durably recorded and the spec already repaired on disk. test_resume_replay_fault_still_routes_recommended_review — transient fault at the resumed reconcile; pre-fix the story committed with no review session at all ([] == ['review']), post-fix the review runs and the fault costs exactly one spec-read-failed. test_resume_replay_persistent_fault_degrades_and_defers — the fault outlives the fallback: routing degrades to False at site followup-routing, the verify gate's faulted read retries, and the attempt budget lands DEFERRED. Never a crash, never a phantom review.

Fault injection is a selective Path.read_text monkeypatch (conftest.fault_read_text), not chmod: chmod is a no-op for root and carries no read bit on Windows, so the fault would silently not fire on half the CI matrix. read_bytes is left untouched so a test can still assert the faulted file is unchanged.

Full suite green (2045 passed, 1 skipped); full trunk check clean.

Deliberately out of scope

  • Bare is_file() calls at engine.py:1825/:1905, sweep.py:1050, verify.py:1178. Path.is_file() itself raises EACCES on an unsearchable parent — but that is a persistent config fault, not a transient, and the loud crash is arguably the correct outcome. Left alone rather than swept in silently.
  • escalate instead of retry at the verify gates — considered and not adopted, for the reason above. It would need consumer changes to be anything other than dead code.
  • stories.py:328's guard, generic.py:539/:607's guards, and every repair write are untouched.

Windows CI caught one test bug on the first push: Path.write_text newline-translates \n\r\n, so asserting read_bytes() == original.encode() compared LF against CRLF. Fixed by snapshotting the on-disk bytes after the write — which is also the truer statement of "these bytes never changed". All 8 checks green.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented unreadable specification or ledger files from crashing runs.
    • Read failures now produce retryable outcomes, allowing processing to continue or defer safely.
    • Added journal records for specification read failures.
    • Preserved follow-up review recommendations when replay results omit them.
    • Prevented premature ledger closure when required specification data cannot be read.
  • Documentation

    • Updated the changelog with details about improved unreadable-spec handling.

Nine spec read-back sites raced the dev skill's own writes, so a transient
OSError escaped to engine.run()'s `except Exception` and marked the whole
run crashed. Route them through three chokepoints, per the doctrine
`devcontract._read_text_or_empty` already states: observation degrades,
repair raises.

- verify._gate_frontmatter: the four verify gates return a retryable outcome
  naming the read fault. Never `{}` -> status "" -- a fault must not
  masquerade as a status mismatch and send a repair session after a bug that
  is not there. The adjacent deferred-work ledger read degrades the same way.
- Engine._observed_frontmatter: the reconcile, post-dev-sync and
  bundle-ledger-close bookkeeping passes skip and journal a new
  `spec-read-failed` event (story_key, spec, site, error). Skipping is safe:
  the deterministic verify gate re-reads the spec right after and supplies
  the retry ladder.
- devcontract.synthesize_result: an unreadable spec reads as not-yet-terminal,
  so the read-back poll keeps waiting and a persistent fault lands as a
  stall/timeout verdict that _post_kill_reconcile can still rescue. One
  chokepoint covers the unwrapped scan path and the UnicodeDecodeError-only
  stories path.

Also deletes the frontmatter re-read after `reset_spec_status` (it rewrites
only the status line, so the re-read could only return the followup flag the
first read already carried -- at the cost of a second racy read).

`read_frontmatter` itself still raises on OSError, now pinned by a test:
repair writes depend on it, since silently skipping a rewrite leaves the spec
in a state the caller believes it fixed.

Closes #97
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Unreadable spec and ledger reads now degrade to retryable or non-terminal outcomes. Engine paths journal failures and skip unsafe reconciliation or synchronization, while replay routing can derive follow-up review recommendations from finalized frontmatter. Tests cover verification, polling, reconciliation, replay, and bundle-ledger behavior.

Changes

Read fault resilience

Layer / File(s) Summary
Retryable verification gates
src/bmad_loop/verify.py, tests/conftest.py, tests/test_verify.py
Unreadable spec and deferred-work ledger reads return retryable verification outcomes; tests distinguish read failures from status mismatches and preserve base reader exception behavior.
Engine observation and replay routing
src/bmad_loop/engine.py, tests/test_engine.py
Engine observation journals spec-read-failed, skips unsafe reconciliation and syncing, and derives missing follow-up recommendations from finalized spec frontmatter during replay.
Read-back and bundle ledger handling
src/bmad_loop/devcontract.py, src/bmad_loop/sweep.py, tests/test_generic_tmux.py, tests/test_sweep.py, CHANGELOG.md
Read-back polling remains non-terminal on OSError; unreadable bundle specs do not close ledger entries, and the changelog records the behavior.
Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Engine
  participant Verify
  participant Spec
  participant Journal
  participant Review
  Engine->>Spec: observe frontmatter
  Spec-->>Engine: frontmatter or read failure
  Engine->>Journal: record spec-read-failed
  Engine->>Verify: run retryable gate
  Verify-->>Engine: retryable outcome
  Engine->>Review: dispatch follow-up when finalized spec recommends it
Loading

Possibly related PRs

Poem

I’m a rabbit with a journal bright,
Guarding specs from read-error fright.
Retry the gate, keep polling on,
Let ledger closure wait till dawn.
If review is due, I’ll send it through—
Hop, hop, the run stays true! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.44% 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
Linked Issues check ✅ Passed The changes address #97 by degrading unreadable spec reads at the affected call sites, while keeping read_frontmatter loud for repair paths.
Out of Scope Changes check ✅ Passed The diff stays focused on spec-read fault handling, with tests and changelog updates aligned to the linked issue.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: spec read faults now degrade instead of crashing the run.
✨ 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 fix/spec-read-oserror-gates-97

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

❤️ Share

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

pbean added 2 commits July 9, 2026 20:18
…e followup key

Final-review finding on this PR: the _observed_frontmatter early return has
one consumer the verify gate does not re-supply. A resumed dev result is the
pre-reconcile snapshot (synthesize_result only writes
followup_review_recommended on a done synth), and the reconcile
already-finalized branch's re-fold is the sole carrier of the flag back onto
the replay. A transient read fault dropped that fold silently; the gate then
passed on status alone and trigger=recommended skipped the recommended
follow-up review. The live path was never exposed — there the repair also
didn't run, so the status gate fails and the retry re-drives reconcile.

The post-dev assignment now treats a present key as authoritative and, only
when absent, re-derives from the finalized spec frontmatter via
_followup_from_spec (through _observed_frontmatter, site followup-routing):
a persistent fault degrades to the pre-existing absent-key False, journaled,
and the verify gate's own faulted read still bounds the story into DEFER.
Gated on the frontmatter's status, not rj["status"] — a faulted replay
leaves the result status at the stale snapshot value.

Both new tests mutate-verified: pre-fix the transient-fault replay commits
with no review session at all ([] == ['review']); the persistent-fault
companion pins the bounded degrade (DEFERRED, sites reconcile +
followup-routing, never a crash).
@pbean pbean merged commit d271d63 into main Jul 10, 2026
8 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@src/bmad_loop/engine.py`:
- Around line 1843-1871: Guard the spec-file existence check in
_followup_from_spec against filesystem errors: wrap spec_path.is_file() in
try/except OSError and return False when the check raises, matching the handling
of other unreadable-spec 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

Run ID: 2dc9bad5-ad93-4092-817a-c90564189b20

📥 Commits

Reviewing files that changed from the base of the PR and between 02fa21c and 754a303.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • src/bmad_loop/devcontract.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/sweep.py
  • src/bmad_loop/verify.py
  • tests/conftest.py
  • tests/test_devcontract.py
  • tests/test_engine.py
  • tests/test_generic_tmux.py
  • tests/test_sweep.py
  • tests/test_verify.py

Comment thread src/bmad_loop/engine.py
Comment on lines +1843 to +1871
def _followup_from_spec(self, task: StoryTask, rj: dict) -> bool:
"""Review-routing fallback for a result that carries no
``followup_review_recommended`` key: re-derive it from the finalized spec
frontmatter — the source ``devcontract.synthesize_result`` and the
reconcile folds read it from, so a readable spec can never disagree.

The key is absent exactly when the result is a *resumed* pre-reconcile
snapshot (``synthesize_result`` only writes it on a ``done`` synth, and the
durable record is persisted before reconcile mutates the live dict). The
reconcile re-fold normally restores it on replay, but a spec read fault
skips that fold — and the verify gate only re-supplies *status*, not this
flag — so without this fallback a recommended follow-up review would be
silently skipped. Gates on the frontmatter's own status (mirroring the
fold): a faulted replay leaves ``rj["status"]`` at the stale snapshot
value, so the result status must not decide. Degrades to False on a read
fault (journaled) — the pre-existing absent-key default.
"""
if not self._generic_dev():
return False
spec_file = rj.get("spec_file")
if not spec_file:
return False
spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths)
if not spec_path.is_file():
return False
fm = self._observed_frontmatter(spec_path, task.story_key, "followup-routing")
if fm is None:
return False
return verify.status_of(fm) == "done" and bool(fm.get("followup_review_recommended", False))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Python 3.14 change pathlib Path.is_file() to no longer raise PermissionError?

💡 Result:

Yes, in Python 3.14, pathlib.Path.is_file and related methods have been changed to suppress OSError exceptions, including PermissionError [1][2][3]. In previous versions, while some exceptions were already suppressed, others like PermissionError when accessing directories without sufficient read permissions would propagate [2][3][4]. As of Python 3.14, the method now returns False if the path is invalid, inaccessible, or missing, providing consistent behavior across these conditions [1][2][5]. The official documentation explicitly notes that any OSError exceptions raised from scanning the filesystem are suppressed, which includes PermissionError [1][2]. If you need to distinguish between these different states (e.g., a file not existing versus lacking permission to check it), you are advised to use Path.stat instead, as it does not suppress such exceptions [1][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate version files =="
git ls-files | rg '(^|/)(pyproject\.toml|setup\.cfg|setup\.py|tox\.ini|noxfile\.py|requirements.*\.txt|Pipfile|poetry\.lock|\.github/workflows/.*\.ya?ml)$' || true

echo
echo "== python version / requires-python references =="
rg -n --hidden --no-messages -S \
  '(requires-python|python-version|python\s*>=|python3\.[0-9]+|matrix:.*python|setup-python|actions/setup-python|Programming Language :: Python)' \
  pyproject.toml setup.cfg setup.py tox.ini noxfile.py .github/workflows . 2>/dev/null || true

Repository: bmad-code-org/bmad-loop

Length of output: 1927


Guard spec_path.is_file() against OSError. This repo supports Python 3.11–3.13, where Path.is_file() can still raise on unreadable paths, so this fallback can crash instead of returning False. Wrap it in try/except OSError like the other unreadable-spec paths.

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

In `@src/bmad_loop/engine.py` around lines 1843 - 1871, Guard the spec-file
existence check in _followup_from_spec against filesystem errors: wrap
spec_path.is_file() in try/except OSError and return False when the check
raises, matching the handling of other unreadable-spec paths.

@pbean pbean deleted the fix/spec-read-oserror-gates-97 branch July 10, 2026 20:33
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.

read_frontmatter: an OSError on the Stop path escapes eight status gates

1 participant