Skip to content

task.baseline_commit can drift mid-flight (and intent_gap reverts discard work with no preserve artifact) #701

Description

@rxm7706

Summary

A story's isolated-worktree task.baseline_commit is meant to stay fixed for its whole dev-retry
loop (the comment at engine.py around line 1229 says so explicitly), captured once via
verify.rev_parse_head(self.workspace.root) when the worktree opens (engine.py:1707). We
observed it change to a later commit — the shared target branch's tip, many stories ahead —
while a dev session was still mid-flight, with the story's own worktree never having moved.
_verify_shared_gates (verify.py:1211-1235) then compares the dev session's own (correct)
baseline_revision against the drifted task.baseline_commit, finds a mismatch, and the story
is permanently deferred (spec baseline ... does not match orchestrator-recorded baseline ...)
even though the completed attempt did real, reviewed, honest work. The run then dispatches the
next story rather than pausing or escalating, so the failure is silent until an operator
notices via other means.

This report also carries a sibling work-loss mode (same session, same package): an
intent_gap halt correctly reverts the attempt but leaves no attempt-preserve/* branch
and no failed/*/changes.patch — unlike the deferred-story path that already preserves via
scm.keep_failed. Evidence for that mode is in the second section below.

Gate record (filing checklist)

  1. Repo access / org relationship (checked 2026-08-23): viewer rxm7706 has pull only on
    bmad-code-org/bmad-loop (no push/triage/maintain). Issues enabled; Discussions disabled.
    Filing channel = GitHub Issue (not PR, not Discussion).
  2. Duplicate search (checked 2026-08-23): searched for baseline_commit,
    orchestrator-recorded, mid-flight drift, intent_gap / attempt-preserve / keep_failed.
    No open issue describes mid-flight task.baseline_commit overwrite during an in-flight
    automatic retry, nor intent-gap revert without preserve. Adjacent but distinct:
    #640 (rearm_escalation advances
    task baseline without updating the spec's baseline_revision) — related shape on the
    re-arm path, not the mid-flight automatic-retry drift this report covers.

Evidence (one occurrence, full journal timeline)

Run 20260813-094919-bfcb, story 9-6-plan-and-action-types-repo-fingerprint-and-the-plan-builder,
journal.jsonl:

worktree-opened      story=9-6  (branch/worktree created — baseline should be ~523e938c79 here)
dev-decision  attempt=1  session_status=timeout  action=retry
rollback-auto         baseline=26102ea12c6d...   <-- ALREADY drifted, ~3h after worktree-opened
attempt-commits-preserved  ref=attempt-preserve/...-523e938c  count=124
dev-decision  attempt=2  session_status=completed  action=defer
  reason="spec baseline 523e938c7978 does not match orchestrator-recorded baseline 26102ea12c6d"
story-deferred  reason=(same)
worktree-opened  story=10-1  (dispatcher moves on)

The worktree's own preserve-ref name (attempt-preserve/20260813-094919-bfcb-523e938c) confirms
its real basis never moved past 523e938c79. task.baseline_commit was already the later commit
by the time rollback-auto fired after attempt 1's timeout — i.e. it drifted during attempt 1,
not at any point our own code touched. Between those two commits, the shared target branch
(loop/pyforge-marshal) had several sibling stories land (an operator-driven out-of-band
recovery in our case, but plausibly any concurrent landing on the same branch).

What we found in the source (bmad-loop 0.9.0)

  • engine.py:1707 (_dev_phase) sets task.baseline_commit exactly once per dev-phase
    invocation, from self.workspace.root's HEAD — which is the per-story isolated worktree
    (self.workspace = unit.workspace at engine.py:622, set before drive() runs). We could
    not find, via static reading, the exact call site that overwrites it again mid-flight for an
    already-open worktree; happy to help narrow this down further if useful.
  • runs.py:872-908 (the operator-driven resolve/re-arm path) already handles an equivalent
    hazard explicitly: it re-stamps BOTH task.baseline_commit (from state.project's live HEAD)
    AND the spec's baseline_revision together, with a comment naming precisely this failure
    ("without this, the re-driven step-04 would build its review diff... clawing back the very
    resolve-session commits the advance above just blessed"). This suggests the maintainers are
    already aware of the shape of this problem for the re-arm path; we couldn't find the
    equivalent protection on the automatic retry path. See also adjacent #640.
  • verify.py:1220-1235's allow_ancestor_baseline carve-out already treats an ancestor
    baseline as sound for one specific case (Sweep bundle that routes into an existing done spec always fails the baseline gate; recovery message then gives wrong (in-place) instructions in worktree mode #161, deferred-work-bundle re-arm). Extending that
    carve-out (or an equivalent) to the plain-retry path looks like it would fix or at least
    contain this.

Impact

Three occurrences in one session across two different projects (9 stories, ~8 hours; then this
one story; then 3 more stories in a sibling project) — each required manual git archaeology
(attempt-preserve/* branches, failed/*/changes.patch) to recover, since scm.keep_failed's
safety net preserved the work but nothing in the automatic path re-lands it.

Environment

bmad-loop 0.9.0 (and later fleet pins), multi-worktree isolated mode, branch_per targeting a
single shared loop/<project> branch across concurrently-scheduled stories.


Sibling mode: intent_gap revert discards work with no preserve artifact

Summary

When a bmad-loop dev session finds an intent_gap, it correctly reverts the attempt rather
than committing contract-violating work — but that revert leaves no recoverable git artifact:
no attempt-preserve/* branch, no failed/*/changes.patch, a clean reflog. The deferred-story
path already preserves via scm.keep_failed / _preserve_attempt_commits; the intent-gap path
does not.

Evidence (one occurrence)

Marshal Story 10.1 (2026-08-14): full implementation + suite green + two adversarial reviews,
then a correct intent-gap halt on a real contract contradiction. Revert left the worktree
"confirmed clean, matching baseline_revision" with nothing to recover via git. Recovery was
only possible by reconstructing the diff from adversarial-review Agent prompts embedded in the
Claude Code session transcript — fragile and not a substitute for the preserve path.

Ask

Same preservation guarantee on the intent-gap revert path that deferred stories already get
(attempt-preserve/* or failed/<story>/changes.patch), without weakening the intent-gap halt
itself.

Local containment (not a package patch)

We are shipping Marshal-side detectors / loud ATTENTION containment for baseline-drift and plan
adapter-seam preservation for intent-gap — we are not patching the installed bmad_loop
package in place. Happy to iterate on a minimal repro if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions