Skip to content

fix(adapters,engine): pin the read-back to the spec the session owes (#261)#298

Merged
pbean merged 8 commits into
mainfrom
fix/readback-authoritative-spec-261
Jul 25, 2026
Merged

fix(adapters,engine): pin the read-back to the spec the session owes (#261)#298
pbean merged 8 commits into
mainfrom
fix/readback-authoritative-spec-261

Conversation

@pbean

@pbean pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #261.

The defect

The generic dev/review read-back located "the artifact this session produced" by scanning the implementation-artifacts dir for the most-recently-modified qualifying *.md, with nothing tying the match to the story being driven. That dir is shared: under worktree isolation the search also covers the main checkout's copy, and with isolation="none" it is that copy.

So a foreign story's spec landing there after launch — a concurrent run's merge-back, a human edit, a sweep — won on mtime and became this session's result. A review that produced nothing was scored completed:done and merged unreviewed code; on the dev leg its followup_review_recommended: false skipped the review entirely. Unlike the rest of this family (#88/#127/#160/#224, all closed) it fails toward landing bad work.

Validation at HEAD

Reproduced against current code before changing anything:

Two findings shaped the fix:

  1. No downstream gate can catch it. verify_review reads task.spec_file — the correct spec — which is legitimately status: done already, because _reset_spec_for_review deliberately never mutates frontmatter (it is load-bearing skill routing). Sprint-status is done and verify commands pass on the dev's code. The gate is vacuously satisfied; the forged session result is the only signal.
  2. The orchestrator already knows the right path. task.spec_file is recorded by verify_dev/verify_dev_bundle on dev success and handed to the review session in its own prompt (/bmad-dev-auto {task.spec_file}). The read-back was scanning a shared directory for a file it had already been told the name of.

Part 1 — authoritative-path read-back

SessionSpec.expected_spec pins the read-back to the one spec the session owes, and the scan is never reached. The pin is taken iff the dispatched prompt names the path — the read-back may demand a file back only because the session was told to write it. Knowing a spec exists is not the same thing: a from-scratch re-drive after an escalation/deferral carries a task.spec_file recorded by _record_dev_spec but dispatches a bare story key, and pinning there would poll a stale path while the re-drive's real output went unread (see review round 2 below). Fix by subtraction — stories mode already resolved by id rather than mtime, so this closes an existing asymmetry rather than adding a concept. The #224 fallback goes through the same seam via a new only parameter.

devcontract keeps its semantics; the two scans are refactored onto per-path predicates (is_result_artifact / is_frontmatter_candidate) so a caller holding the owed path can test one file instead of a directory. Every test pinning the current scan behaviour stays green untouched, including the three the issue names.

Deliberately not a filename/story-key prefix rule. Spec names come from an LLM-derived slug (step-01-clarify-and-route.md:79), so a prefix check risks trading this unsafe failure for a lossy one — the exact trade this repo has repeatedly paid to avoid. A pinned path also needs no exemption for either caveat the issue flags: the bmad-dev-auto-result-* fallback carries no story key by design, and sweep bundles legitimately adopt a differently-named pre-existing story spec (#161) — a filename rule would have broken that.

Withheld from injected plugin-workflow sessions (a TEA pre_commit_gate runs the generic adapter but owes the completion marker, not the story spec) — the same doctrine as StoriesEngine withholding BMAD_LOOP_SPEC_FOLDER from labeled sessions. A dev attempt 1 has no recorded spec yet and keeps the scan.

Part 2 — proof-of-work gate

A read-back artifact may no longer upgrade a dead session to completed when it shows no evidence it ran at all: no turn ever ended and its pane log never grew past PROOF_OF_WORK_MIN_LOG_BYTES.

The hook signal is a Stop event specifically — SessionStart and SessionEnd populate the same session_id/transcript_path and are both emitted by a CLI that launched and wedged. The pane log is created empty at launch, so a window that dies before pipe_pane attaches reports "rendered nothing" rather than "no pane signal here". And the gate is scoped to the shared-directory read-back: a task-scoped result.json is unique to its session and unlinked at launch, so no foreign writer can reach it and it stays authoritative. (All three landed in review round 2 — see below.)

The two signals are ORed because each has a known blind spot — a misbound pane sink still fires hooks (#254/#217, where a healthy session logs zero bytes), and a hook-less profile still logs. Requiring both to be absent is what makes it safe to apply to a completed upgrade. No signal at all (no pane log — the opencode-http transport, and every unit fixture) leaves the gate inert: unknown never blocks, matching the tristate liveness doctrine.

Applies to the crash path and to _post_kill_reconcile (the call path the issue's second occurrence took), and journals readback-refused-no-proof-of-work. The floor is not zero: the report's wedged windows left 0-byte and 2-byte logs.

One existing test changed, deliberately

test_tmux_timeout_with_flushed_spec_rescued_post_kill's fake CLI now renders to its pane before writing the spec. I measured the original: it produced a 0-byte pane log with zero hook events — byte-identical to the wedge this issue is about. It was an unfaithful stand-in for the #61 scenario it documents (total hook loss), since a CLI that genuinely implemented a story always renders; the run it represents logged 1.4 MB. The test's actual invariant (rescue a finished session whose Stop was lost) is untouched, and its new companion test_tmux_timeout_silent_session_not_rescued pins the complementary refusal on the same call path.

That companion was itself defective on first submission, and CodeRabbit caught it: the foreign spec was written before adapter.run() — where start_session stamps launched_ns — so its mtime sat below the since_ns floor, the scan discarded it, and nothing ever reached the gate. It passed with the gate entirely removed — the same vacuously-satisfiable-gate defect this PR fixes, inside the test written to prove the fix. The merge-back now lands mid-window from a separate thread, plus an explicit assertion that the foreign spec really is a qualifying candidate.

Verification

  • Full suite: 2979 passed, 1 skipped.
  • Ablation-verified, both directions: with _produced_work short-circuited to True, test_tmux_timeout_silent_session_not_rescued fails with status=completed (the foreign spec adopted through post-kill reconcile — a genuine end-to-end reproduction of Session read-back adopts another story's spec: a review that produced nothing is scored done #261 through real tmux); restored, it passes. Both real-tmux tests stable over 6 consecutive local runs.
  • trunk check (no filter): clean. pyright@1.1.411: 0 errors.
  • Re-ran the original repro: devcontract primitives behave identically (the fix is not there); the adapter unpinned still reproduces the bug, pinned returns None.
  • 15 new adapter tests + 1 engine test, including over-refusal guards on every branch (own spec with marker synthesizes; own marker-less spec still harvests; dev attempt 1 keeps the scan; hook event outvotes an empty log).

Review round 2 (post-e880768)

A full review pass raised six findings; all six reproduced at HEAD, and one was worse than reported. Fixed in a84bc44 (engine) + 91b6b68 (adapters):

# Finding Fix
1 expected_spec pinned a re-drive to a spec it was never told to write — a work-losing regression this PR introduced Pin iff the dispatched prompt names the path; stop recording the bmad-dev-auto-result-* marker as a story's spec
2 SessionStart metadata counted as proof of work — and SessionEnd did too, so only a session with zero hook events ever reached the gate Track a Stop event as its own signal (SessionResult.stop_seen)
3 A missing pane log failed the gate open, on exactly the dead-on-arrival case it targets start_session creates the log empty before the window exists
4 The gate also discarded authoritative task-scoped result.json Scoped to _DevSynthesisMixin via _READBACK_NEEDS_PROOF_OF_WORK
5 The silent-session e2e could still pass vacuously on a slow box Writer waits on the real launch; asserts against the recorded launched_ns
6 Routing/evidence branches uncovered 7 tests: pin routing (×3 dispatch shapes + labeled workflow + sweep bundle), marker filter, SessionStart-only, dead-on-arrival, task-scoped result

Ablation-verified per mechanism, each restored afterwards: disabling _produced_work fails all 7 refusal tests; restoring the any-hook-metadata rule fails the SessionStart-only test; removing the log pre-create fails the dead-on-arrival test; gating the base mixin fails the task-scoped-result test; reverting the pin rule fails the routing tests; dropping the label is None guard fails the labeled-workflow test alone; removing the marker filter fails its test. One new test was itself vacuous on first write — the foreign spec predated launch, so since_ns rejected it before the gate saw it — and this pass is what caught it.

Full suite 2986 passed, 1 skipped. trunk check (no filter) clean; pyright 0 errors. Both real-tmux tests stable over 6 consecutive runs.

Not addressed

  • _artifact_dirs left alone. The issue calls narrowing it the smallest option and notes no test pins it — but the rendered skill bakes an absolute main-checkout spec path, which is plausibly why the "defensive fallback" exists. Dropping it could re-introduce a lossy failure. Parts 1 and 2 make it unnecessary.
  • Dev attempt 1 residual. With no recorded spec the scan is genuinely required — and it is now the only standing residual, since round 2 closed the SessionStart-only and absent-log holes that had left Part 2 largely inert. It is partly covered by _verify_shared_gates' baseline check (vacuous between parallel worktrees cut from the same baseline) and by Part 2 whenever the session produced nothing. A session that did work, died, and was outraced on mtime remains theoretically exposed — worth a follow-up rather than widening this change.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented sessions from read-back adopting another story’s specification by pinning read-back to the intended spec path.
    • Added stronger completion safeguards: sessions won’t be marked completed without observable work evidence (including after timeouts or silent failures).
    • Improved handling of fallback/no-spec cases and ensured sweep bundle dispatches don’t pin unrelated expected specs.

…261)

Closes #261.

The generic dev/review read-back located "the artifact this session produced"
by scanning the implementation-artifacts dir for the most-recently-modified
qualifying *.md, with nothing tying the match to the story being driven. That
dir is shared: under worktree isolation the search also covers the main
checkout's copy, and with isolation="none" it IS that copy. A foreign story's
spec landing there after launch (a concurrent run's merge-back, a human edit, a
sweep) won on mtime and became this session's result — a review that produced
nothing was scored completed:done and merged unreviewed code, and on the dev leg
its followup_review_recommended: false skipped the review entirely. Unlike the
rest of this family (#88/#127/#160/#224) it failed toward LANDING bad work.

Part 1 — authoritative-path read-back. Where the orchestrator already knows
which spec the session owes (every review leg and every dev retry, via
StoryTask.spec_file — the path it hands the session in its own prompt),
SessionSpec.expected_spec pins the read-back to that one file and the scan is
never reached. Fix by subtraction: stories mode already resolved by id rather
than mtime, so this closes an asymmetry. The #224 missing-marker fallback (a
second mtime-only scan of the same shared dir, added after the issue was filed)
goes through the same seam via its new `only` parameter. devcontract keeps its
semantics; find_result_artifact / find_frontmatter_candidates are refactored
onto per-path predicates (is_result_artifact / is_frontmatter_candidate) so a
caller holding the owed path can test one file instead of a directory.

Deliberately NOT a filename/story-key rule: spec names come from an LLM-derived
slug, so a prefix check risks trading this unsafe failure for a lossy one — and
a pinned path needs no exemption for the bmad-dev-auto-result-* fallback or for
sweep bundles, which legitimately adopt a differently-named story spec (#161).
Withheld from injected plugin-workflow sessions, which owe the completion marker
rather than the story spec. A dev attempt 1 has no recorded spec and keeps the
scan.

Part 2 — proof-of-work gate. A read-back artifact may no longer upgrade a dead
session to completed when it shows no evidence it ran at all: no hook event
arrived AND its pane log never grew past PROOF_OF_WORK_MIN_LOG_BYTES. The two
signals are ORed because each has a blind spot (a misbound pane sink still fires
hooks (#254/#217); a hook-less profile still logs), and no signal at all leaves
the gate inert (unknown never blocks). Applies to the crash path and to
_post_kill_reconcile — the call path the issue's second occurrence took — and
journals readback-refused-no-proof-of-work. The floor is not zero: the report's
wedged windows left 0-byte AND 2-byte logs.

test_tmux_timeout_with_flushed_spec_rescued_post_kill's fake CLI now renders to
its pane before writing the spec. Measured, the silent fake produced a 0-byte
log with zero hook events — byte-identical to the wedge this issue is about, so
it was unfaithful to the #61 scenario it stands for (that run logged 1.4 MB).
Its new companion, test_tmux_timeout_silent_session_not_rescued, pins the
complementary refusal on the same call path.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e8ca13f3-a7dd-4cd2-903c-47e3214decfa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The session adapter now uses an explicit expected spec path for read-back and restricts fallback candidates accordingly. Completion upgrades and post-kill rescues require observable hook or pane-log evidence. Artifact predicates and integration tests cover foreign specs, silent sessions, and relative paths.

Changes

Session integrity

Layer / File(s) Summary
Expected-spec session wiring
src/bmad_loop/adapters/base.py, src/bmad_loop/engine.py, tests/test_engine.py, tests/test_sweep.py
SessionSpec carries expected_spec; the engine wires it only for eligible generic development and review prompts, rejects fallback markers, and tests bundle and labeled-workflow behavior.
Authoritative artifact read-back
src/bmad_loop/devcontract.py, src/bmad_loop/adapters/generic.py, tests/test_generic_tmux.py
Read-back prioritizes the expected file, constrains frontmatter fallback, centralizes artifact predicates, and tests foreign, marker-less, absent, and relative paths.
Proof-of-work completion gating
src/bmad_loop/adapters/generic.py, tests/test_generic_tmux.py
Completion and post-kill rescue require Stop-hook evidence or sufficient pane-log growth; tee-log initialization and productive or silent tmux sessions are covered.
Release documentation
CHANGELOG.md
The unreleased changelog records the read-back pinning, proof-of-work gate, and refusal breadcrumb.

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

Possibly related PRs

Suggested reviewers: dracic

Poem

I pinned the spec, said the rabbit with glee,
No foreign markdown shall burrow past me.
Silent panes cannot claim a bright “done,”
Only real work earns completion won.
Hop, hop—read-backs now stay where they belong!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: pinning read-back to the session's owed spec.
Linked Issues check ✅ Passed The changes address #261 by pinning expected specs, preserving fallback behavior, and gating completion on proof of work with tests.
Out of Scope Changes check ✅ Passed The refactors and tests are all in service of the spec pinning and completion-gating fix; no unrelated changes stand out.
Docstring Coverage ✅ Passed Docstring coverage is 88.57% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/readback-authoritative-spec-261

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 24, 2026 20:25
…es resume

CI py3.11/py3.12 failed test_tmux_timeout_with_flushed_spec_rescued_post_kill
(3.13/3.14 and both Windows jobs passed). The fake CLI emitted its pane output as
one burst at startup, which can finish before pipe_pane attaches to the freshly
created window — leaving a 0-byte log and tripping the new proof-of-work gate.
Spread the output across ~2s (40 lines x 50ms, still well inside the 6s session
timeout) so some of it always lands after the sink exists. Stable over 5 local
repeats. Its silent companion now asserts "at or below the floor" rather than
exactly zero: below the floor is the invariant, zero is incidental.

Also, two corrections to the expected_spec seam:

- The SessionSpec.expected_spec comment claimed the field is process-transient
  like SpecSnapshot and that a crash-resume falls back to the scan. That is
  wrong in the direction that matters: StoryTask.spec_file round-trips through
  state.json and WorktreeFlow.reopen_unit re-absolutizes it before any session
  launches, so the engine re-derives expected_spec on every launch and a resumed
  run IS protected. Documented as the property it is.

- Rebase a relative expected_spec against spec.cwd, the same idiom the stories
  read-back uses for BMAD_LOOP_SPEC_FOLDER. The engine always threads an absolute
  path, but a relative one would resolve against the process CWD, miss, and read
  as "the session wrote nothing" — turning this guard into exactly the silent
  work-losing failure it exists to prevent.
…to prove

The threshold comment claimed "any session that reached the agent loop echoes at
least its own prompt into the pane". That is an inference and it is wrong as
stated: the prompt is delivered by send-keys, and a program that never echoes it
leaves a 0-byte log (measured with the silent fake CLI). The floor measures the
CLI's OWN rendering, not that the session was launched — which is precisely why
_produced_work ORs it with the hook-event signal instead of trusting it alone.
Restated around the observed separation (1.4 MB working vs 0/2 bytes wedged), and
noted that the exact value is therefore not load-bearing.

@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 `@tests/test_generic_tmux.py`:
- Around line 3002-3006: Update the foreign spec setup in the concurrent
merge-back test so its modification time is explicitly newer than the launch
timestamp recorded by start_session, or create it through the fake CLI after
launch. Ensure find_result_artifact accepts it and the proof-of-work gate,
rather than the since_ns filter, rejects the foreign result.
🪄 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: 2a815e72-e460-4c74-aa37-81a006405974

📥 Commits

Reviewing files that changed from the base of the PR and between 1e6ec9f and 0729d2a.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/bmad_loop/adapters/base.py
  • src/bmad_loop/adapters/generic.py
  • src/bmad_loop/devcontract.py
  • src/bmad_loop/engine.py
  • tests/test_engine.py
  • tests/test_generic_tmux.py

Comment thread tests/test_generic_tmux.py Outdated
pbean added 2 commits July 24, 2026 20:35
…e death

_log_evidence is declared inert on _ResultFileMixin and overridden on
GenericAdapter, which owns the pane log. Both mixins sit ahead of the concrete
adapter in the MRO, and this file already documents that shadowing hazard for
send_text. C3 puts GenericAdapter first today (it subclasses _ResultFileMixin),
but a future base reshuffle that let the inert stub win would silently disable
the proof-of-work gate everywhere. Assert both resolutions, including that
OpencodeDevAdapter — which has no pane log — keeps the inert one.
#298 review)

CodeRabbit caught that test_tmux_timeout_silent_session_not_rescued proved
nothing. The foreign spec was written BEFORE adapter.run(), which is where
start_session stamps launched_ns — so its mtime sat below the since_ns floor,
find_result_artifact discarded it outright, and the read-back never produced a
candidate for the proof-of-work gate to refuse. Verified by ablation: the test
passed with the gate entirely removed.

That is the same vacuously-satisfiable-gate failure mode this whole issue is
about, in the test written to prove the fix.

The merge-back now lands mid-window from a separate thread (1s into a 6s window),
so the foreign spec post-dates launch exactly as the report's did, plus an
explicit assertion that it IS a qualifying candidate. Re-verified by ablation:
with the gate disabled the test now FAILS with status=completed — the foreign
spec adopted through post-kill reconcile, i.e. a genuine end-to-end reproduction
of #261 through real tmux. Stable over 6 local repeats.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive review findings

I reviewed the full PR diff against issue #261 and the PR’s stated behavior, including parallel adversarial, edge-case, and verification-gap passes.

Recommendation: request changes before merge. The authoritative-path mechanism is directionally correct and works for the primary review/retry case, but the new proof-of-work and path-selection seams still contain three high-severity edge cases.

Findings

  1. High — expected_spec can pin a re-drive to an artifact the new session does not owe.

    Engine._run_session derives expected_spec from any existing task.spec_file, but a from-scratch re-drive dispatches /bmad-dev-auto <story-key> rather than explicitly pointing at that file. This is especially problematic when _record_dev_spec captured a bmad-dev-auto-result-* no-spec fallback. If the re-drive creates a real spec-*.md, authoritative read-back polls only the obsolete fallback and ignores the real output.

    Evidence: src/bmad_loop/engine.py:1265, src/bmad_loop/engine.py:2277, src/bmad_loop/engine.py:2431, src/bmad_loop/adapters/generic.py:1197.

  2. High — SessionStart metadata is treated as proof that the session performed work.

    _produced_work accepts any session_id or transcript path. Those fields are accumulated before the SessionStart branch continues. A CLI can therefore emit SessionStart, wedge before doing work, and still pass the proof gate if a foreign artifact appears during an unpinned first dev attempt. I reproduced this at current HEAD: an empty-log session with only a start-derived session ID returned completed and adopted a foreign spec.

    Evidence: src/bmad_loop/adapters/generic.py:202-222, src/bmad_loop/adapters/generic.py:787-791.

  3. High — A missing pane log fails the proof gate open.

    _log_evidence returns None when the log is absent, and _produced_work converts None to success. start_session explicitly permits a window to die before pipe_pane attaches, so a hookless, dead-on-arrival session can have no log at all. I reproduced that case at current HEAD: no hooks + no log + a foreign artifact returned completed with the foreign spec.

    Evidence: src/bmad_loop/adapters/generic.py:216-222, src/bmad_loop/adapters/generic.py:457-465, src/bmad_loop/adapters/generic.py:838-852.

  4. Medium — The proof gate also discards authoritative task-scoped result.json files.

    The gate lives in shared _ResultFileMixin._final, so it affects triage/migration and other base-adapter sessions whose result is the freshly cleared, task-unique tasks/<task-id>/result.json, not a shared artifact scan. A valid task result with no hook and a small/empty log is downgraded to crashed; I reproduced this behavior locally.

    Evidence: src/bmad_loop/adapters/generic.py:195-200, src/bmad_loop/adapters/generic.py:231-264, src/bmad_loop/runsetup.py:76-145.

  5. Medium — The silent-session E2E can still pass without exercising the proof gate.

    The delayed foreign writer starts before adapter.run(). On a slow tmux setup, its one-second delay can expire before start_session records launched_ns. The post-run assertion uses since_ns=0, so it proves only that the file is generally valid, not that it postdates this session’s launch. This can recreate the vacuous pass the final test commit intended to eliminate.

    Evidence: tests/test_generic_tmux.py:3004-3029.

  6. Medium — Critical routing branches lack direct regression coverage.

    Add explicit tests for: dev-retry pinning, from-scratch/no-spec-fallback re-drive behavior, the labeled-workflow exclusion, and delayed flush polling through _known_spec_synth_result. Existing authoritative-path tests cover the review path well, but not these routing invariants.

Remediation plan

  1. Make path authority explicit at dispatch. Compute/pass expected_spec from the actual session contract. Set it only when the prompt/session is required to update that exact file (review, explicit repair, patch-restore). Withhold or clear it for freeform/from-scratch planning and obsolete bmad-dev-auto-result-* fallbacks.
  2. Separate launch evidence from work evidence. Do not let SessionStart alone satisfy proof-of-work. Track substantive activity such as a non-start lifecycle/progress event, pane output beyond the floor, or observed transcript growth.
  3. Make tmux log capability fail closed. Prepare/truncate the pane log before launching the window and treat an unexpectedly absent GenericAdapter log as negative evidence. Reserve the None/inert state for transports that genuinely have no pane-log capability.
  4. Narrow the proof gate. Apply it to _DevSynthesisMixin shared-directory/spec synthesis, where foreign adoption is possible. A freshly cleared, task-scoped result.json should remain authoritative evidence for base adapters.
  5. Make the E2E deterministic. Synchronize the foreign writer on start_session() returning (or otherwise capture the real handle), then assert qualification with since_ns=handle.launched_ns.
  6. Add a routing/evidence test matrix. Cover review, explicit repair retry, from-scratch re-drive, no-spec fallback re-drive, labeled workflow, missing pane attachment, SessionStart-only wedge, task-scoped result, and delayed pinned flush. Re-run both safety/rescue ablations afterward.

Validation performed

  • Full local suite: 2,979 passed, 1 skipped
  • Targeted engine/adapter suite: 389 passed
  • trunk check: clean
  • Working tree remained clean

The main authoritative-path change successfully rejects foreign marker and markerless specs when the session genuinely owes a known path. The items above are about making that authority accurate for every launch route and ensuring the proof gate neither fails open on unsafe shared scans nor fails closed on authoritative task-local results.

pbean added 2 commits July 24, 2026 22:35
…298 review)

The #261 read-back may demand a spec back solely because the session was told
to write it. `_run_session` derived the pin from any recorded `task.spec_file`,
but knowing a spec exists is not the same as having pointed a session at it: a
from-scratch re-drive after an escalation or deferral carries a spec recorded by
`_record_dev_spec` and dispatches a bare story key. Pinning there polls a stale
path while the re-drive's real output goes unread — trading #261's unsafe
failure for a work-losing one, the exact trade this fix exists to avoid. Same
shape for a sweep bundle (dispatches intent.md) and stories mode (folder+id).

Pin iff the dispatched prompt names the path. One rule, covering all three
engines without per-engine plumbing since each already builds its own prompt;
it reads the post-gate text so a plugin rewrite cannot desynchronize the pin
from the contract that justifies it. A miss falls back to the scan — the
pre-#261 behavior, and the safe direction.

Also stop recording the skill's `bmad-dev-auto-result-*` no-spec fallback as a
story's spec. It is not one, and every consumer misroutes on it: the escalation
re-arm flips frontmatter on a marker no re-drive reads, the repair leg re-opens
it as the frozen intent contract, and the read-back then pins to it.

Ablation-verified: reverting the rule to the old `task.spec_file` presence check
fails the routing tests; dropping the `label is None` guard fails the
labeled-workflow test on its own; removing the marker filter fails its test.
 review)

Three defects, all the same shape: the #261 gate was inert exactly where it was
needed and over-broad where it was not.

1. Launch metadata was read as proof of work. `session_id`/`transcript_path` are
   populated by SessionStart and SessionEnd too, both of which a CLI that
   launched and wedged emits — so only a session with literally ZERO hook events
   ever reached the gate. Track a `Stop` event (a turn that ENDED) as its own
   signal instead; `SessionResult.stop_seen` carries it to `_post_kill_reconcile`.
   The #254/#217 rationale is untouched: a healthy session ends its turn.

2. A missing pane log failed the gate OPEN. `pipe_pane` tolerates a window that
   already died and then attaches no tee, so a dead-on-arrival session left no
   log at all — the "this transport has no pane signal" state, which the gate
   treats as inert. `start_session` now creates the log empty before the window
   exists, so no-tee-ever-attached reports False (rendered nothing) rather than
   None. The inert state now means only "a handle this adapter never launched".

3. The gate applied to authoritative task-scoped results. It lived in
   `_ResultFileMixin._final`, shared by every adapter whose skill writes
   `tasks/<task_id>/result.json` — a file unique to the task and unlinked at
   launch, which no foreign writer can reach. Gating it could only ever discard a
   real completion. Scoped to `_DevSynthesisMixin`, the one read-back that scans
   a shared directory.

The silent-session e2e is also de-raced: its merge-back thread waited a fixed 1s
from before `run()`, but the gap to `launched_ns` covers a real `tmux
new-session` and a loaded box can outrun any margin picked in advance, silently
restoring the vacuous pass. It now waits on the actual launch and asserts
qualification against the floor the session recorded, not a permissive 0.

Ablation-verified, each mechanism separately: disabling `_produced_work` fails
all 7 refusal tests; restoring the any-hook-metadata rule fails the
SessionStart-only test; removing the log pre-create fails the dead-on-arrival
test; gating the base mixin fails the task-scoped-result test. One new test was
itself vacuous on first write (the foreign spec predated launch, so `since_ns`
rejected it before the gate saw it) and was caught by exactly this pass.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Round-2 disposition — all six confirmed, all six fixed

Validated every finding against HEAD before changing anything. All six reproduce, and #2 is worse than reported. Fixed in a84bc44 (engine) + 91b6b68 (adapters).

The through-line is worth naming: findings 2–4 all say the same thing — the proof-of-work gate was inert exactly where it was needed and over-broad where it was not — and finding 1 says the pin introduced a new lossy path. Both are the failure modes this PR exists to eliminate, reproduced inside the fix.

1 — expected_spec pinned a re-drive to a spec it was never told to write · CONFIRMED

engine.py:2277 pinned from any task.spec_file, but _generic_dev_prompt dispatches a bare /bmad-dev-auto {story_key} when feedback is None and nothing is latched. _record_dev_spec sets task.spec_file on DEFER/escalate — and devcontract.synthesize_result names the bmad-dev-auto-result-* marker as spec_file, so the marker case you flagged is real. Either way the old path's mtime sits below the new since_ns floor, so a successful re-drive reads back None.

Fixed by making the authority rule explicit: pin iff the dispatched prompt names the path. The read-back may demand a file back only because the session was told to write it; knowing a spec exists is not the same thing.

One rule in _run_session rather than per-engine plumbing, because each engine already builds its own prompt — so SweepEngine's intent.md dispatch and StoriesEngine's folder+id dispatch fall out correctly with no carve-out. It reads the post-gate prompt, so a plugin rewrite cannot desynchronize the pin from the contract that justifies it, and a miss falls back to the scan — the pre-#261 behaviour, and the safe direction.

Also took the marker out at the source: _record_dev_spec now refuses bmad-dev-auto-result-*. It is not a story's spec, and every consumer misroutes on it — rearm_escalation flips frontmatter on a marker no re-drive reads, and _reset_spec_for_repair re-opens it as the frozen intent contract.

2 — SessionStart counted as proof of work · CONFIRMED, and wider

You are right, and it is worse: EVENT_KINDS is {SessionStart, Stop, SessionEnd}, all three populate session_id/transcript_path at generic.py:787-788 before the SessionStart continue — and _final's crash path is reached from the SessionEnd branch. So only a session with literally zero hook events ever reached the gate.

Fixed by tracking Stop — a turn that ended — as its own signal, threaded out on SessionResult.stop_seen so _post_kill_reconcile reads the same thing. The #254/#217 rationale survives intact: a healthy session with a misbound pane sink still ends its turn.

3 — A missing pane log failed the gate open · CONFIRMED

Exactly as described: pipe_pane tolerates an already-dead window and then attaches no tee, so a DOA session had no log file at all — the None/inert state — and the gate failed open on precisely the case it exists to catch.

Fixed at the source rather than in the predicate: start_session now creates the log empty before the window exists, so no-tee-ever-attached reports False (rendered nothing) instead of None. That keeps the tristate honest — None now means only "a handle this adapter never launched", which is the unit-fixture case where unknown never blocks is the right answer. Behaviour-neutral for stall detection (_log_activity_key reports (mtime, 0) instead of None; every reader compares signatures, none tests existence).

4 — The gate discarded authoritative task-scoped results · CONFIRMED

Agreed, and it is a pure-downside application: tasks/<task_id>/result.json is task-unique and unlinked at launch, so its presence already proves this session wrote it. Scoped via _READBACK_NEEDS_PROOF_OF_WORKFalse on _ResultFileMixin, True on _DevSynthesisMixin, the one read-back that scans a shared directory. _post_kill_reconcile already lived in the dev mixin and is unchanged.

5 — The e2e could still pass vacuously · CONFIRMED

The 1s sleep started before run(), but the gap to launched_ns covers _ensure_session and a real tmux new-session — a loaded box outruns any margin picked in advance, and the since_ns=0 assertion could not detect it. The writer now waits on the actual launch (a captured handle + event) and the assertion tests against handle.launched_ns rather than a permissive 0.

6 — Coverage · scoped down

Seven tests rather than the proposed nine-cell matrix — one per invariant actually asserted: pin routing across all three dispatch shapes, labeled-workflow exclusion, sweep-bundle non-pin, the marker filter, SessionStart-only, dead-on-arrival, and the task-scoped result. The delayed-flush polling and dev-retry cases you listed are already covered by the existing _known_spec_synth_result tests; adding parallel ones would have pinned the same code twice.

Ablation

Per mechanism, each restored afterwards:

Ablation Fails
_produced_workTrue all 7 refusal tests, incl. the real-tmux e2e
restore any-hook-metadata rule SessionStart-only test
remove the log pre-create dead-on-arrival + reused-task-log tests
gate the base mixin task-scoped-result + MRO tests
revert the pin rule pin-routing + sweep-bundle tests
drop the label is None guard labeled-workflow test alone
remove the marker filter its test

Worth recording: one of the new tests was itself vacuous on first write — the foreign spec predated launch, so since_ns discarded it before the gate saw it, and it passed with the gate removed. The same defect CodeRabbit caught in the e2e, in a test written to prove the fix for it. This pass is what caught it.

Full suite 2986 passed, 1 skipped. trunk check (no filter) clean, pyright 0 errors, both real-tmux tests stable over 6 consecutive local runs.

Standing residual

Unchanged and now the only one: dev attempt 1, where no spec exists yet and the scan is genuinely required. Round 2 closed the two holes that had left Part 2 largely inert there, so the gate is now real cover for it — but a session that did work, died, and was outraced on mtime remains theoretically exposed. Still a follow-up rather than a widening of this change.

…ng (#298 review)

CI Windows/py3.14 only. The dead-on-arrival test asserts its foreign spec is a
candidate the read-back would otherwise adopt — a precondition assertion, so that
setup drift fails loudly instead of passing vacuously. It established that by
writing the file after `start_session` returned, which is not sufficient on
Windows: `launched_ns` comes from `time.time_ns()` (a precise clock) while an
NTFS mtime is stamped from the coarse system-time tick (~15.6 ms), so a file
written a millisecond later can carry an mtime BELOW the floor.

Re-stamp until the precondition actually holds, bounded, instead of assuming the
two clocks are comparable. Production is unaffected: no session writes its
terminal spec within 15 ms of launch.

Re-ablated after the change — with `_produced_work` short-circuited the test
still fails, so the fix did not turn it into a test that agrees for free.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pbean
pbean merged commit b6a0d5d into main Jul 25, 2026
10 checks passed
@pbean
pbean deleted the fix/readback-authoritative-spec-261 branch July 25, 2026 16:15
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.

Session read-back adopts another story's spec: a review that produced nothing is scored done

1 participant