Skip to content

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

Description

@Polloinfilzato

Description

A session's read-back locates "the artifact this session produced" by scanning a shared
directory for the most recently modified qualifying *.md, with no constraint that it belongs
to the story being driven. When the session dies having produced nothing, a different story's
spec — written into that directory moments earlier by a concurrent run's merge-back — is adopted
as its result, and its status: done becomes this story's verdict.

Observed twice in one night, on two different call paths. On the review leg it merged unreviewed
code; on the dev leg it caused the review to be skipped entirely.

Unlike the rest of this failure family (#224, #127, #88, #160), this fails in the unsafe
direction: every prior member loses good work; this one lands unverified work.

Evidence — a natural experiment with two controls

Run 20260722-025215-9ffc, story i-12, dev=claude / review=codex, mux=tmux, macOS,
isolation="worktree", four runs launched as separate processes.

(The i-… story keys are this project's own epic-naming convention, carried by a small local patch to the sprint-status key parser. It changes only which keys are accepted — artifacts are located by mtime and heading, never by key shape — so it plays no part in what follows.)

Three review sessions wedged identically that night. Only one was scored a pass:

story review-1 launched died pane log hook events qualifying foreign .md present verdict
i-9 04:14:56 04:58:22 2 B none no crashed
i-13 04:23:07 04:59:20 0 B none no crashed
i-12 04:55:25 05:36:41 0 B none yes — spec-i-13-*.md, mtime 05:15:27 completed

The only delta is the presence of a foreign artifact. (Control on the control: the review-2 /
review-3 passes of i-9 and i-13 — same codex adapter — wrote normal SessionStart+Stop
events and multi-MB logs, so the absent events are anomalous, not normal for this adapter.)

What the journal then recorded for i-12:

{"kind":"session-end","task_id":"i-12-…-review-1","status":"completed","tokens":null,"log_pos":0}
{"kind":"spec-reconcile-skipped-out-of-tree","story_key":"i-12-personality-projection",
 "spec":"…/dm20-protocol/_bmad-output/implementation-artifacts/spec-i-13-combat-stateview-projection.md"}
{"kind":"review-result","story_key":"i-12-personality-projection","cycle":1,"status":"done",
 "followup_review_recommended":false}
{"kind":"story-done","story_key":"i-12-personality-projection","commit":"5416fc3"}

Note the adopted spec path: it is the main checkout's, not the worktree's.

Where the foreign artifact came from — reflog and mtime agree to the second:

reflog: 43ef7b0 HEAD@{2026-07-22 05:15:27}: merge bmad-loop/…/i-13-combat-stateview-projection
stat:   _bmad-output/implementation-artifacts/spec-i-13-…md → 2026-07-22 05:15:27

i-13's own run merged its unit branch back into the target branch, which by design happens in
the main repo's working tree
(workspace.py module docstring). That write landed inside i-12's
review window.

Mechanism

  1. _DevSynthesisMixin._artifact_dirs builds the search list as the worktree-rebased dir plus
    the configured main-checkout dir, the latter labelled in-comment as a "defensive fallback".
    Under worktree isolation that second entry is a directory shared with every concurrent run.
  2. _DevSynthesisMixin._synth_result takes the first hit from
    devcontract.find_result_artifact(dir, since_ns=handle.launched_ns).
  3. find_result_artifact returns the most-recently-modified *.md carrying an
    ## Auto Run Result heading (or the bmad-dev-auto-result-* fallback, matched by name)
    with mtime >= since_ns. It is a glob("*.md") over a directory holding every story's spec.
    Nothing ties the match to the current story.
  4. devcontract.synthesize_result already receives story_key but only stamps it into the
    returned dict — it never checks it against the spec it just read.
  5. _ResultFileMixin._final then sets status = "completed" if result_json is not None, with
    accept_result=True by default.
  6. escalation.decide_review_session PROCEEDs on completed, and the engine reads the foreign
    spec's status/followup_review_recommended as the review's verdict.

Why the scan reached the shared fallback at all: Engine._reset_spec_for_review strips the prior
## Auto Run Result from the story's own spec before every review launch (the #160 fix), and
the dead session never rewrote it — so the worktree dir held no candidate and the scan fell
through to the main checkout.

Exit path (established by exhaustive elimination, since events/ holds nothing for this
task): window death → _final(fallback="crashed") → upgraded to completed by the artifact.
Not the stall branch (never armed — that requires a result-less Stop), not timeout (2 476 s
elapsed vs session_timeout_min = 180), not the budget branch (gated on a transcript_path that
only a hook event populates), not _post_kill_reconcile (only fires on stalled/timeout/over_budget).

Second occurrence, same batch, different call path

Run …-94a5 (story i-11), dev leg, via _post_kill_reconcile:

05:52:03  session-rescued-post-kill   i-11-…-dev-1
05:52:04  session-end status=completed
05:52:04  spec-reconcile-skipped-out-of-tree  spec=…/spec-i-13-combat-stateview-projection.md
05:52:04  review-not-recommended → review-skipped
05:52:05  story-done

The same foreign spec was adopted, and its followup_review_recommended: false skipped the review
altogether. The repo later had to reopen that story (abb49e7 fix(sprint-status): reopen i-11 — the run marked it done but shipped no code).

Stated, not proven: I cannot show the i-11 code loss was caused by the adoption rather than
merely concurrent with it — that worktree is gone. It is offered as a second independent
occurrence of the mechanism, not as a proven causal chain.

Not limited to parallel runs

The single production call site is reached from _result_json (every Stop, every window death)
and from _post_kill_reconcile. The only guard is if spec.env.get("BMAD_LOOP_SPEC_FOLDER"),
exported only by StoriesEngine — so sprint and sweep modes both take the scan path.
Any writer touching a qualifying .md after launch wins on mtime: a git merge/checkout/rebase, a
human editing another spec, a concurrent sweep. With isolation="none" the search collapses to
one directory, but it is the same shared one — identical exposure.

Suggested fix direction

Stories mode already has this defence; the ask is to bring it to the sprint/sweep read-back.
verify_dev_stories rejects a spec whose filename does not start with the story id, and
stories.resolve_story_spec resolves by id rather than by mtime. Nothing equivalent exists for
sprint/sweep, and synthesize_result already holds the story_key needed to do it — so this
closes an existing asymmetry rather than introducing a new concept.

Two caveats a naive filename rule would break:

  1. The no-spec fallback carries no story key in its name. bmad-dev-auto-result-* is matched
    by filename precisely because it has no heading (shipped fixtures:
    bmad-dev-auto-result-unclear-1234.md). A "name must contain the story key" rule breaks that
    documented contract — it needs an explicit exemption.
  2. Sweep bundles have no story-shaped key (dw-<name> / dw<N>-<name>), and
    synthesize_result is legitimately called with story_key=None. The check must no-op on a
    falsy key.

A third, smaller option worth putting on the table: the root enabler is the main-checkout fallback
in _artifact_dirs, which under worktree isolation deliberately widens the search into a shared
directory. Narrowing or dropping it is a smaller change, and no test pins it
test_generic_dev_finds_spec_in_worktree leaves the main dir empty and asserts only that the
cwd-derived dir is used.

Tests that pin the current mtime-only behaviour, for whoever takes this (all in
tests/test_devcontract.py): test_find_artifact_picks_newest_with_heading is the one that
literally pins "newest wins, identity irrelevant"; test_find_artifact_accepts_no_spec_fallback_prefix
and test_synthesize_result_non_utf8_fallback_marker_is_not_terminal are caveat 1. The
adapter-level tests in tests/test_generic_tmux.py all use story_key="3-1" against
spec-3-1-foo.md, so a spec-<key> prefix rule leaves them green — except
test_scan_readback_non_utf8_fallback_marker_returns_none, which is caveat 1 again.

Relationship to existing issues

Optional second hardening

Independently of the identity check: require positive evidence that the session produced
something (non-empty transcript delta, or non-null token usage) before a read-back artifact can
upgrade a dead session to completed. That is the review-leg counterpart of #88's proof-of-work
gate, and it would catch this class even when no foreign artifact is present.

Not established

  • Why three codex windows died silently after 36-43 minutes with no output. Not determinable
    from surviving evidence, and not needed: the bug is how the engine reacts to a dead session.
  • The i-11 causal link (see above).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclearea:parallelParallel story executionbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions