Skip to content

feat(adapters,engine,devcontract): deterministic missing-marker catch + repair#277

Open
pbean wants to merge 5 commits into
mainfrom
feat/deterministic-marker-catch-276
Open

feat(adapters,engine,devcontract): deterministic missing-marker catch + repair#277
pbean wants to merge 5 commits into
mainfrom
feat/deterministic-marker-catch-276

Conversation

@pbean

@pbean pbean commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Hardens the #224/#271 missing-marker fallback (PR #273), whose attribution was heuristic — a 2-stable-Stops fingerprint, or a single sighting post-kill — and which left the offending spec non-compliant on disk. PR #273 shipped with three documented accepted risks; this PR retires all three by making catch and fix deterministic, across four harness-side mechanisms delivered in four single-session phases on one branch.

The problem

The bmad-dev-auto review HALT intermittently finalizes a spec's frontmatter to a terminal status: (done/blocked) without appending the ## Auto Run Result section our harvest scan keys on. A finished story then reads no-artifact on every Stop, the stall nudges re-invoke an already-exited workflow (#149), and after max_review_cycles the verify-passing work is rolled back (#224). PR #273's synthesis-from-frontmatter rescued this, but its attribution was a heuristic and it never repaired the on-disk spec.

Mechanisms

  1. Launch-state snapshot + content-hash gate (M1). Right after the [BUG] The #109 stall-grace fix cannot reach the review leg: a review pass is killed on its first Stop by the dev pass's stale ## Auto Run Result #160 marker strip, the engine captures a SpecSnapshot (content sha256, mtime, frontmatter status) and threads it onto the review SessionSpec. The fallback refuses to synthesize from a candidate whose bytes still hash-equal the snapshot — provably untouched by this session (a done spec re-opened for review, never re-written) — in every mode, including the dead-window post-kill reconcile. This kills the documented dead-window false positive (a review killed after an mtime-only bump but before the in-review flip, previously scored done without having run). New verdict unmodified-since-launch; dead-window crumb frontmatter-unmodified-refused. Snapshot is process-transient (crash-resume degrades to the conservative path).

  2. Mid-session status-transition observation (M2). On each heartbeat tick the generic (and OpenCode) dev adapter samples the snapshotted spec and records the first status it observes this session drive off its launch state to a live, non-terminal value (in practice in-review), crumb spec-status-transition-observed. A recorded transition is deterministic proof the terminal frontmatter the spec later carries is this session's own write, so the fallback synthesizes on one terminal sighting instead of the 2-observation fingerprint (synth crumb gains transition=). The M1 hash gate still outranks it.

  3. Artifact repair (M3). On synthesis the engine appends the owed ## Auto Run Result marker onto the on-disk spec (new devcontract.append_auto_run_result, the inverse of the [BUG] The #109 stall-grace fix cannot reach the review leg: a review pass is killed on its first Stop by the dev pass's stale ## Auto Run Result #160 strip), so the once-invisible spec re-enters the normal marker scan and the next review launch strips it exactly like a skill-written marker. Best-effort at the session-synthesized-from-frontmatter site (covers live-Stop, crash-path, and post-kill synthesis); refused for a spec outside the orchestrator-owned roots or whose fresh frontmatter no longer agrees with the synthesized status, so it can never author a marker that disagrees (spec-marker-repaired / -repair-failed / -repair-skipped).

  4. Targeted contract nudge (M4), gated by new knob limits.dev_contract_nudge (bool, default true). On the first terminal-frontmatter-pending Stop — one candidate, not the hash-gate refusal, transition not yet proven — the adapter sends one nudge asking the skill to append the section itself and end its turn, repairing the omission at its source; a compliant append is harvested by the ordinary scan (no synthesis flag). Fires exactly once per session — marked before the send (a raising transport still counts), never refilled, touching no stall counters — so an mtime bump that resets the observation counter can never re-nudge (Claude adapter: Stop hook + idle detection re-entrant loop prevents session from ending #149's refill hazard structurally cannot apply). contract-nudge-sent crumb; MultiplexerError swallowed.

Decision ladder (_frontmatter_fallback, final shape)

scan → 0 candidates: no-artifact → >1: ambiguous-frontmatter → read candidate (mtime, fm status, sha256 when the snapshot path matches; OSError → torn-read degrade) → hash gate (M1, all modes) → evidence: transition_proven (M2) + fingerprint/stable/observations → synthesize when dead_window or transition_proven or (stable and obs >= 2) → else record-obs branch: fingerprint + terminal-frontmatter-pending + contract nudge (M4).

HARD CONSTRAINT

The spec's status: at review launch is load-bearing routing input to the upstream skill (a done spec routes to a fresh step-04 review pass). The review-launch frontmatter is therefore never mutated — all four mechanisms are observation or a prose-append, never a status write.

Upstream

The omission originates upstream: bmad-dev-auto intermittently skips the ## Auto Run Result append at the review HALT while finalizing frontmatter to a terminal status. Filed as bmad-code-org/BMAD-METHOD#2616 with the resultless-Stop evidence pattern and a request to make the HALT append unconditional-and-verified. This PR is the downstream harness-side mitigation regardless of the upstream fix.

Tests

Full uv run pytest -q green — 2887 passed, the only 2 failures the pre-existing test_module_skills_sync.py bmad-loop-setup module.yaml drift (unrelated). Full trunk check --no-fix clean across all 15 modified files. New coverage: adapter hash-gate refusal (live/dead-window/wait-false/other-path), transition single-sighting harvest + missed-transition-stays-conservative + hash-gate-beats-transition, heartbeat-drives-observe-tick, append_auto_run_result roundtrip + guards, engine snapshot threading + _repair_spec_marker (repaired/failed/skipped/fm-mismatch), 9 contract-nudge cases (sent-once / exactly-once-across-mtime-reset / not-sent on transition/ambiguous/dead-window/unmodified / send-failure-marks-sent / disabled-by-policy / e2e nudge-then-skill-appends-marker), and the policy/settings-schema knob sync.

Closes #276.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of completed runs when the expected result marker is missing.
    • Prevented stale or unchanged specifications from being incorrectly treated as newly completed.
    • Added safeguards for ambiguous or incomplete completion states.
  • New Features

    • Automatically repairs eligible finalized specifications by adding the missing result section.
    • Added an optional one-time prompt to request the missing result marker, enabled by default.
  • Documentation

    • Updated feature and journal documentation to describe missing-marker recovery and related diagnostics.

pbean added 4 commits July 23, 2026 09:08
Second of four harness-side mechanisms hardening the #224 missing-marker
fallback. A new `_observe_tick(handle, spec)` no-op hook on `CodingCLIAdapter`
is called each heartbeat tick from both dev wait loops (generic + opencode).
`_DevSynthesisMixin` implements it: sample the snapshotted spec's frontmatter
and record the FIRST status this session drives off its launch state to a
live, non-terminal value (in practice `in-review`) into `_fm_transition_obs`,
with a `spec-status-transition-observed` lifecycle crumb.

A recorded transition is deterministic proof the terminal frontmatter the spec
later carries is this session's own write, so `_frontmatter_fallback` widens its
synthesis predicate to `dead_window or transition_proven or (stable and obs >=
FM_FALLBACK_MIN_OBS)` — a single terminal sighting harvests, live or dead, and
the synthesized crumb gains a `transition` flag. The M1 hash gate still outranks
it: bytes reverted exactly to the launch snapshot refuse even with a transition
recorded. A transition that flips between two ticks is missed and falls back to
the conservative 2-observation fingerprint. Sampling-only: no snapshot or a torn
read is a silently skipped sample, and terminal/blank/unchanged statuses record
nothing.
…276 M4)

Final mechanism of the deterministic missing-marker catch+repair (#276): a
targeted, once-per-session tmux nudge asking the bmad-dev-auto skill to append
the '## Auto Run Result' section it owed when a Stop finds a marker-less
terminal-frontmatter spec, so the omission is repaired at its source (a
compliant append is harvested by the ordinary marker scan; frontmatter
synthesis stays the backstop).

- policy: LimitsPolicy.dev_contract_nudge (bool, default true) + parse + template
  + core.toml [limits] switch entry (default_ref, sync tests pass bidirectionally).
- adapters/generic.py: CONTRACT_NUDGE_TEXT; _configure_dev_knobs gains the
  never-cleared _contract_nudge_sent set + _contract_nudge_enabled; the
  _frontmatter_fallback record-obs branch fires the nudge on the first pending
  observation (marked BEFORE the send so a raising transport still counts;
  MultiplexerError swallowed; touches no stall counters, so #149's refill hazard
  cannot apply). _DevSynthesisMixin declares send_text as a bare annotation.
- docs (FEATURES.md, tui-guide.md) + consolidated CHANGELOG #276 entry finalized
  with all four mechanisms, the knob, and the never-mutate-launch-frontmatter
  hard constraint.
- tests: 9 adapter nudge tests (sent/exactly-once/transition/ambiguous/dead-
  window/unmodified/send-failure/disabled/e2e-append) + policy knob test.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a8570b8-9fd1-47bc-abda-e99f4333e832

📥 Commits

Reviewing files that changed from the base of the PR and between c5c85c2 and 9a94e43.

📒 Files selected for processing (3)
  • src/bmad_loop/engine.py
  • tests/test_engine.py
  • tests/test_generic_tmux.py

Walkthrough

The review adds launch-time spec snapshots, heartbeat frontmatter observation, deterministic fallback refusal, optional contract nudging, and guarded engine-side repair of missing ## Auto Run Result markers. Policy parsing, documentation, and focused tests cover the new behavior.

Changes

Missing-marker recovery

Layer / File(s) Summary
Launch snapshot and fallback gate
src/bmad_loop/adapters/base.py, src/bmad_loop/adapters/generic.py, src/bmad_loop/engine.py, tests/test_engine.py, tests/test_generic_tmux.py
Review sessions capture spec fingerprints, thread them through SessionSpec, and refuse synthesis when candidate bytes match the launch snapshot.
Session observation and contract nudge
src/bmad_loop/adapters/generic.py, src/bmad_loop/adapters/opencode_http.py, src/bmad_loop/policy.py, src/bmad_loop/data/settings/core.toml, tests/test_generic_tmux.py, tests/test_opencode_http.py, tests/test_policy.py
Heartbeat hooks record frontmatter transitions and optionally send one targeted nudge to append the missing marker.
Marker repair and validation
src/bmad_loop/devcontract.py, src/bmad_loop/engine.py, tests/test_devcontract.py, tests/test_engine.py, CHANGELOG.md, docs/FEATURES.md, docs/tui-guide.md
The engine validates synthesized results before appending an idempotent, line-ending-preserving marker and journals repair outcomes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: dracic

Poem

A rabbit checked the spec at night,
Found one small heading out of sight.
“Nudge the skill,” the moonbeams said,
Then repair the trail with ink well-spread.
Snapshots guard each hopping track—
The marker found its way back!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.22% 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 implement the four required mechanisms for #276 and add the requested docs, policy, and tests.
Out of Scope Changes check ✅ Passed The docs, tests, settings, and changelog updates directly support the missing-marker catch-and-repair work and stay in scope.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: deterministic missing-marker detection and repair across adapters, engine, and devcontract.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/deterministic-marker-catch-276

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.

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

🤖 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/data/settings/core.toml`:
- Line 144: Remove the empty duplicate [[section.field]] declaration immediately
before max_tokens_per_story in the settings configuration, leaving only
field-table entries that define their key and widget metadata.

In `@src/bmad_loop/engine.py`:
- Around line 1947-1956: Update the exception handler around
devcontract.append_auto_run_result in the spec-marker repair flow to catch both
OSError and UnicodeDecodeError. Preserve the existing spec-marker-repair-failed
journal entry and return behavior so undecodable specs are handled like other
append failures and the run can complete cleanly.
🪄 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: c8a501cd-575b-42b4-8cf0-f2e09c737bf7

📥 Commits

Reviewing files that changed from the base of the PR and between 2fd4f1a and c5c85c2.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • docs/FEATURES.md
  • docs/tui-guide.md
  • src/bmad_loop/adapters/base.py
  • src/bmad_loop/adapters/generic.py
  • src/bmad_loop/adapters/opencode_http.py
  • src/bmad_loop/data/settings/core.toml
  • src/bmad_loop/devcontract.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/policy.py
  • tests/test_devcontract.py
  • tests/test_engine.py
  • tests/test_generic_tmux.py
  • tests/test_opencode_http.py
  • tests/test_policy.py

Comment thread src/bmad_loop/data/settings/core.toml
Comment thread src/bmad_loop/engine.py
…elper DRY

Addresses CodeRabbit review on #277:
- engine._repair_spec_marker widened its best-effort guard from OSError to
  (OSError, UnicodeDecodeError): append_auto_run_result reads the spec's raw
  bytes and, by contract, raises UnicodeDecodeError (a ValueError, not an
  OSError) on an undecodable spec — a torn-mid-write between the frontmatter
  read and the append. Unguarded it would crash a run whose result was already
  synthesized and recorded. Matches _post_kill_reconcile's torn-read doctrine.
  New engine test covers the UnicodeDecodeError path.
- test-helper cleanup: fold _transition_crumbs/_nudge_crumbs into one
  parameterized _lifecycle_events(adapter, event) so both read uniformly (and
  black stops collapsing only the shorter of the two).

The other CodeRabbit finding (core.toml line 144 'empty duplicate section.field')
is a false positive: line 144's [[section.field]] is the header for
max_tokens_per_story (key on the next line); the settings-schema sync tests
confirm every field has a key.
@pbean

pbean commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive review findings

Reviewed 2fd4f1a..9a94e43 against issue #276 and the PR contract using independent adversarial, edge-case, verification-gap, and acceptance audits.

Blocking findings

  1. High: hash equality overrides stronger same-session transition evidence. The hash gate returns before transition_proven is evaluated (generic.py L1278-L1307). A clean review can legitimately round-trip done -> in-review -> done without changing any other spec bytes. When the marker omission occurs, the final digest equals the launch digest even though M2 observed deterministic proof that the review ran. The current order permanently refuses synthesis, skips the contract nudge, and can end in stall/retry/defer or rollback. Decision: transition evidence should win; hash equality should refuse only when no transition was observed.

  2. High: stories-mode readback bypasses all four Deterministic missing-marker catch + repair (follow-up to #224/#271, PR #273) #276 mechanisms. BMAD_LOOP_SPEC_FOLDER short-circuits directly into stories readback (generic.py L1086-L1096). That branch accepts a terminal spec using only the launch-time mtime floor (generic.py L1403-L1422); it ignores the snapshot hash, transition evidence, marker presence, nudge, and repair. An unchanged launch-state done spec with only an mtime bump is accepted immediately, retaining the exact false-positive completion and non-compliant artifact behavior Deterministic missing-marker catch + repair (follow-up to #224/#271, PR #273) #276 is intended to remove.

  3. High: best-effort marker repair can truncate the canonical spec. append_auto_run_result reads the complete spec and rewrites it with truncating Path.write_text (devcontract.py L510-L528). The caller catches an OSError and continues (engine.py L1947-L1962), but a disk-full event, interruption, or short write may already have left the spec empty or partial. Fault injection reduced a 46-byte spec to 12 bytes. This violates the stated invariant that a failed repair never loses work.

Verification gap

  1. Medium: the primary review repair path lacks engine-level coverage. Existing engine coverage exercises only a synthesized dev/done result with follow-up review disabled (test_engine.py L5820-L5847). Add integration coverage proving synthesized review and blocked results reach marker repair and journal the correct outcome.

Suggested resolution plan

  1. Compute transition evidence before the hash decision. Refuse an equal launch hash only when no same-session transition was observed. Add regressions for equal-hash/no-transition refusal and equal-hash/observed-transition synthesis.
  2. Route stories-mode terminal readback through the same evidence ladder. Missing-marker stories results must receive the synthesis flag, optional one-time nudge, and engine repair; add an mtime-only stale-done rejection test.
  3. Replace the whole-file truncating repair with a sibling temporary file plus atomic replacement, preserving original bytes, permissions, and line endings. Add injected short-write/disk-full tests asserting the original spec remains intact.
  4. Add a cross-path matrix for sprint/stories, live/dead-window, equal/different hash, observed/missed transition, done/blocked, Generic/OpenCode, and dev/review engine repair.
  5. Re-run targeted tests, full pytest, Trunk, and pyright, then re-review the PR.

Verification performed

  • Focused suite: 596 passed
  • Full suite: 2,888 passed, 1 skipped; only the two documented pre-existing installed-skill module.yaml drift failures remained
  • trunk check --no-fix: clean across all 15 modified files
  • Current GitHub CI, lint, typecheck, and version-sync checks: green

Recommendation: hold merge until findings 1-3 are addressed and the review-path coverage is added.

@pbean

pbean commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Review addendum: lower-priority findings

The main review comment contains all blocking findings and the medium verification gap. For completeness, these additional hardening findings were raised during the independent review layers and should remain visible even though they are not merge blockers:

  1. Low: lexical path comparison can disable M1/M2 evidence. Snapshot matching uses raw string equality at generic.py L1256-L1259 and generic.py L1305-L1307, while snapshot capture stores the task path spelling directly at engine.py L2439-L2443. An equivalent path containing .., a symlink alias, or a case variant on a case-insensitive filesystem can skip both hashing and transition attribution. Normal generated paths are canonical, which lowers reachability, but the deterministic gate should compare resolved paths or filesystem identity. Add alias-path coverage.

  2. Low: dev_contract_nudge silently coerces invalid TOML types. policy.py L720-L722 applies bool(...), so dev_contract_nudge = "false" and other non-empty invalid values enable the nudge. This follows an existing repository-wide parsing convention, so it is better handled as strict policy-type validation rather than broadening this PR unexpectedly. Validate that the raw value is exactly boolean and add invalid-type tests.

  3. Low: a spec ending with a bare carriage return can receive an invisible marker. devcontract.py L519-L528 treats a trailing bare \r as an existing newline but chooses \n for the appended block. The marker regex recognizes line starts after \n, not bare \r, so the appended heading can remain invisible to future scans. Bare-CR files are uncommon, but newline normalization should ensure the heading begins after a recognized line boundary; add a regression test.

These do not change the prior recommendation: findings 1-3 remain the merge blockers, finding 4 is the required verification gap, and findings 5-7 are lower-priority hardening work.

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.

Deterministic missing-marker catch + repair (follow-up to #224/#271, PR #273)

1 participant