Skip to content

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

Description

@pbean

Follow-up to #224/#271 (PR #273, merged). The missing-marker fallback synthesizes a result when the bmad-dev-auto skill finalizes a spec's frontmatter to terminal status: without appending the ## Auto Run Result marker — but its attribution is heuristic (2-stable-Stops fingerprint, or single sighting post-kill), the spec is left non-compliant on disk, and PR #273 documented three accepted risks. This issue makes catch + fix deterministic with four harness-side mechanisms, in ONE PR, delivered in 4 single-session phases on branch feat/deterministic-marker-catch-<this issue>.

Mechanisms

  1. M1 — launch-state snapshot + content-hash gate. _reset_spec_for_review (engine.py) captures SpecSnapshot(path, mtime_ns, sha256, fm_status) immediately 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 and threads it through _run_session into a new SessionSpec.spec_snapshot field. _frontmatter_fallback deterministically refuses to synthesize when the candidate's bytes hash equals the snapshot (spec provably untouched by this session) — in ALL modes, including dead_window. Kills the documented dead-window false positive (review killed after an mtime-only bump, before flipping to in-review, scored done without running). New resultless verdict unmodified-since-launch; dead-window lifecycle crumb frontmatter-unmodified-refused. Snapshot is process-transient (crash-resume degrades to today's conservative path).
  2. M2 — mid-session status-transition observation. New _observe_tick(handle, spec) no-op hook on CodingCLIAdapter (must be on the ABC — OpencodeDevAdapter shares _DevSynthesisMixin with its own wait loop), called from both heartbeat-throttled blocks (generic + opencode). The mixin impl reads the snapshot path's frontmatter each ~30s tick and records the FIRST observed normalized status that is non-empty, non-terminal, and ≠ the launch status (in practice in-review) into _fm_transition_obs, with lifecycle crumb spec-status-transition-observed. A recorded transition makes a later terminal frontmatter deterministic proof this session wrote it → sound single-sighting synthesis, live or dead window. Missed transitions (flip between ticks) fall back to the 2-obs fingerprint. Hash gate outranks transition evidence (bytes reverted to launch state must refuse).
  3. M3 — artifact repair append_auto_run_result. New devcontract writer, inverse of strip_auto_run_result: appends ## Auto Run Result / Status: <fm status> / provenance note (ORCHESTRATOR_SYNTH_NOTE). False on absent file or existing real (non-fenced) heading; present-but-unreadable raises (caller imposes best-effort). Engine-side _repair_spec_marker(task, rj) called at the session-synthesized-from-frontmatter journal site (covers live-Stop, crash-path, and post-kill synthesis): guards = _generic_dev(), within-roots (skip reason out-of-tree), fm re-read must be terminal AND match rj["status"] (skip reason fm-mismatch — never author an inconsistent marker); journal kinds spec-marker-repaired / spec-marker-repair-failed / spec-marker-repair-skipped. Non-interference: _reconcile_generic_terminal_status only acts when fm LAGS prose; _salvage_review_timeout reads fm fresh; the append is engine-side ONLY (an adapter-side write would perturb its own mtime/hash observations).
  4. M4 — targeted contract nudge, gated by new policy knob limits.dev_contract_nudge (bool, default true). At the first terminal-frontmatter-pending observation (wait=True, exactly one candidate, not dead-window, transition not proven), send one tmux nudge (CONTRACT_NUDGE_TEXT, parameterized spec path + status) telling the skill to append its required marker section, then end its turn. Exactly once per session via a never-cleared _contract_nudge_sent set, marked BEFORE the send (a raising transport still satisfies exactly-once); MultiplexerError swallowed; touches NO stall counters (Claude adapter: Stop hook + idle detection re-entrant loop prevents session from ending #149: an mtime bump resets the observation counter but must never re-nudge). A compliant skill's append is then harvested by the NORMAL marker scan; synthesis stays the backstop.

Decision ladder (_frontmatter_fallback, final shape)

scan → 0 candidates: pop obs, no-artifact → >1: ambiguous-frontmatter → read candidate (mtime, fm status, sha256 when 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) (crumb gains transition=) → record-obs branch: fingerprint + terminal-frontmatter-pending + contract nudge (M4).

HARD CONSTRAINT

The spec's status: done at review launch is load-bearing routing input to the upstream skill (step-01 → fresh step-04 review pass; _reset_spec_for_review docstring). Frontmatter status is NEVER mutated at review launch — all four mechanisms are observation or prose-append only.

Phases (one fresh session each, shared branch, PR opened in phase 4)

  • S1 — M1: SpecSnapshot in adapters/base.py + engine capture/threading + hash gate in _frontmatter_fallback + adapter/engine tests + consolidated CHANGELOG entry
  • S2 — M2: base _observe_tick hook + both heartbeat call sites (generic.py, opencode_http.py) + mixin observation + widened synthesis predicate + tests incl. opencode mirror
  • S3 — M3: append_auto_run_result + ORCHESTRATOR_SYNTH_NOTE in devcontract.py + engine _repair_spec_marker + devcontract/engine tests
  • S4 — M4: nudge + dev_contract_nudge knob (policy.py + core.toml + template + tests) + docs (FEATURES.md, tui-guide.md) + CHANGELOG finalize + open PR (ready-for-review, closes this issue) + file & cross-link the upstream BMAD-METHOD issue (bmad-dev-auto omits the marker append at review HALT)

Every phase ends: targeted pytest → full uv run pytest -qtrunk check --no-fix → commit → push (branch stays shippable).

Traps (carry into every phase)

  • Do NOT add kwargs to _synth_result — the monkeypatched stub in tests/test_generic_tmux.py (def raising(handle, spec, *, wait, dead_window=False)) must stay valid; snapshot rides SessionSpec, state rides adapter dicts.
  • _DevSynthesisMixin must declare send_text as a BARE class annotation, never a def (MRO: the mixin precedes the concrete adapter — a def would shadow the real send_text on GenericDevAdapter AND OpencodeDevAdapter).
  • CHANGELOG Unreleased heading order is Added/Changed/Fixed; inserting a heading mid-section silently reparents entries. One consolidated entry, extended per phase.
  • core.toml settings entries are default_ref/options_ref into policy.py, never literals; sync tests in tests/test_settings_schema.py enforce bidirectional coverage.
  • Engine mock tests run the generic path (_generic_dev() always True): review runs via dev_effect(followup_review=True), sprint advances at DEV time.
  • trunk check --no-fix (ruff F401 autofix deletes re-exports). uv run pytest (PEP668). 2 pre-existing test_module_skills_sync.py failures are known drift.
  • First heartbeat tick always fires (last_heartbeat is None) — M2 observation runs on the first tick too.

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