"""The deterministic control loop. Per story: dev session -> artifact verification -> bounded review loop -> deterministic verify commands -> orchestrator commit. The engine never edits sprint-status.yaml or spec files; it re-reads them to decide and verify. All creative work happens inside disposable adapter sessions. """ from __future__ import annotations import contextlib import contextvars import functools import hashlib import os import re import shutil import signal import sys import time import traceback from dataclasses import dataclass from enum import StrEnum from pathlib import Path from stat import S_ISREG from typing import TYPE_CHECKING, Callable, Literal, NamedTuple, NoReturn, Protocol, Sequence from . import ( artifact_publication, deferredwork, devcontract, envvars, gates, operatoractions, ticketstore, verify, ) from .adapters.base import CodingCLIAdapter, SessionResult, SessionSpec, SpecSnapshot from .bmadconfig import ProjectPaths from .childrun import ChildInterrupted, install_stop_probe, reset_stop_probe from .escalation import ( REVIEW_TIMEOUT_STATUSES, Action, Decision, UsageLimitPause, critical_session_reason, decide_dev, decide_reverify, decide_review_session, display_critical_reason, display_pause_reason, env_fault_claim, env_fault_pause_reason, no_work_pause_reason, parked_pause_reason, pause_title, preference_escalations, review_exhausted, review_retry_or_exhaust, session_failure_reason, unresolved_escalation_keys, usage_limit_pause, ) from .frontmatter import FrontmatterWriteError from .install import dev_primitive_or_default from .journal import SELF_MINTED_FIELDS, Journal, save_state from .model import ( ENV_FAULT_SITE_DISPATCH_PREFIX, PAUSE_ENVIRONMENT, PAUSE_EPIC_BOUNDARY, PAUSE_ESCALATION, PAUSE_SPEC_APPROVAL, PAUSE_STORY_GATE, SWEEP_REFUSED_DIRTY, SWEEP_REFUSED_FAILED, SWEEP_REFUSED_NOT_STARTED, Phase, RunState, SessionRecord, StoryTask, TokenUsage, VerifyOutcome, env_fault_site_reverifiable, result_mapping, ) from .mountpaths import rebased_project from .platform_util import ( atomic_replace, atomic_write_text, atomic_write_text_confined, make_dirs_confined, require_root_pinned, retrying_unlink, safe_segment, unlink_confined, ) from .plugins import GrantSet, HookBus, HookContext, PluginRegistry, PluginTrustError, Refusal from .policy import Policy from .recovery_flow import OwnedPlanWriter, RecoveryFlow from .runs import ( StateRootError, clear_graceful_stop, consume_stop_request, deferred_stash_path, deferred_stash_staged_path, drain_refused_kills, events_dir_for, graceful_stop_requested, kill_session, latest_completed_dev_record, mount_root_identity, owner_run_dir, pinned_state_env, read_stop_request_mode, reset_owner_run_dir, set_owner_run_dir, task_spec_path, ) from .sprint_source import SprintWorkSource from .sprintstatus import ( STATUS_ORDER, SprintStatusError, SprintStatusWriteRefused, ) from .sprintstatus import advance as sprint_advance from .sprintstatus import advanced_bytes as sprint_advanced_bytes from .sprintstatus import status_in_bytes as sprint_status_in_bytes from .sprintstatus import story_status as sprint_story_status from .statemachine import advance from .worksource import Halt, SourceUnreadable, WorkRef, WorkSource, resumed_traits_for from .workspace import UnitWorkspace, Workspace, discard_worktree, open_unit_workspace from .worktree_flow import WorktreeFlow if TYPE_CHECKING: # Type-only: the worktree-provisioning helpers speak in CLI profiles. from .adapters.profile import CLIProfile # `origin:` marker prefix for ledger entries harvested out of a spec's # frontmatter `deferred:` list (BMAD-METHOD #2640). The full marker is # ` ` — matched verbatim on every later harvest, so it is # the dedup key and must never be reworded; `-malformed ` # marks the aggregated unparseable-items meta-entry. HARVEST_ORIGIN = "spec-deferred" # A completed session owns the spec bytes it just wrote. Give a transient repair # read one immediate retry, but never dispatch another session over an unread # source: that later pass is allowed to replace the frontmatter list. HARVEST_REPAIR_READ_ATTEMPTS = 2 # Journal field names a bound `journal.append(...)` call owns, and which therefore # cannot be carried by a `**splat` of LLM-authored keys. `self`/`kind` are `append`'s # bound parameters and `story_key` is already bound at the one splat site that needs # this, so any of them arriving in the splat raises `TypeError: got multiple values # for argument`; `ts` does not raise and instead silently overwrites the entry's real # timestamp, since `append` builds `{"ts": now, "kind": kind, **fields}`. # `log_task`/`log_pos` also do not raise: `append` stamps them with `setdefault`, so a # supplied value silently wins and forges the pane-log pointer. Import the defining # set from the minting site so this filter cannot drift from that pair. _JOURNAL_RESERVED_KEYS = frozenset({"self", "kind", "story_key", "ts"}) | SELF_MINTED_FIELDS def _digest_of(text: str | None) -> str: """Hash ledger text for attribution; absent and empty are equivalent.""" return hashlib.sha256((text or "").encode("utf-8")).hexdigest() @dataclass(frozen=True) class _UndecodableLedger: """The typed degraded answer of :meth:`Engine._ledger_text` for a ledger whose bytes do not decode (DW-231). A TYPE rather than a sentinel string, deliberately: a ``str`` sentinel could equal a snapshot, be digested as "ours" and be WRITTEN back by a restore. A frozen dataclass equals nothing a consumer compares against — ``==`` with a ``str`` or ``None`` is always False — and cannot be passed where a ``str`` is typed, so pyright makes every consumer take its skip arm deliberately: no site can hand this to ``atomic_write_text`` or ``_merge_snapshot_entries``. ``digest`` is the sha256 of the RAW bytes, which keeps ``_ledger_digest``'s equality contract exact: every ``_digest_of`` answer hashes a valid UTF-8 encoding, and undecodable bytes are never one, so "did the ledger change" is still answered without guessing. """ digest: str error: str @dataclass(frozen=True) class _UnreadableLedger: """The typed degraded answer of :meth:`Engine._ledger_text` for a ledger the OS refused to read — EACCES, EIO, ELOOP (DW-258). A second type beside :class:`_UndecodableLedger` rather than a discriminator on it: that type's ``digest`` is a contract (sha256 of the raw bytes, exact equality) an OS refusal cannot honor, because there are no bytes in hand — the ``OSError`` branch reads NOTHING from the ledger. Keeping ``digest`` non-optional there and absent here lets pyright reject any consumer that tries to digest the unreadable answer, and the frozen dataclass keeps the DW-231 guarantees: it equals no ``str``/``None`` snapshot, cannot be passed where a ``str`` is typed, and every consumer takes its skip arm explicitly. For attribution the answer is UNKNOWN, which :meth:`Engine._ledger_digest` spells as :data:`_UNREADABLE_LEDGER_DIGEST` and :meth:`Engine._ledger_changed_since_baseline` treats as "not credited". """ error: str # `isinstance` tuple for the two typed degraded answers of `_ledger_text`. _DEGRADED_LEDGER = (_UndecodableLedger, _UnreadableLedger) # The `str` `_ledger_digest` answers for an `_UnreadableLedger`, so that # `baseline_ledger_digest` (persisted, typed `str | None`) can still be captured # over a refused read. Not a hex sha256, so no `_digest_of` answer and no raw-bytes # digest ever collides with it. It is the ABSENCE of a digest, not a digest: the # only compare that may inspect it is `_ledger_changed_since_baseline`. _UNREADABLE_LEDGER_DIGEST = "" def _ledger_fault_text(ledger: Path, e: deferredwork.LedgerReadError | OSError) -> str: """Attribute a ledger read fault for the journal and the repair notice. A `LedgerReadError` already names the path and the decode or OS read fault. A raw `OSError` gets the path and its class name, because ``[Errno 13] Permission denied`` alone does not say what kind of refusal it was — the wording ``runs.unreadable_sweep_ledger``'s DW-234 arm uses. """ if isinstance(e, deferredwork.LedgerReadError): return str(e) return f"{ledger} could not be read ({e.__class__.__name__}: {e})" def _bounded_stream_tail(text: str, max_bytes: int) -> tuple[str, int, int]: """Cut a verifier stream down to what ``verify.stream_capture_kb`` retains. Returns ``(tail, full_bytes, retained_bytes)``. Both counts measure the DECODED STREAM encoded as UTF-8 — never the file the caller writes it to, whose size differs on Windows because text mode translates ``\\n``. Keeping the counts on one side of that boundary is what makes the journal record unambiguous: ``full_bytes`` is what the command emitted, ``retained_bytes`` is how much of it survived the cap, and their inequality IS the truncation. The TAIL is kept, the direction every other bound on this output takes (``run_verify_commands``' merged ``[-2000:]``): a failing suite puts its failure at the end. A byte cut can land mid-character, so the leading partial is dropped rather than decoded into a ``\\ufffd`` this function would be inventing — the stream already carries whatever replacement chars its own decode produced, and minting one here would put a corruption marker at a boundary WE chose. ``max_bytes <= 0`` needs no branch of its own: the slice is empty by construction, which is exactly "capture nothing". """ tail, full_bytes = verify.byte_tail(text, max_bytes) return tail, full_bytes, len(tail.encode("utf-8")) @dataclass(frozen=True) class VerifyCommandRecords: """What one verify-command pass published to ``post_dev_verify`` / ``post_review_verify``. The records themselves plus the two keys that say WHICH pass they are: ``stage`` (``"dev"`` | ``"fix"`` on ``post_dev_verify``, ``"review"`` on ``post_review_verify``) and the story's ``sequence`` ordinal. Both already ride the journal's ``verify-command-result`` entries; carrying them on the hook context too is what lets a plugin tell the dev and repair legs apart and join back to those entries — neither of which the results alone can do, since both legs emit the same stage from the same phase on one shared ``attempt`` counter. The default instance (:data:`NO_VERIFY_COMMANDS`) is the "no pass ran" value the callers start from, so a leg that never reaches verification publishes three explicit ``None``/empty fields rather than three unexplained defaults. ``sequence`` stays ``None`` when the pass ran but recorded nothing (no ``[verify] commands`` configured) — nothing was journalled, so there is no ordinal to join on. See ``HookContext.command_results`` for the full taxonomy a reader has to apply. """ results: tuple[verify.CommandResult, ...] = () stage: str | None = None sequence: int | None = None NO_VERIFY_COMMANDS = VerifyCommandRecords() class RunPaused(Exception): def __init__(self, reason: str, stage: str, story_key: str | None = None): super().__init__(reason) self.reason = reason self.stage = stage self.story_key = story_key # The usage-limit facts of a pause whose reason is the usage-limit reason # (DW-591), attached by `Engine._escalate_session`; None on every other. self.usage_limit: UsageLimitPause | None = None SpecWriteReason = Literal["salvage", "reconcile", "adoption", "repair"] """Why an engine site writes a spec's frontmatter ``status`` — the token :meth:`Engine._spec_status_write` receives, one per writer site.""" class SpecStatusWriteRefused(RuntimeError): """:meth:`Engine._spec_status_write` refused a write the run's mode does not allow: nothing was written. A ``RuntimeError``, so a repair-write site that does not catch it fails as loudly as a write that raised.""" def __init__(self, reason: SpecWriteReason, target: str, why: str): super().__init__(f"{reason} write to status {target!r} refused: {why}") self.reason = reason self.target = target self.why = why class RunStopped(Exception): """Raised to unwind the loop cleanly so the engine can mark the run `stopped` (a deliberate stop, distinct from a crash). Two flavors, distinguished by ``graceful``: - ``graceful=False`` (default) — a *hard* stop: the SIGTERM/SIGINT handler, or a ``mode: "hard"`` stop request the engine honored at an item boundary (:meth:`Engine._check_stop_request`) or on either side of a session (:meth:`Engine._run_session`). The loop may have been interrupted mid-session, so the in-flight agent window can still be live and must be torn down unconditionally. - ``graceful=True`` — a stop requested via the ``stop-request.json`` control file in its default ``graceful`` mode and detected at an item boundary (:meth:`Engine._check_stop_request`). The in-flight item already completed through commit, so ``run()`` runs the wanted subset of the clean-finish path (worktree GC + ``post_run`` + policy-gated session teardown) rather than a hard kill, and the run stays resumable. ``via`` names the channel a hard stop arrived on — ``"stop-request"`` for the control file, ``None`` for a signal — and rides the ``run-stop`` journal entry. It is the only evidence that separates the two on a native-Windows run, where the signal path cannot fire at all (#319).""" def __init__(self, graceful: bool = False, via: str | None = None): super().__init__("graceful stop" if graceful else "stopped") self.graceful = graceful self.via = via if not graceful: # Every hard stop is constructed here (the signal handler, the # boundary and in-session sites, the verify/hook conversions), so this # is where the pending request becomes honored: disarm the child # runner's probe so the stop's own unwind — teardown and rollback # hooks — runs to completion instead of being refused (DW-353). probe = _hard_stop_probe.get() if probe is not None: probe.unwinding = True class SweepFactory(Protocol): """Call shape of the child-sweep launcher :meth:`Engine._maybe_auto_sweep` drives — in the product, the inner function ``cli._sweep_factory`` returns, injected so this module need not import ``cli``, ``runsetup`` or ``sweep``. Spelled as a Protocol rather than a ``Callable[[str], None]`` alias, mirroring :class:`runsetup.MakeAdapters`, only because the keyword-only ``started`` thunk is part of the contract and a positional callable alias cannot say so. ``started`` fires once the child sweep is composed and its run dir published (:func:`runsetup.compose_sweep`), and is what lets the engine spend the run's trigger on a child that actually started. It is **required, with no default**: the engine reads "raised without calling it" as "no child was ever launched" and leaves the trigger unspent, so a defaulted no-op would let an un-updated implementation make that claim for a child that ran — the one direction that costs a duplicate sweep. It is idempotent, so an implementation in doubt should call it.""" def __call__(self, trigger: str, *, started: Callable[[], None]) -> None: ... @dataclass(frozen=True) class RunSummary: run_id: str done: int deferred: int escalated: int paused: bool paused_reason: str # Raw: cache reads at full weight. Kept as the historical field name. total_tokens: int # Cost-proportional: cache reads discounted by limits.cache_read_weight, the # same total every budget judges (#129). Deliberately has NO default — a 0 # would render "0 weighted tokens (716M raw)", silently wrong in exactly the # direction this field exists to fix. There is one construction site; a # TypeError beats a plausible zero. weighted_tokens: int # stories that committed but owe human-only external actions (Phase # AWAITING_OPERATOR). Defaulted, unlike weighted_tokens: a miscounted 0 here # is a count that is genuinely 0 on every run that never parks a story, so # the default is honest rather than silently wrong. awaiting_operator: int = 0 crashed: bool = False crash_error: str | None = None # auto-sweep triggers this run did not deliver, as (trigger, SWEEP_REFUSED_*) # pairs (#501). Defaulted for the same reason as awaiting_operator: empty is # the honest value on every run that refused nothing. # # A tuple of pairs, NOT the dict `RunState` holds it in, because this class is # `frozen=True`: a dict field leaves the "snapshot" mutable through its own # container, and — silently — makes every RunSummary unhashable, since # frozen+eq synthesizes `__hash__` from the field tuple. Nothing hashes one # today, which is exactly why that would go unnoticed. `summary()` converts. sweeps_refused: tuple[tuple[str, str], ...] = () def render(self) -> str: # Lead with weighted (what spend actually costs) and name both units: # raw is 5-8x higher on agentic workloads, where cache reads are 80-95% # of the count, and an unlabeled raw figure reads as a cost overrun. if self.total_tokens: tokens = ( f"{self.weighted_tokens:,} weighted tokens " f"({self.total_tokens:,} raw incl. cache reads)" ) else: # No usage tracked at all (usage_parser = "none", or Copilot's # shutdown-only flush). Splitting this into "0 weighted (0 raw)" # would assert free work twice over; one plain zero is honest. tokens = "0 tokens" # Appended only when non-zero: a run that parks nothing is the norm, and # a standing ", 0 awaiting operator" would train readers to skip the very # clause that matters on the run where it is not zero. parked = f", {self.awaiting_operator} awaiting operator" if self.awaiting_operator else "" lines = [ f"run {self.run_id}: {self.done} done, {self.deferred} deferred, " f"{self.escalated} escalated{parked}, {tokens}" ] # The two embedded reasons are routinely multi-line (a CRITICAL carrying a # verify tail, a crash traceback tail): fold each onto its own line, so the # multiline run-finished / graceful-stop notice keeps one line per record. if self.crashed: crash = self.crash_error lines.append(f"CRASHED: {gates.notice_line(crash) if crash is not None else crash}") if self.paused: lines.append(f"PAUSED: {gates.notice_line(self.paused_reason)}") # Appended only when it fired, like `parked` above. Under # `[sweep] auto = "run-end"` there is exactly one trigger per run and it # is never re-asked once the run finishes (see `_maybe_auto_sweep`), so # this line IS the remedy — the refusal is otherwise journal-only, and # the operator never learns the deferred work went untouched. The clean # worktree is named because `cmd_sweep` hard-refuses an unclean tree: # without it the follow-up lands the operator in a second refusal. if self.sweeps_refused: detail = ", ".join(f"{trigger} ({why})" for trigger, why in self.sweeps_refused) lines.append( f"SWEEP NOT RUN: {detail} — deferred work is untouched; " "run `bmad-loop sweep` with a clean worktree" ) return "\n".join(lines) # Appended to an injected plugin-workflow session prompt AHEAD of the completion # contract below, whenever the run has a sprint board (`_sprint_board_instruction` # non-empty; StoriesEngine empties it and this section disappears with it). Same # words as the dev/review seams inject, so the three surfaces cannot drift apart — # `{clause}` is that method's return value verbatim, not a second copy of it. # # A workflow session is dispatched in exactly the window `_sprint_board_instruction` # describes: post_dev_phase and post_review_result run after `_post_dev_state_sync` # has advanced sprint-status.yaml, pre_commit_gate runs before `finalize_commit` # lands the story's single commit — so all three open on the same uncommitted, # unattributed board change that #437's review session reverted. # # Carries the prohibition ONLY. The `blocked` hand-back redirect stays review-only # for the reason its own docstring gives: it synthesizes a CRITICAL that halts the # whole run, which is the wrong trade for a session that is not the sign-off # authority. A workflow that genuinely cannot proceed already has a channel — the # completion contract's own `status: blocked` marker, which the orchestrator reads # as a non-completion and routes through the blocking/advisory decision instead. # # A section rather than a bare sentence because a workflow prompt is plugin-authored # markdown of unknown shape; a trailing sentence would read as a clause of whatever # it happens to land after. The heading names the owner, matching the clause's # opening words. WORKFLOW_BOARD_CONTRACT = """ ## Sprint board (orchestrator-owned) {clause}""" # Appended to every injected plugin-workflow session prompt. The dev/review # skills carry their own result conventions, but a workflow prompt is arbitrary # text from a plugin manifest — without an explicit protocol the session has to # *infer* the completion-marker convention, and one that finishes its work but # never writes the marker leaves the orchestrator waiting (a completion-signal # livelock, bounded only by session_timeout_min). The orchestrator's adapter # discovers the marker by its `-result-` filename prefix and # mtime, not by exact name (devcontract.FALLBACK_RESULT_PREFIXES accepts both # the pre- and post-rename spellings, so either resolution reads back). WORKFLOW_COMPLETION_CONTRACT = """ ## Completion signal (required) When you have finished this workflow — fully done OR blocked and unable to proceed — you MUST create the file: {marker_path} containing YAML frontmatter that declares the outcome, then end your turn: --- status: done --- Use `status: blocked` (plus a short explanation in the body) if you could not finish. This marker is the orchestrator's only completion signal for this session; it is required in addition to any artifacts the workflow itself produces. If you end your turn without it, the session is eventually declared stalled and its work may be discarded.""" # The whole prompt of a `gates.retrospective = "auto"` session (DW-389). The retro # role runs a plain (non-synthesizing) adapter, so its completion signal is the # session-written `tasks//result.json` the adapter reads back on Stop — # the same channel the sweep triage session uses. The clause is spelled out # because `bmad-retrospective` is an upstream skill that knows nothing of that # convention. Success is never read off this document alone: `_verify_retro` # re-checks the board and the retro doc on disk as well. RETRO_AUTO_PROMPT = """/bmad-retrospective -H {epic} ## Completion signal (required) When the retrospective is finished — or blocked — write $BMAD_LOOP_RUN_DIR/tasks/$BMAD_LOOP_TASK_ID/result.json as {{"workflow": "bmad-retrospective", "status": "done"}} (or "blocked" plus "reason"), \ then end your turn.""" # The skill `RETRO_AUTO_PROMPT` invokes; probed in the retro adapter's skill tree # before dispatch (`retro-auto-skipped reason=skill-missing`). RETRO_SKILL = "bmad-retrospective" # How many leftover paths a retro dirty-tree pause names in its reason. _RETRO_DIRTY_REASON_PATHS = 10 # The frontmatter `verdict:` vocabulary bmad-retrospective writes into its retro doc # (its `references/retro-document.md`; the skill's `sprint_status.py --verdict` # refuses any other spelling). The board key reads `done` whatever the verdict, so # the doc is the only place a rejection shows (DW-487). Anything else — no doc # frontmatter, no `verdict:`, an unknown spelling, an unreadable doc — is journaled # as `RETRO_VERDICT_UNKNOWN`, never folded into an acceptance. RETRO_VERDICTS = frozenset({"accepted", "accepted-with-open-items", "rejected"}) RETRO_VERDICT_REJECTED = "rejected" RETRO_VERDICT_UNKNOWN = "unknown" def _retro_auto_prompt(target: str) -> str: """The auto-retro session prompt for ``target``, the source's ``retro_target(epic)`` (the sprint source's is ``str(epic)``).""" return RETRO_AUTO_PROMPT.format(epic=target) def _session_task_id(story_key: str, part: str, seq: int, generation: int) -> str: """Single composition point for session task ids. Sanitize the whole composition, not the parts: two individually capped parts can still compose past a Windows filename segment limit, and ``safe_segment``'s digest suffix differs between the two orders. ``_resumable_session``'s resume match must be byte-identical to what ``_run_session`` stored, so both MUST call this. ``generation`` is ``StoryTask.generation``, bumped whenever an escalated task is reopened while resetting ``attempt`` to 0 (``runs.rearm_escalation`` and the sweep engine's ESCALATED restart arms). The next dispatch bumps the attempt back to 1, so without this the re-minted id is BYTE-EQUAL to a record the abandoned attempt already appended to ``task.sessions``. For dev/review tasks, ``_resumable_session`` would then replay that abandoned verdict (#705); sweep task records would alias the same task-directory artifacts. REQUIRED, with no default, for the reason ``verify_dev_exclude_relpaths``' ``root`` is: an implicit ``generation=0`` is correct in every run that never re-armed — which is nearly every test — and wrong only on the re-armed one, so a fourth mint site that omitted it would look right everywhere it was exercised and silently re-open #705. Requiring it turns OMISSION into a type error; it does not police a wrong value. The suffix is composed INSIDE the f-string, before ``safe_segment``, because the whole-composition cap and digest contract above is what makes the id a legal single segment; appending after sanitization could push it back over ``MAX_SEGMENT``. It is emitted ONLY when ``generation > 0``, so every id an existing run already wrote to disk stays byte-identical and a run resumed across this upgrade still finds its ``tasks/`` directories.""" gen = f"-g{generation}" if generation > 0 else "" return safe_segment(f"{story_key}-{part}-{seq}{gen}") def _at_or_past(landed: str | None, target: str) -> bool: """Whether ``sprintstatus.advance``'s return means the row REACHED ``target``. Mirrors ``advance``'s own never-regress comparison so the two agree on what "already there" means; `None` (missing file, or absent row) is never reached. A status outside ``STATUS_ORDER`` is unorderable rather than late, so it counts only on an exact match — the same direction ``advance`` takes when it declines to compare an unknown current against a known target. """ if landed is None: return False if landed == target: return True if landed not in STATUS_ORDER or target not in STATUS_ORDER: return False return STATUS_ORDER.index(landed) >= STATUS_ORDER.index(target) # A story id in this repo is a dash/dot composite ("1.1", "3-2", "1-1-a"), so the # leading-label strip must start at a DIGIT and may then run on through letters. # Both looser and tighter patterns get this wrong in opposite directions: `\S+` # eats the real title in "Story Points: Add estimates", while `\d+\.\d+` stops # matching the dash composites this project actually issues. _STORY_LABEL_RE = re.compile(r"^story\s+\d[\w.\-]*:\s*", re.IGNORECASE) # C0 controls + DEL + unpaired surrogates. Not cosmetic: this title reaches # `git commit -m` as an argv element, and both classes are unspawnable there — # `subprocess.run` rejects an embedded NUL with a plain ValueError, and a lone # surrogate has no UTF-8 encoding, so encoding the argv raises UnicodeEncodeError. # Neither is in `_run_git`'s translated set (TimeoutExpired, UnicodeDecodeError, # OSError — and UnicodeEncodeError is a sibling of the decode class, not a # subclass), so either escapes as itself, hits `_finalize_commit_phase`'s # `except BaseException` re-raise, and crashes the run with the task already # persisted as COMMITTING — wedging every later resume on the same spec. YAML # reaches both without any exotic file bytes: `title: "\0"` and `title: "\uD800"` # are ordinary double-quoted scalars that PyYAML hands back verbatim. The rest of # the control class goes along because a newline or CR in a commit subject is # mangling, not a title. Translating these at the `_run_git` chokepoint instead # of here is the general fix, tracked as #506. _TITLE_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f\ud800-\udfff]+") # An ATX heading's optional closing hash run, which CommonMark requires to be # preceded by whitespace — so "# C#" keeps its trailing hash and "# Wire it ###" # does not. _ATX_CLOSING_RE = re.compile(r"[ \t]+#+[ \t]*$") def _story_label_stripped(value: object, story_key: str = "") -> str: """A commit-ready story title from a frontmatter value or heading text: coerced to str, control characters neutralized, label dropped, whitespace collapsed. Returns "" for anything that leaves no title behind, which is every caller's signal to fall back. ``story_key`` is the task's own id, matched literally as a second way to recognize the label. `StoriesEngine` inherits this renderer and `stories` issues alphabetic ids ("auth", "oauth-setup") that the digit-led pattern cannot see. It is an ADDITION to that pattern and not a replacement: a sprint spec labels itself "Story 1.1:" while its key is "1-1-a", so keying only off the task id would stop stripping the common case. Coerces rather than type-checks because the value arrives from YAML, where an unquoted title parses to whatever it looks like — ``title: 2026-01-01`` is a ``date``, ``title: 1.1`` a float — and a rendered date still beats falling back to the bare story key. A ``None`` (blank ``title:``) coerces to "" and falls back, matching how `status_of` treats a null status.""" if value is None or isinstance(value, bool): # `title: yes` is a typo, not a title return "" text = _TITLE_CONTROL_RE.sub(" ", str(value)).strip() stripped = _STORY_LABEL_RE.sub("", text) if stripped == text and story_key: # The id is escaped, so a key carrying regex metacharacters ("a.b") is # matched literally rather than compiled into a wildcard. stripped = re.sub(rf"^story\s+{re.escape(story_key)}\s*:\s*", "", text, flags=re.IGNORECASE) # Collapse after the label strip, so a label split by control characters # ("Story\x001.1:") is still recognized rather than surviving into the subject. return " ".join(stripped.split()) # Call-stack nesting depth for engine runs. A nested auto-sweep runs synchronously # in the same thread as its parent (see _maybe_auto_sweep), so a ContextVar carries # the parent's depth into the child. Tracked independently of signal ownership so an # off-main-thread top-level run (which cannot own signals) is still seen as depth-0. _run_depth: contextvars.ContextVar[int] = contextvars.ContextVar("bmad_loop_run_depth", default=0) class _HardStopProbe: """The stop-aware child runner's ambient hard-stop probe (DW-353). Installed by the outermost :meth:`Engine.run` (via :func:`childrun.install_stop_probe`) and read by ``childrun.run_child`` before it spawns a verify command or declarative hook and every poll while one runs. ``dirs`` holds the run dirs whose ``stop-request.json`` is read: the owner's, plus each nested auto-sweep's own while it runs — so ``stop `` and ``stop `` both reach a nested sweep's children, mirroring ``adapters/generic.py::_hard_stop_requested``. Read-only by contract; consuming the request stays with ``run()``'s hard arm. ``unwinding`` is a one-way latch set by :class:`RunStopped` when a *hard* stop is constructed. From then on the pending request has been honored, and what runs is the stop's own unwind — worktree teardown hooks, rollback hooks — which must run to completion rather than be refused before spawn and turned into a second ``RunStopped`` inside a ``finally``. A latch on the shared object (not a ContextVar reset) survives a nested engine's ``finally``, while its owner is still unwinding. The probe is thrown away with the outermost run.""" def __init__(self, dirs: list[Path]): self.dirs = dirs self.unwinding = False def __call__(self) -> bool: return not self.unwinding and any(read_stop_request_mode(d) == "hard" for d in self.dirs) # The current run's _HardStopProbe, set beside childrun's probe by the outermost # run() so a nested engine can append its own dir and RunStopped can disarm it. _hard_stop_probe: contextvars.ContextVar[_HardStopProbe | None] = contextvars.ContextVar( "bmad_loop_hard_stop_probe", default=None ) class _ArmedClose(NamedTuple): """One armed story-close rollback: what ``Engine._restore_deferred_closes`` undoes if the commit the close was written for never lands (#234, #286). ``ids`` is entry-scoped, never a whole-document snapshot: the restore reopens exactly these ledger entries through their operation-specific undo markers, so anything a concurrent writer appended, closed or decided inside the commit window survives the rollback untouched. ``exact`` says which set ``ids`` is. Armed BEFORE the write it is the INTENDED set (``exact=False``) — a raise inside the close itself must still be undoable, and reopening an id that never flipped is a safe no-op. Replaced after a normal return by the set actually marked (``exact=True``), which is the only form where an id that fails to reopen means something: its undo marker was there moments ago and is not now, so a foreign edit displaced it, and that is worth journaling rather than repairing around. Lives at module scope, not beside its method, because ``SweepEngine`` annotates the same parameter — a class body cannot host a module-level type. """ ledger: Path ids: tuple[str, ...] exact: bool class _StagedStash(NamedTuple): """A deferred spec copied into the run dir but not yet landed (DW-528). ``staged`` is the copy beside ``target`` in the stash dir; ``spec`` is the live spec it was copied from, left in place for the rollback to judge.""" spec: Path staged: Path target: Path class _LedgerAnchor(StrEnum): """How much authority a baseline probe established for a ledger restore (#735). Three states, because the domain has three and a boolean silently merged two of them into a write: ``BASELINE`` — ``reset --hard`` republished the baseline's own content at this path, so the accompanying text (or its determinate absence, ``None``) is what the reset itself put there. Only this state may authorize a reset-owned WRITE. ``NO_RESET_CONTENT`` — the path is real but the reset republished no ledger TEXT for it: a ledger proven external, or one the baseline holds as a non-regular entry (a symlink, whose blob is a target pathname and whose target the reset cannot reach through). There is nothing of the reset's to compare against, so a caller with an anchor of its own — the sweep's rejected rewrite — may use it, and a caller without one must not treat a missing file as the reset's work. ``None`` here means "no text to offer", NEVER "the reset deleted it". ``NONE`` — nothing could be derived: no baseline commit, or a probe that faulted. Authorizes nothing. """ NONE = "none" BASELINE = "baseline" NO_RESET_CONTENT = "no-reset-content" def _harvest_row( finding: devcontract.DeferredFinding, ) -> tuple[str, str, str, str | None, str | None]: """One harvest row — `(origin, title, reason, location, severity)` — for a finding this harvest may file. Shared by the snapshot scan and by the stale-sighting recovery below, so a finding whose `seen-again:` match died inside the ledger lock files the byte-identical row it would have filed had the match never existed. Two spellings of this tuple would diverge on exactly the rare path nothing exercises. It therefore stays a pure function of the finding, including the declared-severity note appended to the reason (#913).""" reason = finding.evidence or finding.summary if finding.declared_severity: reason += f" (declared severity: {finding.declared_severity})" return ( f"{HARVEST_ORIGIN} {finding.fingerprint}", finding.summary, reason, finding.location or None, finding.severity or None, ) def _publication_refusal(path: Path, family: Literal["ledger", "store"]) -> ( tuple[ Literal["target-absent", "target-unreadable", "target-not-a-file", "target-undecodable"], str | None, ] | None ): """Why the carries below must not publish `path`, or `None` when they may. The resolve-then-guard half of `decisions.apply_pre_answer`'s GATE TWO, lifted here because FIVE best-effort publishers — three in this module, `SweepEngine._carry_isolated_ledger_writes`, and `cli._land_confirmation`'s per-operand loop — ask it in the same shape (DW-237). The RESOLVED target is what `verify.commit_paths` publishes and therefore what the guard must be asked about; a resolve that FAILS is folded into `target-unreadable`, exactly as `apply_pre_answer` folds its own, because a carry that cannot name its operand has no more business reaching git than one whose operand is a directory. The FAMILY is DECLARED by each caller and never derived from the path — the rule `verify.unpublishable_target` states for itself, and the reason this helper takes it as an argument rather than testing `path == paths.deferred_work` and choosing one. The four causes it can return are NOT interchangeable to a caller holding a durable retry latch, and `_carry_harvested_deferrals` acts on the difference. The rule is DURABLE versus TRANSIENT, and the guard draws it, not the caller (DW-237): `target-absent`, `target-not-a-file` and `target-undecodable` are DURABLE on-disk shapes — nothing there, a directory there, bytes there nobody can decode — that a replay re-reads and refuses identically, while `target-unreadable` is whatever a probe RAISED — a transient host answer (an EACCES parent, or the WinError 64 a registered-but-not-serving UNC provider gives) that the next pass may well not see. This helper's own resolve fault lands on the transient cause too, so a caller reads the cause alone and never asks which call produced it, and never re-reads the ledger or matches fault text to tell an undecodable file from an unreadable one. `ValueError` is in the tuple beside `OSError` and `RuntimeError` because `Path.resolve()` raises it for an embedded NUL, and its `UnicodeEncodeError` subclass for a lone surrogate, on CPython 3.11-3.14 POSIX (DW-275) — the same three-class tuple `_park_spec_relpath` folds its own resolve through. Returns the `Literal` cause unchanged so every caller's `refuse_cause` journal field stays the closed four-value enum `tests/test_portability_guard.py` declares benign.""" try: target = path.resolve() except (OSError, RuntimeError, ValueError) as e: return ("target-unreadable", str(e)) return verify.unpublishable_target(target, family) def _harvested_carry_specs(task: StoryTask, text: str) -> list[deferredwork.EntrySpec]: """The batch `_carry_harvested_deferrals` appends over ledger `text`. ONE builder for the carry's write and its replay's ownership proof (DW-355), so what the proof recomputes cannot drift from what the carry wrote: the carry hands it the ledger it just read, the proof hands it HEAD's. The provenance skip is status-agnostic, and it has to be: a row this unit's finding already earned and that the sweep has since CLOSED must not be re-filed, and the batch writer's own idempotence scan is open-only by design (a closed entry means the work came back). This one `parse_ledger` read is therefore the whole on-disk guard; the batch's evolving scan covers only twins minted inside the same call, which it does see, every row it appends being open.""" seen = deferredwork.parse_ledger(text) specs: list[deferredwork.EntrySpec] = [] for item in task.harvested_deferrals: origin = str(item["origin"]) source_spec = str(item["source_spec"]) if any( deferredwork.field_line_present(entry, "origin", origin) and deferredwork.field_line_present(entry, "source_spec", source_spec) for entry in seen ): continue location = item.get("location") severity = item.get("severity") specs.append( deferredwork.EntrySpec( title=str(item["title"]), origin=origin, location=str(location) if location else "n/a", source_spec=source_spec, reason=str(item["reason"]), severity=str(severity) if severity else None, # Backstop the lock-free isolation-carry snapshot: its # fingerprint may land under another spec before the # writer's locked re-read (DW-98). cross_spec_dedupe=True, ) ) return specs class Engine: # The engine that installed the process-wide stop handlers. Signal handling is # single-owner per process; only this engine reinstalls/restores them. Run # nesting is tracked separately via _run_depth (see run()). _stop_signals_owner: "Engine | None" = None def __init__( self, paths: ProjectPaths, policy: Policy, adapter: CodingCLIAdapter, run_dir: Path, journal: Journal, state: RunState, max_stories: int | None = None, epic_filter: int | None = None, story_filter: str | None = None, review_adapter: CodingCLIAdapter | None = None, sweep_factory: SweepFactory | None = None, registry: PluginRegistry | None = None, retro_adapter: CodingCLIAdapter | None = None, plugin_grants: GrantSet = GrantSet.empty(), ): self.paths = paths # where code+git work + artifact reads happen. isolation="none" (today's # only mode) → the repo root in place; Phase 3 swaps in per-unit worktrees. self.workspace = Workspace.default(paths) self.policy = policy verify.configure_git_timeout(policy.limits.git_timeout_s) self.adapters = { "dev": adapter, "review": review_adapter if review_adapter is not None else adapter, # the headless epic-boundary retrospective (DW-389, `_maybe_auto_retro`) "retro": retro_adapter if retro_adapter is not None else adapter, } self.run_dir = run_dir self.journal = journal # Hand the run's journal to every adapter this engine owns, so an adapter # can record what only it sees — the #680 `session-idle`/`session-active` # pair rides it. Deduplicated by identity: dev and review commonly share # one adapter object. Attached, never wrapped: the adapter appends on the # engine thread while the engine's own journal is quiescent, so this is # one writer with one set of `log_task`/`log_pos` stamps, not two. seen_adapters: list[CodingCLIAdapter] = [] for owned in self.adapters.values(): if not any(owned is done for done in seen_adapters): owned.journal = journal seen_adapters.append(owned) self.state = state self.max_stories = max_stories self.epic_filter = epic_filter self.story_filter = story_filter # the runtime work source this engine picks from (worksource.WorkSource). # Built without I/O — SweepEngine inherits this __init__ — but it parses # the --epic/--story selector here, so a bad --story still raises now. self.source: WorkSource = self._make_work_source() # spawns a child deferred-work sweep run (injected by the CLI to # avoid an engine -> sweep import cycle); see _maybe_auto_sweep self.sweep_factory = sweep_factory # plugin hook bus. Built silently (no journal handed to the registry) so a # zero-plugin run — the only builtin is the data-only `example` — adds # nothing to the journal and stays byte-identical to today. The bus # journals actual hook activity itself; a single "plugins-active" line # records the live plugins only when at least one binds a stage. The # game-engine layer (Unity) is now itself a plugin: enabling it in # [plugins] gives it lifecycle hooks that gate/manage the Editor. # `plugin_grants` is the launch's frozen grant set (#496/#497); the # default, none, refuses every project plugin with an executable surface # — a layer this deep never reads the grant store itself. # # Refusals are journaled HERE, not by the registry (it gets no journal, see # above), one `plugin-refused` per refused plugin and only when there is # one, so a zero-plugin run's journal is unchanged. A fatal verdict # (granted-then-changed, enabled-but-ungranted) aborts the engine before any # plugin ran; it is recorded with `fatal=True` first, so the run dir — a # resume, or the auto-sweep child's own — says why it never started. if registry is None: try: registry = PluginRegistry.build(self.paths.repo_root, policy, grants=plugin_grants) except PluginTrustError as e: for refusal in e.refusals: self._journal_plugin_refusal(refusal, fatal=True) raise for refusal in registry.refusals: self._journal_plugin_refusal(refusal, fatal=False) self._registry = registry # let every in-process plugin reject an incompatible config at startup # (e.g. the Unity plugin's editor_mode↔scm.isolation coupling) so the run # fails fast rather than mid-unit. self._registry.validate(policy) self._bus = HookBus(self._registry, journal) # stages at which some active plugin injects a provided workflow session # (Phase 4). Precomputed once for an O(1) guard so a run whose plugins # provide no workflows stays byte-identical (no extra sessions, no journal). self._workflow_stages = self._registry.workflow_stages() if self._bus.any_active(): self.journal.append("plugins-active", plugins=self._bus.active_plugins()) # stop-signal bookkeeping (see run()) self._owns_signals = False # Set authoritatively at run() entry from the _run_depth call-stack counter: # True iff this engine runs nested inside another engine's run() (a nested # auto-sweep). Independent of _owns_signals — a top-level run off the main # thread owns no signals yet is still non-nested. Defaults False pre-run. self._is_nested = False self._stopping = False self._prev_handlers: dict[int, object] = {} # Set by run()'s graceful-stop arm so the trailing notify (which fires for # every exit path) can word itself for a graceful stop and quote how many # stories a resume would still have to run. _graceful_remaining is a # best-effort hint (None when the estimate could not be computed). self._graceful_stopped = False self._graceful_remaining: int | None = None # dev-primitive name resolved from disk, memoized per (workspace project # root, skill tree) — see _dev_skill. By tree because one run can mix them # (dev=claude reads .claude/skills, review=codex reads .agents/skills), with # None for an adapter that carries no profile at all; by project root # because under isolation each unit resolves against its OWN worktree and # one Engine drives every unit of a run. self._dev_skill_cache: dict[tuple[Path, str | None], str] = {} # story_key -> the highest `verify-command-result` sequence allocated so # far. None until the first verify pass seeds it from the journal — see # _next_verification_sequence, which owns the whole invariant. self._verification_sequences: dict[str, int] | None = None # The records the most recent review-gate verifier pass handed its sink # (`_review_command_sink`), read back by `_review_verify_gate` for the # `post_review_verify` emit. Reset to NO_VERIFY_COMMANDS before every gate # so a gate that fails before reaching its commands publishes "no pass # ran", never a previous gate's records. self._review_verify_records: VerifyCommandRecords = NO_VERIFY_COMMANDS # The workspace root `[environment] probes` last passed in since the last # session launch (DW-523), or None. Transient on purpose — never # persisted: a resumed process has not probed anything yet. Set by # `_run_environment_probes` on a healthy pass, reset at the top of every # `_run_session`, because a session may have changed the environment the # pass vouched for. A root, not a flag: probes may be cwd-sensitive, so a # pass in one worktree vouches for nothing in another. self._env_probes_fresh_root: Path | None = None # Per-unit worktree isolation + integration flow (issue #244 F-3/F-9a). # Built from narrow deps + engine callbacks; the same-name Engine._* worktree # methods below delegate to it. `emit` is late-bound (a lambda, not the bound # method) so a test's monkeypatched `_emit` still wins; workspace get/set read # and swap the engine's live `self.workspace`; `_escalation_pause` raises # RunPaused for it (injected so worktree_flow need not import engine). self._worktree_flow = WorktreeFlow( paths=self.paths, policy=self.policy, state=self.state, journal=self.journal, run_dir=self.run_dir, registry=self._registry, adapters_get=lambda: self.adapters, open_unit_workspace=lambda *a, **k: open_unit_workspace(*a, **k), emit=lambda *a, **k: self._emit(*a, **k), save=self._save, gate_unit=self._gate_unit, carry_isolated_ledger_writes=lambda task: self._carry_isolated_ledger_writes(task), land_post_landing_writes=lambda task: self._land_post_landing_writes(task), reopen_unlanded_close=lambda task: self._reopen_unlanded_close(task), escalation_pause=self._escalation_pause, workspace_get=lambda: self.workspace, workspace_set=lambda ws: setattr(self, "workspace", ws), unit_branch_epic=lambda task: self._unit_branch_epic(task), sprint_board=lambda: self._has_sprint_board(), ) # Attempt rollback + recovery-ref preservation flow (issue #244 PR 2/2). # Same narrow-deps + engine-callbacks pattern as _worktree_flow: `emit` is # late-bound (a lambda, not the bound method) so a test's monkeypatched # `_emit` still wins; `workspace_get` reads the engine's worktree-swappable # active workspace; `escalate` routes an intent-gap restore failure through # the engine's escalation; `escalation_pause` raises RunPaused for it # (injected so recovery_flow need not import engine — that would reintroduce # a runtime<->engine cycle); `dev_attempt_dispatched` is the preserve-ref # provenance probe (#777); `spec_confine_root` and `owned_plan` are the # work source's say over where an attempt-owned spec may be restored, and # who restores it (`_spec_confine_root`, `_owned_plan_writer`). self._recovery_flow = RecoveryFlow( paths=self.paths, policy=self.policy, state=self.state, journal=self.journal, run_dir=self.run_dir, workspace_get=lambda: self.workspace, emit=lambda *a, **k: self._emit(*a, **k), save=self._save, escalate=self._escalate, escalation_pause=self._escalation_pause, dev_attempt_dispatched=self._dev_attempt_dispatched, spec_confine_root=lambda task: self._spec_confine_root(task), owned_plan=self._owned_plan_writer(), ) def _escalation_pause( self, reason: str, story_key: str = "", *, cause: BaseException | None = None ) -> NoReturn: """Raise the engine's ``RunPaused`` (PAUSE_ESCALATION) on behalf of the worktree collaborator. Injected as a callable so ``worktree_flow`` need not import ``RunPaused`` — that would reintroduce a runtime<->engine cycle.""" raise RunPaused(reason, PAUSE_ESCALATION, story_key) from cause # ------------------------------------------------------------- top level def _warn_desktop_notifier_inert(self) -> None: """One-time run-start alert for #231: ``notify.desktop`` is requested but this platform has no notifier, so every ``gates.notify`` desktop sink is a silent no-op. Journalled + stderr so an unattended launch that skips ``validate`` still surfaces it.""" if not (self.policy.notify.desktop and gates.desktop_notifier_kind() is None): return self.journal.append("notify-desktop-unavailable", platform=sys.platform) # The ATTENTION file only exists as a fallback when notify.file is on; with # it off there is no human channel left, so point at that rather than a file # that is never written. channel = ( f"watch the ATTENTION file in {self.run_dir}" if self.policy.notify.file else "notify.file is also off, so no alert channel is configured" ) print( f"warning: notify.desktop is set but no desktop notifier is available " f"on {sys.platform}; desktop alerts are silently skipped — {channel}.", file=sys.stderr, ) def _journal_plugin_refusal(self, refusal: Refusal, *, fatal: bool) -> None: """One `plugin-refused` row for a plugin the registry gate refused (#496/#497). ``fatal`` separates a refusal that aborts the engine from an inert plugin the run goes on without; ``reason`` is the operator-facing message.""" self.journal.append( "plugin-refused", plugin=refusal.name, reason=refusal.reason, origin=refusal.origin, path=refusal.path, fatal=fatal, ) def run(self) -> RunSummary: # Establish call-stack nesting depth before anything else: _is_nested is read # by the warning gate and the stop/crash re-raise arms below. Reset in the # outermost finally so a nested child's re-raise still decrements the depth. depth = _run_depth.get() self._is_nested = depth > 0 token = _run_depth.set(depth + 1) # Publish this run dir as the owner for everything below, so a nested # auto-sweep's adapters poll the file an operator can actually write to # (#319): `stop ` lodges here, while the child's own dir stays # empty. Gated on depth, not on `_owns_signals` — a top-level run off the # main thread installs no handlers yet still owns the channel. Reset by # token in the same finally, ahead of the depth, so the nested re-raise arms # unwind through both. owner_token = None if self._is_nested else set_owner_run_dir(self.run_dir) # The stop-aware child runner's ambient probe (DW-353), claimed on the same # outermost-only rule: verify commands and declarative hooks poll it while # their child runs and kill the tree on a HARD request. A nested auto-sweep # appends its own run dir to the owner's probe for its lifetime, so both # `stop ` and `stop ` reach its children. The runner # only reads; consuming stays with the hard-stop arm below. probe_token = None runner_token = None nested_probe: _HardStopProbe | None = None if self._is_nested: nested_probe = _hard_stop_probe.get() if nested_probe is not None: nested_probe.dirs.append(self.run_dir) else: probe = _HardStopProbe([self.run_dir]) probe_token = _hard_stop_probe.set(probe) runner_token = install_stop_probe(probe) try: return self._run_inner() finally: if nested_probe is not None: nested_probe.dirs.remove(self.run_dir) if runner_token is not None: reset_stop_probe(runner_token) if probe_token is not None: _hard_stop_probe.reset(probe_token) if owner_token is not None: reset_owner_run_dir(owner_token) _run_depth.reset(token) def _unresolved_escalation_key(self) -> str | None: """The first ESCALATED task that still blocks ``finished`` at run end (DW-386), or None. ``StoriesEngine`` narrows it: its pick-time wedges are disk projections a drained schedule has already cleared. The predicate is ``escalation.unresolved_escalation_keys``, which resolve's admission shares.""" return next(iter(unresolved_escalation_keys(self.state)), None) def _kill_run_session(self) -> None: """Tear down this run's agent session, journaling a kill the ownership gate refused (`runs.foreign_session_refusal`). `kill_session` reports a refusal on stderr and in a queue only the TUI drains, so an unattended run would otherwise leave no durable trace of it — and a degrade must be visible (AGENTS.md: journal it). It matters beyond a foreign session: the gate fails closed when the shared registry cannot be listed, so a transient listing fault leaves this run's own agent window live. One `session-kill-refused` entry per drained reason; a refusal with none queued is the control-session alias arm. `is False`, not falsy: only a refusal answers ``False``.""" if kill_session(self.state.run_id) is False: refusals = drain_refused_kills() or ["run id aliases a control session"] for refusal in refusals: self.journal.append("session-kill-refused", detail=refusal) def _run_inner(self) -> RunSummary: self._install_stop_signals() try: try: # Warn once per top-level run before pre_run: a plugin `pause` veto in # _emit_run_boundary("pre_run") raises RunPaused, which would otherwise # skip the promised inert-notifier warning + journal event. Gated on # `not _is_nested` (call-stack depth), not `_owns_signals`: a top-level # run that could not install signal handlers (off the main thread) owns # no signals yet is not nested, and must still surface the warning. if not self._is_nested: self._warn_desktop_notifier_inert() # target-branch setup can raise RunPaused (detached HEAD, unborn # repo), so it must sit inside the pause handler, not before it. self._emit_run_boundary("pre_run") self._ensure_target_branch() self._prune_preserve_refs() self._replay_unlatched_ledger_carries() self._replay_post_landing_writes() self._loop() # A hard request that landed after `_loop`'s head check reaches none # of the raise sites on an exhausted-queue return: sites A and B live # inside `_run_session`, which the `story is None` branch never # enters, and the run-end auto-sweep predicate is mode-blind, so it # *suppresses* and returns rather than raising. Without this the run # would record `finished` — which `documents.py` ranks above # `stopped` — while the operator's hard stop went unhonored, and # `stop_run`'s fallback would then journal `fallback=True` against a # perfectly responsive engine, contradicting what that flag now means. # Covering it here rather than at the suppression site closes every # `_loop` return path at once (including `max-stories-reached`) and # keeps the per-epic sweep caller untouched. Mode-exact on purpose: a # *graceful* request at an exhausted queue finishes truthfully, which # is long-documented, separately tested behavior this must not disturb. if read_stop_request_mode(self.run_dir) == "hard": clear_graceful_stop(self.run_dir) raise RunStopped(via="stop-request") # GC first: it can pause (a pinned-config edit, DW-368, or an # unpublished bundle source), and a run recorded finished refuses # `bmad-loop resume`, the very remedy the pause names. self._gc_run_worktrees() # Never stamp `finished` over an unresolved escalation (DW-386). A # `resume` past an ESCALATED task skips it and can drain the queue, but # every consumer reads `finished` as "nothing left to do": `resume` and # `resolve` refuse the run, and worktree reconciliation reclaims the # escalated task's kept worktree. Pause at the escalation instead — the # exact shape `resolve` accepts. escalated = self._unresolved_escalation_key() if escalated is not None: raise RunPaused( f"story {escalated!r} is still escalated — run " f"`bmad-loop resolve {self.state.run_id}`", PAUSE_ESCALATION, escalated, ) self.state.finished = True self._emit("post_run") self.journal.append("run-complete") # tear down the run's agent session now that it finished. Only # the outermost engine owns this (nested auto-sweep never sets # _owns_signals); stop already kills it, and pause/interrupt # leave it for resume to reuse. if self._owns_signals and self.policy.adapter.cleanup_session_on_finish: self._kill_run_session() except RunPaused as pause: usage = pause.usage_limit self.state.record_pause( pause.reason, pause.stage, pause.story_key, reset_at=usage.reset_at if usage is not None else None, limit_class=usage.limit_class if usage is not None else None, ) # Present-only, like `session-end`'s usage-limit fields. pause_extras: dict = {} if self.state.paused_reset_at: pause_extras["usage_limit_reset_at"] = self.state.paused_reset_at if self.state.paused_limit_class: pause_extras["usage_limit_class"] = self.state.paused_limit_class self.journal.append( "run-paused", reason=pause.reason, stage=pause.stage, story_key=pause.story_key, **pause_extras, ) except RunStopped as stop: if stop.graceful: # Graceful stop: the request was consumed at an item boundary # (_check_stop_request), so the in-flight item already ran to # completion through commit — nothing mid-session to kill. Run # the wanted subset of the clean-finish path so a resumable # `stopped` run is finalized as tidily as a finished one. self.state.stopped = True self._graceful_stopped = True try: # These run plugin code (post_run) + git worktree admin; # an exception raised inside an except arm escapes run() # uncaught, so guard them inline and journal the failure # rather than let it mask the stop. self._gc_run_worktrees() self._emit("post_run") except Exception as finalize_exc: # see comment above self.journal.append("run-stop-finalize-error", error=str(finalize_exc)) remaining = self._remaining_estimate() self._graceful_remaining = remaining self.journal.append("run-stop", graceful=True, remaining=remaining) # Session teardown follows the same policy gate the clean-finish # path uses (mirrors the finished-run branch), NOT the hard # stop's unconditional kill. Deliberately NO _is_nested re-raise: # a gracefully stopped child sweep is a clean completion from the # parent's perspective (the parent journals sweep-auto-finished). if self._owns_signals and self.policy.adapter.cleanup_session_on_finish: self._kill_run_session() else: # Hard stop: the loop was interrupted inside adapter.run() (a # signal), or unwound on either side of it because a hard stop # request was honored — so the agent window may still be live. # Tear the whole run session down. self._kill_run_session() if self._is_nested: raise # nested auto-sweep: let the owner record the stop self.state.stopped = True # The signal path consumes nothing on its way here, and `stop_run` # now lodges a hard request *before* it signals — so on POSIX the # file is still on disk for every routine stop. `run()`'s finally # would then discard it as *stale* and journal # `stop-request-discarded`, misreporting the very request this # stop delivers. Consume it here, on the same rule the boundary and # in-session sites already follow. Mode-exact: a pending *graceful* # request really is superseded by a hard stop, so it is left for # the finally to discard and journal, as it always has been. if read_stop_request_mode(self.run_dir) == "hard": clear_graceful_stop(self.run_dir) # `via` rides only when the control file delivered the stop; # the signal path keeps journaling a bare `run-stop` (precedent: # the KeyboardInterrupt arm's `reason=` extra below). extras = {"via": stop.via} if stop.via is not None else {} self.journal.append("run-stop", **extras) except KeyboardInterrupt: # Some Windows console/control events can still surface as a raw # KeyboardInterrupt without routing through the installed signal # handler. Persist a controlled stop rather than letting the # engine disappear with stale state. self._stopping = True # swallow stop signals landing mid-teardown try: self._kill_run_session() except ( BaseException ): # nosec B110 - best-effort teardown; the stop must still record pass if self._is_nested: raise self.state.stopped = True self.journal.append("run-stop", reason="KeyboardInterrupt") except Exception as exc: # an unexpected exception escaped the loop (e.g. a transport # hang that leaked past the seam). Don't let it die to the lossy # parked control pane: persist the traceback, tear down the # orphaned agent session, and fall through to a crashed summary. tb = traceback.format_exc() # a crash is never also "finished": the loop may have set # finished=True (line above) before a post-run step threw, and # status classification checks finished first — so a recorded # crash would otherwise read as FINISHED. Reset before the nested # re-raise so the trailing _save() persists it on both paths. self.state.finished = False try: (self.run_dir / "crash.txt").write_text(tb, encoding="utf-8") except OSError: pass try: self._kill_run_session() except ( Exception ): # nosec B110 - best-effort teardown; a crashing run must still record pass if self._is_nested: raise # nested auto-sweep: let the owner record the failure try: message = str(exc) except Exception: message = type(exc).__name__ self.state.crashed = True self.state.crash_error = f"{type(exc).__name__}: {message}" try: self.journal.append( "run-crash", error=type(exc).__name__, message=message, epic=self.state.current_epic, ) except ( Exception ): # nosec B110 - journal write is best-effort; crash.txt + state flag already persisted pass finally: # Any pending stop-request control file that outlived this run is # discarded here so a later resume does not re-honor a stale request. # Every arm that *honors* a request consumes its own file first — the # boundary and in-session sites, and the hard arm above, which has to # because `stop_run` lodges before it signals and the signal path # reads nothing. So this fires only for a request no arm honored: the # run finished, paused or crashed with one pending, or a hard stop # superseded a *graceful* one. Journaling those as discarded is # accurate; journaling a request that just stopped the run would not # be, which is the whole reason the honoring arms consume. if clear_graceful_stop(self.run_dir): with contextlib.suppress(Exception): self.journal.append("stop-request-discarded") self._save() finally: self._restore_stop_signals() summary = self.summary() if self._graceful_stopped: body = [summary.render()] if self._graceful_remaining is not None: stories_word = "story" if self._graceful_remaining == 1 else "stories" body.append(f"{self._graceful_remaining} {stories_word} remaining") body.append(f"resume with `bmad-loop resume {self.state.run_id}`") gates.notify( self.policy, self.run_dir, "bmad-loop run stopped gracefully", "\n".join(body), multiline=True, ) else: gates.notify( self.policy, self.run_dir, "bmad-loop run finished", summary.render(), multiline=True, ) return summary # ---------------------------------------------------------- stop signals def _install_stop_signals(self) -> None: """Make SIGTERM/SIGINT unwind the loop as a RunStopped. Only the outermost engine in the process owns the handlers (nested auto-sweep runs let the exception propagate up to it); install is best-effort and silently skipped off the main thread (signal.signal raises there).""" # Signal ownership is process-global and independent of run nesting (tracked # via _run_depth in run()): a non-None owner means an outer engine already # installed the handlers, so this engine must not reinstall them. if Engine._stop_signals_owner is not None: return windows_ctrl_signals = {signal.SIGINT} sigbreak = getattr(signal, "SIGBREAK", None) if sigbreak is not None: windows_ctrl_signals.add(sigbreak) def handler(signum, frame): # stdlib signal signature if sys.platform == "win32" and signum in windows_ctrl_signals: # best-effort: a journal error must never escape a signal handler. with contextlib.suppress(Exception): self.journal.append("console-ctrl-ignored", signum=signum) return if self._stopping: return # already unwinding; don't re-raise during teardown self._stopping = True raise RunStopped() try: signals = [signal.SIGTERM, signal.SIGINT] if sys.platform == "win32" and sigbreak is not None: signals.append(sigbreak) for sig in dict.fromkeys(signals): self._prev_handlers[sig] = signal.signal(sig, handler) except ValueError: # not on the main thread — cannot install; degrade to no handler self._restore_stop_signals() return self._owns_signals = True Engine._stop_signals_owner = self def _restore_stop_signals(self) -> None: for sig, prev in self._prev_handlers.items(): try: # prev is a prior OS handler from signal.signal(); the dict is # object-typed to avoid importing the private stdlib _HANDLER alias. signal.signal(sig, prev) # pyright: ignore[reportArgumentType] except (ValueError, TypeError): pass self._prev_handlers.clear() if Engine._stop_signals_owner is self: Engine._stop_signals_owner = None self._owns_signals = False # ----------------------------------------------------- worktree isolation # Same-name delegators onto the WorktreeFlow collaborator (issue #244 F-3): # the isolation/integration cluster moved to worktree_flow.py; these keep the # Engine surface (and its tests + SweepEngine/StoriesEngine subclasses) # byte-compatible, so `self._` monkeypatches and calls still resolve here. @property def _isolated(self) -> bool: return self._worktree_flow.isolated def _mount_root_identity(self, task: StoryTask, root: Path) -> os.stat_result | None: """The ``root_identity`` a mount writer pins ``root`` with (DW-445) — take it fresh at each write, beside the ``confine_root`` it pins, and pass the SAME root: an identity for another directory would refuse every legitimate write. ``None`` (unpinned) when the workspace in hand is the default one (``workspace.root == paths.repo_root``): ``root`` is then the operator's project, which the pin rule leaves unpinned. Otherwise ``root`` is inside the orchestrator-minted unit mount ``workspace.root``, whose parent is session-writable, so `runs.mount_root_identity` pins it against ``task.worktree_identity`` — the mount's MINT-TIME identity (DW-446) — and reaches ``root`` from it by an ``O_NOFOLLOW`` walk (``/`` under a nested project, DW-486). A mount or any ancestor (``worktrees/``, ``runs//``) swapped for a link, or a missing record, answers never-matching, so the refusal lands AT THE WRITE, through the site's existing handling. Mountedness is read off the live workspace, not ``task.worktree_path``: the writes go wherever ``self.workspace`` points, including a mount ``_finish_inflight`` reopened (the same directory, so the same record).""" if self.workspace.root == self.paths.repo_root: return None return mount_root_identity(root, mount=self.workspace.root, recorded=task.worktree_identity) def _ensure_target_branch(self) -> None: self._worktree_flow.ensure_target_branch() def _worktree_profiles(self) -> list[CLIProfile]: return self._worktree_flow.worktree_profiles() def _engine_agent_ids(self) -> list[str]: return self._worktree_flow.engine_agent_ids() def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> None: self._worktree_flow.run_isolated(task, drive) def _failed_diff_max_bytes(self) -> int | None: return self._worktree_flow.failed_diff_max_bytes() def _integrate_unit(self, task: StoryTask, unit: UnitWorkspace) -> None: self._worktree_flow.integrate_unit(task, unit) def _merge_local( self, task: StoryTask, unit: UnitWorkspace, *, replay: bool = False, replay_strategy: str | None = None, first_integration: bool = False, ) -> None: self._worktree_flow.merge_local( task, unit, replay=replay, replay_strategy=replay_strategy, first_integration=first_integration, ) def _keep_branch_and_escalate(self, task: StoryTask, unit: UnitWorkspace, reason: str) -> None: self._worktree_flow.keep_branch_and_escalate(task, unit, reason) def _escalate_unit(self, task: StoryTask, reason: str) -> None: self._worktree_flow.escalate_unit(task, reason) def _merge_message(self, task: StoryTask) -> str: return self._worktree_flow.merge_message(task) def _gc_run_worktrees(self) -> None: self._worktree_flow.gc_run_worktrees() def _reopen_unit(self, task: StoryTask) -> UnitWorkspace: return self._worktree_flow.reopen_unit(task) def summary(self) -> RunSummary: tasks = self.state.tasks.values() # Weight from the run's persisted snapshot, NOT self.policy — every # display surface must be reproducible from state.json alone, which is # all the TUI, `bmad-loop status` and `diagnose` can see; sourcing this # from live policy would make them print different totals for the same # run. Reading the snapshot is safe precisely because every engine start # (run, sweep, resume) stamps it with the policy that process enforces # (#189) — so it agrees with self.policy rather than substituting for it. # Do not "unify" these. weight = self.state.cache_read_weight() return RunSummary( run_id=self.state.run_id, done=sum(1 for t in tasks if t.phase == Phase.DONE), deferred=sum(1 for t in tasks if t.phase == Phase.DEFERRED), escalated=sum(1 for t in tasks if t.phase == Phase.ESCALATED), awaiting_operator=sum(1 for t in tasks if t.phase == Phase.AWAITING_OPERATOR), paused=self.state.paused, paused_reason=display_pause_reason(self.state), total_tokens=sum(t.tokens.total for t in tasks), # Sum PER TASK, not over one aggregated TokenUsage: weighted_total # rounds internally, so sum-of-rounds != round-of-sum (they drift by # a few tokens under banker's rounding). Per-task summation is what # tui/widgets.py does, which is what makes the CLI and the TUI agree # to the token. This is not a redundant loop — do not collapse it. weighted_tokens=sum(t.tokens.weighted_total(weight) for t in tasks), crashed=self.state.crashed, crash_error=self.state.crash_error, # Snapshotted, not aliased: this dict is still live on the engine's # state, and the tuple makes the copy structural rather than a # convention a later edit could drop. sweeps_refused=tuple(self.state.sweeps_refused.items()), ) def _remaining_estimate(self) -> int | None: """Best-effort count of stories a resume would still have to run, for the graceful-stop journal + notify. Sprint mode: actionable sprint-status stories this run has not already picked up (mirrors ``cmd_status``'s sprint backlog count, minus anything already in ``state.tasks``). It is a hint, never a contract — the whole body is guarded so an unreadable/invalid sprint-status file returns None rather than derailing a graceful stop. Stories mode counts against the manifest scheduler through its own source.""" try: return self.source.remaining_estimate(self._phases()) except Exception: # a hint must never break the stop return None def _check_stop_request(self) -> None: """Honor a pending stop request at an item boundary, in the mode it asks for. Consumes (deletes) the ``stop-request.json`` control file and raises :class:`RunStopped` — ``graceful=True`` for a ``graceful`` request (the default mode, and every pre-#319 modeless body, which :func:`runs.read_stop_request_mode` deliberately reads as graceful) so ``run()`` unwinds into the clean-finalization arm; ``via="stop-request"`` for a ``hard`` one so it takes the hard arm instead. An exception, not a sentinel return, because the sweep check fires two frames below ``_loop`` (inside ``_cycle``) where a return could not stop the loop. Called as the first statement of the loop body (and, in the sweep engine, before each bundle): by the time control reaches here the in-flight item has already completed through commit, so the stop takes effect cleanly at the next boundary and the run stays resumable. A *hard* request that reaches a boundary is honored right here rather than deferred to the adapter's in-session poll — aborting at the boundary is both faster and cleaner than launching the next session only to abort it mid-flight.""" # One atomic take, never a read then an unlink: a `stop` escalating to # "hard" between the two would be deleted unread while this engine routed # on the stale graceful mode it already held. Consuming on BOTH arms is # still required — `run()`'s finally discards any surviving file as *stale* # and journals `stop-request-discarded`, which would misreport a request # this engine just honored. mode = consume_stop_request(self.run_dir) if mode is None: return if mode == "hard": raise RunStopped(via="stop-request") raise RunStopped(graceful=True) def _loop(self) -> None: self._finish_inflight() self._clear_accept_baseline() if self.state.replay_of: # DW-525: a replay run (`unitreplay`) owns only its seeded unit, which # `_finish_inflight`'s reverify arm just drove — no `_pick_next`, no # run-end retrospective, no auto-sweep. `_run_inner` still GCs, runs the # DW-386 escalation check and records `finished`. return while True: # First statement of the loop body: one site covers every story # boundary this base loop reaches — between stories, right after # _finish_inflight on resume, and the epic boundary + run-end (the # StoriesEngine has no _loop override, so it is covered here too). self._check_stop_request() if self.max_stories is not None and self._dispatched_count() >= self.max_stories: self.journal.append("max-stories-reached", count=self._dispatched_count()) self._run_end_retrospective() return self._emit("pre_pick_next") story = self._pick_next() self._emit("post_pick_next", story_key=(story.key if story is not None else None)) if story is None: # Before the run-end sweep, as the per-epic retro precedes the # per-epic sweep: the sweep then sees the committed retro (DW-488). self._run_end_retrospective() self._maybe_auto_sweep("run-end", "run-end") return # Before ANY state mutation for this story, and deliberately so — see # _refuse_gated_story. The story is not in state.tasks yet, so a resume # re-picks it and re-asks the ledger. self._refuse_gated_story(story.key) if self.state.current_epic is not None and story.epic != self.state.current_epic: self._epic_boundary(self.state.current_epic, story.epic) self.state.current_epic = story.epic task = StoryTask(story_key=story.key, epic=story.epic) self.state.tasks[story.key] = task self.journal.append("story-start", story_key=story.key) self._save() self._run_story(task) self._after_story(task) def _dispatched_count(self) -> int: """Stories this run has dispatched, counted durably from run state so the ``--max-stories`` bound survives a pause/resume (a story checkpoint, an escalation) — unlike a ``_loop``-local counter that resets to 0 on every re-entry. Every picked story is recorded in ``state.tasks`` before its session runs (and a wedge/selector pause records its task too), the same "touched this run" set ``_pick_next`` keys ``base_skip`` on, so the task count is the durable dispatch tally. Without this, a checkpoint pause then resume would reset the counter and let the run dispatch past its cap.""" return len(self.state.tasks) def _refuse_gated_story(self, story_key: str) -> None: """Pause the run rather than dispatch a story an unlanded ledger entry gates. The enforcing half of ``gate:``. ``bmad-loop validate`` refuses the same story at preflight, but a preflight is only as strong as the operator's habit — ``run`` never called it, and ``_pick_next`` reads the board alone, so before this the field's whole promise rested on someone remembering to type a second command. **Placement is load-bearing.** Called from ``_loop`` before ``state.tasks[key] = task``, so the gated story is *not* recorded as touched by this run. That is what makes the refusal re-askable: a resume re-picks the same story and re-reads the ledger, so closing the entry and resuming runs it. Registering the task first — the obvious placement, next to ``_run_story`` — would put the key in ``_pick_next``'s ``base_skip``, and the gate would fire once and then silently retire the story for the rest of the run and every resume of it. A gate that drops the work it was protecting is worse than no gate. **Pause, not skip.** ``validate`` fails the whole preflight over one gated story, and the two surfaces have to agree or the operator learns to distrust both. It raises the reserved :data:`PAUSE_STORY_GATE` stage, which the TUI already renders and routes to its gate viewer. The ledger is re-read here rather than carried from preflight: a sweep (or a human) may have closed the entry since, and a gate answering from a stale snapshot would refuse work that has landed. **Unreadable ledger pauses too.** Degrading to "not gated" would let the one deferred check that is a refusal be disabled by a broken file, and the question "does this project use gates?" is answerable only from the file that will not open. ``deferred.ledger-unreadable`` is a ``validate`` problem for the same reason. Two exemptions, both deliberate. ``SweepEngine`` overrides ``_loop`` and so never reaches this call — it must not, because the sweep is the only automated closer of the gating entry (``sweep.py`` `_close_resolved` / bundle close), and gating the sweep would deadlock the gate against its own remedy. And a resumed story *finishes* rather than stranding a half-done session with a live worktree; the gate applies to work that must not *start*, which is the same line ``validate`` draws when it passes a story the board has already finished. That second exemption belongs to ``_finish_inflight``'s finishing arms — the defer replay, the spec-approval continuation, the recorded-session replay, the commit completion — and not to its restart arm, which finishes nothing: it discards the worktree (or resets to baseline) and re-runs the story from scratch. So the restart arm re-asks this gate, and unconditionally. ``_pick_next`` cannot ask for it, having skipped the key as touched, and the run's own crash must not be what disables the one deferred check that refuses. Both call sites ask **before** their caller mutates anything, and that is one rule rather than two coincidences. In ``_loop`` it keeps the refusal re-askable; in the restart arm it keeps the ledger readable, because that arm's in-place rollback is ``git reset --hard `` and a gate committed while the run was down is a commit *after* that baseline. Deliberately no "but did a session really run?" test there. Every available signal is wrong somewhere: ``attempt`` is bumped before the session launches, ``sessions`` is written only after one returns, and ``rearmed`` covers a stories-mode wedge (``StoriesEngine._pause_wedged``) that reaches ESCALATED with no session at all. The arm's own unwinding is the stronger guarantee. Which tokens gate which key is :meth:`_gate_hits`; tokens in a grammar the queue no longer reads (:meth:`_legacy_gate_tokens`) pause every pick, since no key can be shown free of them. """ ledger = self.paths.deferred_work # OBSERVATION arm of the ledger-read contract (DW-146), kept inline rather # than routed through `read_for_observation`: this site carries behavior # the helper cannot — it notifies the human and REFUSES the story instead # of degrading to an empty ledger. Same classification, richer response. # The presence probe is `stat` + `S_ISREG` INSIDE the `try` (DW-266): the # `is_file()` it replaced suppresses every OS error on Python 3.14 and # answers False, so a refused ledger read as an empty one and this hard # gate failed OPEN — the story dispatched, and the pause below was # unreachable. Only absence (`ENOENT`/`ENOTDIR`, a present non-regular # file) is the empty text; a refused probe takes the pause arm exactly # as a refused `read_text` does. `ValueError`, not `UnicodeDecodeError` # (its subclass): `Path.stat` raises a plain `ValueError` for an embedded # NUL in the configured path and a `UnicodeEncodeError` for a lone # surrogate, neither an `OSError`, which `is_file()` had answered False # for — an observation arm attributes those as a fault, never as absence # (`deferredwork.probe_absence`'s contract), so they take this pause too. try: try: text = ledger.read_text(encoding="utf-8") if S_ISREG(ledger.stat().st_mode) else "" except (FileNotFoundError, NotADirectoryError): text = "" except (OSError, ValueError) as e: self.journal.append("story-gate-unreadable", story_key=story_key, error=str(e)) reason = ( f"{ledger} cannot be read ({e}), so the `gate:` hard gates protecting " f"{story_key} could not be evaluated — fix the file, then " f"`bmad-loop resume {self.state.run_id}`" ) gates.notify(self.policy, self.run_dir, f"story gated: {story_key}", reason) raise RunPaused(reason, PAUSE_STORY_GATE, story_key) from e blocking: list[tuple[str, list[str]]] = [] legacy: list[tuple[str, tuple[str, ...]]] = [] # `done`, not `not open`: an entry whose status the format cannot read is # not evidence the work landed, and reading it as closed would let a # one-character typo disable the gate. for entry in deferredwork.parse_ledger(text): if entry.done: continue hits = self._gate_hits(entry, story_key) if hits: blocking.append((entry.id, hits)) stale = self._legacy_gate_tokens(entry) if stale: legacy.append((entry.id, stale)) if not blocking and not legacy: return clauses: list[str] = [] if blocking: named = ", ".join(f"{dw_id} (gate: {', '.join(hits)})" for dw_id, hits in blocking) clauses.append( f"{story_key} is gated by unlanded deferred work: {named} — close the " f"entry in {ledger.name} (`status: done `) or run `bmad-loop sweep`" ) if legacy: clauses.append(self._legacy_gate_clause(story_key, legacy, ledger.name)) reason = "; ".join(clauses) + f", then `bmad-loop resume {self.state.run_id}`" dw_ids = [dw_id for dw_id, _ in blocking] dw_ids += [dw_id for dw_id, _ in legacy if dw_id not in dw_ids] self.journal.append("story-gated", story_key=story_key, dw_ids=dw_ids) gates.notify(self.policy, self.run_dir, f"story gated: {story_key}", reason) raise RunPaused(reason, PAUSE_STORY_GATE, story_key) def _gate_hits(self, entry: deferredwork.DWEntry, story_key: str) -> list[str]: """The ``gate:`` tokens of ``entry`` that gate ``story_key``, in entry order. The predicate half of :meth:`_refuse_gated_story`, split out so a queue with its own key grammar reads the same ledger the same way: the base engine matches story keys (sprint and stories queues), and ``TicketsEngine`` matches ticket refs.""" return [ token for token in deferredwork.gates(entry).tokens if deferredwork.gates_story(token, story_key) ] def _legacy_gate_tokens(self, entry: deferredwork.DWEntry) -> tuple[str, ...]: """``entry``'s ``gate:`` tokens written in a key grammar this queue no longer reads, which pause every pick until rewritten. None for the base engine: its queue reads the story grammar the tokens were written in.""" return () def _legacy_gate_clause( self, story_key: str, legacy: list[tuple[str, tuple[str, ...]]], ledger_name: str ) -> str: """The pause-reason clause for :meth:`_legacy_gate_tokens` hits. Only an engine that overrides that seam ever reaches it.""" named = ", ".join(f"{dw_id} (gate: {', '.join(tokens)})" for dw_id, tokens in legacy) return f"{named} declare gate tokens {story_key}'s queue cannot read ({ledger_name})" def _make_work_source(self) -> WorkSource: """The work source this engine picks from. The base engine reads the sprint board through the live ``self.paths`` (the main checkout).""" return SprintWorkSource(lambda: self.paths, self.epic_filter, self.story_filter) def _review_converged_statuses(self) -> frozenset[str]: """The review result statuses that finalize a pass: the review loop converges on them (verify, then commit) unless the pass recommends a follow-up. Base: the v6 ``done``.""" return frozenset({devcontract.DONE}) def _has_sprint_board(self) -> bool: """Whether this run keeps the v6 sprint board: the worktree seed carries it into a unit. Base: the persisted source's ``SourceTraits.sprint_board``. A sweep on a v7 project has none.""" return resumed_traits_for(self.state.source).sprint_board def _phases(self) -> dict[str, Phase]: """Every unit this run already touched, by key, with its phase.""" return {key: task.phase for key, task in self.state.tasks.items()} def _pick_next(self) -> WorkRef | None: pick = self.source.pick(self._phases(), self.state.current_epic) if pick.unknown_keys: self.journal.append("sprint-status-unknown-keys", keys=list(pick.unknown_keys)) if pick.halt is not None: self._halt_pick(pick.halt) return pick.ref def _halt_pick(self, halt: Halt) -> NoReturn: """Stop the run on a pick the source halted. The sprint source never halts; an engine whose source does overrides this to pause.""" raise AssertionError(f"{self.source.name} source halted the pick: {halt!r}") # --------------------------------------- attempt rollback / recovery refs # Same-name delegators onto the RecoveryFlow collaborator (issue #244 PR 2/2): # the rollback/preserve cluster moved to recovery_flow.py; these keep the # Engine surface (its tests + the SweepEngine/StoriesEngine subclasses) # byte-compatible, so `self._` calls still resolve here. def _protected_relpaths(self) -> tuple[str, ...]: return self._recovery_flow.protected_relpaths() def _retry_preserve_notice(self, task: StoryTask) -> str: return self._recovery_flow.retry_preserve_notice(task) def _rollback_or_pause( self, task: StoryTask, *, cause: str = "stopped", restart: bool = False ) -> None: # A probe pass vouched for the tree this reset is about to rewind — a # cwd-sensitive probe may have passed on config the failed attempt wrote — # so the next dispatch re-probes the restored tree (DW-523). self._env_probes_fresh_root = None self._recovery_flow.rollback_or_pause(task, cause=cause, restart=restart) self._after_code_rollback(task) def _after_code_rollback(self, task: StoryTask) -> None: """The work source's follow-up once a rollback (or its clean no-op) returned: ``TicketsEngine`` re-arms the unit's plan, which the reset keeps when the attempt created it (the artifact folders are kept from the untracked sweep). Base: nothing — the spec route is the recovery flow's own.""" del task def _spec_confine_root(self, task: StoryTask) -> Path: """The root an attempt-owned spec write for ``task`` is confined to (``RecoveryFlow``'s restore and normalization sites). Base: the active workspace's project, where every v6 spec lives.""" del task return self.workspace.paths.project def _owned_plan_writer(self) -> OwnedPlanWriter | None: """Who restores and re-routes an attempt-owned spec instead of the recovery flow's own anchored writers: ``TicketsEngine``'s ticket chokepoint for a bound plan. Base: None — the recovery flow writes v6 specs itself.""" return None def _accept_current_baseline(self, task: StoryTask) -> None: self._recovery_flow.accept_current_baseline(task) def _discard_unit_for_restart(self, task: StoryTask) -> None: """Drop a half-built unit worktree and the four fields that LOCATE it. Scoped deliberately: `worktree_path`, `branch`, `baseline_commit` and `baseline_untracked` all name the mount or a measurement taken inside it, and each is wrong the moment it is gone. Spec ownership is released through `task.release_mount_owned_state()`, which clears the attempt-owned pair and returns `spec_file` to the spelling relative to the mount project (`_mount_project`, DW-379). It runs BEFORE `worktree_path` is cleared, because the relativization is measured against that mount. An earlier version left that pair alone, reasoning that `_bind_dispatched_spec_for_attempt` rebinds on the next attempt before any reader acts on it. The rebind does run — and returns None. The caller re-anchors both fields immediately above, so by the time the mount is deleted `spec_file` is an ABSOLUTE path into it; `_dispatched_spec_for_attempt` resolves that `strict=True`, raises, and leaves the fresh attempt unbound on a story whose spec is sitting in the replacement mount at the same relative place. Nothing downstream repairs it — `_record_dev_spec` no-ops while `spec_file` is set — so the repair prompt goes on naming the deleted path. The relative spelling is what `verify.resolve_spec_path` re-probes against the live workspace, which is how this bound correctly before the re-anchor existed. `baseline_ledger_digest` and the `pre_harvest_ledger` pair are measured in the mount too (`_ledger_digest` reads `workspace.paths.deferred_work`, which is rebased onto the unit under isolation) and are deliberately NOT cleared. The criterion is not "measured in the mount" but "read before anything re-measures it": `baseline_commit` has the `elif task.baseline_commit:` arm below, which fires on a later resume that finds `worktree_path` empty, while every path out of here forces `Phase.PENDING` and saves — so `_resumable_session` (which answers only for `*_RUNNING`/`*_VERIFY`) returns None and `_dev_phase` always re-enters with `resume_result is None`, re-stamping the digest at its own fresh-entry block. Clearing the ledger pair would be actively wrong: it is the crash-replay attribution `_disarm_ledger_snapshot` exists to preserve. `worktree_path`/`branch` name the mount; `baseline_commit`/`baseline_untracked` were MEASURED in it (`_dev_phase` stamps both from `self.workspace.root`, which is the unit under isolation). Clearing only the first pair leaves the second describing a tree that no longer exists, and the restart arm's own `elif task.baseline_commit:` hands them to `recovery_flow.rollback_or_pause` against the MAIN checkout on any later resume that finds `worktree_path` empty. That state is durable and reachable without a host death: the caller saves right after this, and `worktree_flow.run_isolated` assigns `task.worktree_path` only AFTER `open_unit_workspace` returns, so a `GitSpawnError` there pauses the run with the cleared value already persisted. Neither operand fails loud there. Linked worktrees share the main repo's object database, so force-deleting the unit branch does NOT make the baseline unresolvable -- a `git reset --hard` onto it succeeds from the main checkout. And a fresh worktree is a tracked-only checkout, so `baseline_untracked` is effectively empty; `verify._rollback_cleanup_plan` computes `untracked_files(repo) - set(baseline_untracked)` as its DELETION list, so every untracked file in the operator's own checkout reads as this attempt's debris. Under `scm.rollback_on_failure` that unlinks them; with the default off it pauses on a dirtiness no operator action can clear, which is the exact non-termination `rollback_or_pause`'s docstring promises against. `_dev_phase` re-stamps both from the replacement mount, so clearing costs the restart nothing -- it turns the `elif` into a correct no-op rather than a probe of the wrong tree. `None` (not `[]`) for the untracked half is the value `attempt_dirty` and `_rollback_cleanup_plan` both read as "nothing here is this attempt's to remove", and the same one `sweep`'s migration refusal already uses. """ discard_worktree(self.paths.repo_root, task.worktree_path, "", run_dir=self.run_dir) # A probe pass vouched for the discarded tree, not the replacement a later # mount may open at the same path (DW-523). self._env_probes_fresh_root = None # before the clears below: the relativization is measured against this field's # mount project (DW-379) task.release_mount_owned_state(self._mount_project(task)) task.worktree_path = "" task.worktree_identity = None task.branch = "" def _mount_project(self, task: StoryTask) -> Path: """The mount project of ``task``'s recorded mount — the anchor its relative spec spellings are persisted against (``model.StoryTask.to_dict``) and read back from. ``self.paths.rebased().project`` without the resolve, through the same :func:`mountpaths.rebased_project`: the mount itself unless the project is nested inside ``repo_root`` (DW-379).""" return rebased_project(self.paths.project, self.paths.repo_root, Path(task.worktree_path)) def _release_orphaned_mount(self, task: StoryTask) -> None: """Release mount ownership for a restart that will run in main. Accepted continuations never call this helper: their persisted mount owns the verified work regardless of live policy, so they reopen, finish, merge, and only then tear down normally. This is restart-only, after the story gate permits replacement work. The spec binding, attempt snapshot, baselines, worktree claim, and branch claim all leave together; the directory itself is retained and journaled for recovery or a later deterministic remount. """ if not task.worktree_path: return orphan = task.worktree_path # before the clears: the relativization is measured against this field's # mount project (DW-379) task.release_mount_owned_state(self._mount_project(task)) task.worktree_path = "" task.worktree_identity = None task.branch = "" self.journal.append( "isolation-flip-orphaned-worktree", story_key=task.story_key, worktree=orphan, ) def _safe_reset(self, task: StoryTask, *, preserve: tuple[str, ...] = ()) -> None: # Same as `_rollback_or_pause`: the reset invalidates a probe pass (DW-523). self._env_probes_fresh_root = None self._recovery_flow.safe_reset(task, preserve=preserve) def _restore_patch(self, task: StoryTask) -> None: self._recovery_flow.restore_patch(task) def _prune_preserve_refs(self) -> None: self._recovery_flow.prune_preserve_refs() def _preserve_attempt_commits( self, task: StoryTask, *, allow_pause: bool ) -> tuple[str, int] | None: return self._recovery_flow.preserve_attempt_commits(task, allow_pause=allow_pause) def _preserve_attempt_worktree(self, task: StoryTask, *, allow_pause: bool) -> str | None: return self._recovery_flow.preserve_attempt_worktree(task, allow_pause=allow_pause) def _pause_for_manual_recovery( self, task: StoryTask, baseline: str, *, preserve_failed: bool = False, snapshot_failed: bool = False, ) -> None: self._recovery_flow.pause_for_manual_recovery( task, baseline, preserve_failed=preserve_failed, snapshot_failed=snapshot_failed, ) def _replay_unlatched_ledger_carries(self) -> None: """Replay a successful isolated unit's carry after merge-time host loss. Called from ``run()`` and required to PRECEDE ``_loop``. ``SweepEngine`` replaces ``_loop`` wholesale and does not override ``run()``, so this frame is the only one both engines share — and a replayed bundle close has to leave the open set before ``_loop`` reads ``deferredwork.open_ids``, or the resumed sweep re-triages and re-drives work that already landed. """ entries = self.journal.entries() merged_units = { ( str(entry.get("story_key", "")), str(entry.get("branch", "")), str(entry.get("target", "")), ) for entry in entries if entry.get("kind") == "unit-merged" } started_units = { ( str(entry.get("story_key", "")), str(entry.get("branch", "")), str(entry.get("target", "")), str(entry.get("source", "")), ): str(entry.get("strategy", "")) for entry in entries if entry.get("kind") == "unit-merge-started" } for task in list(self.state.tasks.values()): if ( task.phase == Phase.DEFERRED and task.worktree_path and task.harvest_carry_commit_pending ): self.journal.append("resume-ledger-carry", story_key=task.story_key) # The HARVEST alone, exactly as `_defer`'s isolated arm calls it. # Replaying a defer through the composite hook would let a mode # override carry writes the defer deliberately withheld — a sweep # bundle's close names work whose code this defer discarded, and # `open_ids` re-bundles only `open` entries, so a wrong `done` is # invisible to every later sweep. self._carry_harvested_deferrals(task) continue publication_pending = ( bool(task.dw_ids) and not task.artifact_publication_complete and ( task.artifact_baseline is not None or task.artifact_payload is not None or (bool(task.worktree_path) and Path(task.worktree_path).exists()) ) ) if (task.isolated_ledger_carried and not publication_pending) or task.phase not in ( Phase.DONE, Phase.AWAITING_OPERATOR, ): continue merged_key = (task.story_key, task.branch, self.state.target_branch) # `not task.worktree_path` is the in-place guard, and a truthiness test # is the whole of it: an in-place task carries "", and `Path("")` is # `PosixPath(".")` whose `is_dir()` is True, so any probe placed ahead of # this one absorbs the in-place case and leaves it unfalsifiable. Merge # evidence comes from the target below, never from the `unit-merged` # row alone — that row is the session's to append, for an ordinary # story as for a bundle — and never from a mounted worktree, which # replays the merge instead. # A close-only payload replays too: a sweep bundle whose ledger writes # are all closures has an empty `harvested_deferrals`, and skipping it # here would strand the close and re-triage resolved work for ever. # Every payload the hook carries has to be named here — a story whose # only ledger write was a damped review-budget follow-up, or a declared # `closes_deferred:` flip, has every other list empty, and omitting it # strands that write in a deleted worktree. The board advance (#350) is # the ordinary case of exactly that: nearly every generic story records # one and nothing else, so leaving it out here would strand the write # that keeps `_pick_next` from re-picking the finished story. # A shared ticket store's latched mark (tickets mode) is such a write # too: it is made only once the code has merged back. if not task.worktree_path or not ( task.harvested_deferrals or task.bundle_closes_intended or task.story_closes_intended or task.board_advance_intended or publication_pending or task.ticket_done_pending or task.ticket_park_pending or task.ticket_commit_pending ): continue merged = merged_key in merged_units if task.dw_ids and task.integration_attempt is not None: # A live receipt's completion is never read from the record. # The `unit-merged` row is the session's to append — it holds # the writable run directory, the receipt's operation identity # included — and so is the target's reflog transition under # that identity: `git update-ref -m bmad-loop-integrate:` # writes exactly that row, on a branch checked out elsewhere # too, and a session in a linked worktree shares the # repository. Retiring the receipt on the two skipped the # deterministic target validation over whatever the session # had put there (#796 review). The replay below runs the # merge, which finds the moved ref under the receipt and # validates it (`landed`), or pauses with evidence. merged = False elif task.dw_ids and merged and self._integration_receipt_required(task): # No live receipt: a successful integration retired it, or a # host was lost before one was armed. The row and the reflog # transition are both the session's to write (above), so # neither stands for the completion (#796 review): while the # source is still mounted the replay below runs the merge, # which stages nothing again over a landed result, # re-validates the target's bytes and re-records; once the # completed integration has consumed the source (worktree # torn down, branch gone) there is no merge to replay, and # the completion stands only on the target as it is now — # the unit's commit in its history and every accepted # artifact blob in its tree and index — or pauses. The # released legacy payload integrates without a receipt and # keeps its bare row. merged = self._consumed_source_completion_holds(task) elif not task.dw_ids and merged: # An ordinary story has no receipt, but its row is the # session's to append all the same, so the target is the # authority here too: a mounted source replays the merge, a # consumed one holds only on the unit's commit or changes in # the target, or pauses with the reason. merged = self._ordinary_completion_holds(task) if not merged: source = task.commit_sha or "" started_key = (*merged_key, source) attempt = task.integration_attempt if task.dw_ids else None if attempt is not None: replay_strategy = ( str(attempt.get("strategy", "")) if isinstance(attempt, dict) else "" ) self.journal.append( "resume-unit-merge", story_key=task.story_key, branch=task.branch, target=self.state.target_branch, strategy=replay_strategy, source=source, operation_id=( str(attempt.get("operation_identity", "")) if isinstance(attempt, dict) else "" ), ) self._merge_local( task, self._reopen_unit(task), replay=True, replay_strategy=replay_strategy, ) merged = True replay_strategy = None else: replay_strategy = started_units.get(started_key) if not merged and (not source or replay_strategy is None): if not source and not publication_pending: continue # Terminal unit persisted before merge intent (DW-385): the # recorded commit is finished, verified work, so integrate it # now — skipping would let GC force-discard the branch holding # it. A bundle with publication pending integrates here too, # before sweep can re-triage or GC can remove its sources. # With neither there is nothing to merge; the carry stays gated. self._merge_local( task, self._reopen_unit(task), replay=True, first_integration=True ) merged = True replay_strategy = None else: replay_strategy = str(replay_strategy) if not merged: # The write-ahead record is intent, never merge proof. Re-run the # exact merge and latch completion only after git confirms it; # merge/ff are naturally idempotent, while squash enables its # recovery-only clean-tree success arm. self.journal.append( "resume-unit-merge", story_key=task.story_key, branch=task.branch, target=self.state.target_branch, strategy=replay_strategy, source=source, ) unit = self._reopen_unit(task) self._merge_local( task, unit, replay=True, replay_strategy=replay_strategy, ) if publication_pending and not task.artifact_publication_complete: if task.artifact_payload is None: unit = self._reopen_unit(task) self._worktree_flow.prepare_publication(task, unit.workspace.paths) self._worktree_flow.finish_publication(task, unit) else: # unit-merged proves integration; saved bytes are the source. # A removed original mount cannot invalidate this payload. self._worktree_flow.finish_publication(task, None) if Path(task.worktree_path).is_dir() and verify.worktree_is_registered( self.paths.repo_root, Path(task.worktree_path) ): self._worktree_flow.finish_publication(task, self._reopen_unit(task)) self.journal.append("resume-ledger-carry", story_key=task.story_key) self._carry_isolated_ledger_writes(task) task.isolated_ledger_carried = True self._save() self._land_post_landing_writes(task) # The failed integration unwound before _run_story returned, so the # loop never reached its normal post-integration continuation. self._after_story(task) def _integration_receipt_required(self, task: StoryTask) -> bool: """Whether ``task`` integrates under a target receipt (modern authority). Malformed authority reads as receipt-required: the replay's merge raises the same refusal and pauses with it. """ try: return artifact_publication.requires_target_integration_receipt(task) except artifact_publication.PublicationError: return True def _consumed_source_completion_holds(self, task: StoryTask) -> bool: """Whether a modern bundle's recorded integration holds on the target now. Read only with no live receipt. ``False`` while the unit's worktree is still mounted (``_source_mounted``: a directory git still registers): the merge is replayed instead, the stronger reading. With the source consumed, the deterministic target validation the record would otherwise have stood in for (#796 review): the target head holds the unit's integration — ``task.commit_sha`` in its history, or, since a squash seals a commit of its own and never that one (#796 review), every change the unit made over ``task.baseline_commit`` folded into its tree — blob for blob, or, where the target had moved the same file before the squash resolved it, as the three-way merge of the unit's change that stages nothing over what is held (#796 review) — and `validate_integrated` accepts its tree and the index at that head. Neither reading branches on the strategy: both are the target as it is now. Anything else pauses the run naming the reason — there is no source left to replay and no receipt left to restore. """ if self._source_mounted(task): return False target = self.state.target_branch repo = self.paths.repo_root try: if not target or not task.commit_sha: raise verify.IntegrationEvidenceError( "the recorded integration names no target branch or source commit" ) head = verify.ref_revision(repo, f"refs/heads/{target}") missing = self._target_lacks_unit(task, head) if missing is not None: raise verify.IntegrationEvidenceError(missing) artifact_publication.validate_integrated(task, self.paths, head) except ( artifact_publication.PublicationError, verify.GitError, OSError, RuntimeError, ValueError, ) as exc: self.journal.append( "artifact-publication-refused", story_key=task.story_key, error=str(exc) ) self._save() raise RunPaused( f"recorded integration of {task.branch} into {target} does not hold on " f"the target and its source is consumed (worktree gone): {exc}", PAUSE_ESCALATION, task.story_key, ) from exc return True def _source_mounted(self, task: StoryTask) -> bool: """Whether the unit's recorded worktree is still its live, registered mount. A directory alone is not a mount: a degraded teardown can drop git's registration and leave the directory, and replaying the merge there reaches ``_reopen_unit``, which escalates an unregistered path — so a leftover reads as consumed and the target is read instead. A registration probe that cannot answer reads as mounted, so the replay's ``_reopen_unit`` takes the same probe and escalates with its fault. """ if not task.worktree_path: return False path = Path(task.worktree_path) if not path.is_dir(): return False try: return verify.worktree_is_registered(self.paths.repo_root, path) except verify.GitError: return True def _target_lacks_unit(self, task: StoryTask, head: str) -> str | None: """What the target ``head`` lacks of the unit's integration, or ``None``. ``None`` when ``task.commit_sha`` is in ``head``'s history, or when every change the unit made over ``task.baseline_commit`` is folded into its tree (a squash seals a commit of its own, never that one). Otherwise a sentence naming what is missing. A pure read: ``GitError`` and ``OSError`` propagate to the caller, which owns the refusal. """ target = self.state.target_branch repo = self.paths.repo_root commit = task.commit_sha if not commit: return f"the unit names no commit for {target} to hold" if verify.is_ancestor(repo, commit, head): return None if not task.baseline_commit: return ( f"{target} does not hold the unit's commit {commit} and " "the unit names no baseline to read its change set from" ) unfolded = verify.unfolded_changes(repo, task.baseline_commit, commit, head) if unfolded: return ( f"{target} does not hold the unit's commit {commit} nor " f"its changes over {task.baseline_commit} at " + ", ".join(unfolded) ) return None def _ordinary_completion_holds(self, task: StoryTask) -> bool: """Whether an ordinary unit's recorded merge holds on the target now. The ordinary-story twin of ``_consumed_source_completion_holds``: the ``unit-merged`` row is the session's to append, so it never stands for the merge alone. ``False`` while the unit's worktree is still mounted (``_source_mounted``): the replay re-runs the merge, which is a no-op over a landed merge/ff and takes squash's recovery arm. With the source consumed, the merge holds only when the target head holds the unit (``_target_lacks_unit``) — never ``validate_integrated``, which refuses a task with no artifact payload. Anything else journals ``unit-merged-unverified`` with the reason and pauses the run: there is no source left to replay. """ if self._source_mounted(task): return False target = self.state.target_branch head: str | None = None cause: Exception | None = None try: if not target: reason, detail = "no-target", "the run names no target branch" elif not task.commit_sha: reason, detail = "no-commit", "the unit recorded no source commit" else: head = verify.ref_revision(self.paths.repo_root, f"refs/heads/{target}") missing = self._target_lacks_unit(task, head) if missing is None: return True reason, detail = "not-in-target", missing except (verify.GitError, OSError, RuntimeError, ValueError) as exc: reason, detail, cause = "evidence-unreadable", str(exc), exc self.journal.append( "unit-merged-unverified", story_key=task.story_key, branch=task.branch, target=target, commit_sha=task.commit_sha, head=head, reason=reason, ) self._save() raise RunPaused( f"recorded merge of {task.story_key} ({task.branch}) into {target} does not " f"hold on the target and its source is consumed (worktree gone): {detail}", PAUSE_ESCALATION, task.story_key, ) from cause def _clear_accept_baseline(self) -> None: """Consume the one-resume ``--accept-baseline`` latch (DW-371) once in-flight recovery has returned normally, so no later rollback of this run adopts a baseline. A pause inside recovery leaves it set; the next resume's `_prepare_resume_locked` overwrites it either way.""" if self.state.accept_baseline: self.state.accept_baseline = False self._save() def _finish_inflight(self) -> None: """Complete or roll back tasks interrupted by a pause or crash.""" for task in list(self.state.tasks.values()): if task.terminal: continue if task.worktree_path: # Portable spec paths are persisted relative to their recorded mount's # project. Re-anchor before dispatching recovery: accepted continuations # reopen that mount regardless of live policy, while a restart must # release ownership before it can begin in main or a replacement # worktree. task.rebase_spec_paths_on(self._mount_project(task)) if ( task.env_fault_site == f"{ENV_FAULT_SITE_DISPATCH_PREFIX}dev" and task.phase == Phase.PENDING ): # The resumed dispatch is a start, so it gets the start's story-gate # question — asked BEFORE the dispatch site clears: a gated refusal # then keeps the site, and the next resume still lands on the # no-rollback arm below instead of the restart arm. self._refuse_gated_story(task.story_key) # DW-523: re-probe a dispatch-site environment pause first (re-pauses # unchanged while the environment is still down). env_role = self._take_env_dispatch_pause(task) mounted = bool(task.worktree_path) restart_isolated = self._isolated and mounted if mounted and task.defer_reason is not None: # _defer records its reason before carrying harvested findings. # A read/commit fault (or host loss before the terminal advance) # can therefore leave a rejected result in the same DEV_VERIFY + # spec_file shape as a verified spec-approval pause. A persisted # defer reason on a nonterminal mounted task is that interrupted # decision's intent; finish it before any session-replay arm. self.journal.append("resume-defer", story_key=task.story_key) unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: self._defer(task, task.defer_reason) finally: self.workspace = prev self._integrate_unit(task, unit) elif task.reverify_from and task.phase == Phase.DEV_VERIFY: # `resolve --reverify` (DW-522) re-armed a DEFERRED or env-fault # ESCALATED story onto its kept attempt product. Ahead of the # spec-approval arm below, which matches the same DEV_VERIFY + # spec_file shape and would review and commit WITHOUT the verify # replay this latch exists to run. self.journal.append( "resume-reverify", story_key=task.story_key, origin=task.reverify_from ) if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: self._resume_reverify(task) finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) self._resume_reverify(task) elif task.phase == Phase.DEV_VERIFY and task.spec_file: # paused at the spec-approval gate (or, in stories mode, a # plan-checkpoint awaiting implementation — _resume_after_dev_verify # dispatches the right leg): dev verified on disk. if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: self._resume_after_dev_verify(task) finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) self._resume_after_dev_verify(task) elif ( resumable := self._pending_salvage_session(task) or self._resumable_session(task) ) is not None: # the host died inside the post-session window: the session # itself completed and its recorded result is on disk, so # continue into the normal verify/decide pipeline instead of # rolling the finished work back through resume-restart. # A pending salvage refile also replays its current timeout here: # the latch authorizes retrying salvage, never session completion. role, result = resumable self.journal.append("resume-verify", story_key=task.story_key, role=role) if role == "dev": # deliberate reset like the restart arm: _dev_phase re-enters # its loop and consumes the recorded result instead of # running a session task.phase = Phase.PENDING continuation = functools.partial(self._drive_story, task, dev_resume=result) else: # deliberate reset to the legal pre-review phase task.phase = Phase.DEV_VERIFY continuation = functools.partial( self._review_and_commit, task, resume_result=result ) if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: continuation() finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) continuation() elif task.phase == Phase.COMMITTING: # the host died in the commit window: the gate+advance save # landed (pre_commit_gate ran clean) but the DONE save that # stamps commit_sha did not. Finish the commit instead of # rolling verified work back — the gates are deliberately NOT # re-run (see _finalize_commit_phase), the pre_commit hook IS # re-emitted (message regenerated; pause veto still honored), # and finalize_commit tolerates both the pre- and post-squash # crash states (#115). self.journal.append("resume-commit", story_key=task.story_key) if task.adopt_pending: # `resolve --adopt-branch` (DW-386) moved this task here from # ESCALATED: the operator vouched for the kept branch, so it takes # this same finalize/merge path without re-running review. self.journal.append( "resume-adopt", story_key=task.story_key, branch=task.branch ) if not mounted: # Adopt refuses a task without a kept worktree, so this is a # state it never writes; fail loud rather than commit in main. self._escalate(task, f"adopted story {task.story_key} has no kept worktree") if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: if task.adopt_pending: self._apply_adoption(task) self._finalize_commit_phase(task) finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) self._finalize_commit_phase(task) elif env_role == "dev" and task.phase == Phase.PENDING: # DW-523: the run paused at the dev dispatch gate, before the # attempt, the baseline, or anything else moved — so NO rollback: # the tree (and any commit the operator made while paused) is the # one the story starts from. The story gate was asked above, before # the site cleared. A review-dispatch pause at DEV_VERIFY + spec_file # (first cycle) or at REVIEW_VERIFY with the completed pass on record # resumes through the spec-approval or completed-pass replay arms; # the remaining REVIEW_VERIFY shape has its own arm below (DW-529). self.journal.append("resume-env-dispatch", story_key=task.story_key, role=env_role) if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: self._drive_story(task) finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) self._drive_story(task) elif env_role == "review" and task.phase == Phase.REVIEW_VERIFY: # DW-529: the review loop paused at its dispatch gate after a cycle # whose pass did not complete (a crashed/stalled session the decision # retried), so the completed-pass replay arm above found nothing to # replay. As with the dev dispatch arm, nothing ran past the pause: # no rollback — re-enter the loop, whose gate dispatches the next # cycle (REVIEW_VERIFY -> REVIEW_RUNNING is legal). No damping spend # is pending: only a completed pass earns one. The loop is already # running, so its entry gate is not re-asked (DW-531). self.journal.append("resume-env-dispatch", story_key=task.story_key, role=env_role) if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: self._review_and_commit(task, resumed_loop=True) finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) self._review_and_commit(task, resumed_loop=True) elif task.reverify_replayed: # DW-527: a `resolve --reverify` replay PROCEEDed, then the host died # mid-review/fix of that continuation. The task is neither DEFERRED # nor ESCALATED, so the operator cannot issue the second `--reverify` # this arm stands in for; it reproduces that re-arm's counter and # generation resets (fresh review loop, bumped generation so the reset # cycle never re-mints the crashed session's id — #705). The other # latches that re-arm clears cannot be set on a task reaching here: the # mounted defer arm, the COMMITTING arm and dispatch-site consumption # all precede this arm, and an in-place `_defer` advances to DEFERRED # before any save. It then re-runs the verify replay, which # re-vouches for whatever tree the dead session left before any # review. Saved after the re-latch, so the next crash lands on the # ordinary reverify arm above. Every earlier finishing arm # (spec-approval, completed-pass replay, COMMITTING) still wins. origin = task.reverify_replayed self.journal.append( "resume-reverify", story_key=task.story_key, origin=origin, replay="post-proceed", ) task.phase = Phase.DEV_VERIFY # deliberate reset, not a normal transition task.reverify_from = origin task.reverify_replayed = "" task.generation += 1 task.review_cycle = 0 task.followup_reviews_spent = 0 task.salvage_refile_pending = False self._save() if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace try: self._resume_reverify(task) finally: self.workspace = prev self._integrate_unit(task, unit) else: self._release_orphaned_mount(task) self._resume_reverify(task) else: # This arm is the one that does not finish work: it discards the # worktree or resets the tree to baseline and re-runs the story # from scratch, so what follows is a *start* and gets the same # question `_loop` asks. Unconditionally — any test for "did a # session really run?" is wrong somewhere: `attempt` is bumped # before the session launches, `sessions` is written only after one # returns, and `rearmed` covers a stories-mode wedge that reached # ESCALATED with no session at all. # # Asked BEFORE the unwinding below, for the same reason `_loop` # asks before it registers the task: the in-place rollback is # `git reset --hard `, and a `gate:` committed while the # run was down lives in a commit *after* that baseline. Rolling # back first would rewind a tracked ledger and put the question to # a file the human never wrote — `keep=(".bmad-loop",)` guards only # untracked deletion, which is exactly why `verify.safe_rollback` # has to restore `policy.toml` by hand. It also keeps the pause # honest under the default `rollback_on_failure = false`, where # `_rollback_or_pause` would otherwise pause for manual recovery # and never reach the gate. The cost is that a refused isolated # task keeps its half-built worktree mounted until a resume gets # past the gate — the same thing an escalation pause does, and the # cheaper of the two mistakes. self._refuse_gated_story(task.story_key) task.salvage_refile_pending = False # abandoning this product's retry self.journal.append( "resume-restart", story_key=task.story_key, phase=str(task.phase) ) if restart_isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) else: # A mount live policy no longer treats as isolated. Released HERE, # below `_refuse_gated_story` — that gate can raise `RunPaused`, and # until an arm commits to acting the anchored spelling is what makes # recovery refuse loudly instead of rewriting the main checkout. self._release_orphaned_mount(task) if not restart_isolated and task.baseline_commit: # latch resolved_redrive so the corrected spec stays protected # through every reset of this re-drive, not just this first one task.resolved_redrive = task.resolved_redrive or task.rearmed if self.state.accept_baseline: # `resume --accept-baseline` (DW-371): adopt the current # checkout BEFORE the rollback so the reset targets HEAD # instead of parking commits made while the run was down. self._accept_current_baseline(task) self._rollback_or_pause( task, cause="resolved" if task.rearmed else "stopped", restart=True ) task.rearmed = False # past rollback (only reached when not paused) task.phase = Phase.PENDING # deliberate reset, not a normal transition self._save() self._run_story(task) # a resumed story that just reached DONE gets the same post-story hook # the _loop path fires (e.g. the stories-mode done_checkpoint pause), # after any worktree integration above — no-op in the base engine. self._after_story(task) def _pending_salvage_session(self, task: StoryTask) -> tuple[str, SessionResult] | None: """Retry only a latched current review timeout, through normal salvage. REVIEW_RUNNING also covers a host loss during this replay's first save. This is separate from completed-session eligibility: the timeout remains incomplete and the preserved product must pass salvage verification again. """ if not task.salvage_refile_pending or task.phase not in ( Phase.REVIEW_RUNNING, Phase.REVIEW_VERIFY, ): return None task_id = _session_task_id(task.story_key, "review", task.review_cycle, task.generation) for record in reversed(task.sessions): if record.task_id != task_id: continue if record.role != "review" or record.status not in REVIEW_TIMEOUT_STATUSES: return None return "review", SessionResult( status=record.status, result_json=record.result_json, session_id=record.session_id, transcript_path=record.transcript_path, ) return None def _resumable_session(self, task: StoryTask) -> tuple[str, SessionResult] | None: """The in-flight session's durably-recorded result, when complete enough to act on: the task died mid-phase (``*_RUNNING``) or in the post-verify decision window (``*_VERIFY`` — persisted by the save right after the verify/decide pass, before the decision's action completed) but its current attempt/cycle record is ``completed`` and carries the parsed result. Consumes only evidence the adapter vouched for at session end — no artifact re-scan, no loosening of completion authority. Anything less returns None and the caller falls through to resume-restart (#100: that restart used to discard a completed-``done`` attempt's commits). DEV_VERIFY reaches this matcher only when no persisted defer identifies an interrupted decision and ``task.spec_file`` is empty (verify did not fully pass before the death): _finish_inflight checks the defer-replay and spec-approval-gate arms first, so a DEV_VERIFY task WITH a verified spec keeps its _resume_after_dev_verify recovery.""" if task.phase in (Phase.DEV_RUNNING, Phase.DEV_VERIFY): role, seq = "dev", task.attempt elif task.phase in (Phase.REVIEW_RUNNING, Phase.REVIEW_VERIFY): role, seq = "review", task.review_cycle else: return None task_id = _session_task_id(task.story_key, role, seq, task.generation) for record in reversed(task.sessions): if record.task_id != task_id: continue if record.status != "completed" or record.result_json is None: return None return role, SessionResult( status="completed", result_json=record.result_json, session_id=record.session_id, transcript_path=record.transcript_path, ) return None def _current_dev_session_index(self, task: StoryTask) -> int | None: """Index of the newest primary dev record for the current attempt.""" task_id = _session_task_id(task.story_key, "dev", task.attempt, task.generation) for index in range(len(task.sessions) - 1, -1, -1): if task.sessions[index].task_id == task_id: return index return None def _dev_attempt_dispatched(self, task: StoryTask) -> bool: """Whether a dev session of the task's current attempt was dispatched — the provenance a rollback stamps on the ref it parks (#777, ``StoryTask.preserve_from_attempt``). A recorded session proves it, but a session is recorded only once it returns: a hard stop or host death mid-session leaves none, and the restart arm then parks that session's tree. A durable ``DEV_RUNNING`` covers that case — it is saved after ``attempt`` is bumped and before the launch, and a resolve re-drive's reset runs from the ``PENDING`` that ``runs.rearm_escalation`` leaves (under a bumped ``generation``, so no current-attempt record either).""" return task.phase == Phase.DEV_RUNNING or self._current_dev_session_index(task) is not None def _current_review_session_index(self, task: StoryTask) -> int | None: """Index of the newest review record for the current cycle.""" task_id = _session_task_id(task.story_key, "review", task.review_cycle, task.generation) for index in range(len(task.sessions) - 1, -1, -1): if task.sessions[index].task_id == task_id: return index return None def _bind_accepted_artifact_source(self, task: StoryTask, identity: str) -> None: """Bind isolated bundle deliverables at an accepted verify boundary.""" if not task.dw_ids or not task.worktree_path: return self._worktree_flow.bind_publication(task, self.workspace.paths, identity) def _accept_current_dev_session(self, task: StoryTask) -> None: """Latch the current dev or repair record as the accepted tree owner.""" accepted_index = self._current_dev_session_index(task) if accepted_index is None: raise RuntimeError(f"accepted dev decision for {task.story_key} has no session record") task.accepted_dev_session_index = accepted_index self._bind_accepted_artifact_source(task, f"dev:{accepted_index}") def _accept_review_artifact_source(self, task: StoryTask) -> None: """Bind the result whose final review verification just passed.""" if not task.dw_ids or not task.worktree_path: return if task.phase == Phase.REVIEW_VERIFY: accepted_index = self._current_review_session_index(task) if accepted_index is None: raise RuntimeError( f"accepted review decision for {task.story_key} has no session record" ) self._bind_accepted_artifact_source(task, f"review:{accepted_index}") return accepted_index = task.accepted_dev_session_index if accepted_index is None: self._bind_accepted_artifact_source(task, "") return # No separate review session ran, but this deterministic final review # gate is still a newly accepted boundary. Derive a distinct identity # from the append-only dev record so it supersedes the provisional dev # binding once, while crash replay of this same gate stays idempotent. self._bind_accepted_artifact_source(task, f"review:dev:{accepted_index}") def _accepted_dev_session_matches(self, task: StoryTask) -> bool: """Whether the current primary dev record owns the PROCEED receipt.""" accepted = task.accepted_dev_session_index return accepted is not None and accepted == self._current_dev_session_index(task) # ------------------------------------------------------------- per story def _gate_unit(self, task: StoryTask) -> bool: """per_worktree gate: emit ``pre_worktree_setup`` then ``pre_ready_gate`` so a plugin (e.g. the Unity engine) can launch + wait for the unit's managed Editor. Returns True to proceed; a veto at either stage routes the unit to DEFERRED/PAUSE via ``_vetoed`` (which raises on pause) and returns False. A zero-plugin run takes the O(1) fast path and proceeds.""" ctx = self._emit("pre_worktree_setup", task) if self._vetoed(ctx, task): return False ctx = self._emit("pre_ready_gate", task) if self._vetoed(ctx, task): return False self._emit("post_ready_gate", task) return True # --------------------------------------------------------- plugin hook bus def _emit(self, stage: str, task: StoryTask | None = None, **fields) -> HookContext | None: """Fire plugin hooks for ``stage``, or return None on the O(1) no-op fast path (no plugin binds the stage → a zero-plugin run does no work). Builds a HookContext from the task + extra fields, dispatches it through the bus, and returns it so the caller can read whitelisted mutations / resolve a veto. ``ctx.shared`` aliases ``state.plugin_shared`` so cross-stage mutations persist automatically.""" if not self._bus.active(stage): return None ctx = self._make_context(stage, task, **fields) if not self._bus_emit(stage, ctx): return None return ctx def _bus_emit(self, stage: str, ctx: HookContext) -> bool: """Dispatch ``ctx`` through the bus, turning a hard-stop interrupt into the engine's stop. Returns False only for an interrupted ``post_run``. A declarative hook's tree killed by a pending HARD stop request (DW-353) surfaces as ``ChildInterrupted``; the bus already journalled ``plugin-hook-interrupted``. Every stage but ``post_run`` raises ``RunStopped(via="stop-request")`` here, before any caller reads a veto or mutation off a half-run stage. ``post_run`` fires on the clean-finish path with ``finished`` already set, so the run still finishes and the lodged request is left for ``run()``'s finally to discard.""" try: self._bus.emit(stage, ctx) except ChildInterrupted: if stage == "post_run": return False raise RunStopped(via="stop-request") from None return True def _make_context(self, stage: str, task: StoryTask | None, **fields) -> HookContext: base: dict = { "run_id": self.state.run_id, "repo_root": str(self.paths.repo_root), "run_dir": str(self.run_dir), "shared": self.state.plugin_shared, # the dev + review CLI agent ids in this unit's worktree, for a plugin # that routes per-agent config (the Unity engine's MCP routing). "agents": tuple(self._engine_agent_ids()), } if task is not None: base.update( story_key=task.story_key, epic=task.epic, phase=str(task.phase), attempt=task.attempt, worktree=task.worktree_path or str(self.workspace.root), branch=task.branch or None, ) base.update(fields) return HookContext(stage, **base) def _vetoed(self, ctx: HookContext | None, task: StoryTask) -> bool: """Route a per-unit veto onto the engine's existing control flow. Returns True if the unit was vetoed (the caller should stop driving it). The phase is set *directly* (not via ``advance``) because a veto can fire from a stage with no legal transition to a terminal phase (e.g. PENDING) — the same deliberate move the engine's own gate-failure / DONE-unit paths make. ``skip`` quietly retires the unit (DEFERRED, no notify) so the loop continues and resume sees a terminal task; ``defer`` notifies; ``pause`` escalates and raises RunPaused.""" if ctx is None: return False veto = ctx.resolved_veto() if veto is None: return False msg = f"plugin {veto.plugin_id!r} vetoed {ctx.stage}: {veto.reason}".rstrip(": ") self.journal.append( "plugin-veto", stage=ctx.stage, action=veto.action, plugin=veto.plugin_id, reason=veto.reason, story_key=task.story_key, ) if veto.action == "pause": task.phase = Phase.ESCALATED # deliberate: veto stage may have no legal advance self.journal.append("story-escalated", story_key=task.story_key, reason=msg) gates.notify( self.policy, self.run_dir, f"CRITICAL escalation: {task.story_key}", f"{msg} — resolve, then `bmad-loop resume {self.state.run_id}`", ) self._save() raise RunPaused(msg, PAUSE_ESCALATION, task.story_key) task.defer_reason = msg task.phase = Phase.DEFERRED # deliberate set; the veto stage may have no legal advance if veto.action == "defer": self.journal.append("story-deferred", story_key=task.story_key, reason=msg) gates.notify(self.policy, self.run_dir, f"story deferred: {task.story_key}", msg) else: # skip: retire quietly, no human notification self.journal.append("story-skipped", story_key=task.story_key, reason=msg) self._save() return True def _emit_run_boundary(self, stage: str) -> None: """Fire a run-level stage (no task). A ``pause`` veto raises RunPaused so the run records as paused; ``defer``/``skip`` have no per-unit target here and are advisory (the bus already journalled them).""" ctx = self._emit(stage) if ctx is None: return veto = ctx.resolved_veto() if veto is not None and veto.action == "pause": raise RunPaused( f"plugin {veto.plugin_id!r} vetoed {stage}: {veto.reason}".rstrip(": "), PAUSE_ESCALATION, None, ) def _emit_session_gate( self, task: StoryTask, role: str, prompt: str, env: dict[str, str], session_stage: str ) -> tuple[str, dict[str, str], HookContext | None]: """Fire the role-specific then generic session hooks before a session launches, sharing one context so the generic ``pre_session`` sees the role hook's mutations. Returns the (possibly rewritten) prompt + env and the context (None on the fast path). A veto is left on the context for the caller to turn into a synthesized ``vetoed`` SessionResult.""" if not (self._bus.active(session_stage) or self._bus.active("pre_session")): return prompt, env, None ctx = self._make_context( "pre_session", task, role=role, proposed_prompt=prompt, proposed_env=dict(env) ) # role-specific stage first (its mutations are visible to pre_session) ctx._stage = session_stage self._bus_emit(session_stage, ctx) ctx._stage = "pre_session" self._bus_emit("pre_session", ctx) if ctx.proposed_prompt is not None: prompt = ctx.proposed_prompt if ctx.proposed_env: env = dict(ctx.proposed_env) return prompt, env, ctx def _run_workflows(self, stage: str, task: StoryTask, seq: int) -> bool: """Run every plugin-provided workflow bound to ``stage`` as an extra agent session through the generic ``_run_session`` path — the conservative form of custom orchestration (no new pipeline stage; an injected session in the unit's live worktree). Returns True iff a *blocking* workflow's session did not complete and the unit was therefore deferred (the caller must stop driving it). O(1) no-op when no active plugin provides a workflow here, so a workflow-free run stays byte-identical. A workflow session is just another session: it fires ``pre_workflow_session`` + ``pre_session`` + ``post_session`` and is recorded on the task like any other, so token budgets and the transcript trail account for it.""" if stage not in self._workflow_stages: return False for lp, wf in self._registry.workflows_for(stage): prompt = ( lp.manifest.render(wf.prompt) .replace("{story_key}", task.story_key) .replace("{run_id}", self.state.run_id) ) self.journal.append( "workflow-start", plugin=lp.name, workflow=wf.name, stage=stage, role=wf.role, story_key=task.story_key, ) result = self._run_session( task, role=wf.role, prompt=prompt, seq=seq, session_stage="pre_workflow_session", label=f"{lp.name}.{wf.name}", ) wf_extras: dict = {"env_fault": result.env_fault} if result.env_fault_evidence: wf_extras["env_fault_evidence"] = result.env_fault_evidence if result.parked: wf_extras["parked"] = True if result.parked_evidence: wf_extras["parked_evidence"] = result.parked_evidence if not result.produced_work: wf_extras["produced_work"] = False if result.usage_limited: wf_extras["usage_limited"] = result.usage_limited if result.usage_limit_resets: wf_extras["usage_limit_resets"] = result.usage_limit_resets if result.usage_limit_class: wf_extras["usage_limit_class"] = result.usage_limit_class if result.quota_wait_s > 0: wf_extras["quota_wait_s"] = round(result.quota_wait_s, 1) self.journal.append( "workflow-end", plugin=lp.name, workflow=wf.name, status=result.status, story_key=task.story_key, **wf_extras, ) if wf.blocking and result.status != "completed": if result.env_fault: # A blocking workflow session that lost its API connection (#194) # never ran — escalate (re-arm restores the budget) instead of # deferring the story on a transport failure. Non-blocking # workflows keep continuing (journaled only): the next story # session will classify and pause if the outage persists. self._escalate_session( task, env_fault_pause_reason( f"blocking workflow {wf.name!r} ({lp.name})", result ), usage_limit_pause(result), ) if result.parked: # Parked on a human prompt (DW-348/DW-350): the adapter withheld # the stall nudge, so the workflow never got a chance to run — # escalate (re-arm restores the budget) instead of deferring. self._escalate_session( task, parked_pause_reason(f"blocking workflow {wf.name!r} ({lp.name})", result), usage_limit_pause(result), ) if not result.produced_work: # Never did anything (#727): a deferral would charge the story # for a session that sat at a prompt only a human can answer — # escalate (re-arm restores the budget). After env_fault and # parked, which explain the silence better. self._escalate( task, no_work_pause_reason(f"blocking workflow {wf.name!r} ({lp.name})", result), ) reason = session_failure_reason( f"blocking workflow {wf.name!r} ({lp.name})", result ) # The defer charges the story for the workflow's failure: re-probe # the environment first (DW-523). gated = self._env_gate_decision( task, Decision(Action.DEFER, reason), role="workflow", result_json=result.result_json, ) if gated.action == Action.PAUSE: self._escalate_decision(task, gated) self._defer(task, reason) return True if wf.blocking: # A completed blocking workflow's "Environment fault:" claim still # forces a probe (DW-523) — at `pre_commit_gate` nothing else may # probe before the commit. A PROCEED carrying no claim returns # unchanged without spawning anything. gated = self._env_gate_decision( task, Decision(Action.PROCEED, ""), role="workflow", result_json=result.result_json, ) if gated.action == Action.PAUSE: self._escalate_decision(task, gated) return False def _run_story(self, task: StoryTask) -> None: ctx = self._emit("pre_story", task) if self._vetoed(ctx, task): return if self._isolated or task.worktree_path: self._run_isolated(task, self._drive_story) else: # in-place (non-isolated) ready gate: a plugin (e.g. a shared-mode # Unity engine) needs the live Editor up before any session starts. # The per_worktree gate runs inside _run_isolated, after that # worktree's own Editor has launched. ctx = self._emit("pre_ready_gate", task) if self._vetoed(ctx, task): return self._emit("post_ready_gate", task) self._drive_story(task) self._emit("post_story", task) def _operator_spec_path(self, task: StoryTask) -> str: """The task's spec spelled the way an operator can actually open it. Every pause that hands a human a path and tells them to review it goes through here, and the journal records the same string. `task.spec_file` is persisted RELATIVE to the mounted worktree under isolation (`model._serialized_worktree_path`), so the raw value resolves against whatever directory the operator happens to be in — the main checkout, which carries the same layout and answers with the wrong tree's copy. That is the identical defect the TUI's `_paused_spec` carries a docstring about; this is the surface the operator meets FIRST, before any dashboard. Defined on `Engine` rather than on `StoriesEngine`, where it started, because sprint mode pauses for spec approval too (`_drive_story` below) and it is isolation-capable through `_run_isolated` — so the same relative spelling reached the same operator from the sibling engine. Falls back to the story key on a spec-less task, matching `spec_ref` in stories mode rather than raising out of a notification path. """ if not task.spec_file: return task.story_key return str(task_spec_path(task, self.state)) def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) -> None: if not self._dev_phase(task, resume_result=dev_resume): return if gates.pause_after_spec(self.policy): gates.notify( self.policy, self.run_dir, f"spec ready for approval: {task.story_key}", f"review {self._operator_spec_path(task)}, then " f"`bmad-loop resume {self.state.run_id}`", ) raise RunPaused( f"awaiting spec approval for {task.story_key}", PAUSE_SPEC_APPROVAL, task.story_key, ) self._review_and_commit(task) def _dispatched_spec_for_attempt(self, task: StoryTask) -> str | None: """Resolve the recorded sprint spec this dev attempt will own. The result is an observation made immediately before the attempt's durable DEV_RUNNING save. Missing, stale, and non-file paths deliberately leave the attempt unbound. Persist the canonical regular-file name rather than a symlink spelling, so a child cannot retarget the binding after launch and make recovery restore the snapshot into another trusted file. """ if not task.spec_file: return None try: spec_path = verify.resolve_spec_path(task.spec_file, self.workspace.paths) if spec_path.is_symlink(): return None resolved = spec_path.resolve(strict=True) if not resolved.is_file() or not verify.spec_within_roots( resolved, self.workspace.paths ): return None return str(resolved) except (OSError, RuntimeError, ValueError): return None def _read_dispatched_spec_snapshot(self, task: StoryTask) -> tuple[str, bytes] | None: """Read stable bytes from the already-authoritative canonical path. This deliberately never resolves ``task.spec_file`` anew: after prompt construction, promoting a transiently unbound attempt would let recovery claim a file the launched bare-key prompt never named. The open-file and post-read pathname identities must agree, so an atomic regular-file replacement cannot pair bytes from the old inode with the new name. This observer never mutates the task. In particular, validating a retained retry-chain snapshot must not temporarily install child-authored bytes: an asynchronous stop in that window would make those bytes durable. """ if not task.dispatched_spec_file: return None spec_path = Path(task.dispatched_spec_file) try: if spec_path.is_symlink(): raise RuntimeError("attempt-owned spec became a symlink") resolved = spec_path.resolve(strict=True) if ( resolved != spec_path or not resolved.is_file() or not verify.spec_within_roots(resolved, self.workspace.paths) ): raise RuntimeError("attempt-owned spec is no longer a trusted regular file") with resolved.open("rb") as stream: before = os.fstat(stream.fileno()) snapshot = stream.read() after = os.fstat(stream.fileno()) current = resolved.stat(follow_symlinks=False) def identity(st): return (st.st_dev, st.st_ino) def contents(st): return (st.st_size, st.st_mtime_ns) if ( identity(before) != identity(after) or identity(after) != identity(current) or contents(before) != contents(after) or contents(after) != contents(current) or resolved.is_symlink() or resolved.resolve(strict=True) != resolved ): raise RuntimeError("attempt-owned spec changed identity while being read") except (OSError, RuntimeError, ValueError): return None return str(resolved), snapshot def _refresh_dispatched_spec_snapshot( self, task: StoryTask, *, clear_on_failure: bool = True, ) -> bool: """Refresh both halves of a fresh attempt's ownership authority. Initial observation may degrade to an unbound bare-key attempt, so its failure clears stale authority. Once a child has been promised an explicit spec, callers pass ``clear_on_failure=False``: preserving the last trusted path and bytes lets crash recovery refuse a vanished or retargeted file instead of forgetting that the unsafe binding existed. """ observed = self._read_dispatched_spec_snapshot(task) if observed is None: if clear_on_failure: task.dispatched_spec_file = None task.dispatched_spec_snapshot = None return False task.dispatched_spec_file, task.dispatched_spec_snapshot = observed return True def _validate_dispatched_spec_snapshot(self, task: StoryTask) -> bool: """Validate the bound path without replacing the retry-chain bytes. A fixable retry deliberately inherits the previous child's working tree, but a later non-fixable retry still resets the whole chain to the phase baseline. The retained snapshot must therefore remain the bound input from that chain's first launch, not a body edit authored by an intermediate repair session. During a resolved re-drive that input is the operator's correction. """ if task.dispatched_spec_snapshot is None: return False observed = self._read_dispatched_spec_snapshot(task) if observed is None: return False if not task.spec_file: return True try: accepted = verify.resolve_spec_path(task.spec_file, self.workspace.paths) if accepted.is_symlink(): return False resolved = accepted.resolve(strict=True) accepted_target = accepted.parent.resolve(strict=True) / accepted.name if ( resolved != accepted_target or not resolved.is_file() or not verify.spec_within_roots(resolved, self.workspace.paths) ): return False except (OSError, RuntimeError, ValueError): return False return str(resolved) == observed[0] def _bind_dispatched_spec_for_attempt(self, task: StoryTask) -> None: """Atomically observe this attempt's regular spec and pre-launch bytes. The path and snapshot are one authority pair: a read fault leaves both unbound, so recovery can never restore bytes that belong to a stale path. Called once before DEV_RUNNING becomes durable. Later orchestrator and hook mutations refresh only this established path through ``_refresh_dispatched_spec_snapshot``. A bind that finds no recorded spec at all latches ``dispatched_spec_absent``; a degraded bind of a recorded spec does not, so its repairs still fail closed. """ task.dispatched_spec_snapshot = None task.dispatched_spec_file = self._dispatched_spec_for_attempt(task) self._refresh_dispatched_spec_snapshot(task) task.dispatched_spec_absent = task.dispatched_spec_file is None and not task.spec_file def _expected_spec_for(self, task: StoryTask, prompt: str, label: str | None) -> str | None: """The spec a session's read-back is pinned to (``SessionSpec.expected_spec``, #261), or None to let the adapter scan. Base: the recorded spec, only for a primary generic-dev session whose prompt names it — see the pin's comment in :meth:`_run_session`. ``TicketsEngine`` pins the bound plan from attempt 1.""" if ( label is None and self._generic_dev() and task.spec_file and self._prompt_names_recorded_spec(task, prompt) ): return task.spec_file return None def _session_success_statuses(self, role: str, label: str | None) -> tuple[str, ...]: """The frontmatter statuses that end a session successfully (``SessionSpec.success_statuses``). Base: the v6 ``done``.""" del role, label return (devcontract.DONE,) def _session_fallback_dirs(self, role: str, label: str | None) -> tuple[Path, ...]: """Extra directories the read-back probes for the skill's no-spec fallback result (``SessionSpec.fallback_result_dirs``). Base: none.""" del role, label return () def _dev_success_status(self) -> str: """The status a dev session finalizes its spec to: ``done`` for a v6 spec. Read by the generic reconcile and the follow-up-review routing.""" return devcontract.DONE @staticmethod def _prompt_names_recorded_spec(task: StoryTask, prompt: str) -> bool: """Whether the prompt contains an engine-authored explicit-spec token.""" if not task.spec_file: return False spec = str(task.spec_file) return f"`{spec}`" in prompt or bool( re.match(rf"^/\S+\s+{re.escape(spec)}(?:\s|$)", prompt) ) def _requires_dispatched_spec_snapshot(self, task: StoryTask, prompt: str) -> bool: """Whether this prompt makes the recorded spec attempt-owned input. Sprint and Stories repair routes name the spec they will mutate, so they may launch only with a recoverable byte snapshot. Engine variants whose explicit spec pointer has different ownership semantics override this predicate rather than being identified here by type or task shape. """ return self._prompt_names_recorded_spec(task, prompt) def _retains_dispatched_spec_snapshot_on_repair(self) -> bool: """Whether fixable repairs remain in the current spec-input chain.""" return True def _preserves_dispatched_spec_snapshot_for_repair(self, task: StoryTask) -> bool: """Whether this repair must retain (or fail closed on) chain authority. A resolved re-drive fails closed even when unbound: its operator-corrected spec may have existed and failed to bind. A re-drive whose chain began with no recorded spec (``dispatched_spec_absent``) has no operator bytes to retain, so its repair binds the spec the chain created (#860). """ return self._retains_dispatched_spec_snapshot_on_repair() and ( (task.resolved_redrive and not task.dispatched_spec_absent) or task.dispatched_spec_file is not None or task.dispatched_spec_snapshot is not None ) def _artifact_baseline(self, task: StoryTask) -> dict[str, list[int] | None] | None: """The attempt-start snapshot behind the artifact-only receipt (DW-273). The receipt is the bundle leg's alone (`verify.verify_dev_bundle`), so the story engine stamps nothing — `None`, on which the receipt refuses — and spends no git call on it; `SweepEngine` overrides with the real `verify.artifact_dir_snapshot`.""" del task return None def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None) -> bool: if resume_result is None: # DW-523: probe before anything below mutates the task, so an # `environment` pause leaves nothing to roll back on resume. self._gate_dispatch(task, "dev") # A fresh invocation cannot consume a snapshot armed by an earlier, # non-replayable invocation. Keep crash replay's snapshot intact. self._disarm_ledger_snapshot(task) if self._vetoed(self._emit("pre_dev_phase", task), task): return False if resume_result is None: task.baseline_commit = verify.rev_parse_head(self.workspace.root) # snapshot untracked files now so a later rollback removes only what # THIS attempt creates, never files the user already had on disk. # A resumed result keeps the persisted baseline: re-capturing here # would shift the rollback/squash reference onto the completed # session's own tree. task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root)) # Start the ledger attribution reference under the same fresh-entry # rule. The harvest exclusion compares each attempt's pre-harvest # ledger with this reference so a session-authored ledger edit is # never hidden along with the orchestrator's own append. A fixable # retry rebases it onto the tree that retry deliberately keeps. task.baseline_ledger_digest = self._ledger_digest(task) feedback: Path | None = None while True: replayed = resume_result is not None if resume_result is None: # a resumed result replays the attempt it was recorded under, so # the counter (and the session task_id derived from it) must not # advance; a second host death then still finds the record and # re-enters this continuation instead of falling back to restart. task.attempt += 1 # A genuinely new attempt starts from a rolled-back tree and owes # nothing to the rejected attempt's harvest. A fixable retry keeps # that tree and ledger intentionally, so preserve its payload for a # later isolated carry. Crash replay never enters this branch. if feedback is None: task.harvested_deferrals = [] # The artifact-only receipt's ownership baseline (DW-273) is # re-stamped on the same rule: a rolled-back attempt's IGNORED # residue survives the rollback (`git clean -x` never runs), so # without a fresh snapshot the next attempt would be credited # with it. A fixable repair keeps the chain's snapshot for the # same reason it keeps the tree. task.baseline_artifacts = self._artifact_baseline(task) # A fresh-baseline dispatch replaces stale ownership. A fixable # repair inherits the current working tree, but retains the chain's # first bound snapshot because a later non-fixable retry resets all # the way to the phase baseline. For a resolved re-drive those are # the operator-corrected bytes. # Recorded-result replay never enters this branch and therefore # retains the persisted binding unchanged. preserve_chain_snapshot = ( feedback is not None and self._preserves_dispatched_spec_snapshot_for_repair(task) ) if not preserve_chain_snapshot: self._bind_dispatched_spec_for_attempt(task) advance(task, Phase.DEV_RUNNING) self._save() if ( resume_result is None and preserve_chain_snapshot and not self._validate_dispatched_spec_snapshot(task) ): # Persist the no-session attempt before failing. Resume must enter # rollback/recovery from DEV_RUNNING, not mistake the preceding # DEV_VERIFY state for a completed spec-approval pause. raise RuntimeError( "attempt-owned spec became unreadable during pre-launch snapshot" ) if resume_result is not None: # the session already ran before the host died; its recorded # result re-enters the verify/decide pipeline. Consumed exactly # once — later iterations run sessions normally. result = resume_result resume_result = None else: # intent-gap patch-restore (#2564): re-lay the saved attempt onto # the baseline before dispatch so the re-driven session resumes # review on the restored diff. `feedback is None` ⇒ the tree is at # baseline (fresh attempt or a non-fixable rollback below), NOT a # fixable-feedback retry that kept the attempt's tree — so this # never double-applies. No-op unless a restore is latched; escalates # (never dispatches) if the patch fails to apply. if feedback is None: self._restore_patch(task) prompt = self._dev_prompt(task, feedback) # Capture the exact bytes a fresh-baseline child will inherit after # orchestrator-owned pre-launch mutations. A fixable repair validates # that same path but retains the chain's first snapshot, because a # later non-fixable retry resets the whole chain. had_binding = task.dispatched_spec_file is not None snapshot_required = ( preserve_chain_snapshot or had_binding or self._requires_dispatched_spec_snapshot(task, prompt) ) snapshot_ok = not snapshot_required if had_binding: if preserve_chain_snapshot: snapshot_ok = self._validate_dispatched_spec_snapshot(task) else: snapshot_ok = self._refresh_dispatched_spec_snapshot( task, clear_on_failure=False ) self._save() if snapshot_required and not snapshot_ok: raise RuntimeError( "attempt-owned spec became unreadable during pre-launch snapshot" ) result = self._run_session( task, role="dev", prompt=prompt, seq=task.attempt, preserve_dispatched_spec_snapshot=preserve_chain_snapshot, ) advance(task, Phase.DEV_VERIFY) outcome = None verified = NO_VERIFY_COMMANDS if result.status == "completed": # Everything below this point that appends to the ledger is the # orchestrator, not the session. Preserve attribution on crash # replay: the replayed harvest dedupes against the dead attempt's # on-disk append. # Before the first harvest write, a replay can still reconstruct # session authorship from the persisted baseline digest. Once an # engine write is latched, the current ledger includes that write # and replay must preserve the attribution saved before harvest. if not replayed and feedback is None: task.harvest_wrote_ledger = False if ( replayed and task.baseline_ledger_digest is None and not task.harvest_wrote_ledger ): # A state written before ledger attribution has no digest to # compare, but it still has the attempt's Git baseline. Use # the pre-harvest path diff to preserve legitimate ledger-only # session work without letting the append below become its # own proof. A later replay with harvest_wrote_ledger already # latched preserves the attribution saved by this first one. task.ledger_changed_before_harvest = self._legacy_ledger_changed_before_harvest( task ) self._save() elif not replayed or not task.harvest_wrote_ledger: if task.baseline_ledger_digest is not None: task.ledger_changed_before_harvest = self._ledger_changed_since_baseline( task ) self._save() # Snapshot after the session has finished, preserving any ledger # edits it authored, and before reconcile/state-sync/harvest can # write on the engine's behalf. The harvest is the engine-side # ledger write this snapshot currently protects; the sweep bundle # close lives below the acceptance gate instead. Keep the chain's # first snapshot # across fixable retries because a later non-fixable reset returns # all the way to the phase baseline. An unarmed replay may safely # arm from disk; an armed replay must retain the dead attempt's # pre-harvest bytes. if (not replayed and feedback is None) or not task.pre_harvest_ledger_captured: snapshot = self._ledger_text( site="pre-harvest-snapshot", story_key=task.story_key ) # Undecodable bytes (DW-231) and a read the OS refused # (DW-258) both leave the snapshot UNARMED: there is no text # a restore could put back, and arming over either typed # answer would only turn a later rejected attempt's restore # into a journaled skip anyway. The degraded read is already # journaled by `_ledger_text`; the harvest below is the site # that decides whether this run can go on. if not isinstance(snapshot, _DEGRADED_LEDGER): task.pre_harvest_ledger = snapshot task.pre_harvest_ledger_captured = True # The snapshot's own text is the first thing this engine # can claim to have left on disk: nothing of ours has been # written over it yet. The harvest below refreshes this to # the bytes it appends, so the CAS anchor always names the # engine's latest write rather than the chain's first. task.post_engine_ledger_digest = _digest_of(snapshot) self._save() # bmad-build-auto sometimes finalizes the spec in prose (## Auto Run # Result: Status done) but leaves the frontmatter status at the # template default. Repair it BEFORE any frontmatter reader runs — # the sync below, verify_dev, and the review-verify gate all key # off the on-disk frontmatter status. self._reconcile_generic_terminal_status(task, result.result_json) # Harvest the session's frontmatter `deferred:` findings into the # ledger before the artifact gate so an accepted attempt carries # them in its story commit. `_harvest_gate_exclude` keeps this # engine-authored append from becoming proof of session work. This # strict read can also finish a reconciliation whose preceding # observation faulted, so state sync follows it and sees the # repaired terminal status. harvest_outcome = self._harvest_spec_deferrals(task, result.result_json) # Generic-path single-writer for the sprint bookkeeping the # decoupled skill never touches, before verify reads that state. # Sweep bundles override this pre-gate seam to a no-op; their # ledger close belongs to the accepted-only seam below. self._post_dev_state_sync(task, result.result_json) # carry the skill's follow-up-review recommendation (PR #2505) # onto the task so _review_and_commit can gate the review loop. # A present key is authoritative (folded from the frontmatter, or # the legacy skill's own result.json); an absent one is a resumed # pre-reconcile snapshot whose re-fold may have been dropped by a # spec read fault — re-derive from the spec instead of defaulting # a recommended review away. rj = result_mapping(result.result_json) if "followup_review_recommended" in rj: task.followup_review_recommended = bool(rj["followup_review_recommended"]) else: task.followup_review_recommended = self._followup_from_spec(task, rj) outcome = harvest_outcome or self._verify_dev_artifacts(task, result.result_json) if outcome.ok and self._run_verify_commands_after_dev(task, result.result_json): # deterministic gates run here too: a broken build must not # reach the (far more expensive) review loop outcome, verified = self._verify_commands_with_results(task, "dev") self._emit( "post_dev_verify", task, session_status=result.status, result_json=result.result_json, verify_reason=(outcome.reason if outcome is not None else None), command_results=verified.results, # The dev-vs-repair discriminator + the journal join key. Left at # NO_VERIFY_COMMANDS' Nones on every arm that never reached # verification, which `session_status`/`verify_reason` name. verification_stage=verified.stage, verification_sequence=verified.sequence, ) # env_fault from EITHER the verify path (a probe, a declared rc, # rc 126/127) or the session-transport classification (#194); # decide_dev PAUSEs on both, so the fall-through below preserves the # worktree. env_fault = bool((outcome is not None and outcome.env_fault) or result.env_fault) # Re-probe the environment before a failure is charged (DW-523) — # ahead of the journal, so `dev-decision` records the routing taken. decision = self._env_gate_decision( task, decide_dev(task, result, outcome, self.policy), role="dev", result_json=result.result_json, already_env_fault=env_fault, ) self._journal_dev_decision(task, result, decision, env_fault=env_fault) if decision.action == Action.PROCEED: # DEV_VERIFY + spec_file is not itself proof of acceptance: this # save also precedes every rejecting decision branch. Persist an # explicit transaction latch so recovery may replay accepted-only # bookkeeping without closing work from a PAUSE/RETRY/DEFER. self._accept_current_dev_session(task) self._save() if decision.action == Action.PROCEED: self._finish_post_dev_accepted_sync(task, result.result_json) self._emit("post_dev_phase", task) if self._run_workflows("post_dev_phase", task, task.attempt): return False return True if decision.action == Action.RETRY: # Tell the operator WHY the attempt is being redone (#640d). RETRY # was the only dev outcome that REJECTS an attempt without raising a # notice (PROCEED raises none either, but it accepts the work rather # than discarding it), and it is the arm that DISCARDS a completed # implementation — the non-fixable leg below rolls the tree back to # baseline. Without # this the only record was the `dev-decision` journal line, so a # run could burn its whole attempt budget throwing away finished # work with nothing on the operator's phone but the eventual # exhaustion notice. # # Placed at the TOP of the branch, ahead of the fixable/non-fixable # split: it is the only point where both `decision.reason` and # `task.attempt` are known-good for this decision, and it is before # `_rollback_or_pause` can raise `RunPaused` and skip the notice for # exactly the attempt whose loss most needs announcing. gates.notify( self.policy, self.run_dir, f"dev retry: {task.story_key} (attempt {task.attempt})", decision.reason.strip() or "dev attempt rejected with no reason recorded", ) if outcome is not None and outcome.fixable: # work exists and the failure is concrete: keep the tree, # hand the failing output to a repair session feedback = self._write_feedback(task, decision.reason) # The repair session is judged against the kept chain. Move # the ledger reference onto that tree so the retained harvest # is accounted for, while a new session-authored ledger edit # still makes `ledger_changed_before_harvest` true — except # when this read is refused by the OS (DW-258): the reference # becomes the `` sentinel and the repair session's # ledger edit is NOT credited, the trade-off # `_ledger_changed_since_baseline` accepts. task.baseline_ledger_digest = self._ledger_digest(task) else: feedback = None try: self._rollback_or_pause(task) finally: # An exception from rollback means this attempt is leaving # the decision pipeline just as surely as a returned # rollback. Detect the active unwind without limiting it to # RunPaused: reset/preserve failures are the #420 gap. unwinding = sys.exc_info()[0] is not None restore_error: OSError | StateRootError | None = None # Recovery resets code/spec state but protects artifact # directories through `_safe_reset`'s # keep=(".bmad-loop", *self._protected_relpaths()) shield. # Consequently an ordinary untracked harvest-created ledger # survives unless restored explicitly; `git reset --hard` # performs the ledger revert only when the ledger is tracked. # Run this for stop-and-wait too: the operator's eventual # reset would not remove an untracked or ignored file either. try: self._restore_persisted_ledger(task, replayed=replayed) except (OSError, StateRootError) as e: # Preserve an exception already in flight; replacing a # RunPaused/reset fault would misclassify the run and # skip the stale-arm cleanup below. The journal keeps # this secondary repair failure visible. # `StateRootError` joins `OSError` because the restore # now serializes on `ledger_lock`, which raises it when # the environment names no state root to put the lock # sidecar under. A lock that could not be taken is the # same class of secondary repair failure as a write # that could not land, and must not be the exception a # paused run reports either. restore_error = e self.journal.append( "ledger-restore-failed", story_key=task.story_key, error=f"{type(e).__name__}: {e}", ) # Rebase from the snapshot already in hand so a filesystem # fault cannot replace a RunPaused while unwinding. if task.pre_harvest_ledger_captured: task.baseline_ledger_digest = _digest_of(task.pre_harvest_ledger) # Leaving this attempt spends the chain snapshot even when # its repair failed: retaining stale bytes across a pause or # crash would let resume overwrite later operator work. A # fixable retry never enters this branch and keeps its arm. self._disarm_ledger_snapshot(task) if unwinding or restore_error is not None: # A pause or rollback failure skips the next loop/save; # persist before it can ride a human window or #420's # crash path. A restore failure must likewise persist # the disarm before it is raised. On a cleanly returned # rollback the next attempt's ordinary save supplies # durability. self._save() if restore_error is not None and not unwinding: # Repair writes fail loud when there is no earlier # exception whose identity must be preserved. raise restore_error continue # DEFER and escalation preserve their current tree/knowledge rather # than rejecting the attempt, so neither may later consume this arm. self._disarm_ledger_snapshot(task) if decision.action == Action.DEFER: self._record_dev_spec(task, result.result_json) self._defer(task, decision.reason) return False self._record_dev_spec(task, result.result_json) if decision.env_site is not None: self._escalate_env(task, decision.reason, site=decision.env_site) elif ( outcome is not None and outcome.env_fault # a session's own CRITICAL outranks the verify env fault in # `decide_dev`, and is not an environment fault to re-verify past and critical_session_reason("dev", result.result_json) is None ): self._escalate_env(task, decision.reason, site="verify:dev") else: self._escalate_session(task, decision.reason, decision.usage_limit) def _journal_dev_decision( self, task: StoryTask, result: SessionResult, decision: Decision, *, env_fault: bool ) -> None: """Journal the dev leg's routing (``dev-decision``). Two literal writes rather than a conditional `**` splat, so the journal field guard reads every name; ``env_fault_site`` appears only on a decision the environment seam reclassified (DW-523).""" if decision.env_site is None: self.journal.append( "dev-decision", story_key=task.story_key, attempt=task.attempt, session_status=result.status, action=str(decision.action), reason=decision.reason, env_fault=env_fault, # The all-roles greppable record rides session-end via # `_session_end_extras` (#489); here the flag pairs the # diagnosis with the decision it fed. session_vanished=result.session_vanished, # Whether the session did anything before it ended (#727); False # is what routed a non-completed result to the no-work PAUSE. produced_work=result.produced_work, # Parked on a human prompt (DW-348/DW-350); True is what routed a # non-completed result to the parked PAUSE. parked=result.parked, parked_evidence=result.parked_evidence, ) else: self.journal.append( "dev-decision", story_key=task.story_key, attempt=task.attempt, session_status=result.status, action=str(decision.action), reason=decision.reason, env_fault=env_fault, session_vanished=result.session_vanished, produced_work=result.produced_work, parked=result.parked, parked_evidence=result.parked_evidence, env_fault_site=decision.env_site, ) def _record_dev_spec(self, task: StoryTask, result_json: dict | None) -> None: """Capture the spec the dev session produced when the session escalates or defers. ``verify_dev`` only records ``task.spec_file`` on full success, so a blocked/escalated spec (the common escalation case) would otherwise leave it unset — and then escalation resolution (``runs.rearm_escalation`` flips the spec's frontmatter status to ``ready-for-dev``) and deferral stashing have no spec path to act on, so the re-drive HALTs on the stale ``blocked`` status. The synthesized result names the spec even on a HALT (``devcontract.synthesize_result``). No-op once set or when the claimed spec is absent. The skill's no-spec fallback artifact (``bmad-build-auto-result-*``, or ``bmad-dev-auto-result-*`` pre-rename — ``FALLBACK_RESULT_PREFIXES`` matches both; written when intent was too unclear to even CREATE a spec) is refused: it is not the story's spec, so every consumer here misreads it. ``rearm_escalation`` would flip frontmatter on a marker no re-drive reads, ``_reset_spec_for_repair`` would re-open it as if it were the frozen intent contract, and the repair prompt would point the session at it — which the #261 read-back then pins to, polling a stale marker while the re-drive's real spec goes unread.""" if task.spec_file: return spec_file = result_mapping(result_json).get("spec_file") if not spec_file: return spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths) if spec_path.name.startswith(devcontract.FALLBACK_RESULT_PREFIXES): return if spec_path.is_file(): task.spec_file = str(spec_path) def _review_and_commit( self, task: StoryTask, resume_result: SessionResult | None = None, *, resumed_loop: bool = False, ) -> None: # A replayed REVIEW result finalized at `awaiting-operator` under # on_review_demotion = "park" is a review demotion (DW-383), not a dev park # (a dev park never reaches a review session): the loop below replays it # into `_park_review_demotion`, which owns the board regression and intent. replayed_demotion = resume_result is not None and self._review_demotion_parks() if not replayed_demotion and self._park_awaiting_operator(task): return if not self.policy.review.enabled and not task.salvage_refile_pending: # review.enabled = false: the bmad-build-auto session's own inline # review is the only review; verify the deterministic gates + commit. # A latched salvage still owes publication/commit even if the # operator disabled new review passes during the repair pause. self._skip_review_and_commit(task) return # review.enabled = true (default): run a follow-up review session by # re-invoking bmad-build-auto on the done spec (BMAD-METHOD #2508 routes a # `done` spec to a fresh step-04 review pass). The dev session self- # finalizes the spec to done (no in-review handoff) and the orchestrator # advances sprint-status at dev time (_post_dev_state_sync), so this runs # as an independent second-opinion pass on a done spec before commit. # # review.trigger = "recommended" (default) gates that loop per-story on the # bmad-build-auto session's `followup_review_recommended` signal (PR #2505): # the skill already self-reviews inline every story and recommends an # independent pass from a severity-weighted score over its patched # findings (upstream #2580). When it didn't, skip the separate session # and let the deterministic gates + commit run (_skip_review_and_commit # still validates them). "always" keeps the pre-#2505 behavior of # reviewing every story. Either way the loop below is bounded by # limits.max_review_cycles (the hard outer cap) and damped by # limits.max_followup_reviews — the guard against a finalized round that # keeps recommending its own follow-up (structural before upstream #2580 # scored the flag; kept as the orchestrator-side bound): once the damping # grant is spent, such a round converges + refiles instead of burning # cycles to the outer cap. # # The gate decides whether to START the loop, so it applies only on a fresh # entry (DW-531). A resume that re-enters a loop already in progress — the # completed-pass replay (`resume_result`) or a caller passing `resumed_loop` # (the DW-529 review dispatch arm, a post-fix DEV_VERIFY resume) — carries # the last pass's flag, which the uninterrupted loop never re-checks: a # done/followup-False pass whose verify gate failed fixably re-reviews # after its fix, and a non-terminal pass loops regardless of the flag. if ( resume_result is None and not resumed_loop and self.policy.review.trigger == "recommended" and not task.followup_review_recommended and not task.salvage_refile_pending ): self.journal.append("review-not-recommended", story_key=task.story_key) self._skip_review_and_commit(task) return if self._vetoed(self._emit("pre_review_phase", task), task): return clean = False # Tracks whether the last *completed* review pass left the story finalized # (status: done) while still recommending an independent follow-up — the # only state the budget-exhaustion rescue below is allowed to commit. refileable_followup = False # The last *completed* pass's parsed frontmatter status, so the exhaustion # defer reason can name what actually happened instead of the fixed # follow-up wording (issue #160). None until a pass reaches the parse below # (a crash/stall that DEFERs never gets there). last_status: str | None = None # A damping grant a completed pass earned, not yet spent: applied only past # the next cycle's dispatch gate (or at loop exit), so a dispatch pause # never persists a spend the completed-pass replay re-derives (DW-523). spend_pending = False # A resumed result must enter the loop even when the crash landed in the # post-session window of the *final* allowed cycle (review_cycle already # == max_review_cycles): its recorded pass was already counted, and the # replay branch below skips the re-increment, so no extra budget is # burned. resume_result is nulled after it is consumed, so every later # iteration falls back to the normal budget guard. while resume_result is not None or task.review_cycle < self.policy.limits.max_review_cycles: if resume_result is None: # DW-523: probe before the cycle is charged; a pause here resumes # through the DEV_VERIFY or completed-pass replay arms. self._gate_dispatch(task, "review") if spend_pending: task.followup_reviews_spent += 1 spend_pending = False # a resumed result replays the cycle it was recorded under: the # counter must not advance, or the replay burns a review-budget # slot and mislabels its journal/session ids. task.review_cycle += 1 refileable_followup = False # only a completed pass this cycle can set it advance(task, Phase.REVIEW_RUNNING) self._save() if resume_result is not None: # the session already ran before the host died; its recorded # result re-enters the decision pipeline. Consumed exactly # once — later cycles run sessions normally. result = resume_result resume_result = None else: # Strip the prior pass's stale `## Auto Run Result` before launch: # the review re-invokes bmad-build-auto on the done spec, and the # session's own entry write would otherwise lift that leftover # marker past the adapter's launch-mtime floor and end the review # on its first result-less Stop (issue #160). Non-replay branch # only — the replay path above launches no session. snapshot = self._reset_spec_for_review(task) result = self._run_session( task, role="review", prompt=self._review_prompt(task), seq=task.review_cycle, spec_snapshot=snapshot, ) advance(task, Phase.REVIEW_VERIFY) self._save() self._emit( "post_review_session", task, role="review", session_status=result.status, result_json=result.result_json, ) decision = self._env_gate_decision( task, decide_review_session(task, result, self.policy), role="review", result_json=result.result_json, already_env_fault=result.env_fault, ) if decision.action != Action.SALVAGE and task.salvage_refile_pending: task.salvage_refile_pending = False self._save() if decision.action == Action.PAUSE: self._escalate_decision(task, decision) if decision.action == Action.DEFER: self._defer(task, decision.reason) return if decision.action == Action.RETRY: self.journal.append( "review-retry", story_key=task.story_key, reason=decision.reason ) continue if decision.action == Action.SALVAGE: # review.on_timeout = "salvage-if-done" (#271): the session hit a # timeout-like verdict, but the dev product may already be # finalized and verify-green — converge (commit + refile the # outstanding follow-up) instead of burning another review cycle # on an empty delta. When salvage is not applicable, fall back # through the default retry/exhaust routing. if self._salvage_review_timeout(task, result): return task.salvage_refile_pending = False self._save() fallback = self._env_gate_decision( task, review_retry_or_exhaust( task, self.policy, f"{decision.reason}; salvage not applicable" ), role="review", ) if fallback.action == Action.PAUSE: self._escalate_decision(task, fallback) if fallback.action == Action.DEFER: self._defer(task, fallback.reason) return self.journal.append( "review-retry", story_key=task.story_key, reason=fallback.reason ) continue rj = result_mapping(result.result_json) for pref in preference_escalations(rj): # `pref` is LLM-authored — it comes straight out of the session's own # result.json — so its keys become journal field NAMES, and three of # them collide with names this call already owns. `self` is bound by # the method call and `kind`/`story_key` are bound above, so a # result.json carrying any of them # raised `TypeError: got multiple values for argument` and aborted # the whole review leg over a field an agent invented (reproduced). # `ts` does not raise and is worse for it: `Journal.append` builds # `{"ts": now, "kind": kind, **fields}`, so a supplied `ts` silently # OVERWRITES the real timestamp — `ts: 0` lands in the journal and # every relative offset in a diagnostic dump is computed off it. # Dropped rather than renamed: the record's declared schema is # `{type, severity, detail}` (see `diagnostics._JOURNAL_KIND_SCHEMAS`), # so a key spelled `kind`/`story_key`/`ts` is off-schema either way and # would collapse to a presence marker in a dump regardless. # `log_task`/`log_pos` also need filtering: `append` sets those with # `setdefault`, so a caller value would silently replace its real # pane-log pointer. All are journal-owned, not preference fields. pref = {k: v for k, v in pref.items() if k not in _JOURNAL_RESERVED_KEYS} self.journal.append("preference-escalation", story_key=task.story_key, **pref) # A review pass is itself a bmad-build-auto run: it produces a spec # (status done/blocked + a refreshed followup_review_recommended), # not a result.json with `clean`. devcontract synthesizes that for us. # Convergence = the pass finished `done` and no longer recommends an # independent follow-up. A blocked pass is already handled above # (decide_review_session PAUSEs on its synthesized CRITICAL). # A review pass can die between writing terminal prose (## Auto Run # Result: done) and flipping frontmatter off the transient `in-review` # marker. Mirror the dev leg (engine.py:1541): repair the spec BEFORE # reading status/followup below — otherwise the stale `in-review` # frontmatter burns a review cycle re-reviewing already-finished work. # On the generic path this advances `in-review`→`done` and re-folds the # frontmatter's followup flag into `rj` (only when present), so the # convergence/damping gate below sees the finalized state. self._reconcile_generic_terminal_status(task, rj) # A follow-up review is another full dev-primitive pass and may add # findings to the same frontmatter list. Harvest after reconciliation # makes a prose-finalized pass visible; the dev-leg entries dedupe. harvest_outcome = None for harvest_attempt in range(1, HARVEST_REPAIR_READ_ATTEMPTS + 1): harvest_outcome = self._harvest_spec_deferrals(task, rj) if harvest_outcome is None: break self.journal.append( "review-verify-failed", story_key=task.story_key, reason=harvest_outcome.reason, env_fault=harvest_outcome.env_fault, contradiction=harvest_outcome.contradiction, harvest_attempt=harvest_attempt, ) if not harvest_outcome.retryable: self._escalate(task, harvest_outcome.reason) if harvest_outcome is not None: # The bounded deterministic retry could not recover the source. # Do not launch another reviewer: a new pass may replace the # unread `deferred:` list and silently erase a finding. Route as # exhausted without spending a session so a resolved CRITICAL # re-drive re-escalates instead of being downgraded to a defer. exhausted = self._env_gate_decision( task, review_exhausted( task, "review deferral harvest remained unreadable after " f"{HARVEST_REPAIR_READ_ATTEMPTS} attempts: {harvest_outcome.reason}", ), role="review", ) if exhausted.action == Action.PAUSE: self._escalate_decision(task, exhausted) self._defer(task, exhausted.reason) return status = str(rj.get("status", "")).strip() last_status = status # remember the last completed pass for the defer reason followup = bool(rj.get("followup_review_recommended", False)) task.followup_review_recommended = followup # latest pass wins converged = status in self._review_converged_statuses() refileable_followup = converged and followup # Damping: a finalized round that still recommends its own follow-up is # honored only while the story has damping grants left. Once # followup_reviews_spent has reached limits.max_followup_reviews, such a # round force-converges (verify → refile the recommendation → commit) # instead of burning another cycle on a runaway recommendation # (pre-#2580, every review pass patched findings and recommended # another pass; the upstream severity-scored flag has since made # that the exception). max_review_cycles stays the hard outer bound. damped = refileable_followup and ( task.followup_reviews_spent >= self.policy.limits.max_followup_reviews ) self.journal.append( "review-result", story_key=task.story_key, cycle=task.review_cycle, status=status, followup_review_recommended=followup, followup_damped=damped, ) self._emit("post_review_result", task, role="review", result_json=rj) if self._run_workflows("post_review_result", task, task.review_cycle): return # A review DEMOTION (DW-383): the pass concluded a `done` story still # owes human-only external actions and finalized the spec at # `awaiting-operator`. Under `[operator] on_review_demotion = "park"` # that is a converging outcome, gated and committed like `done` (the # park is chosen downstream by `_finalize_commit_phase` from the # latched actions). Under the default "escalate" it is not taken: the # status falls through as non-terminal, exactly as before. demoted = status == verify.AWAITING_OPERATOR and self._review_demotion_parks() if demoted or (converged and (not followup or damped)): if demoted: outcome = self._park_review_demotion(task, result) else: outcome = self._review_verify_gate( task, session_status=result.status, result_json=result.result_json ) if outcome.ok: if damped: # Verify-green here is the same authority as the converged / # rescue paths — never ships uncompleted work. self._journal_review_budget_spent(task, damped=True) clean = True break self.journal.append( "review-verify-failed", story_key=task.story_key, reason=outcome.reason, env_fault=outcome.env_fault, contradiction=outcome.contradiction, ) if not outcome.retryable: # escalate-grade failure (environment fault, git error, a # review revoking the sprint sign-off): a repair session # cannot fix it and another review cycle would replay it — # pause the run instead of burning budget self._escalate_outcome(task, outcome, role="review") # Either path below charges the story — a fix session spends a dev # attempt, another pass a review cycle — so re-probe first (DW-523). gated = self._env_gate_decision( task, Decision(Action.RETRY, outcome.reason), role="review" ) if gated.action == Action.PAUSE: self._escalate_decision(task, gated) if outcome.fixable and task.review_cycle < self.policy.limits.max_review_cycles: # failing verify commands are dev work, not review work: a # re-review of the same tree cannot make them pass. Repair # with the failing output as feedback, then re-review. This # verify-repair round never spends the damping cap. fix = self._fix_phase(task, outcome.reason) if fix.action == Action.PAUSE: self._escalate(task, fix.reason) if fix.action == Action.DEFER: self._defer( task, fix.reason or "verify commands kept failing after clean review", ) return continue if refileable_followup: # Spend one damping grant for honoring this pass's own follow-up # recommendation. Deliberately AFTER the # _run_workflows("post_review_result") gate, and applied only past # the next cycle's dispatch gate (`spend_pending`): the increment is # persisted only by the NEXT cycle's _save(), by which point # _resumable_session can no longer replay this result — so a # crash-replay or a dispatch-pause replay re-derives the spend # exactly once instead of double-counting it. (A non-terminal status # or a non-followup done — the two other ways to reach here — never # sets refileable_followup, so neither spends the cap.) spend_pending = True # still recommends a follow-up (or a non-terminal status): loop runs a # fresh review pass on the newly-patched tree, bounded by max_review_cycles if spend_pending: # the loop exhausted its cycles with the last pass's grant unspent task.followup_reviews_spent += 1 if not clean: # Budget exhausted. Before discarding work, distinguish two modes: # (a) the last *completed* pass left the story finalized + verify-green # (status: done) but kept recommending an independent follow-up # (`refileable_followup`, `clean` stays False). That work is # committable — commit it and journal the spent budget instead of # rolling everything back (the failure mode that silently threw # away review-passing work). # (b) anything else (non-terminal status, no outstanding follow-up, # verify failing): a genuine failure → defer + roll back as before. # A failed *final* review session never reaches here at all: with the # budget spent, decide_review_session returns DEFER (not RETRY), so the # loop above already deferred — a RETRY only ever loops again. The # rescue therefore requires both `refileable_followup` (the last # completed pass's own signal) AND _verify_review — the same authoritative # gate the converged path uses (frontmatter status==done AND sprint==done # AND verify commands pass) — so it can never ship uncompleted work, nor # record a follow-up the last pass did not actually recommend. Only when # the work does NOT live in a mount — the same # `self._isolated or task.worktree_path` pair `_defer` and `_run_story` # route on, and for the same reason: a recorded mount owns this attempt's # work whether or not live policy still says worktree. `_finish_inflight` # reopens that mount and swaps `self.workspace` onto it, while # `self._isolated` is a read-only property over LIVE policy — so after an # `isolation` flip an accepted continuation arrives here mounted with # `self._isolated` False. Gated on policy alone the rescue commits into # the mount and `_integrate_unit` merges it out to the target: a story # that never converged lands DONE-and-merged, where the identical mounted # work under unchanged policy would be DEFERRED with the unit's worktree # and patch preserved for review. A defer under a mount already keeps # both, so there is nothing to rescue there. if refileable_followup and not (self._isolated or task.worktree_path): # `result` is the last COMPLETED pass's: `refileable_followup` # is reset at the top of every cycle and only that pass sets it. rescue = self._review_verify_gate( task, session_status=result.status, result_json=result.result_json ) if rescue.ok: self._journal_review_budget_spent(task) self._commit(task) return if rescue.contradiction or rescue.env_fault: # The rescue gate is the first place this story's sprint # sign-off was re-read (every in-loop cycle recommended its own # follow-up, so none of them verified). A defer here would roll # the work back under a "did not converge" reason that names # neither side of the disagreement — pause with both instead. # An environment fault likewise says nothing about the story: # deferring on it filed verify-green work as unconverged # (DW-523) — pause, recording the site, like the in-loop gates. # Journaled under the same kind as the two in-loop gates so a # consumer keying on `contradiction` sees all three escalating # paths. The remaining arm keeps its existing silence: its # story is told by the defer reason below. self.journal.append( "review-verify-failed", story_key=task.story_key, reason=rescue.reason, env_fault=rescue.env_fault, contradiction=rescue.contradiction, ) self._escalate_outcome(task, rescue, role="review") # Name the last completed pass's real outcome (issue #160): the fixed # follow-up wording is only correct when a finalized pass actually left # a refileable recommendation. "did not converge" stays in every variant # (callers grep it). if refileable_followup: detail = "still recommending a follow-up pass" elif last_status in self._review_converged_statuses(): detail = "last review pass finalized but its verification failed" elif last_status is not None: # A pass completed but its status is non-terminal — or "" when the # observe-degrade paths left `rj` with no status (spec read fault, # out-of-tree spec, or a result.json with no spec_file so the # reconcile bailed). Render "" honestly rather than mislabeling it as # "no pass ran" (which is what `last_status is None` below means). detail = ( f"last review pass ended at non-terminal status {(last_status or 'unknown')!r}" ) else: detail = "no review pass completed" reason = f"review did not converge within budget ({detail})" gated = self._env_gate_decision( task, Decision(Action.DEFER, reason, budget_exhausted=True), role="review" ) if gated.action == Action.PAUSE: self._escalate_decision(task, gated) self._defer(task, reason) return self._commit(task) def _salvage_review_timeout(self, task: StoryTask, result: SessionResult) -> bool: """``review.on_timeout = "salvage-if-done"`` (#271): try to converge a timed-out review by committing the already-finalized dev product instead of burning another review cycle. Returns True when salvage either committed or terminally routed an unreadable harvest; False means salvage was not applicable and the caller falls back to the default retry/exhaust routing. The cycle the timed-out session charged is deliberately not refunded — salvage changes what the *next* cycle costs, not what this one did. Applicability, all deterministic: the work does not live in a mount — neither live isolation NOR a recorded ``worktree_path``, the same pair ``_defer`` and the budget-exhaustion rescue route on. A mount owns the attempt's work even when live policy no longer says worktree, because `_finish_inflight` reopens it and swaps ``self.workspace`` onto it while ``self._isolated`` still reads live policy; salvaging there would commit into that mount for `_integrate_unit` to merge out, turning a timed-out review into a DONE-and-merged story where the defer would have kept the unit's worktree + diff. The gate also has to precede the ``in-review`` repair writes below (`reset_spec_status`, `strip_auto_run_result`), which the mounted path never performs at all. Second: a spec is recorded and its frontmatter reads ``done`` (the review never got far enough to touch it — rare once the adapter's missing-marker fallback (#224) completes those sessions, but a review that never wrote the spec at all still lands here) or ``in-review`` (the mid-review interrupt: the dying pass flipped the transient marker and died — reset it forward, stripping any partial terminal section so the next launch's mtime-floor scan can't misread it). Anything else — ``blocked``, ``in-progress``, a custom token — was set deliberately or means unfinished dev work: never salvage over it. The commit is gated on the same authoritative ``_verify_review`` as every other converge path, so salvage can never ship unverified work; a timeout that produced no review result neither re-arms ``followup_review_recommended`` nor spends a damping grant — the outstanding recommendation is refiled to deferred work instead.""" if self._isolated or task.worktree_path or not task.spec_file: return False spec_path = Path(task.spec_file) fm = self._observed_frontmatter(spec_path, task.story_key, "review-timeout-salvage") if fm is None: return False fm_status = verify.status_of(fm) success_status = self._dev_success_status() if fm_status not in (success_status, "in-review"): return False reset_from: str | None = None if fm_status == "in-review": # Repair-write doctrine: these raise on an unreadable spec rather # than silently proceeding stale (see _reset_spec_for_repair). A mode # that refuses the reset leaves salvage inapplicable, before any write. reset_from = fm_status confine_root = self.workspace.paths.project try: self._spec_status_write( task, spec_path, success_status, "salvage", confine_root=confine_root ) except SpecStatusWriteRefused: return False devcontract.strip_auto_run_result( spec_path, confine_root=confine_root, root_identity=self._mount_root_identity(task, confine_root), ) # A timed-out review can still have recorded new frontmatter findings. # Normalize first so the success-status gate sees `done`, then mirror the # normal review path before deterministic verification and commit. harvest_outcome = None for harvest_attempt in range(1, HARVEST_REPAIR_READ_ATTEMPTS + 1): harvest_outcome = self._harvest_spec_deferrals(task, {"spec_file": str(spec_path)}) if harvest_outcome is None: break self.journal.append( "review-timeout-salvage-failed", story_key=task.story_key, cycle=task.review_cycle, reason=harvest_outcome.reason, env_fault=harvest_outcome.env_fault, harvest_attempt=harvest_attempt, ) if not harvest_outcome.retryable: self._escalate(task, harvest_outcome.reason) if harvest_outcome is not None: exhausted = self._env_gate_decision( task, review_exhausted( task, "review timeout deferral harvest remained unreadable after " f"{HARVEST_REPAIR_READ_ATTEMPTS} attempts: {harvest_outcome.reason}", ), role="review", ) if exhausted.action == Action.PAUSE: self._escalate_decision(task, exhausted) self._defer(task, exhausted.reason) return True outcome = self._review_verify_gate( task, session_status=result.status, result_json=result.result_json ) if not outcome.ok: self.journal.append( "review-timeout-salvage-failed", story_key=task.story_key, cycle=task.review_cycle, reason=outcome.reason, env_fault=outcome.env_fault, ) if not outcome.retryable: # escalate-grade failure (environment fault, git error): another # review cycle would replay it — pause the run (mirrors the # review loop's own verify-failed routing). self._escalate_outcome(task, outcome, role="review") return False refiled: str | None = None if task.followup_review_recommended: # Refile BEFORE _commit so the ledger edit squashes into the story # commit. A distinct origin string: `review-budget-followup` is the # re-review-cap key `_journal_review_budget_spent` scans for, and a # timeout salvage must not trip it. ledger = self.workspace.paths.deferred_work # A PUBLISH site with no pre-read of its own: the writer's locked # `read_for_write` is the only read, so undecodable bytes raise from # the call itself and take the repair pause (DW-259). The # recommendation is cleared only AFTER the call. A repair pause # latches the owed refile so resume retries this current timeout's # salvage over the preserved product, with fresh verification. try: refiled = deferredwork.append_entry( ledger, title=( f"Follow-up review still outstanding for {task.story_key}" " after a review timeout" ), origin="review-timeout-salvage", source_spec=spec_path.name if task.spec_file else task.story_key, reason=( f"The review session ended {result.status} with the story already " f"finalized (status: done, verify green). Per review.on_timeout = " f"'salvage-if-done' the work was committed by bmad-loop run " f"{self.state.run_id} without another review pass; this entry " f"preserves the outstanding follow-up recommendation for a " f"deliberate later review." ), severity="low", ) except deferredwork.LedgerReadError as e: task.salvage_refile_pending = True self._pause_for_ledger_repair( task, ledger, _ledger_fault_text(ledger, e), site="review-timeout-salvage-refile-locked", ) task.followup_review_recommended = False # Latch the salvage BEFORE the handoff save, on the fault-free path too # — not only from the repair-pause arm above (#794 review). This save is # the last one before `_commit`'s COMMITTING save, and `gates.notify` plus # any `pre_commit_gate` workflow run between them: a host death there # leaves REVIEW_VERIFY over a timeout record, which `_resumable_session` # never matches (the record is not `completed`), so without the latch # resume falls to restart recovery — a rollback erases the refile just # published and re-drives dev and review over finished, verify-green # work, and no rollback pauses for manual recovery. Latched, resume # replays THIS timeout through `_pending_salvage_session`: the preserved # product is verified again, and the cleared recommendation records the # successful publication so the replay appends nothing twice. COMMITTING # takes over the latch (`_commit` clears it with its own save). task.salvage_refile_pending = True self._save() self.journal.append( "review-timeout-salvage", story_key=task.story_key, cycle=task.review_cycle, session_status=result.status, reset_from=reset_from, refiled=refiled, ) gates.notify( self.policy, self.run_dir, f"review timeout salvaged, work committed: {task.story_key}", f"review session {result.status}; the finalized, verify-green dev product " f"was committed and any outstanding follow-up refiled to deferred work.", ) self._commit(task) return True def _park_awaiting_operator(self, task: StoryTask) -> bool: """Take a dev-declared ``awaiting-operator`` park down the commit path, skipping the review loop. Returns True when it did (the caller is done). The review loop is skipped because there is nothing for it to converge ON. A review pass is bmad-build-auto re-invoked on the spec to second-guess the diff and finalize `done`; a park's outstanding work is not in the diff at all — it is outside the repo, in a human's hands — so every cycle would either re-park (no progress, budget burned) or "fix" the park away by finalizing `done`, which is the exact false-green the state exists to prevent. What the loop DOES contribute, the deterministic gate, is kept: this delegates to the same skip-review commit path, whose `_verify_review` now holds the park to its (awaiting-operator, awaiting-operator) pair, a non-empty action list, and the project's verify commands. Parked work clears every check that still applies to it — no commit path skips verification. Scope that claim to this gate: the dev gate's proof-of-work is the one check a park does NOT face, skipped there because a park may legitimately have produced no code at all (#676, `verify.verify_dev`). No `_defer` machinery: a park is a SUCCESS that commits, so there is no stash or rollback, and the ordinary path has no ledger snapshot to unwind. A crash-resumed DEV_VERIFY park can inherit an arm from before PROCEED's disarm; that exceptional snapshot is spent below. (A park that fails verification is not a park yet; it defers like any other unverified work, through the shared path below.)""" if not self._operator_park_enabled(): return False spec_path = Path(task.spec_file) if task.spec_file else None if spec_path is None or not spec_path.is_file(): return False fm = self._observed_frontmatter(spec_path, task.story_key, "operator-park") if fm is None or verify.status_of(fm) != verify.AWAITING_OPERATOR: return False # A crash can resume a persisted DEV_VERIFY task directly into this # main-only path, bypassing `_dev_phase`'s accepted-attempt disarm. Spend # any inherited snapshot before the commit/awaiting-human window, and # persist it now so a host loss inside that window cannot strand stale # pre-harvest bytes on the terminal task. if task.pre_harvest_ledger_captured: self._disarm_ledger_snapshot(task) self._save() # Latched BEFORE _commit's advance(COMMITTING) + _save, so a host death # anywhere in the commit window resumes into _finalize_commit_phase with # the actions already on the persisted task — and that arm re-derives # AWAITING_OPERATOR from them with no change of its own (#115). task.operator_actions = list(verify.operator_actions_of(fm)) self._skip_review_and_commit(task, kind="review-skipped-awaiting-operator") return True def _review_demotion_parks(self) -> bool: """Whether a review pass may demote a ``done`` story to an ``awaiting-operator`` park (DW-383). Needs BOTH the opt-in ``[operator] on_review_demotion = "park"`` AND parking itself being live (``_operator_park_enabled``): ``StoriesEngine`` and ``SweepEngine`` override that seam to False, and ``[operator] enabled = false`` turns parking off, so the knob is inert there rather than a ``PolicyError`` — the verify gates in those modes cannot hold a park to its pair anyway.""" return self.policy.operator.on_review_demotion == "park" and self._operator_park_enabled() def _park_review_demotion(self, task: StoryTask, result: SessionResult) -> VerifyOutcome: """Gate a review pass that demoted the story to ``awaiting-operator``. The review loop's counterpart of ``_park_awaiting_operator``: the pass finalized the spec at the park status, so the board must follow before the gate reads it. The dev leg already advanced the board to ``done``, and ``done -> awaiting-operator`` is a regression — the ONE pair ``statemachine.BOARD_REGRESSIONS`` allowlists, performed through the sole writer on explicit ``allow_regression=True``. ``board_advance_intended`` follows it so ``_carry_board_advance`` re-applies the park stage, not ``done``, to the main checkout under isolation. Then the ordinary review gate: ``verify_review`` accepts the ``(awaiting-operator, awaiting-operator)`` pair under ``operator_park``, demands a non-empty action list, and runs the verify commands. Only once it passes are the actions latched onto the task — immediately before the caller's ``_commit``, so a failed demotion can never leave a latch that turns a later ``done`` commit into a park. A failed gate unwinds the board write: the row is re-advanced FORWARD to ``done`` (only when the orchestrator's own sign-off was ``done`` — the row read ``done`` before the demotion, or reads the park stage with the recorded intent still ``done``, which is this write replayed; the orchestrator never promotes a row it did not itself sign off) and the prior ``board_advance_intended`` is restored, so a later pass that finalizes ``done`` does not trip the sign-off-regression escalation on the orchestrator's own write. The caller routes the returned failure exactly like a failed ``done`` gate. A RAISE out of the gate or the re-read (a hard stop's ``RunStopped`` from the review command sink, an FS fault) unwinds the same way before it travels on — best effort, never replacing the exception — because the run's trailing save would otherwise persist the park-stage intent over a regressed board, and a later ``done`` pass would escalate a false sign-off regression. Crash-replay: a replayed result re-enters here, the board write is a no-op on a row already at the park stage, the gate re-runs, and a failure still unwinds the row to ``done`` when the persisted intent is still ``done`` (a host death between the write and any save). A death after a save that carried the park-stage intent — none happens inside this method, but a raise that escaped the unwind would leave one — is not recognized as this write, and the row stays where it is. A demotion the writer REFUSES (``SprintStatusWriteRefused``, #842) wrote nothing, so it escalates before the gate with no unwind and the prior ``board_advance_intended`` untouched. The unwind above is for a demotion that landed; its re-advance meets the row the demotion just rewrote.""" board = self.workspace.paths.sprint_status # A board gone by now is the gate's to refuse (sprint None -> retry), not a # crash here: `advance` answers None over a missing file, `load` raises. prior_board = sprint_story_status(board, task.story_key) if board.is_file() else None prior_intended = task.board_advance_intended def unwind() -> None: # A row already at the park stage while the recorded sign-off is still # `done` is this method's own unrestored write, replayed after a host # death between the write and the restore (nothing in this method # saves the park-stage intent), so it unwinds the same way. if prior_board == "done" or ( prior_board == verify.AWAITING_OPERATOR and prior_intended == "done" ): sprint_advance(board, task.story_key, "done") task.board_advance_intended = prior_intended try: sprint_advance(board, task.story_key, verify.AWAITING_OPERATOR, allow_regression=True) except SprintStatusWriteRefused as refused: # Nothing was written, so there is nothing to unwind and the prior # intent still stands; a row the writer cannot rewrite is operator # work, not a reason to retry the review. self._escalate_board_refusal(task, refused, "review pass") raise # unreachable: `_escalate` raises RunPaused task.board_advance_intended = verify.AWAITING_OPERATOR try: outcome = self._gate_review_demotion(task, result) except BaseException: with contextlib.suppress(Exception): unwind() raise if not outcome.ok: unwind() return outcome def _gate_review_demotion(self, task: StoryTask, result: SessionResult) -> VerifyOutcome: """``_park_review_demotion``'s gate + latch, run with the board already demoted. Latches ``task.operator_actions`` only on a pass; the caller owns every unwind.""" outcome = self._review_verify_gate( task, session_status=result.status, result_json=result.result_json ) if outcome.ok: # The gate just read these same actions and found them non-empty; a # re-read that now finds none (the spec changed or became unreadable # in between — journaled by `_observed_frontmatter`) must not commit # a park with nothing owed, which `_finalize_commit_phase` would file # as DONE on a board reading `awaiting-operator`. Route it as a failed # gate instead. fm = ( self._observed_frontmatter(Path(task.spec_file), task.story_key, "review-demotion") if task.spec_file else None ) actions = list(verify.operator_actions_of(fm)) if fm is not None else [] if actions: task.operator_actions = actions return outcome return VerifyOutcome.retry( f"spec for {task.story_key} no longer declares usable operator_actions " "after the review-demotion gate passed" ) return outcome def _skip_review_and_commit(self, task: StoryTask, *, kind: str = "review-skipped") -> None: """review.enabled = false: no separate review session runs. The bmad-build-auto session ran its own inline review and finalized the story to done. Validate the deterministic gates (verify commands, spec/sprint = done) and commit, repairing once if verify is fixable. Also the commit path for an ``awaiting-operator`` park, which reaches here with ``kind="review-skipped-awaiting-operator"`` — same gates, same repair-once, same commit; only the reason the review was skipped differs, and the journal says which.""" self.journal.append(kind, story_key=task.story_key) # No review session ran on this path: session_status/result_json stay None. outcome = self._review_verify_gate(task) if not outcome.ok and outcome.fixable: # The repair spends a dev attempt: re-probe first (DW-523). gated = self._env_gate_decision( task, Decision(Action.RETRY, outcome.reason), role="review" ) if gated.action == Action.PAUSE: self._escalate_decision(task, gated) fix = self._fix_phase(task, outcome.reason) if fix.action == Action.PAUSE: self._escalate(task, fix.reason) if fix.action == Action.DEFER: self._defer( task, fix.reason or f"verify failed with review disabled: {outcome.reason}", ) return outcome = self._review_verify_gate(task) if not outcome.ok: # same event kind as the review-enabled loop so journal consumers # see the structured env_fault flag on this path too self.journal.append( "review-verify-failed", story_key=task.story_key, reason=outcome.reason, env_fault=outcome.env_fault, contradiction=outcome.contradiction, ) if not outcome.retryable: # escalate-grade failure (environment fault, git error, a review # revoking the sprint sign-off): a defer would just replay it on # the next story — pause the run self._escalate_outcome(task, outcome, role="review") reason = f"verify failed with review disabled: {outcome.reason}" gated = self._env_gate_decision(task, Decision(Action.DEFER, reason), role="review") if gated.action == Action.PAUSE: self._escalate_decision(task, gated) self._defer(task, reason) return self._commit(task) def _commit(self, task: StoryTask) -> None: # pre_commit_gate: the unconditional workflow-injection point before a # commit, on every path here (review-converged, skip-review, and the # review-budget rescue) — unlike post_review_result, which fires only # when the orchestrator review loop runs. Gate sessions (e.g. TEA's # trace/nfr/review) evaluate the exact tree about to commit and write # the artifacts the pre_commit hook then enforces on. Placed BEFORE # advance(COMMITTING): the task is still DEV_VERIFY / REVIEW_VERIFY, # both of which may legally defer, so a blocking gate whose session # does not complete can unwind cleanly (COMMITTING cannot defer). if self._run_workflows("pre_commit_gate", task, task.review_cycle): return advance(task, Phase.COMMITTING) task.salvage_refile_pending = False # Latched in the COMMITTING save itself, so no crash window between here # and the mark after landing can lose it. self._latch_post_landing_writes(task) self._save() self._finalize_commit_phase(task) def _finalize_commit_phase(self, task: StoryTask) -> None: """Drive an already-COMMITTING task to DONE: regenerate the message, emit ``pre_commit`` (rewrite honored; a pause veto escalates — COMMITTING→ESCALATED is legal), squash via ``finalize_commit``, stamp ``commit_sha``, advance to DONE. Precondition: ``task.phase == COMMITTING`` and that phase is PERSISTED (the gate+advance+save in ``_commit``, or a resume that found it on disk). The persisted phase is durable proof the ``pre_commit_gate`` workflows already ran and passed — the COMMITTING save lands only after the gate loop returns clean — which is why the resume arm calls this WITHOUT re-running them: a re-run would double-charge the session budget, and a blocking failure would need an illegal COMMITTING→DEFERRED move (#115). Re-drive contract: safe to call again after a host death anywhere inside it. ``finalize_commit`` is content-idempotent across both crash states — pre-squash (skill commit chain above baseline) squashes normally; post-squash (squashed commit at HEAD, clean tree) re-squashes to an equivalent-content commit, orphaning the pre-crash squash (harmless; "equivalent" rather than "identical" because a park record regenerated across midnight carries a new ``parked_at``). ``commit_sha`` is stamped only here and is write-only (never routing), so the empty persisted value is harmless.""" message = self._commit_message(task) # pre_commit: a plugin may rewrite the commit message or escalate (pause). # A defer/skip veto would have to unwind a COMMITTING task (no legal move # to DEFERRED), so only pause is honored here — _escalate sets ESCALATED # directly, which COMMITTING does allow. ctx = self._emit("pre_commit", task, proposed_commit_message=message) if ctx is not None: veto = ctx.resolved_veto() if veto is not None and veto.action == "pause": self._escalate(task, f"plugin {veto.plugin_id!r} vetoed pre_commit: {veto.reason}") if ctx.proposed_commit_message: message = ctx.proposed_commit_message # The success boundary for story-declared ledger closure (#234): every # verify gate, checkpoint, review cycle and pre-commit workflow is behind # us, and finalize_commit's `git add -A` is still ahead, so an in-repo # annotation rides this story's own commit. `snapshot` is armed inside # the close, before its write, so both failure arms below hold the ids to # reopen no matter where in the window a raise lands. snapshot: list[_ArmedClose] = [] park_record: tuple[Path, str | None] | None = None plan_undo: object | None = None try: self._close_declared_deferred(task, snapshot) # A parked story's record rides this same commit (#356): written into # the workspace ahead of the `git add -A`, it reaches every clone the # story's commit does — including through the worktree merge-back. park_record = self._write_park_record(task) # The work source's own terminal write to the unit's plan (tickets # mode marks `done`) rides the commit the same way; base: none. plan_undo = self._terminal_plan_write(task) # bmad-build-auto commits its own work each iteration; the orchestrator # squashes that chain plus its uncommitted bookkeeping back onto the # pre-dev baseline as one commit carrying `message`. None means there # was nothing to finalize (NO_VCS, or the tree already at baseline). def validate_staged_publication() -> object: return self._worktree_flow.validate_staged_publication(task, self.workspace.paths) def validate_committed_publication(revision: str, staged_snapshot: object) -> None: self._worktree_flow.validate_committed_publication( task, self.workspace.paths, revision, staged_snapshot ) staged_validator = ( validate_staged_publication if task.dw_ids and task.worktree_path else None ) committed_validator = ( validate_committed_publication if task.dw_ids and task.worktree_path else None ) legacy_commit_replay = bool( task.dw_ids and task.worktree_path and task.artifact_tracked_source_oids is None and task.artifact_payload is not None and task.commit_sha is not None and verify.rev_parse_head(self.workspace.root) == task.commit_sha ) if legacy_commit_replay: sha = task.commit_sha else: sha = verify.finalize_commit( self.workspace.root, task.baseline_commit, message, staged_validator=staged_validator, committed_validator=committed_validator, ) task.commit_sha = sha or task.baseline_commit # the corrected spec is now durable in HEAD; later attempts need no # special preservation, so drop the re-drive latch. The restored diff # is likewise committed, so clear its latch too — a subsequent re-arm # (if any) decides afresh whether to restore again. task.resolved_redrive = False task.restore_patch = None # An adopted branch (DW-386) is likewise committed; its latch is spent. task.adopt_pending = False # So is a replay continuation (DW-527): the post-replay resume arm # re-latches only a continuation that has not committed yet. task.reverify_replayed = "" task.dispatched_spec_file = None task.dispatched_spec_snapshot = None task.dispatched_spec_absent = False except verify.GitError as e: self._restore_deferred_closes(task, snapshot) self._restore_park_record(task, park_record) unrestored = self._undo_terminal_plan_write(task, plan_undo) self._escalate(task, f"commit failed: {e}" + (f"; {unrestored}" if unrestored else "")) except BaseException: # A failed commit is not the only way out of this window: the signal # handler `_run` installed raises RunStopped from wherever the main # thread is standing, and an OSError raised outside `_run_git` (an # FS fault; the spawn class arrives as GitSpawnError since #343 and # takes the arm above) passes through as itself. Either leaves the # ledger flipped — and the park record written — for a commit that # does not exist. Restore, then re-raise untouched. self._restore_deferred_closes(task, snapshot) self._restore_park_record(task, park_record) # A failed put-back is journaled by the work source; the task stays # COMMITTING, so a resume re-drives this window over the written plan. self._undo_terminal_plan_write(task, plan_undo) raise if task.dw_ids and task.worktree_path: self._worktree_flow.prepare_publication(task, self.workspace.paths) # Final-phase rule: AWAITING_OPERATOR iff the task carries actions, # otherwise DONE. Derived from PERSISTED task state, never from a local # flag, so the crash-resume arm that re-enters this method reaches the # same verdict the pre-crash run would have — the park latch is the only # thing that has to survive, and it does. if task.operator_actions: advance(task, Phase.AWAITING_OPERATOR) # The durable record of what a human owes: the actions themselves, # not just a count, because the journal and the spec frontmatter are # what survive the run. The follow-up review recommendation is the # dev pass's (a dev park) or the last review pass's (a review-demotion # park); a park never runs the recommended pass, so this row and the # park record are where it survives. self.journal.append( "story-awaiting-operator", story_key=task.story_key, commit=task.commit_sha, actions=list(task.operator_actions), followup_review_recommended=task.followup_review_recommended, ) self._notify_park(task) else: advance(task, Phase.DONE) self.journal.append("story-done", story_key=task.story_key, commit=task.commit_sha) self._emit("post_commit", task) self._save() # In place the code is on the target now; under isolation it lands with # the merge-back (`WorktreeFlow.integrate_unit`), which makes this call. if not task.worktree_path: self._land_post_landing_writes(task) def _latch_post_landing_writes(self, task: StoryTask) -> None: """Record on ``task``, before ``_commit``'s COMMITTING save, the work source's writes owed once the unit's code has landed on the target (tickets mode with a shared ticket store marks the plan then: ``StoryTask.ticket_done_pending`` / ``ticket_park_pending``, or only commits it: ``ticket_commit_pending``). Base: none.""" del task def _land_post_landing_writes(self, task: StoryTask) -> None: """Make the writes :meth:`_latch_post_landing_writes` latched, now that ``task``'s code is on the target: after ``finalize_commit`` in place, after the merge-back under isolation, or on resume. Clears the latch once made; a write that fails raises ``RunPaused`` with the latch kept. Base: none.""" del task def _reopen_unlanded_close(self, task: StoryTask) -> None: """Undo the work source's writes that assumed ``task``'s code would land, now that it ends without landing: before the terminal save of every escalation (:meth:`_escalate`, and ``WorktreeFlow``'s ``escalate_unit`` / ``keep_branch_and_escalate``) and every plugin veto (:meth:`_vetoed`). A sweep reopens an isolated bundle's close in a shared-store ledger (DW-569). Base: none.""" del task def _landed_code_commit(self, task: StoryTask) -> str | None: """The code commit a store commit's trailers name for ``task`` (a tickets run's marked plan, a v7 sweep's bundle plan): the unit's own commit while the target holds it (in place, or merged back by ``merge`` / ``ff``), else the target's head — a ``squash`` merge-back lands a different commit and the unit branch's teardown leaves the unit's unreachable. None when git cannot read the head: the trailer is provenance, and the commit still lands.""" sha = task.commit_sha if not task.worktree_path or not sha: return sha repo = self.paths.repo_root try: head = verify.rev_parse_head(repo) except verify.GitError: return None return sha if verify.is_ancestor(repo, sha, head) else head def _replay_post_landing_writes(self) -> None: """Make, on resume, every latched post-landing write whose code had already landed when the host stopped. Called from ``run()`` after :meth:`_replay_unlatched_ledger_carries` (which replays a terminal unit's lost merge-back first) and before ``_loop`` picks. Base: none.""" def _unit_branch_epic(self, task: StoryTask) -> str | None: """The epic segment of ``task``'s unit branch under worktree isolation (``bmad-loop///``). Base: None — the unit key alone names the branch.""" del task return None def _terminal_plan_write(self, task: StoryTask) -> object | None: """The work source's terminal write to ``task``'s plan, made inside the commit window — after every gate, before ``finalize_commit``'s ``git add -A`` — so it rides the unit's own commit and lands only with its code. Returns the token :meth:`_undo_terminal_plan_write` takes, None when nothing was written. Base: no write (the sprint board and a stories spec are finished by the session and the board advance, not here).""" del task return None def _undo_terminal_plan_write(self, task: StoryTask, undo: object | None) -> str | None: """Put back what :meth:`_terminal_plan_write` wrote, for the commit window's failure arms: a plan marked finished for a commit that does not exist would unblock work on code that is not there. Never raises, like :meth:`_restore_park_record`; a put-back that failed is returned as the operator-facing detail the escalation carries, None when there was nothing to undo or it was undone. Base: nothing to undo.""" del task, undo return None def _park_spec_relpath(self, task: StoryTask) -> str: """The parked story's spec as a path relative to the WORKSPACE root, so it re-roots onto the project when read back. Under worktree isolation `task.spec_file` has been rewritten to an absolute path inside the unit worktree, and that worktree is torn down moments later — indexing it verbatim would record a path that is dead before the human ever reads it. Relative-to-workspace is the same string in both isolation modes, and `verify.resolve_spec_path` resolves it against the project. A spec that somehow lies outside the workspace is recorded as-is rather than dropped: a wrong path a human can see beats a missing one they cannot. `RuntimeError` joins the guard because `Path.resolve` reports a symlink loop that way below 3.13 — the same non-OSError `_ledger_in_repo` already catches, and `requires-python` is 3.11. Catching it HERE rather than only around the call is what keeps the fallback: the park record is still written, with `spec_file` recorded verbatim, and per #356 the record is load-bearing (a story key does not yield a spec path).""" spec_file = task.spec_file or "" if not spec_file: return "" try: return Path(spec_file).resolve().relative_to(self.workspace.root.resolve()).as_posix() except (OSError, RuntimeError, ValueError): return spec_file def _write_park_record(self, task: StoryTask) -> tuple[Path, str | None] | None: """Write the parked story's committed record — the store `bmad-loop confirm` reads (#356) — returning ``(path, prior_text)`` for the restore arm, or None when nothing was written (not a park, or the write failed). Runs inside the commit window, into the WORKSPACE (the unit worktree under isolation, like the sprint-status mirror and the deferred-close annotations), so `finalize_commit`'s `git add -A` folds it into the story's own commit and the merge-back carries it to the target. Rooted at `self.workspace.paths.project`, NOT `self.paths.project`: the main root is the wrong tree here — a record written there would sit untracked beside the commit instead of inside it. Best-effort by design, and the only write on this path that is. The park itself is defined by the spec frontmatter and the board token; raising here would abort a story that genuinely succeeded over its bookkeeping, so an unwritable record degrades to a journal line — and `validate` reports the board-parked-but-recordless drift. The kind stays `operator-index-failed` so nothing greping journals re-learns a name. `RuntimeError` stays in the guard for `_park_spec_relpath`'s reason (symlink-loop `resolve` below 3.13) and as insurance: the cost of degrading a non-OSError here is a missing record, the cost of NOT catching it is an aborted commit for a finished story.""" if not task.operator_actions: return None path = operatoractions.record_path(self.workspace.paths.project, task.story_key) try: prior = path.read_text(encoding="utf-8") if path.is_file() else None operatoractions.record_park( self.workspace.paths.project, task.story_key, actions=list(task.operator_actions), spec_file=self._park_spec_relpath(task), run_id=self.state.run_id, parked_at=self._today(), root_identity=self._mount_root_identity(task, self.workspace.paths.project), followup_review_recommended=task.followup_review_recommended, **self._park_record_fields(task), ) except (OSError, RuntimeError) as e: self.journal.append("operator-index-failed", story_key=task.story_key, error=str(e)) return None return (path, prior) def _park_record_fields(self, task: StoryTask) -> dict[str, str]: """The work source's extra park-record keys (``source``, ``ref``) for ``operatoractions.record_park``. Base: none, so a v6 record keeps its shape.""" del task return {} def _restore_park_record(self, task: StoryTask, record: tuple[Path, str | None] | None) -> None: """Put the park record back the way `_write_park_record` found it, for the failure arms of the commit window: a `GitError` escalation or a pass-through raise must not leave a record — untracked, in a tree the next story's `git add -A` would sweep — for a commit that does not exist. Best-effort like `_restore_deferred_closes`, and for the same two reasons that arm imposes: in the `BaseException` arm an exception is genuinely travelling and this must not replace it, while in the `GitError` arm the error is already HANDLED and `_escalate` raises a fresh `RunPaused` — so the hazard there is preempting the escalation, which would strand the story in COMMITTING with no diagnosis on the record. Either way, this never raises. The put-back is atomic (#379). A torn restore leaves the record neither as the park wrote it nor as it was found, and `load` reads a truncated record as an entry owing nothing — a park silently discharged by a rollback of the commit it was written for. `OSError` stays the guard, and stays wide enough BECAUSE the put-back never resolves: the confined writer walks the components below the project root with `O_NOFOLLOW` and never calls `Path.resolve`, so the pre-3.13 `RuntimeError`-on-symlink-loop that forced `_restore_deferred_closes` (and `tui.launch`) to widen to `Exception` cannot arise on this path. That widening is a property of the resolve, not of the helper — do not copy it back here. `UnconfinedWriteError` is an `OSError` subclass precisely so a refusal arrives in this guard and gets journaled rather than escaping as a type nothing catches. Confined to `self.workspace.paths.project` — the WORKTREE root when one is mounted, matching `_write_park_record`, since that is the tree this record was written into — for the reason `operatoractions.record_park` is (#593): refusing a link at the record itself left the directories above it resolved by name, so a link planted at `.bmad-loop/` redirected the put-back out of the project entirely. `require_writable_target=True` (#597) keeps this writer's semantics identical to the other two writers of this same file; a read-only record is answered, not routed around. A failure is journaled rather than dropped, matching the model above: `validate` reports a board parked with no record but never a record left over for a park that is in no commit, so nothing else would ever surface this. The journal call is itself suppressed — a restore that must not raise cannot be allowed to raise on the way to saying it failed. Under worktree isolation both arms pin the mount project (DW-445, `_mount_root_identity`): the put-back through the confined writer's ``root_identity``, and the ``prior is None`` arm through a `platform_util.require_root_pinned` pre-check that refuses before anything is touched, so a mount swapped for a link never has files deleted at the link's target. That pre-check is check-then-act, the no-handle fallback's documented residual. The ``prior is None`` arm unlinks the record and prunes its emptied directory through `platform_util.unlink_confined` (DW-497), not by path: a path unlink followed a link planted BELOW the project — at ``.bmad-loop/`` or ``.bmad-loop/operator/`` — and deleted a same-named record at its target. Now the walk from the (pinned) project refuses such a link before anything is removed. A record or directory already gone is still nothing to roll back. Every refusal is an `UnconfinedWriteError` and is journaled like any other.""" if record is None: return path, prior = record root = self.workspace.paths.project try: root_identity = self._mount_root_identity(task, root) if prior is None: require_root_pinned(root, root_identity) unlink_confined( path, confine_root=root, root_identity=root_identity, missing_ok=True, prune_empty_parent=True, ) else: atomic_write_text_confined( path, prior, confine_root=root, require_writable_target=True, root_identity=root_identity, ) except OSError as e: with contextlib.suppress(Exception): self.journal.append( "park-record-rollback-failed", story_key=task.story_key, record=str(path), # named, not bare `str(e)`: an errno message alone cannot say # whether the disk or the path was at fault. Matches # `deferred-close-rollback-failed`. error=f"{e.__class__.__name__}: {e}", ) def _notify_park(self, task: StoryTask) -> None: """Tell the human a story is waiting on them, and exactly what for. Notify-only: a park is non-blocking by design, so unlike an escalation it must not read as "the run stopped for you". The run has already moved on. The actions are enumerated in the body rather than counted because this notification is the one artifact that reaches someone who is not looking at the repo. Each action is agent-authored text, so it is folded through ``gates.notice_line`` (DW-417): an embedded line break stays on its own numbered line as a `` ⏎ `` segment instead of landing loose in ATTENTION. When the build session recommended a follow-up review, a line before the confirm hint says so: a park skips that pass, so the human runs it after confirming.""" actions = "\n".join( f" {i}. {gates.notice_line(a)}" for i, a in enumerate(task.operator_actions, 1) ) followup = ( "the build session also recommended a follow-up review, which a park skips: " "after confirming, re-run the build skill on the spec for that pass.\n" if task.followup_review_recommended else "" ) gates.notify( self.policy, self.run_dir, f"story awaiting operator: {task.story_key}", f"committed, but {len(task.operator_actions)} action(s) are owed outside the repo:\n" f"{actions}\n" f"{followup}" f"run `bmad-loop confirm {task.story_key}` once they are done.", multiline=True, ) # ----------------------------------------------------- override seams # SweepEngine reuses the dev/review pipeline for deferred-work bundles by # overriding these (bundles have no sprint-status entry). def _generic_dev(self) -> bool: """True when the orchestrator is driving the decoupled `bmad-dev-auto` dev skill — currently the only supported dev skill, so always True. Kept as the predicate the decoupled-path seams (B2/B4/B6/B7) read through, so a future alternative dev skill can re-introduce the legacy branch.""" return self.policy.dev.skill == "bmad-dev-auto" def _dev_skill(self, role: str = "dev") -> str: """The dev-primitive skill NAME to spell in ``role``'s session prompt. Upstream renamed the primitive ``bmad-dev-auto`` → ``bmad-build-auto`` (BMAD-METHOD #2651), so the invoked name is resolved from what is actually on disk rather than hardcoded: a target project can be on either era. This is NOT ``policy.dev.skill`` — that stays the adapter discriminator ``_generic_dev`` reads; only the spelled name moves. Resolution is per skill tree because one run can mix them (dev=claude → ``.claude/skills``, review=codex → ``.agents/skills``), and memoized because every prompt build would otherwise re-stat the tree. An adapter with no ``profile`` (test fakes) yields tree None, which ``dev_primitive_or_default`` maps to the legacy name. Resolved against the WORKSPACE, never the main checkout: the session runs with ``cwd=self.workspace.paths.project``, so the tree deciding whether the spelled name is a command at all is the worktree's. The two agree on a freshly provisioned unit — ``provision_worktree`` copies the primitive in from the main repo — but NOT on resume: ``reopen_unit`` re-mounts an existing worktree without re-provisioning it, so a main checkout upgraded across the pause would resolve the new name into a worktree carrying only the legacy one, and the session HALTs on an unknown command having written nothing. The cache is keyed on the workspace root for the same reason: one Engine drives every unit of a run, so a resumed unit's worktree must not answer for the fresh worktrees mounted after it.""" adapter = self.adapters.get(role) tree = getattr(getattr(adapter, "profile", None), "skill_tree", None) project = self.workspace.paths.project if (project, tree) not in self._dev_skill_cache: self._dev_skill_cache[project, tree] = dev_primitive_or_default(project, tree) return self._dev_skill_cache[project, tree] def _operator_park_enabled(self) -> bool: """Whether a dev session may park a story at ``awaiting-operator`` (#335). Sprint mode only. Stories mode has no sprint board, so the pair the verify gates hold a park to does not exist there (``verify_review_stories`` still demands ``done``); sweep bundles carry no board entry either, and a deferred-work bundle owing a human action is a different question from a story owing one. Both subclasses override this to False rather than inheriting a path their verify tail cannot gate — support for either is a follow-up, not an accident of where the branch happens to sit.""" return self.policy.operator.enabled def _dev_review_enabled(self) -> bool: """Spec-status/sprint semantics for verify_dev and the sprint sync. The generic skill always self-finalizes to ``done`` (no in-review handoff), so its dev artifacts are verified as the review-disabled case regardless of whether a B3 deep review will later run; the legacy skill follows ``policy.review.enabled``.""" if self._generic_dev(): return False return self.policy.review.enabled # the date stamped into ledger edits; isolated for tests def _today(self) -> str: return time.strftime("%Y-%m-%d") def _observed_frontmatter(self, spec_path: Path, story_key: str, site: str) -> dict | None: """Read a spec's frontmatter on a *bookkeeping* path, degrading an unreadable spec to ``None`` (journaled) instead of a whole-run crash. These reads observe what the dev skill left behind so the orchestrator can sync the sprint board / ledger. They race the skill's own writes, so an OSError is a designed transient, not a broken orchestrator. Returning None tells the caller to skip its bookkeeping pass entirely: skipping is safe because everything a skipped pass would have derived is re-supplied later — the spec *status* by the deterministic verify gate's own re-read (which turns a still-unrepaired spec into a retry), and the review-routing flag by ``_followup_from_spec`` at the point ``_dev_phase`` consumes it. Silent it is not — every skip lands a ``spec-read-failed`` event in the journal. Repair writes (``reset_spec_status``, ``mark_done``) deliberately do the opposite and let OSError raise: silently skipping a rewrite would leave the spec in a state the caller believes it fixed. Observation degrades, repair raises.""" try: return verify.read_frontmatter(spec_path) except OSError as e: self._journal_spec_read_failed(spec_path, story_key, site, e) return None def _journal_spec_read_failed( self, spec_path: Path, story_key: str, site: str, e: OSError ) -> None: self.journal.append( "spec-read-failed", story_key=story_key, spec=str(spec_path), site=site, error=f"{e.__class__.__name__}: {e}", ) def _followup_from_spec(self, task: StoryTask, rj: dict) -> bool: """Review-routing fallback for a result that carries no ``followup_review_recommended`` key: re-derive it from the finalized spec frontmatter — the source ``devcontract.synthesize_result`` and the reconcile folds read it from, so a readable spec can never disagree. The key is absent exactly when the result is a *resumed* pre-reconcile snapshot (``synthesize_result`` only writes it on a ``done`` synth, and the durable record is persisted before reconcile mutates the live dict). The reconcile re-fold normally restores it on replay, but a spec read fault skips that fold — and the verify gate only re-supplies *status*, not this flag — so without this fallback a recommended follow-up review would be silently skipped. Gates on the frontmatter's own status (mirroring the fold): a faulted replay leaves ``rj["status"]`` at the stale snapshot value, so the result status must not decide. A parked spec counts too, as it does in the synthesis: its park record carries the recommendation. Degrades to False on a read fault (journaled) — the pre-existing absent-key default. """ if not self._generic_dev(): return False spec_file = rj.get("spec_file") if not spec_file: return False spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths) if not spec_path.is_file(): return False fm = self._observed_frontmatter(spec_path, task.story_key, "followup-routing") if fm is None: return False return verify.status_of(fm) in ( self._dev_success_status(), verify.AWAITING_OPERATOR, ) and bool(fm.get("followup_review_recommended", False)) def _reconcile_generic_terminal_status(self, task: StoryTask, result_json: dict | None) -> None: """Repair a generic-skill spec the session finalized in prose but not in frontmatter. ``bmad-build-auto`` sometimes appends a terminal ``## Auto Run Result`` (``Status: done``) yet leaves the frontmatter ``status`` at the template default. The orchestrator reads ONLY frontmatter, so without this the sprint sync and accepted bundle close no-op and the verify gate falsely defers completed, tested work. When (and only when) the prose terminal Status is ``done`` AND the frontmatter sits at a reconcilable non-terminal status, advance the frontmatter to the success status the skill should have set. This includes the transient ``in-review`` marker, which on the generic path is never a deliberate terminal (the legacy review-handoff fork is retired). Never reconciles ``blocked`` (it must still route to PAUSE) and never overrides an already-``done`` or unknown frontmatter status. Idempotent and never-regress: every deterministic verify gate still runs afterward against real on-disk/git state, so this repairs bookkeeping only — it cannot pass uncompleted work. Runs ahead of ``_post_dev_state_sync`` and the later ``_post_dev_accepted_sync`` so the story board, bundle ledger, and verify all read the reconciled spec.""" if not self._generic_dev(): return spec_file = result_mapping(result_json).get("spec_file") if not spec_file: return spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths) if not spec_path.is_file(): return # Refuse to mutate a spec the session reported outside the orchestrator-owned # roots — reconcile is the only write keyed off a session-supplied path. if not verify.spec_within_roots(spec_path, self.workspace.paths): self.journal.append( "spec-reconcile-skipped-out-of-tree", story_key=task.story_key, spec=str(spec_path), ) return fm = self._observed_frontmatter(spec_path, task.story_key, "reconcile") if fm is None: return self._reconcile_generic_terminal_frontmatter(task, result_json, spec_path, fm) def _reconcile_generic_terminal_frontmatter( self, task: StoryTask, result_json: dict | None, spec_path: Path, fm: dict, ) -> str: """Apply generic terminal reconciliation from an already-read mapping. The bookkeeping path supplies an observed mapping; required deferral harvesting supplies a strict one. Sharing the decision/write step lets a recovered harvest read finish a reconciliation whose earlier observation faulted, without trusting result JSON or launching a replacement session over the first session's findings. Returns the effective status. """ success_status = "in-review" if self._dev_review_enabled() else self._dev_success_status() # A blank `status:` reads "" here (status_of normalizes YAML-null), so the # blank-status template shape reconciles like a missing key does. fm_status = verify.status_of(fm) if fm_status == success_status: # Already finalized — idempotent for the spec. But a *resumed* result # is the pre-reconcile snapshot persisted before the original run's # reconcile mutated its in-memory dict (the durable record is now a # defensive copy, so it never saw that mutation). Re-fold the derived # keys from the frontmatter we just read so the replay's # `followup_review_recommended` gate matches the finalized spec # instead of the stale template default. Only fold followup when the # frontmatter actually carries it: on the generic path the frontmatter # is the source `devcontract.synthesize_result` already reads it from, # so a present key can never disagree — but when it is absent, the # value the session put in result.json is authoritative and must not # be clobbered to a phantom False. if isinstance(result_json, dict): result_json["status"] = success_status if ( success_status == self._dev_success_status() and "followup_review_recommended" in fm ): result_json["followup_review_recommended"] = bool( fm.get("followup_review_recommended") ) return success_status if fm_status not in devcontract.RECONCILABLE_FROM: # blocked / unknown custom status: never override a deliberate one. The # refusal is journaled (DW-382) so a spec left at a status the repair # cannot move is attributable rather than indistinguishable from a # reconcile that never ran. Only the bookkeeping path reaches this # arm — the harvest caller pre-filters on the same allowlist. self.journal.append( "spec-reconcile-skipped-status", story_key=task.story_key, spec=str(spec_path), status=fm_status, ) return fm_status try: text = spec_path.read_text(encoding="utf-8") except OSError as e: self._journal_spec_read_failed(spec_path, task.story_key, "reconcile-prose", e) return fm_status arr = devcontract.parse_auto_run_result(text) if not arr.present or arr.status != self._dev_success_status(): return fm_status # no terminal prose, or blocked: leave for escalation # Repair-write doctrine: the False arm is "nothing to change" only. A status # the reader can see but no line edit can move raises instead, and that raise # is deliberately left uncaught (see _reset_spec_for_repair). A mode that # refuses the write leaves the status for verification, as a no-op would. try: if not self._spec_status_write( task, spec_path, success_status, "reconcile", confine_root=self.workspace.paths.project, ): return fm_status except SpecStatusWriteRefused: return fm_status # Keep the in-place result_json the rest of _dev_phase reads consistent with # the now-reconciled spec (the followup flag is only carried on a done exit). # `reset_spec_status` rewrites only the status line, so `fm` (read above) # still holds every other key — a re-read here could only return the same # followup flag, at the cost of a second racy read that can now fail. if isinstance(result_json, dict): result_json["status"] = success_status if success_status == self._dev_success_status(): result_json["followup_review_recommended"] = bool( fm.get("followup_review_recommended", False) ) self.journal.append( "spec-status-reconciled", story_key=task.story_key, spec=str(spec_path), frm=fm_status, to=success_status, ) return success_status def _repair_spec_marker(self, task: StoryTask, rj: dict) -> None: """Append the ``## Auto Run Result`` marker a missing-marker synthesis (#224) proved the session owed but never wrote — #276 Mechanism 3, the artifact-repair leg. Called at the ``session-synthesized-from-frontmatter`` journal site, which fires for live-Stop, crash-path, and post-kill dead-window synthesis alike (all carry the ``synthesized_from_frontmatter`` flag), so this ONE call site covers every synthesis path. After the append the spec leaves `find_frontmatter_candidates`' territory (zero real markers) and enters `find_result_artifact`'s (>= 1), so a later re-read is harvested on the normal marker path and the next review launch strips it exactly like a skill-written marker. Best-effort by doctrine: the result was already synthesized, so a failed or skipped repair only leaves the spec non-compliant — it never loses work. Guards mirror `_reconcile_generic_terminal_status`, the sibling session-path spec writer: the generic path only; the session-supplied ``spec_file`` must resolve to a real file inside the orchestrator-owned roots (else `spec-marker-repair-skipped`, reason ``out-of-tree`` — this is a write keyed off a session-reported path); and a FRESH frontmatter re-read must be terminal (``done``/``blocked``) AND agree with the synthesized ``rj["status"]`` (else reason ``fm-mismatch``). Never author a marker whose ``Status:`` disagrees with the frontmatter the synthesis trusted — that would trip `synthesize_result`'s consistency cross-check on the next read. Non-interference: `_reconcile_generic_terminal_status` only acts when the frontmatter LAGS the prose, so once this append lands (frontmatter already terminal) reconcile hits its idempotent / refusal branches; `_salvage_review_timeout` reads the frontmatter fresh and stays disjoint. The append is engine-side ONLY — an adapter-side write would perturb the adapter's own mtime/hash observation state (#276 M1/M2).""" # Normalize once for both the spec path and the later status read. rj = result_mapping(rj) if not self._generic_dev(): return spec_file = rj.get("spec_file") if not spec_file: return spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths) if not spec_path.is_file(): return if not verify.spec_within_roots(spec_path, self.workspace.paths): self.journal.append( "spec-marker-repair-skipped", story_key=task.story_key, spec=str(spec_path), reason="out-of-tree", ) return fm = self._observed_frontmatter(spec_path, task.story_key, "marker-repair") if fm is None: return fm_status = verify.status_of(fm) rj_status = str(rj.get("status", "")).strip().lower() # Same terminal set `devcontract.is_frontmatter_candidate` scans for: that # scan is what FINDS a marker-less finalized spec, and this repair is what # brings it back into contract. A status accepted by one and refused by # the other would leave a park harvested but permanently un-markered. terminal = (devcontract.DONE, devcontract.BLOCKED, devcontract.AWAITING_OPERATOR) if fm_status not in terminal or fm_status != rj_status: self.journal.append( "spec-marker-repair-skipped", story_key=task.story_key, spec=str(spec_path), reason="fm-mismatch", ) return detail = ( f"Synthesized by the bmad-loop orchestrator from frontmatter status " f"`{fm_status}` for story `{task.story_key}` (session finalized the spec " f"without appending its marker)." ) try: repaired = devcontract.append_auto_run_result( spec_path, fm_status, confine_root=self.workspace.paths.project, detail=detail, root_identity=self._mount_root_identity(task, self.workspace.paths.project), ) except (OSError, UnicodeDecodeError) as e: # UnicodeDecodeError as well as OSError: the writer reads the spec's raw # bytes and, by contract, raises on an undecodable spec (the same # torn-mid-write hazard `_post_kill_reconcile` guards — a spec truncated # through a multi-byte UTF-8 sequence between `_observed_frontmatter`'s # read and this one). This repair is pure best-effort forensics; it must # never turn a synthesized-and-recorded result into a run crash. self.journal.append( "spec-marker-repair-failed", story_key=task.story_key, spec=str(spec_path), error=f"{e.__class__.__name__}: {e}", ) return if repaired: self.journal.append( "spec-marker-repaired", story_key=task.story_key, spec=str(spec_path), status=fm_status, ) def _apply_adoption(self, task: StoryTask) -> None: """Bring an adopted kept branch's spec and board to the story's terminal stage before ``_finalize_commit_phase`` squashes it (DW-386). Runs inside the reopened unit worktree (``self.workspace`` is the unit's), so the spec flip rides the unit's squash and the board write lands in the unit's board copy, carried to the main checkout by ``_integrate_unit`` through ``board_advance_intended``. The terminal stage is ``awaiting-operator`` for a task that declared operator actions (so ``_finalize_commit_phase`` parks it) and ``done`` otherwise. A kept spec the dev session already parked (``awaiting-operator`` with declared ``operator_actions``) latches those actions onto the task first, as ``_park_awaiting_operator`` would have, so the adoption parks rather than dropping what a human still owes. Both writes are idempotent, so a crash replay re-enters this safely. A spec that is gone, cannot be rewritten, or does not read back at the target is not adoptable: the task re-escalates (COMMITTING→ESCALATED is legal; ``_escalate`` spends the latch), which pauses the run.""" spec_file = task.spec_file or "" spec = verify.resolve_spec_path(spec_file, self.workspace.paths) if spec_file else None if spec is None or not spec.is_file(): self._escalate( task, f"cannot adopt {task.story_key}: story spec {spec_file or '(none)'} is " "missing from the kept worktree", ) return try: if not task.operator_actions and self._operator_park_enabled(): fm = verify.read_frontmatter(spec) actions = verify.operator_actions_of(fm) if verify.status_of(fm) == verify.AWAITING_OPERATOR and actions: task.operator_actions = list(actions) target = verify.AWAITING_OPERATOR if task.operator_actions else "done" self._spec_status_write( task, spec, target, "adoption", confine_root=self.workspace.root ) status = verify.status_of(verify.read_frontmatter(spec)) except (OSError, FrontmatterWriteError, SpecStatusWriteRefused) as e: self._escalate( task, f"cannot adopt {task.story_key}: setting the status of {spec} failed " f"({e.__class__.__name__}: {e})", ) return if status != target: self._escalate( task, f"cannot adopt {task.story_key}: story spec {spec} has no frontmatter " f"status to set to {target!r}", ) return self._post_dev_state_sync(task, {"spec_file": str(spec)}) def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None: """Single-writer for the on-disk bookkeeping the generic skill never touches. For a story that is sprint-status: the decoupled ``bmad-build-auto`` skill knows nothing of the bmad_loop's sprint board, so the orchestrator writes it — and must do so before ``verify_dev`` checks the sprint stage. Mirrors ``verify_dev``: advance the story to the sprint stage matching the spec status the skill actually reached, so a failed or blocked session (spec not at the success status) never advances the sprint. No-op for the legacy path. This runs above the artifact gate because ``verify_dev`` reads the board it writes. That ordering does not generalize to bookkeeping a gate does not consume: ``SweepEngine`` makes this a no-op and closes bundle ledger entries from ``_post_dev_accepted_sync`` instead. Each advance also records ``board_advance_intended`` for ``_carry_board_advance`` (#350). The board written here is ``self.workspace.paths``', which under isolation is the unit worktree's copy — seeded and shielded for a gitignored board, so it never rides the merge — and the record is what lets the post-merge carry re-apply the same stage to the main checkout. The requested TARGET, never ``advance``'s return: that return is the status the board LANDED at, which a never-regress echo makes equal to the CURRENT status rather than to the intent. They can diverge only when the board was already at or past ``target``, and ``verify_dev`` reads that same board immediately after and rejects the attempt for the mismatch — so an attempt whose record and board disagree never reaches integration. Unconditional and latest-wins. Both writes sit inside the ``_generic_dev()`` arm of the one method ``SweepEngine`` and ``StoriesEngine`` override to a no-op, so a run type with no sprint board cannot leave a value here and the record IS the carry's guard — no second predicate downstream. Re-entering this on a DEV_VERIFY replay re-records the same value, and an attempt that ends at the other terminal overwrites it, because the phase that selects the terminal is the phase that writes the record. Not saved here, deliberately: the write it describes lands in a unit worktree that a host loss discards whole, and the re-drive re-derives the intent from the spec. Only the merge makes that write survivable, and every path to a merge persists the task before reaching it. A row the writer refuses (``SprintStatusWriteRefused``: the row is there, below target, in a shape the line edit cannot rewrite) escalates instead of recording anything (#842). Left to ``verify_dev`` it read as an ordinary stage mismatch — a session that never finished — so a finished attempt was rolled back and its story re-driven into the same refusal. The pause keeps the attempt's tree and spec for the operator, who repairs the row and then adopts or re-drives; ``board_advance_intended`` stays unset because no board holds the advance.""" if not self._generic_dev(): return spec_file = result_mapping(result_json).get("spec_file") if not spec_file: return spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths) if not spec_path.is_file(): return review_enabled = self._dev_review_enabled() # always False for the generic path success_status = "in-review" if review_enabled else "done" fm = self._observed_frontmatter(spec_path, task.story_key, "post-dev-sync") if fm is None: return status = verify.status_of(fm) # A park is the other terminal a session can reach, so it gets the same # mirror: the board moves to `awaiting-operator`, which sits immediately # below `done` in STATUS_ORDER and is therefore an ordinary FORWARD # advance through the sole writer — no exception to never-regress, and # `bmad-loop confirm` later advances the same board the same way. Mirrored # on the STATUS alone, before the actions are validated: verify_dev owns # the "declared nothing" retry, and its feedback reads far better against # a board that already agrees with the spec than against one two stages # behind it. if self._operator_park_enabled() and status == verify.AWAITING_OPERATOR: target = verify.AWAITING_OPERATOR elif status == success_status: target = "review" if review_enabled else "done" else: return try: sprint_advance(self.workspace.paths.sprint_status, task.story_key, target) except SprintStatusWriteRefused as refused: # The session reached its terminal; the board row is what failed. Pause # here, BEFORE `verify_dev` reads the board, records no intent (nothing # landed for a carry to re-apply), and keep the attempt the way the dev # loop's own escalation arm does. self._record_dev_spec(task, result_json) self._disarm_ledger_snapshot(task) self._escalate_board_refusal(task, refused, "dev session") raise # unreachable: `_escalate` raises RunPaused task.board_advance_intended = target def _escalate_board_refusal( self, task: StoryTask, refused: SprintStatusWriteRefused, leg: str ) -> None: """Pause on a sprint-board row the sole writer would not rewrite (#842). Shared by the two terminal writes a finished session's work hangs on — the post-dev mirror and the review demotion — and by nothing best effort: the carry journals its refusal and returns. ``_escalate`` raises ``RunPaused``, so the in-flight attempt is neither verified, retried, nor rolled back, and the reason carries the writer's own message (board, row, current, target, reason token, repair).""" self._escalate( task, f"the {leg} for {task.story_key} reached its terminal status, but the sprint " f"board refused the advance, so its work is kept rather than rolled back or " f"retried: {refused}", ) def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> None: """Write bookkeeping that is valid only after a dev attempt is accepted. This is the accepted-only counterpart to ``_post_dev_state_sync``. It is called only after ``decide_dev`` returns PROCEED, not merely when ``outcome.ok`` is true: CRITICAL escalations preempt a passing outcome, and a failing verify command can replace the artifact outcome before the decision. The base path has nothing to write; ``SweepEngine`` closes bundle ledger entries here so a rejected attempt cannot leave resolved work hidden from later sweeps. Do not combine this seam with ``_close_declared_deferred``. That regular- story mechanism already runs at the commit boundary and its caller takes a snapshot and restores it on commit failure, so it already has the acceptance property this seam supplies for sweep bundles through a different, correct transaction boundary. """ return def _finish_post_dev_accepted_sync( self, task: StoryTask, result_json: dict | None = None ) -> None: """Finish and durably acknowledge accepted-only bookkeeping. ``accepted_dev_session_index`` is persisted only for a PROCEED decision. A crash before or during the mode-owned write therefore replays this transaction without mistaking an arbitrary DEV_VERIFY park for an accepted attempt. The append-only record index prevents a reused attempt number from authorizing a later re-arm. Mode writes must be idempotent; the sweep close uses a stable run/task operation identity for exactly this replay. """ if not self._accepted_dev_session_matches(task): return if result_json is None and task.spec_file: result_json = {"spec_file": task.spec_file} self._post_dev_accepted_sync(task, result_json) # Pre-snapshot runs may replay an accepted dev result carrying only the # old path half of attempt ownership. It can still guide rollback before # acceptance, but it cannot authorize later review mutation. Retire an # incomplete pair here; complete authority intentionally survives review # repair/rollback and is retired only after commit. if (task.dispatched_spec_file is None) != (task.dispatched_spec_snapshot is None): task.dispatched_spec_file = None task.dispatched_spec_snapshot = None task.dispatched_spec_absent = False # The attempt is accepted; no later path may restore the pre-harvest # ledger snapshot. Attempt-owned spec authority intentionally survives # through review repair/rollback and is retired only after commit. self._disarm_ledger_snapshot(task) self._save() def _harvest_spec_path(self, task: StoryTask, result_json: dict | None) -> Path | None: """Resolve the spec whose frontmatter this mode may harvest.""" spec_file = result_mapping(result_json).get("spec_file") if not spec_file: return None return verify.resolve_spec_path(str(spec_file), self.workspace.paths) def _may_move_ledger_anchor(self, task: StoryTask, preimage: str | None) -> bool: """May a ledger write that replaced `preimage` become the restore anchor? Only when `preimage` is still the bytes this engine last claimed. The published text a `deferredwork` mutator hands back is the WHOLE post-edit file, so a rival entry that landed between this task's pre-harvest snapshot and the mutator's locked read is inside it — re-published under our name. Anchoring on that would have :meth:`_restore_ledger` read ``ours`` as True on a rejected attempt and whole-file-overwrite the rival's entry away, which is the very loss the anchor exists to prevent. Declining to move the anchor leaves it naming the snapshot instead, so the restore sees ``ours`` as False, skips, and journals ``ledger-restore-skipped-diverged``. The anchor is what is compared, not ``task.pre_harvest_ledger`` itself: the harvest's mark leg legitimately moves the anchor before its append leg runs, so the append's preimage is the mark's published text and matching it against the raw snapshot would decline every contended-free mark+append pair. An UNARMED anchor (``None``) claims nothing yet, so the first publish establishes it — a state the engine's own harvest path never reaches, since the snapshot at ``_run_story`` arms both together. """ return ( task.post_engine_ledger_digest is None or _digest_of(preimage) == task.post_engine_ledger_digest ) def _absorb_harvest_records( self, task: StoryTask, rows: Sequence[tuple[str, str, str, str | None, str | None]], spec_name: str, ) -> None: """Fold harvest rows into ``task.harvested_deferrals`` under the stable-union rule, and checkpoint when the union actually grew. Persist the full intended set, not only newly-filed rows: a replay can dedupe every append while a later isolation carry still needs the data. The union is stable across a retained retry/review chain — a later pass may replace the frontmatter list, but every earlier accepted finding is still present in an ignored unit ledger and must survive final carry — so the key is ``(origin, source_spec)`` and a repeat is dropped rather than doubled. The isolation carry reads only persisted records after a hard loss, so the checkpoint has to precede the ledger write that files these rows. That holds for both callers: the snapshot scan's rows, and the rows recovered when a `seen-again:` match went stale inside the mark's lock — the latter still land ahead of the append, which is the write that files them. Checkpoint every expansion, including later passes where ``harvest_wrote_ledger`` is already latched and its separate pre-write save will be skipped.""" known = { (str(item.get("origin", "")), str(item.get("source_spec", ""))) for item in task.harvested_deferrals } changed = False for origin, title, reason, location, severity in rows: key = (origin, spec_name) if key in known: continue task.harvested_deferrals.append( { "origin": origin, "title": title, "reason": reason, "location": location, "severity": severity, "source_spec": spec_name, } ) known.add(key) changed = True if changed: self._save() def _harvest_success_statuses(self) -> frozenset[str]: """The spec statuses at which a dev session's ``deferred:`` findings are harvested: the status the session finalizes to — ``in-review`` when the legacy dev skill hands off to review, else :meth:`_dev_success_status`.""" if self._dev_review_enabled(): return frozenset({"in-review"}) return frozenset({self._dev_success_status()}) def _harvest_source_name(self, spec_path: Path) -> str: """The ``source_spec:`` value harvested entries carry for ``spec_path``. On v6 it is the spec's bare file name. On v7 plan names repeat across epics (``epic-auth/story-login-plan.md`` and ``epic-billing/story-login-plan.md``), so it is the POSIX path relative to the initiative folder — the artifact dir the ledger itself sits in — and the bare name for a plan outside it. The value is half of the replay dedupe key, so it must be stable per plan.""" paths = self.workspace.paths if paths.era == "v7": try: rel = spec_path.resolve().relative_to(paths.implementation_artifacts.resolve()) except (OSError, RuntimeError, ValueError): pass else: return rel.as_posix() return spec_path.name def _harvest_spec_deferrals( self, task: StoryTask, result_json: dict | None ) -> VerifyOutcome | None: """Carry a successful dev primitive's ``deferred:`` findings into the ledger. BMAD-METHOD #2640 moved defer-triaged review findings from ``deferred-work.md`` into the spec frontmatter. The orchestrator owns the follow-up policy, so without this bridge the recorded findings are invisible to every sweep reader downstream. This is era-agnostic: the gates are the generic-dev seam and the field's presence, never the skill name resolved on disk. A pre-#2640 spec simply takes the no-op path. The spec must also be at the current dev success status, matching the append-on-success behavior of the former writer. Replays are idempotent even after an entry is closed. ``append_entry`` intentionally dedupes open entries only, so the pre-scan checks the fingerprinted ``origin:`` plus ``source_spec:`` across entries of every status. The spec frontmatter is never mutated; the ledger watermark is sufficient and avoids unsafe YAML block-scalar surgery. Reading the finding source is part of this required repair. A transiently missing or unreadable source returns a retry outcome rather than degrading like optional bookkeeping observation: a later verify read must not accept the session while silently dropping its recorded work. Ledger writes remain unguarded so a failed repair write raises. Cross-spec dedupe (DW-88 vs DW-65): a finding that is not this spec's own replay but matches an OPEN entry — identical fingerprinted ``origin:`` harvested from another spec, or a summary that begins with the entry's own ``DW-:`` id — files nothing and stamps a ``seen-again:`` line on the match instead. Done entries never match: a recurrence after a close files fresh, consistent with ``_apply_append``'s open-only scan. A matched finding is also excluded from ``harvested_deferrals``, so the isolated carry cannot re-file the duplicate. That exclusion is provisional, because the match is decided against a SNAPSHOT and the stamp happens later under the ledger lock. A rival that closes or archives the entry in between leaves the sighting with nowhere to land, and the finding — already dropped from the append on the strength of the match — would vanish entirely. ``mark_seen_again_many`` therefore rechecks ``entry.open`` inside its hold and reports the ids whose match went stale; those findings are folded back into both the append and the records here, and the loss is journaled as ``spec-deferral-sighting-stale``.""" if not self._generic_dev(): return spec_path = self._harvest_spec_path(task, result_json) if spec_path is None: return # A session supplies `spec_file`; a mode may replace its untrusted value # with a deterministic artifact path. Like reconcile, marker repair, and # declared closes, harvesting must not let any readable path outside the # orchestrator-owned roots steer a ledger write. try: within = verify.spec_within_roots(spec_path, self.workspace.paths) except (OSError, RuntimeError, ValueError): # resolve() faulted (an invalid spelling, symlink loop, unreadable component): # containment can vouch for nothing, so refuse the same way. within = False if not within: self.journal.append( "spec-deferrals-skipped-out-of-tree", story_key=task.story_key, spec=str(spec_path), ) return try: is_file = spec_path.is_file() except OSError as e: self._journal_spec_read_failed(spec_path, task.story_key, "spec-deferrals", e) return VerifyOutcome.retry( "spec unreadable while harvesting deferrals " f"({e.__class__.__name__}: {e}): {spec_path}" ) if not is_file: return VerifyOutcome.retry(f"spec missing while harvesting deferrals: {spec_path}") try: fm = verify.read_frontmatter(spec_path) except OSError as e: self._journal_spec_read_failed(spec_path, task.story_key, "spec-deferrals", e) return VerifyOutcome.retry( "spec unreadable while harvesting deferrals " f"({e.__class__.__name__}: {e}): {spec_path}" ) # The shared observation reader deliberately collapses a second missing- # file probe, invalid UTF-8, malformed YAML, and a non-mapping document to # `{}`. That is safe for status observation, whose verifier re-reads and # retries, but not for this required repair input: a later clean verifier # read could otherwise accept the session after the harvest silently # skipped every recorded finding. A successful spec always has at least a # status mapping, so this does not reject the valid pre-`deferred:` era. if not fm: self.journal.append( "spec-read-failed", story_key=task.story_key, spec=str(spec_path), site="spec-deferrals", error="empty or invalid frontmatter", ) return VerifyOutcome.retry( "spec unreadable while harvesting deferrals " f"(empty or invalid frontmatter): {spec_path}" ) if devcontract.DEFERRED_FIELD not in fm: return status = verify.status_of(fm) success_statuses = self._harvest_success_statuses() parked = self._operator_park_enabled() and status == verify.AWAITING_OPERATOR if status not in success_statuses and not parked: # Stories mode's explicit plan checkpoint is a verified terminal for # that leg, not a half-finalized implementation. Its deferred notes # remain in the spec until the post-checkpoint implementation pass. if ( status == devcontract.PLAN_HALT_STATUS and result_mapping(result_json).get("plan_halt") is True ): return if status not in devcontract.RECONCILABLE_FROM: return # Reconcile's preceding bookkeeping observation may have faulted even # though this required read recovered. Reuse the strict mapping to # finish the same session's terminal-prose repair before any later # session can replace its `deferred:` list. status = self._reconcile_generic_terminal_frontmatter(task, result_json, spec_path, fm) parked = self._operator_park_enabled() and status == verify.AWAITING_OPERATOR if status not in success_statuses and not parked: shown_status = status or "" return VerifyOutcome.retry( "spec remained at reconcilable nonterminal status " f"{shown_status!r} while harvesting deferrals: {spec_path}" ) findings, malformed = devcontract.parse_deferred_findings(fm) if not findings and not malformed: return spec_name = self._harvest_source_name(spec_path) # Read BEFORE building records: the seen-again match below decides which # findings become records at all. The durability ordering (records saved # before any ledger write) is preserved by the reorder — both writes, # the seen-again marks and the appends, still run after the record save. ledger = self.workspace.paths.deferred_work # REPAIR/WRITE (DW-146): the seen-again match derived here decides which # findings are appended, and both writes run off this text. A PUBLISH # site, so undecodable bytes (DW-231) and a read the OS refuses (DW-258) # pause the run for repair rather than degrade: the findings are real # and this is the write that files them. The phase is left where it is, # so `bmad-loop resume` replays the recorded session result straight # back into this harvest. try: text = deferredwork.read_for_write(ledger) or "" except (deferredwork.LedgerReadError, OSError) as e: self._pause_for_ledger_repair( task, ledger, _ledger_fault_text(ledger, e), site="spec-deferrals-harvest" ) seen = deferredwork.parse_ledger(text) # Cross-spec dedupe (DW-88 vs DW-65). A real finding that is not this # spec's own replay (same origin AND source_spec, any status — checked # first, unchanged) but matches an OPEN entry — same fingerprinted # origin harvested from another spec, or a summary beginning with the # entry's own `DW-:` id — is a sighting of recorded work, not new # work: file nothing, stamp `seen-again:` on the match instead. Done # entries never match; a recurrence after a close files fresh, exactly # as `_apply_append`'s open-only scan treats its own dedupe. Matched # findings are also excluded from `harvested_deferrals`, so the # isolated carry cannot re-file the duplicate — provisionally: this # reads a SNAPSHOT, and the stale arm below restores any finding whose # match did not survive to the mark's lock. seen_again_ids: list[str] = [] # Every finding behind each matched id, so a match that goes stale inside # `mark_seen_again_many`'s lock can still be filed. Dropping the finding # here — as this loop used to — makes that recovery impossible. matched: dict[str, list[devcontract.DeferredFinding]] = {} harvestable: list[devcontract.DeferredFinding] = [] for finding in findings: origin = f"{HARVEST_ORIGIN} {finding.fingerprint}" if any( deferredwork.field_line_present(entry, "origin", origin) and deferredwork.field_line_present(entry, "source_spec", spec_name) for entry in seen ): harvestable.append(finding) # this spec's own replay: dedupe below continue match = next( ( entry for entry in seen if entry.open and ( deferredwork.field_line_present(entry, "origin", origin) or finding.summary.startswith(f"{entry.id}:") ) ), None, ) if match is None: harvestable.append(finding) continue matched.setdefault(match.id, []).append(finding) if match.id not in seen_again_ids: seen_again_ids.append(match.id) # (origin, title, reason, location, severity), one row per entry this # harvest may file. The malformed loss is aggregated per spec so a bad # sibling never suppresses a valid finding and never disappears silently. pending: list[tuple[str, str, str, str | None, str | None]] = [ _harvest_row(finding) for finding in harvestable ] # An unrecognized severity is no reason to drop the finding: its entry is # still filed (without `severity:`), and this event keeps the missing # level visible instead of a silent drop (#913). unrecognized = [f for f in harvestable if f.declared_severity and not f.severity] if unrecognized: self.journal.append( "spec-deferrals-severity-unrecognized", story_key=task.story_key, spec=spec_name, items=[ {"summary": f.summary, "declared": f.declared_severity} for f in unrecognized ], ) if malformed: self.journal.append( "spec-deferrals-malformed", story_key=task.story_key, spec=spec_name, items=malformed, ) pending.append( ( f"{HARVEST_ORIGIN}-malformed " f"{devcontract.harvest_fingerprint(spec_name, *malformed)}", f"Unreadable `deferred:` items in {spec_name}", "The dev session recorded deferred findings the orchestrator could not " "parse, so they were NOT filed as entries: " + "; ".join(malformed) + f". Read `{spec_name}`'s frontmatter and re-file them by hand.", None, "low", ) ) self._absorb_harvest_records(task, pending, spec_name) if seen_again_ids: # Latch + save BEFORE the write, exactly as the append path does: a # crash between the mark and the next save must replay as "the # engine wrote this ledger". A gitignored isolated ledger loses the # annotation with its worktree — acceptable: the suppression (no # duplicate entry) is the point, and the mark is best-effort memory. if not task.harvest_wrote_ledger: task.harvest_wrote_ledger = True self._save() # The mutator's own locked re-read (DW-259): bytes that went bad # after the pre-read above raise here, ahead of any write, and take # the same repair pause — the phase is untouched, so the resume # replay re-enters this harvest and retries the mark. try: _, marked_published, stale, marked_preimage = deferredwork.mark_seen_again_many( ledger, seen_again_ids, self._today(), f"spec-deferral harvest of {spec_name}", ) except deferredwork.LedgerReadError as e: self._pause_for_ledger_repair( task, ledger, _ledger_fault_text(ledger, e), site="spec-deferrals-harvest-mark-locked", ) if marked_published is not None and self._may_move_ledger_anchor(task, marked_preimage): # Re-anchor the pre-harvest restore's CAS on what the mark # published; a following append overwrites this with its own # published text (its locked read includes these marks). # # Conditional on the preimage: the published text is the whole # post-edit file, so a rival that landed before this mark's # locked read is re-published inside it. Anchoring then would let # a rejected attempt's restore retract the rival's entry as ours. task.post_engine_ledger_digest = _digest_of(marked_published) self._save() if stale: # The match was OPEN in the snapshot read above, and gone or # closed by the time the mark took the ledger lock. The sighting # landed nowhere, and the finding was already excluded from # `pending` on the strength of that match — so without this it is # lost in silence, recorded neither as a sighting nor as an entry. # A recurrence after a close files fresh (`_apply_append` dedupes # open entries only), which is exactly what the scan above would # have decided had it run inside the lock. recovered = [ _harvest_row(finding) for dw_id in stale for finding in matched.get(dw_id, ()) ] self.journal.append( "spec-deferral-sighting-stale", story_key=task.story_key, spec=spec_name, items=stale, refiled=len(recovered), ) pending.extend(recovered) # Records before the ledger write that files them, exactly as the # first pass ordered it: the isolation carry reads only what was # persisted, and the append below is still ahead of us. self._absorb_harvest_records(task, recovered, spec_name) # Keep the harvest record honest: `seen_again` below names the # entries that actually took a sighting, and a stale one took none. seen_again_ids = [i for i in seen_again_ids if i not in set(stale)] specs: list[deferredwork.EntrySpec] = [] deduped = 0 for origin, title, reason, location, severity in pending: if any( deferredwork.field_line_present(entry, "origin", origin) and deferredwork.field_line_present(entry, "source_spec", spec_name) for entry in seen ): deduped += 1 continue # Persist the provenance before the write. A hard host loss after # append but before the later dev-decision save leaves the ledger on # disk; replay then dedupes and cannot reconstruct authorship from a # newly-filed id. Pre-latching is conservative if the append itself # fails: excluding an unchanged path cannot create proof of work. if not task.harvest_wrote_ledger: task.harvest_wrote_ledger = True self._save() specs.append( deferredwork.EntrySpec( title=title, origin=origin, location=location or "n/a", source_spec=spec_name, reason=reason, severity=severity, # Backstop the lock-free harvest snapshot above: a rival may # file this spec-independent fingerprint under another spec # before the writer's locked re-read (DW-98). cross_spec_dedupe=True, ) ) # One locked read->edit->write for the whole harvest (#286/#469) rather # than one per finding: a concurrent mutator can no longer interleave # between two of this spec's own rows, and the ids stay sequential # because each spec is applied to the text the previous one produced. # The scan above already ran, and the latch above already fired, so the # durability ordering the comment there describes is unchanged. # # The writer's own locked re-read (DW-259): a ledger that turned # undecodable between the pre-read above and this lock raises here, # before any write, and takes the same repair pause as the pre-read — # the latch above already fired, which is fine: a replay retries the # append, and dedupes to nothing if a rival filed the row meanwhile. try: minted, published, append_preimage = deferredwork.append_entries_published( ledger, specs ) except deferredwork.LedgerReadError as e: self._pause_for_ledger_repair( task, ledger, _ledger_fault_text(ledger, e), site="spec-deferrals-harvest-append-locked", ) filed = [dw_id for dw_id in minted if dw_id is not None] if filed and self._may_move_ledger_anchor(task, append_preimage): # Re-anchor the pre-harvest restore's compare-and-set on what this # append actually published. `append_entries_published` writes only # when some spec minted an id (it hands back None when every one # dedupes), so `filed` IS the "did we write" answer and no extra # probe is needed to derive it. # # Taken from the writer, not read back off disk. The lock is released # when the call returns, so a rival landing between that release and # a read here would be folded into an anchor whose entire job is to # say "these bytes are ours" — and on a rejected attempt the restore # would then retract the rival's entry as if it were our own harvest. # That is the loss this change exists to prevent, so the anchor comes # from inside the hold instead. # # The preimage guards the other side of the same window. A rival that # landed BEFORE the writer's locked read is inside the text it # re-published, so the published bytes alone cannot say whose they # are; `_may_move_ledger_anchor` declines the move when what we wrote # over is no longer what we last claimed, leaving the anchor on the # snapshot so the restore skips and journals instead of retracting. # # Durable before the decision that consumes it: a crash replay # re-runs the harvest, which either writes again (refreshing this) # or dedupes to no write at all, leaving the dead attempt's bytes # exactly as this digest recorded them. task.post_engine_ledger_digest = _digest_of(published) self._save() # The writer's open-entry guard can catch two frontmatter items with # the same clamped fingerprint inside this one pre-scan snapshot. deduped += sum(1 for dw_id in minted if dw_id is None) # The flag is set-only within an attempt and was latched durably before # the first append. A crash replay dedupes to an empty `filed` list while # the dead attempt's engine-authored ledger diff is still on disk, so it # must never be cleared from the return values here. self.journal.append( "spec-deferrals-harvested", story_key=task.story_key, spec=spec_name, dw_ids=filed, seen_again=seen_again_ids, deduped=deduped, malformed=len(malformed), severity_unrecognized=len(unrecognized), ) def _manifest_closes_deferred(self, task: StoryTask) -> tuple[str, ...]: """Deferred-work ids declared for this story by a *manifest* the orchestrator reads, as opposed to the story spec's own frontmatter. Empty here: sprint mode has no manifest, so frontmatter is its only channel. ``StoriesEngine`` overrides this with its ``stories.yaml`` entry — the channel that matters for an unattended run, since the spec is generated later by a dev skill that knows nothing of the ledger.""" return () def _declared_deferred_ids(self, task: StoryTask) -> tuple[str, ...]: """The ids this story declares it closes, unioned across both channels and order-preserving (a story that names the same id in the manifest and in its spec must be marked once and reported once). Both halves are read live here, at the commit boundary: what the spec and the manifest say at the moment of the close is what the story declares — a declaration edited after the dev artifacts verified must not be closed against a stale snapshot, because the stale half of that can close an entry the final spec no longer names. Every degraded reading declares nothing, which is the safe direction for an advisory annotation: the entries stay ``open`` — a miss the next sweep or retro re-verifies — rather than closed on evidence nobody could read. An unreadable spec is journaled by ``_observed_frontmatter``; an unparseable one flattens to ``{}`` there like every other status gate; and a session-supplied path outside the orchestrator's roots is refused under the same containment rule the frontmatter-status reconcile applies, so a surprising absolute path can never steer a ledger write.""" ids: list[str] = list(self._manifest_closes_deferred(task)) spec_path = Path(task.spec_file) if task.spec_file else None if spec_path is not None: try: within = verify.spec_within_roots(spec_path, self.workspace.paths) except (OSError, RuntimeError, ValueError): # resolve() faulted (an invalid spelling, symlink loop, unreadable component): # containment can vouch for nothing, so refuse the same way. within = False if not within: self.journal.append( "deferred-close-skipped-out-of-tree", story_key=task.story_key, spec=str(spec_path), ) else: fm = self._observed_frontmatter(spec_path, task.story_key, "deferred-close") declared, error = deferredwork.parse_declaration((fm or {}).get("closes_deferred")) if error: self.journal.append( "deferred-close-malformed", story_key=task.story_key, spec=str(spec_path), error=f"closes_deferred {error}", ) ids += declared return tuple(dict.fromkeys(ids)) def _close_declared_deferred( self, task: StoryTask, snapshot: list[_ArmedClose] | None = None ) -> None: """At the commit boundary, flip every ledger entry the story declares via ``closes_deferred:`` to ``status: done `` + a ``resolution:`` note (#234) — the regular-story counterpart of the sweep bundle close at ``SweepEngine._close_bundle_ledger_when_spec_status``. Declaration is the only signal: closure is never inferred from a diff. **Advisory by contract.** The annotation is traceability for the next sweep or retro, never a gate: no reading of the spec, the manifest or the ledger can fail the story, and every degraded reading leaves the entries ``open`` — the direction a sweep re-verifies and a retro can repair. That contract is the design: closure needs no transactional coupling to git, because the ledger's consumers re-verify entries against the codebase anyway. **Placement.** Runs from ``_finalize_commit_phase`` — after artifact verification, the verify commands, every checkpoint, the review loop, the ``pre_commit_gate`` workflows and the ``pre_commit`` veto — and still before ``finalize_commit``, whose ``git add -A`` stages an in-repo annotation into the story's own commit. Marking at dev-sync time instead let a story that later failed verification or review leave the ledger permanently claiming its work resolved. **In-repo is not stageable, and under isolation that is the difference between a delivered close and a lost one (#458).** This paragraph used to promise the opposite — "worktree isolation included: the unit's ledger rides the unit commit and reaches the target branch with the ordinary merge" — which holds only for a TRACKED ledger. A gitignored one (the default shape, since the ledger lives under the BMAD artifacts dir) is skipped by that ``git add -A`` in silence, brings nothing over with the merge, and dies with the worktree, while this method has already journaled ``story-deferred-closed``. ``story_closes_intended`` + ``_carry_story_deferred_closes`` is the delivery path; ``_ledger_in_repo`` below is a containment test and answers a different question. A ledger outside the repo (``ProjectPaths.rebased`` deliberately shares an external artifact dir between worktrees) is written at the same moment; the annotation is simply part of no commit, which is journaled (``deferred-close-external-ledger``). A merge that later fails leaves that annotation standing — visible, journaled and human-attended, the accepted advisory trade-off. ``snapshot``, when given, receives ``(ledger, pre-close text)`` the statement before the write, so the caller's failure arms can undo the edit no matter where inside the window a raise lands — including a journal append failing after the write published. Cleared again when nothing was flipped: an empty close needs no restore. Idempotent, so the COMMITTING resume arm may re-drive the commit phase freely — ids are classified against the ledger text, and an entry already ``done`` is a satisfied declaration, not an unmatched one.""" ids = self._declared_deferred_ids(task) # Reset before the early return, never after it. This method is the sole # producer of the record and runs unconditionally at every commit boundary, # and DONE/AWAITING_OPERATOR are reachable only through the caller — so a # re-drive that reaches the carry has always re-entered here first, and this # assignment IS the staleness guard (a separate `_dev_phase` clear would # be a second branch saying the same thing, which no test could redden). # The live read is authoritative: a resolve session # that WITHDREW `closes_deferred:` must not have the abandoned attempt's # declaration carried on its behalf. task.story_closes_intended = [] if not ids: return ledger = self.workspace.paths.deferred_work # OBSERVATION arm of the ledger-read contract (DW-146), kept inline rather # than routed through `read_for_observation`: the helper collapses absence # and fault, and this site has to split them — a MISSING ledger classifies # every id unmatched, while a dangling symlink is an outage that must be # journaled and written nothing from. Either way this site writes nothing. # # OBSERVATION *by the discriminator*, not as an exception to it, because # this is the site that looks most like a counterexample: the text below is # classified and the result arms `_ArmedClose`, and a write does follow. But # the arm is decided by whose text THIS site edits and publishes, never by # whether a write happens downstream — and this site publishes nothing. The # close is `deferredwork.mark_done_many_reopenable`, whose own locked # `read_for_write` is the repair/write read for it and which never re-uses # the snapshot taken here. So the documented "Advisory by contract" stands: # a degraded read journals `deferred-close-ledger-unavailable`, writes # nothing, and leaves the entries `open`. It must NOT raise # `LedgerReadError` — nothing here was about to be published. try: text = ledger.read_text(encoding="utf-8") except FileNotFoundError: # Nothing at the path — but a dangling link IS something: the link # names a ledger on a mount that is not answering, and blaming a # typo (`unmatched`) would misreport an outage. try: dangling = ledger.is_symlink() except OSError: dangling = True if dangling: self._journal_ledger_unavailable(task, ids, ledger, "dangling symlink") return text = "" # no ledger: classify reports every id unmatched; nothing is written except (OSError, UnicodeDecodeError) as e: # An unavailable or undecodable location is not an answer about the # entries: close nothing and say so, rather than read an outage as # "no such entries". self._journal_ledger_unavailable(task, ids, ledger, f"{e.__class__.__name__}: {e}") return # Classified ONCE, here, and handed down: the arm below and the write in # `_apply_deferred_closes` have to name the same set, and a second # `classify` over the same text would only make that a coincidence rather # than a fact. The "one document" contract the write already kept now # covers the rollback too. declared = deferredwork.classify(text, ids) if snapshot is not None: # Armed BEFORE the write, with the INTENDED set (#284): a raise inside # the close itself must still be undoable, and `mark_open_many` skips an # id that never flipped, so an over-broad arm costs nothing. `exact` is # False precisely because these ids are a plan, not an outcome — the # unmatched journal below must not fire for an id the write never # reached. snapshot.append(_ArmedClose(ledger, declared.open_ids, False)) # The DECLARED set, never `marked` — the transposed lesson of `e88776a`. A # host loss in this window resumes into `_finalize_commit_phase` again, and # by then the worktree ledger already reads `done`, so `classify` returns # every id as `already_done` and `marked` is EMPTY. A record derived from it # would never be made on that replay, and the carry would find nothing while # `close_unit_workspace` deletes the only copy. Recorded after the snapshot # arm so the degraded read paths above, which write nothing, record nothing. # # No `_save()` here, unlike the follow-up producer: every crash path re-enters # this method, which re-derives the ids from the spec and the manifest. A # persisted record would instead SURVIVE a declaration edited across the # crash, which is the stale snapshot `_declared_deferred_ids` reads live to # avoid. task.story_closes_intended = list(ids) try: marked = self._apply_deferred_closes(task, declared, ledger) except deferredwork.LedgerReadError as e: # The close's own locked re-read (DW-259): the snapshot above decoded, # the bytes under the lock did not. `read_for_write` is the sole # raiser and it fires ahead of the write, so NOTHING flipped — the # armed plan above is disarmed before the pause, or the caller's # `BaseException` arm would run `_restore_deferred_closes` over the # same bytes, whose `mark_open_many` cannot read them either, and # journal a false `deferred-close-rollback-failed`. The phase stays # COMMITTING, so the `resume-commit` arm re-drives this close. The # snapshot read above degrades the same bytes to # `deferred-close-ledger-unavailable` because it decides nothing and # publishes nothing; this read was about to publish a declared # close, so it pauses — the DW-146 discriminator. if snapshot is not None: snapshot.clear() # The pause `_save()`s, and the intent record must not outlive a close # that flipped nothing: the re-drive re-derives it from the spec. task.story_closes_intended = [] self._pause_for_ledger_repair( task, ledger, _ledger_fault_text(ledger, e), site="story-close-locked" ) if snapshot is not None: if marked: # Narrow the plan to the outcome. `exact` from here on: every id # carries an undo marker this method wrote moments ago, so one that # will not reopen has had it displaced by somebody else. snapshot[-1] = _ArmedClose(ledger, tuple(marked), True) else: # The close writes only when it marks: the ledger is byte-identical, # so a restore would record a rollback of nothing. snapshot.clear() if marked and not self._ledger_in_repo(ledger): self.journal.append( "deferred-close-external-ledger", story_key=task.story_key, dw_ids=list(marked), ledger=str(ledger), note="ledger is outside the repo; the annotation is not part of any commit", ) def _journal_ledger_unavailable( self, task: StoryTask, ids: Sequence[str], ledger: Path, error: str ) -> None: self.journal.append( "deferred-close-ledger-unavailable", story_key=task.story_key, dw_ids=list(ids), ledger=str(ledger), error=error, ) gates.notify( self.policy, self.run_dir, f"declared deferred closes unapplied: {task.story_key}", f"Could not read the deferred-work ledger {ledger}; declared closes were not applied: " f"{', '.join(ids)}. Fault: {error}. " "The story continues without these ledger updates. Restore ledger readability, " "then run a sweep to reconcile the still-open entries against the completed " "story's commit.", ) def _ledger_in_repo(self, ledger: Path) -> bool: """Whether the ledger's annotation rides the story's commit. Decided on RESOLVED paths: an in-repo symlink to a shared external ledger must count as external. A path that cannot be resolved reports as external — the write has already happened either way, and "part of no commit" is the claim that stays true when nothing else is known.""" try: return ledger.resolve().is_relative_to(self.workspace.root.resolve()) except (OSError, RuntimeError, ValueError): return False def _restore_deferred_closes(self, task: StoryTask, snapshot: list[_ArmedClose]) -> None: """Undo the closes ``_close_declared_deferred`` wrote, after the commit they were written for failed (#234): left alone the entries read ``done`` for work that is in no commit, and the likeliest recovery makes that permanent — a human-resolved re-drive sets ``resolved_redrive``, which has ``safe_reset`` preserve the artifact folders' tracked content through the rollback. **Entry-scoped, through the closes' own undo markers (#286).** This used to rewrite the whole document from the pre-close text and call the collateral an accepted advisory trade-off: within the commit window the engine was held to be the only writer worth knowing about, so whatever anyone else had added was restored away with the close. That contract is overturned. The window spans `finalize_commit`'s git spawns and, on the escalation leg, an operator-blocking pause, so a second orchestrator process, a sweep, the TUI decision modal or `sweep --archive` can and does write inside it — and a lock cannot be held across a window shaped like that (#286's own acceptance criterion). So the rollback reopens exactly the armed ids through the operation-specific markers ``mark_done_many_reopenable`` wrote, in ONE locked read-edit-write: a concurrent append, an unrelated close, a recorded human decision are each left standing, and a row this run never closed is never touched. An armed id that will not reopen is reported, never worked around. It only means anything for an ``exact`` arm — one narrowed to the ids actually marked — where the marker was on disk moments ago: something has since broken the ``resolution:``/``resolution-undo:`` adjacency the undo matches on (a foreign ``decision:`` line inserted after the status line does exactly this), so the entry stays ``done`` and the foreign content is preserved, with ``deferred-close-reopen-unmatched`` naming the ids. A pre-write arm carries the INTENDED set instead, where an id that never flipped is an ordinary silence rather than a signal, and nothing is reported. Advisory itself, twice over: a failed restore is journaled, never raised, and the journaling is suppressed rather than allowed to become the crash — this runs inside except arms whose exception must travel unchanged. Skipping the `raise` would replace a `RunStopped` with a symlink complaint; skipping the `GitError` arm's `_escalate` would strand the story in COMMITTING with no diagnosis on the record. The guard is type-agnostic on purpose, and `OSError` is not wide enough to hold it: the write under `mark_open_many` resolves the path before its own try, and below 3.13 `Path.resolve` reports a symlink loop as `RuntimeError` — the same non-OSError `_ledger_in_repo` already catches for this very path. Deriving the lock's own sidecar path can raise `runs.StateRootError`, which is likewise no `OSError`. Catching `Exception` and not `BaseException` is the other half: `RunStopped` is an `Exception`, so a second stop signal landing inside the restore is absorbed while the first still travels, and a genuine KeyboardInterrupt still gets out.""" if not snapshot: return ledger, ids, exact = snapshot[-1] try: reopened = deferredwork.mark_open_many( ledger, list(ids), self._story_close_note(task), self._story_close_operation_id(task), ) except Exception as e: with contextlib.suppress(Exception): self.journal.append( "deferred-close-rollback-failed", story_key=task.story_key, ledger=str(ledger), # named, not bare `str(e)`: now that every type is admitted, # a message alone cannot say whether the disk or the path # was the fault. Matches `_journal_ledger_unavailable`. error=f"{e.__class__.__name__}: {e}", ) return if reopened: with contextlib.suppress(Exception): self.journal.append( "deferred-close-rolled-back", story_key=task.story_key, ledger=str(ledger), dw_ids=reopened, ) failed = [i for i in ids if i not in reopened] if exact else [] if failed: with contextlib.suppress(Exception): self.journal.append( "deferred-close-reopen-unmatched", story_key=task.story_key, ledger=str(ledger), dw_ids=failed, error="the close's undo marker is gone; the entry is left done", ) def _story_close_note(self, task: StoryTask) -> str: """Resolution note shared by the commit-boundary close and its isolation carry, so a carried row cannot drift from one the merge delivered.""" return f"resolved by story {task.story_key}" def _story_close_operation_id(self, task: StoryTask) -> str: """Owner of the undo markers a story's declared closes write, shared by the close, its rollback and its isolation carry. Recomputed from already-persisted identity — never minted — so the rollback arm reaches the same owner the write used even across a crash and replay, which is `mark_done_many_reopenable`'s stated requirement of its callers. The ``/closes-deferred`` suffix is what keeps it disjoint from ``SweepEngine._bundle_close_operation_id``, which is this string's prefix exactly. The two never coexist on one task — a bundle has no ``closes_deferred:`` declaration and ``SweepEngine`` no-ops this whole hook — but a shared ledger holds rows from both, and an undo must not reach across.""" return f"{self.state.run_id}/{task.story_key}/closes-deferred" def _apply_deferred_closes( self, task: StoryTask, declared: deferredwork.Declared, ledger: Path ) -> list[str]: """Write the closure `declared` describes, journal exactly what landed, and return the ids actually flipped. ``declared`` is classified by the caller from the ledger snapshot it already read, never re-read here: classification, the rollback arm and the write all have to describe the same document, and a second read is a second chance for the location to have gone away underneath them. The REOPENABLE close (#286): each flipped row gains a ``resolution-undo:`` line owned by this story's close operation, which is what lets ``_restore_deferred_closes`` undo these entries and only these — rather than restoring the whole document over a concurrent writer's work. The marker is permanent and rides the story's own commit; the sweep bundle close has published the same format since #284, so this is an extension of the ledger format, not an invention (user decision, 2026-08-25).""" marked = deferredwork.mark_done_many_reopenable( ledger, declared.open_ids, self._today(), self._story_close_note(task), self._story_close_operation_id(task), ) if marked: self.journal.append("story-deferred-closed", story_key=task.story_key, dw_ids=marked) if declared.unknown: self.journal.append( "deferred-close-unmatched", story_key=task.story_key, dw_ids=list(declared.unknown) ) if declared.malformed: # Present in the ledger but carrying neither an `open` nor a `done` # status: nothing was marked, and staying quiet would read to the # operator exactly like a successful close. self.journal.append( "deferred-close-malformed", story_key=task.story_key, dw_ids=list(declared.malformed), error="ledger entry status is neither open nor done", ) if declared.duplicates: # One id, two entries: only the first was classified and only the # first can be marked, so whatever the second says about this work is # neither read nor written. A corrupt ledger (#286) must not close # quietly — the operator has to know which id to go look at. self.journal.append( "deferred-close-duplicate-id", story_key=task.story_key, dw_ids=list(declared.duplicates), error="the ledger carries more than one entry for this id; only the first was read", ) return marked def _extra_session_env( self, task: StoryTask, role: str, label: str | None = None ) -> dict[str, str]: """Engine-variant additions to a session's environment. Base: none. StoriesEngine overrides this to export BMAD_LOOP_SPEC_FOLDER for the adapter's deterministic id-keyed read-back; SweepEngine exports BMAD_LOOP_LEDGER to its triage sessions. ``label`` is None for the primary dev/review session and set for an injected plugin-workflow session, so a variant can scope its env to primary sessions only.""" return {} def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | None) -> bool: """Whether the deterministic verify commands run after a completed dev pass. Base: always. StoriesEngine skips them on a plan-halt leg — a plan (spec at ready-for-dev) has no implementation to build/test, so a project build/test gate would spuriously fail before the plan review.""" return True def _verify_commands_with_results( self, task: StoryTask, verification_stage: str ) -> tuple[VerifyOutcome, VerifyCommandRecords]: """Execute, retain, and classify verifier results as one engine action. Core alone executes and classifies commands. The returned immutable records are only journalled and exposed to ``post_dev_verify`` plugins. ``stage`` is set on the returned records whenever this method ran at all, including the zero-command case: "the pass ran and executed nothing" and "no pass ran" are different facts, and only the caller that never reaches here may publish the second one. """ if verify.preflight_required(self.policy): # The environment preflight (DW-523): a failed probe is an env fault # with cause "probe", returned BEFORE any command runs — so there are # no command records, and the env fault rides the existing escalation # path (`decide_dev` CRITICAL), which pauses without charging. probe = self._run_environment_probes(task, site=f"verify:{verification_stage}") if not probe.ok: return verify.environment_preflight_outcome(probe), NO_VERIFY_COMMANDS results = tuple(verify.run_verify_commands(self.policy, self.workspace.root)) sequence = self._journal_verify_command_results(task, verification_stage, results) self._stop_if_verify_interrupted(results) outcome = verify.verify_command_results_outcome( list(results), self.workspace.root, env_fault_rc=self.policy.verify.env_fault_rc, command_timeout_min=self.policy.verify.command_timeout_min, ) return outcome, VerifyCommandRecords( results=results, stage=verification_stage, sequence=sequence ) def _run_environment_probes( self, task: StoryTask, *, site: str, root: Path | None = None ) -> verify.ProbeOutcome: """Run ``[environment] probes`` in ``root`` — by default the workspace root, where the verify commands run — and record the result: a failed pass journals ``env-probe-failed`` naming the ``site`` that asked; a healthy one marks the probes fresh for that root until the next session launch. An interrupted pass stops the run (DW-353) before anything decides on it.""" where = self.workspace.root if root is None else root probe = verify.run_environment_probes(self.policy, where) self._observe_environment_probes(task, probe, site=site, root=where) return probe def _observe_environment_probes( self, task: StoryTask, probe: verify.ProbeOutcome, *, site: str, root: Path ) -> None: """The record-and-stop half of :meth:`_run_environment_probes`, shared with the review gates' :meth:`_review_probe_sink` (whose pass core runs).""" self._stop_if_verify_interrupted(probe.results) failed = probe.failed if failed is None: self._env_probes_fresh_root = root return self._env_probes_fresh_root = None # Two literal writes rather than a conditional `**` splat, so the journal # field guard reads every name; `spawn_error` appears only when set. if failed.spawn_error is None: self.journal.append( "env-probe-failed", story_key=task.story_key, site=site, command=failed.command, rc=failed.returncode, output_tail=failed.output_tail, ) else: self.journal.append( "env-probe-failed", story_key=task.story_key, site=site, command=failed.command, rc=failed.returncode, output_tail=failed.output_tail, spawn_error=failed.spawn_error, ) def _gate_dispatch(self, task: StoryTask, role: str) -> None: """Probe the environment before a ``role`` session launches (DW-523). Called before ANY counter, phase, or baseline mutation of the dispatch (the top of :meth:`_dev_phase`, and the review loop before ``review_cycle`` advances), so a failed probe pauses with nothing to undo: no session started, no attempt or cycle was charged, and the tree is the one the pause found. Skipped when no probes are configured (the default — nothing spawns or journals) and when a probe pass already ran since the last session launch (``_env_probes_fresh_root``): a retry or fix dispatch follows the failure-decision seam, which probed before charging it. A rollback clears freshness: the pass vouched for the tree it rewound, so the next gated dispatch (the next story after a rolled-back defer) re-probes. Fresh means fresh for THIS workspace root: a pass in another story's worktree (or in main) does not vouch for a cwd-sensitive probe here.""" if not self.policy.environment.probes or self._env_probes_fresh_root == self.workspace.root: return site = f"{ENV_FAULT_SITE_DISPATCH_PREFIX}{role}" probe = self._run_environment_probes(task, site=site) if not probe.ok: self._pause_environment(task, site=site, probe=probe) def _pause_environment( self, task: StoryTask, *, site: str, probe: verify.ProbeOutcome ) -> NoReturn: """Pause the run at the ``environment`` stage over a failed dispatch probe. Shaped like :meth:`_pause_for_ledger_repair`: notify, ``_save()``, ``RunPaused`` — and the task's phase, attempt and review cycle stay exactly where they were. NOT :meth:`_escalate`: nothing ran, so there is nothing for ``resolve`` to adjudicate. ``env_fault_site`` records the dispatch site so a plain ``bmad-loop resume`` re-probes first (:meth:`_take_env_dispatch_pause`) and then dispatches the same session without a rollback.""" failed = probe.failed assert failed is not None # only a failed pass pauses role = site.removeprefix(ENV_FAULT_SITE_DISPATCH_PREFIX) reason = ( f"environment fault before {role} session dispatch — probe failed " f"({probe.reason}): {failed.command}\n" "no session was started and nothing was charged; fix the environment, then run " f"`bmad-loop resume {self.state.run_id}` (the probes re-run first)" ) task.env_fault_site = site gates.notify(self.policy, self.run_dir, f"environment fault: {task.story_key}", reason) self._save() raise RunPaused(reason, PAUSE_ENVIRONMENT, task.story_key) def _take_env_dispatch_pause(self, task: StoryTask) -> str | None: """Consume a dispatch-site environment pause on resume (DW-523). Returns None unless ``task`` paused at a ``probe:dispatch:`` site. Then the probes re-run where the paused dispatch would have launched — the task's recorded worktree when it has one (resume has not swapped the workspace onto the unit yet, and a probe may be cwd-sensitive), else the workspace root; a missing worktree falls back to the workspace root and is escalated by the unit reopen that follows. Still failing re-pauses at the same site with the task unchanged; healthy journals ``env-fault-cleared``, clears the site, saves, and returns the role whose dispatch the pause withheld — the caller dispatches it without a rollback, because nothing ran before the pause.""" site = task.env_fault_site if site is None or not site.startswith(ENV_FAULT_SITE_DISPATCH_PREFIX): return None mount = Path(task.worktree_path) if task.worktree_path else None root = mount if mount is not None and mount.is_dir() else None probe = self._run_environment_probes(task, site=site, root=root) if not probe.ok: self._pause_environment(task, site=site, probe=probe) self.journal.append("env-fault-cleared", story_key=task.story_key, site=site) task.env_fault_site = None self._save() return site.removeprefix(ENV_FAULT_SITE_DISPATCH_PREFIX) def _env_gate_decision( self, task: StoryTask, decision: Decision, *, role: str, result_json: dict | None = None, already_env_fault: bool = False, ) -> Decision: """The environment seam every failure decision passes through (DW-523). A decision that would CHARGE the story — a RETRY (spends an attempt or a review cycle), a DEFER, or any budget-exhausted action — or one whose session claimed an environment fault, first re-runs ``[environment] probes``. A failed probe means the failure may well be the environment's, not the story's, so the charge is withheld: the decision is replaced by a PAUSE carrying ``env_site`` (``probe:decision:``, or ``probe:claim:`` for a claim-only trigger), and the caller escalates through :meth:`_escalate_env`. A healthy pass returns ``decision`` unchanged. The deciders in ``escalation`` stay pure; this post-filters their answer. Nothing runs when no probes are configured, when ``already_env_fault`` says the failure was classified as an environment fault already (it pauses without charging on its own), or when the decision charges nothing (PROCEED, a non-exhausted PAUSE, SALVAGE). A session's "Environment fault:" claim (``result_json``) is handled by :meth:`_env_gate_claim` instead, whatever the decision: it forces a probe but never decides on its own.""" if already_env_fault: return decision claim = env_fault_claim(result_json) if claim is not None: return self._env_gate_claim(task, decision, claim, role=role) if not self.policy.environment.probes: return decision charges = decision.action in (Action.RETRY, Action.DEFER) or decision.budget_exhausted if not charges: return decision site = f"probe:decision:{role}" probe = self._run_environment_probes(task, site=site) if probe.ok: return decision reason = self._env_decision_reason(decision, probe, role=role) self.journal.append( "env-fault-reclassified", story_key=task.story_key, site=site, action=str(decision.action), reason=reason, ) return Decision(Action.PAUSE, reason, env_site=site) def _env_gate_claim( self, task: StoryTask, decision: Decision, claim: str, *, role: str ) -> Decision: """Check a session's "Environment fault:" claim against the probes (DW-523). The claim is prose, so it decides nothing: it only makes the orchestrator run its own ``[environment] probes`` (site ``probe:claim:``), whatever ``decision`` is — a PROCEED included. A failed probe is the evidence that pauses: ``decision`` is replaced by a PAUSE carrying that site. A passing probe, or no probes configured, returns ``decision`` unchanged. Every claim journals one ``env-fault-claim`` row naming the probe outcome and the action taken.""" if not self.policy.environment.probes: self.journal.append( "env-fault-claim", story_key=task.story_key, role=role, reason=claim, probe_outcome="not-configured", action=str(decision.action), ) return decision site = f"probe:claim:{role}" probe = self._run_environment_probes(task, site=site) if probe.ok: self.journal.append( "env-fault-claim", story_key=task.story_key, role=role, reason=claim, probe_outcome="passed", action=str(decision.action), ) return decision reason = self._env_claim_reason(decision, probe, claim, role=role) self.journal.append( "env-fault-claim", story_key=task.story_key, role=role, reason=claim, probe_outcome="failed", action=str(Action.PAUSE), ) self.journal.append( "env-fault-reclassified", story_key=task.story_key, site=site, action=str(decision.action), reason=reason, ) return Decision(Action.PAUSE, reason, env_site=site) @staticmethod def _env_claim_reason( decision: Decision, probe: verify.ProbeOutcome, claim: str, *, role: str ) -> str: """The escalation reason for a claim a failed probe confirmed: the probe that failed and why, the session's claim, the remedy, the withheld decision (bounded), and the probe's output tail.""" failed = probe.failed assert failed is not None # only a failed pass confirms a claim lines = [ f"environment fault: {role} session reported an environment fault and a probe " f"confirmed it — probe failed ({probe.reason}): {failed.command}", f"session claim: {claim}", "the attempt is not charged: the orchestrator's own [environment] probe failed, " "so the environment, not the story, is the likelier cause — fix the environment, " "then re-arm the escalation (the attempt budget resets on re-arm), or keep the " "attempt's work with `bmad-loop resolve --reverify`", ] if decision.reason: lines.append(f"withheld {decision.action}: {decision.reason[:500]}") if failed.output_tail: lines.append(failed.output_tail) return "\n".join(lines) @staticmethod def _env_decision_reason(decision: Decision, probe: verify.ProbeOutcome, *, role: str) -> str: """The escalation reason for a failure :meth:`_env_gate_decision` reclassified: what was withheld, which probe failed and why, the remedy, the original reason (bounded), and the probe's output tail.""" failed = probe.failed assert failed is not None # only a failed pass is reclassified if decision.action == Action.RETRY: withheld = "retry" elif decision.action == Action.DEFER: withheld = "defer" else: withheld = "re-escalation" lines = [ f"environment fault: {role} {withheld} withheld — probe failed " f"({probe.reason}): {failed.command}", f"the attempt is not charged: an [environment] probe failed after the {role} " "failure, so the environment, not the story, is the likelier cause — fix the " "environment, then re-arm the escalation (the attempt budget resets on re-arm), " "or keep the attempt's work with `bmad-loop resolve --reverify`", ] if decision.reason: lines.append(f"withheld {decision.action}: {decision.reason[:500]}") if failed.output_tail: lines.append(failed.output_tail) return "\n".join(lines) @staticmethod def _stop_if_verify_interrupted(results: Sequence[verify.CommandResult]) -> None: """Raise ``RunStopped(via="stop-request")`` when a hard stop request cut this verify pass short (DW-353). Called right after the pass is journalled and before anything decides on it: a PROCEED or retry taken on an interrupted pass would commit unverified work or dispatch a repair, so this is the first point the result would otherwise be acted on. Not consumed here — ``run()``'s hard arm consumes the request (and a nested sweep hands the stop up unconsumed, exactly as the in-session raise sites do).""" if any(result.interrupted for result in results): raise RunStopped(via="stop-request") def _next_verification_sequence(self, story_key: str) -> int: """Allocate this story's next ``verify-command-result`` sequence. The ordinal is a public journal field and a ``post_dev_verify`` correlation key, so it has to stay monotonic per story ACROSS A RESUME — a fresh process must not restart at 1 and mint a second record claiming an ordinal an earlier one already used. That property is the whole reason this used to re-derive the ordinal by rescanning the journal on every verification, which read and JSON-parsed the entire file each time — a file this same method keeps appending to, so the cost grew with the run that was paying it. The rescan survives here, once: the first allocation of an engine's life seeds the per-story map from the journal, and every later one is an in-memory increment. One scan, not one per verification, and the resume property is unchanged because a resumed run's seed reads the same journal the rescan did. Seeding EVERY story in one pass (rather than lazily per story) is sound because :meth:`_journal_verify_command_results` is the sole writer of this record kind and one Engine drives every unit of a run, so after the seed the map — not the file — is authoritative. A nested auto-sweep is not an exception: a child run composes its own run dir and ``Journal``. Deliberately an ``Engine`` field and not a ``StoryTask`` one: the value is recoverable from the journal on every resume, so persisting it would add a ``state.json`` field that can only disagree with the record it duplicates. It is also NOT ``attempt`` — a human re-arm reuses attempt numbers, which is exactly why this counter exists beside it. """ if self._verification_sequences is None: self._verification_sequences = self._seed_verification_sequences() allocated = self._verification_sequences.get(story_key, 0) + 1 self._verification_sequences[story_key] = allocated return allocated def _seed_verification_sequences(self) -> dict[str, int]: """The highest sequence already journalled per story — the resume seed. Tolerant by design, like every other journal read-back: a truncated or hand-edited line that lost either key is skipped rather than raising, and the worst case is an ordinal reused in a run whose journal was already corrupt. Missing story = 0, so the first allocation is 1. """ highest: dict[str, int] = {} for entry in self.journal.entries(): if entry.get("kind") != "verify-command-result": continue story_key = entry.get("story_key") sequence = entry.get("verification_sequence") if isinstance(story_key, str) and isinstance(sequence, int): highest[story_key] = max(highest.get(story_key, 0), sequence) return highest def _journal_verify_command_results( self, task: StoryTask, verification_stage: str, results: tuple[verify.CommandResult, ...], ) -> int | None: """Record each verifier subprocess result plus bounded log pointers, and return the sequence they were recorded under — ``None`` when there was nothing to record. ``attempt`` and ``verification_stage`` make the public journal records correlate to a concrete dev, repair, or review verification pass — the third arrived with the review gates' sink (``_review_command_sink``) and is why ``verification_stage`` is not a two-value field. The filenames contain only engine-derived ordinal values; command text never becomes a filesystem path. Sanitize the whole composition, not the parts, for the reason :func:`_session_task_id` gives: two individually capped parts can still compose past a filename segment limit, and ``safe_segment``'s digest suffix differs between the two orders. Retention is bounded by ``verify.stream_capture_kb`` per stream, and the record says so rather than leaving the reader to guess: ``*_bytes`` is what the command emitted, ``*_captured_bytes`` how much of that reached disk, ``*_truncated`` their inequality. Both counts are UTF-8 lengths of the decoded stream, NOT file sizes — see :func:`_bounded_stream_tail`. A zero cap writes no file at all and leaves the pointer null; the record still lands, still carrying the full byte count, because "nothing was retained" and "the command was silent" are different facts. Stream retention is best-effort observation. An ``OSError`` from the stream write — ENOSPC, a read-only run dir, ENAMETOOLONG on a path this composition did not shorten enough — is journalled as ``capture_error`` beside a null pointer and verification continues. The journal record itself is durable run state, not degradable capture: ``Journal.append`` remains unguarded and any failure propagates fail-loud. No results means no records, and therefore no sequence: the ordinal is allocated only when at least one record lands, so it never runs ahead of the journal it indexes. That is also the pre-existing behaviour — the max-of-journalled rescan this replaced could not observe an ordinal it had not written — and keeping it is what makes a resumed run number its passes identically to an uninterrupted one. """ if not results: return None verification_sequence = self._next_verification_sequence(task.story_key) max_bytes = self.policy.verify.stream_capture_kb * 1024 for command_index, result in enumerate(results): stem = safe_segment( f"verify-{task.story_key}-" f"{verification_stage}-{task.attempt}-{verification_sequence}-{command_index}" ) streams: dict[str, str | int | bool | None] = {} capture_error: str | None = None for kind, text, emitted in ( ("stdout", result.stdout, result.stdout_full_bytes), ("stderr", result.stderr, result.stderr_full_bytes), ): tail, full_bytes, captured_bytes = _bounded_stream_tail(text, max_bytes) # `full_bytes` is what we still HOLD; when the in-memory ceiling # already cut this stream, what the command EMITTED is larger and # only the result knows it. Reporting the held size would quietly # under-report emission and, worse, could call a truncated stream # whole — the one thing `*_truncated` exists to prevent. full_bytes = full_bytes if emitted is None else emitted path: str | None = None if max_bytes > 0: try: path = self.journal.write_verify_stream( f"{stem}.{kind}.log", tail, run_dir_identity=self.state.run_dir_identity, ) except OSError as exc: # Nothing published: the atomic write removes its temp and # leaves the target absent, so 0 retained is the literal truth. captured_bytes = 0 capture_error = capture_error or f"{kind}: {exc}" streams[f"{kind}_path"] = path streams[f"{kind}_bytes"] = full_bytes streams[f"{kind}_captured_bytes"] = captured_bytes streams[f"{kind}_truncated"] = captured_bytes < full_bytes # Present only on a pass a hard stop cut short (DW-353), so every # record from a pass that ran through is byte-identical to before. marks: dict[str, bool] = {} if result.interrupted: marks["interrupted"] = True self.journal.append( "verify-command-result", story_key=task.story_key, attempt=task.attempt, verification_stage=verification_stage, verification_sequence=verification_sequence, command_index=command_index, command=result.command, returncode=result.returncode, output_tail=result.output_tail, # The discriminator rides the record because its readers are # out-of-process: one record kind now carries three stages and # two fault shapes, and `returncode` alone cannot separate them — # a child that never started has no exit status, only a sentinel # (`verify.SPAWN_FAULT_RC`). Null on every result from a process # that actually ran, a timeout included. spawn_error=result.spawn_error, capture_error=capture_error, **streams, **marks, ) return verification_sequence def _resume_after_dev_verify(self, task: StoryTask) -> None: """Resume a task the run paused at DEV_VERIFY (dev verified, spec on disk). Base: the spec-approval-gate resume — run the review loop + commit. StoriesEngine overrides this to re-drive the implement leg of a plan-checkpoint-paused story (leg-2) instead. A spent review cycle means the DEV_VERIFY being resumed is a fix-phase repair inside the review loop whose next step the host died before launching (no dispatch pause lands there: the fix's verify preflight leaves the probes fresh), so the loop is re-entered past its entry gate (DW-531). A green fix gets the re-review it owed. A failed fix with budget left resumes into a review of the failing tree, whose verify gate then routes back to repair: one extra pass, the same as with the flag set. Every fresh entry starts at ``review_cycle`` 0, except a restart-arm re-drive. That re-drive keeps its counters and so reviews once instead of skipping, which is the conservative direction.""" self.journal.append("resume-review", story_key=task.story_key) self._finish_post_dev_accepted_sync(task) self._review_and_commit(task, resumed_loop=task.review_cycle > 0) def _resume_reverify(self, task: StoryTask) -> None: """Replay dev verification against the kept attempt product (DW-522). `runs.rearm_for_reverify` moved a DEFERRED or env-fault ESCALATED story to DEV_VERIFY with `reverify_from` latched; the tree (HEAD plus its dirty state) is the attempt, and no dev session runs. The replay re-derives the board from the latest completed dev result, re-runs the artifact gate and, when it passes, the `[verify]` commands (environment preflight included), then routes through the pure `decide_reverify`: PROCEED continues exactly as an accepted dev leg does in `_drive_story` (post_dev_phase workflows, the spec-approval gate, review per policy, commit); DEFER re-defers; PAUSE escalates. Nothing is charged — the replay never retries. The latch is cleared in memory BEFORE any decision acts, so the save each action makes persists it cleared: a crash mid-defer then replays through the defer arm, never through a second verify replay. The accepted-session latch is deliberately not stamped: the generation the re-arm bumped means no record matches the current attempt, and story runs never read it. A PROCEED leaves `reverify_replayed` set to the origin in the same save that spends the latch (DW-527): a host death mid-review/fix of the continuation then re-latches the replay through `_finish_inflight`'s post-replay arm instead of falling to resume-restart. Every other outcome clears it.""" origin = task.reverify_from record = latest_completed_dev_record(task) if record is None or record.result_json is None: task.reverify_from = "" task.reverify_replayed = "" self._escalate(task, "reverify: no completed dev result to re-verify") return result_json = record.result_json self._disarm_ledger_snapshot(task) # The replay-safe board writer (never-regress, latest-wins); a refused row # escalates through `_escalate_board_refusal` as on the dev leg. self._post_dev_state_sync(task, result_json) outcome = self._verify_dev_artifacts(task, result_json) verified = NO_VERIFY_COMMANDS if outcome.ok: outcome, verified = self._verify_commands_with_results(task, "dev") self._emit( "post_dev_verify", task, # no session ran: the replay verified a kept product session_status=None, result_json=result_json, verify_reason=outcome.reason, command_results=verified.results, verification_stage=verified.stage, verification_sequence=verified.sequence, ) decision = decide_reverify(task, outcome) self.journal.append( "reverify-decision", story_key=task.story_key, origin=origin, action=str(decision.action), reason=decision.reason, env_fault=outcome.env_fault, verification_sequence=verified.sequence, ) task.reverify_from = "" task.reverify_replayed = origin if decision.action == Action.PROCEED else "" if decision.action == Action.PROCEED: self._save() self._emit("post_dev_phase", task) if self._run_workflows("post_dev_phase", task, task.attempt): return # parity with `_drive_story`'s accepted dev leg if gates.pause_after_spec(self.policy): gates.notify( self.policy, self.run_dir, f"spec ready for approval: {task.story_key}", f"review {self._operator_spec_path(task)}, then " f"`bmad-loop resume {self.state.run_id}`", ) raise RunPaused( f"awaiting spec approval for {task.story_key}", PAUSE_SPEC_APPROVAL, task.story_key, ) self._review_and_commit(task) elif decision.action == Action.DEFER: self._defer(task, decision.reason) elif outcome.env_fault: self._escalate_env(task, decision.reason, site="verify:dev") else: self._escalate(task, decision.reason) def _after_story(self, task: StoryTask) -> None: """Hook fired once a story is fully processed and (under isolation) integrated — from _loop after _run_story and from _finish_inflight after a resumed task completes. Base: no-op. StoriesEngine uses it for the done_checkpoint pause, which must land after integration so a committed unit is merged before the run stops.""" return def _ledger_text( self, *, site: str, story_key: str | None = None ) -> str | None | _UndecodableLedger | _UnreadableLedger: """Return the active workspace ledger text, preserving absence. REPAIR/WRITE arm (DW-146): this feeds proof-of-work attribution, and absence is preserved because an absent and an empty ledger are a real distinction to the caller. Neither fault class raises out of here any more, because every consumer of this read is an observation — a digest compared for equality, a snapshot armed for a later restore, a restore's own compare-and-set probe — and none of them is about to publish the text. The two reads that DO precede a publish (the harvest append and the isolated carry) call ``read_for_write`` directly and pause through :meth:`_pause_for_ledger_repair` instead, except for a sweep's terminal post-merge harvest carry, whose caller-sensitive dispatch selects the sweep-owned story-gate repair route. Undecodable bytes DEGRADE to the typed :class:`_UndecodableLedger` (DW-231), which carries the raw bytes' digest so "did the ledger change" stays exact. A read the OS refuses — EACCES, EIO, ELOOP — DEGRADES to the typed :class:`_UnreadableLedger` (DW-258), which carries NO digest: that branch reads nothing from the ledger (no ``read_bytes()``, no re-``stat`` — either would raise again), so the attribution answer is unknown and :meth:`_ledger_changed_since_baseline` declines to credit it. Both are journaled as ``ledger-read-degraded`` naming the ``site`` so the degraded read stays visible, and neither can be written back or anchor a write. """ ledger = self.workspace.paths.deferred_work try: return deferredwork.read_for_write(ledger) except (deferredwork.LedgerReadError, OSError) as e: error = _ledger_fault_text(ledger, e) attributed = {} if story_key is None else {"story_key": story_key} self.journal.append( "ledger-read-degraded", site=site, ledger=str(ledger), error=error, **attributed, ) if isinstance(e, (OSError, deferredwork.LedgerReadFault)): return _UnreadableLedger(error) # Hash the RAW bytes ALREADY IN HAND: the fault means they are not # valid UTF-8, so no `_digest_of` answer can ever equal this one. # `read_text()` decodes the whole buffer in one `final=True` call, so # the chained `UnicodeDecodeError.object` IS the full file — never a # second read, which could digest a ledger repaired in between as # valid UTF-8, or raise `FileNotFoundError` for one unlinked in # between where `read_for_write` would have answered `None`, and # would bypass the DW-221 `S_ISREG` guard. The fallback read is # reachable only for a `LedgerReadError` raised without that cause. cause = e.__cause__ raw = cause.object if isinstance(cause, UnicodeDecodeError) else ledger.read_bytes() return _UndecodableLedger(hashlib.sha256(raw).hexdigest(), error) def _ledger_digest(self, task: StoryTask) -> str: """Digest the current ledger text for proof-of-work attribution. An absent ledger and an empty one intentionally hash alike: neither carries an entry, and this comparison never restores or unlinks the file. Undecodable bytes hash as themselves (see :class:`_UndecodableLedger`), so the equality this feeds stays exact without guessing either answer. A read the OS refused has no bytes to hash and answers the constant :data:`_UNREADABLE_LEDGER_DIGEST` (DW-258) — still a ``str``, because the answer is persisted in ``baseline_ledger_digest``, but not a digest: :meth:`_ledger_changed_since_baseline` is the one compare allowed to read it, and it reads it as "unknown". """ text = self._ledger_text(site="ledger-digest", story_key=task.story_key) if isinstance(text, _UndecodableLedger): return text.digest if isinstance(text, _UnreadableLedger): return _UNREADABLE_LEDGER_DIGEST return _digest_of(text) def _ledger_changed_since_baseline(self, task: StoryTask) -> bool: """Has the ledger changed from the attempt's attribution reference? The one consumer of the answer is ``ledger_changed_before_harvest``, which :meth:`_harvest_gate_exclude` reads as "stand the exclusion down": True credits the ledger diff as the session's own work. So the answer must never be True on a guess. :data:`_UNREADABLE_LEDGER_DIGEST` on EITHER side means one of the two reads had no bytes to compare, and the rule for unknown attribution (:meth:`_legacy_ledger_changed_before_harvest`, :meth:`_harvest_gate_exclude`) is that uncertainty never turns the engine's own harvest append into session proof: the answer is False and the ledger path stays excluded. A plain ``!=`` would call "unreadable then, readable now" CHANGED and let a session that wrote nothing pass the gate on the engine's append after the human repair. The accepted trade-off is that a session whose sole work was repairing and editing the ledger is rolled back and retried once, over a readable baseline where attribution is exact. This rule lives here ONLY: ``_harvest_gate_exclude`` never inspects the sentinel. """ current = self._ledger_digest(task) if _UNREADABLE_LEDGER_DIGEST in (current, task.baseline_ledger_digest): # Unknown attribution is never credited: the engine's own append # must not become the session's proof of work. return False return current != task.baseline_ledger_digest def _pause_for_ledger_repair( self, task: StoryTask, ledger: Path, error: str, *, site: str ) -> NoReturn: """Pause the run over a ledger a PUBLISH site could not read (DW-231; a read the OS refuses routes the same way since DW-258). Mirrors :meth:`recovery_flow.RecoveryFlow.pause_for_manual_recovery`'s shape — journal, ``ACTION REQUIRED`` notice, ``_save()``, ``RunPaused`` at ``PAUSE_ESCALATION`` — and, like it, leaves the task's phase exactly where it was. NOT :meth:`_escalate`: an ``ESCALATED`` task is resolved through ``bmad-loop resolve``'s interactive session and a clean rebuild, which would throw away a completed session over a fault that is not the story's (and ``advance(ESCALATED)`` is illegal from the terminal phases the isolated carry runs in). With the phase untouched the existing recovery arms redo the write on ``bmad-loop resume``: resume recovery replays the recorded session result where one exists — ``_resumable_session`` finds the completed dev or review record and re-enters the harvest with it — and a latched timeout salvage refile retries salvage over the preserved product with fresh verification. Other incomplete records re-drive the leg (the fix leg, at ``DEV_VERIFY`` with ``task.spec_file`` already set, resumes into ``_resume_after_dev_verify`` and a fresh review). The ``defer_reason`` re-entry or ``_replay_unlatched_ledger_carries`` re-runs the carry. Since DW-259 the ``deferredwork`` mutators' own locked re-reads route here too, under a ``site`` ending in ``-locked``: every mutator takes its own ``read_for_write`` under the ledger lock AFTER the site's pre-read, so undecodable bytes or an OS read fault (DW-279) raise ``LedgerReadError`` from the mutator call itself — the harvest's ``mark_seen_again_many`` and ``append_entries_published``, the commit-boundary close's ``mark_done_many_reopenable``, the review-timeout salvage's ``append_entry`` (which has no pre-read at all), the isolated carries' ``append_entries`` and ``mark_done_many_reopenable``. The catch is ``LedgerReadError`` ALONE, including ``LedgerReadFault`` for OS metadata and text-read failures: lock/write failures remain raw ``OSError``. ``read_for_write`` is its sole raiser and fires ahead of every write, so a catch at the call proves the mutator wrote nothing — which is what lets the commit-boundary close disarm its rollback before pausing. The mutators keep raising; the engine owns the route, and the same mutators' other callers — ``SweepEngine``'s bundle close and its carry override, the CLI — own their own routing (the sweep's bundle-close calls are NOT covered here: since DW-280 they pause through ``SweepEngine._pause_for_bundle_close_repair`` under their own ``sweep-bundle-close-refused`` row, at the story gate). The same resume arms above retry the write: the COMMITTING re-drive re-runs the close, the latched salvage refile retries without a new session, and the carries replay as described. Unlatched timeouts retain restart/manual recovery governed by rollback policy. """ self.journal.append( "ledger-read-refused", story_key=task.story_key, site=site, ledger=str(ledger), error=error, ) # `error` is `_ledger_fault_text`'s attribution and already begins with # the ledger's path, so the notice does not name the path a second time. # It is folded to one segment of its line (DW-417); the row above keeps it raw. notice = ( "**ACTION REQUIRED — deferred-work ledger unreadable**\n" f"Story **{task.story_key}** has a ledger write to publish (findings to " "file, or a declared close to record), but the orchestrator could not " f"read the deferred-work ledger to publish it: {gates.notice_line(error)}.\n" "This write did not land and no work was discarded. Repair the ledger by hand " "(it must be valid UTF-8, and the path's permissions or storage must let " "it be read), then run " f"`bmad-loop resume {self.state.run_id}`: resume recovery replays the " "recorded session result where one exists (the dev and review legs) and " "otherwise re-drives the leg, retrying the ledger write either way." ) gates.notify( self.policy, self.run_dir, f"ACTION REQUIRED: repair the deferred-work ledger for {task.story_key}", notice, multiline=True, ) self._save() raise RunPaused(notice, PAUSE_ESCALATION, task.story_key) def _legacy_ledger_changed_before_harvest(self, task: StoryTask) -> bool: """Recover pre-feature ledger attribution from the attempt's Git baseline. Old resumable state has no ``baseline_ledger_digest``. The completed session can still have authored ledger-only work, and path-granular Git evidence distinguishes that existing diff from the engine harvest about to run. External ledgers cannot satisfy the project proof gate. An attribution probe that raises or returns unknown keeps the path excluded, so uncertainty never credits the engine's own append as session work. """ if not task.baseline_commit: return False ledger = self.workspace.paths.deferred_work root = self.workspace.root try: rel = ledger.resolve().relative_to(root.resolve()).as_posix() except (OSError, RuntimeError): try: rel = ledger.relative_to(root).as_posix() except ValueError: return False except ValueError: return False try: changed = verify._changes_since( root, task.baseline_commit, literal_path=rel, baseline_untracked=task.baseline_untracked, ) if changed is None: self.journal.append( "legacy-ledger-attribution-failed", story_key=task.story_key, error="git could not determine whether the ledger changed", ) return False return changed except (verify.GitError, OSError, RuntimeError) as e: self.journal.append( "legacy-ledger-attribution-failed", story_key=task.story_key, error=str(e), ) return False def _ledger_rel(self) -> tuple[str | None, Exception | None]: """Name the active ledger relative to the workspace root, for a git probe. Three answers, and never a raise. ``(rel, None)`` derived. ``(None, fault)`` — resolution itself failed, so the ledger's scope is unknown. ``(None, None)`` — proven external: it resolved cleanly and still fell outside the root, so no revision of this repo can name it. The fault answer is deliberately left undecided here, because the two consumers degrade in OPPOSITE directions: :meth:`_ledger_is_gits_to_restore` keeps the file, while :meth:`_ledger_baseline_text` withholds the write anchor. """ ledger = self.workspace.paths.deferred_work root = self.workspace.root try: return ledger.relative_to(root).as_posix(), None except ValueError: try: resolved_ledger = ledger.resolve() resolved_root = root.resolve() except (OSError, RuntimeError, ValueError) as e: return None, e try: return resolved_ledger.relative_to(resolved_root).as_posix(), None except ValueError: return None, None def _ledger_is_gits_to_restore(self, task: StoryTask) -> bool: """Whether git owns the active ledger and reset is responsible for it. Probe failures degrade toward keeping the file: uncertainty must never authorize deleting a tracked ledger that ``reset --hard`` restored. """ rel, fault = self._ledger_rel() if fault is not None: self.journal.append( "ledger-scope-probe-failed", story_key=task.story_key, error=str(fault), ) return True if rel is None: # An external ledger was outside the reset's reach. A None # snapshot means this harvest created it, so it remains ours to # unlink. return False try: return verify.path_tracked(self.workspace.root, rel) except (verify.GitError, OSError, RuntimeError) as e: self.journal.append( "ledger-tracked-probe-failed", story_key=task.story_key, error=str(e), ) return True def _ledger_baseline_text(self, task: StoryTask) -> tuple[_LedgerAnchor, str | None]: """The ledger text ``reset --hard`` republishes, taken from git itself (#735). Answers ``(BASELINE, text)`` when the baseline commit carries the ledger, and ``(BASELINE, None)`` when it determinately does not — the reset removed it, so a missing file IS the reset's own work. ``(NO_RESET_CONTENT, None)`` when the path is real but the reset republished no text for it: proven external, or a non-regular baseline entry such as a symlink. ``(NONE, None)`` when nothing could be derived at all: no baseline commit, or a probe that failed. :class:`_LedgerAnchor` carries why only the first of those may authorize a write. Newlines are normalized to LF because the only thing this text is ever compared against is :meth:`_ledger_text`, which reads in Python's universal-newline mode. The blob comes back with the path's working-tree filters applied, so under ``core.autocrlf=true`` it is CRLF; without this normalization the reset-owned write arm would go silently never-true on Windows and every such restore would degrade to a skip. **The fault direction is INVERTED from :meth:`_ledger_is_gits_to_restore`, deliberately.** That probe degrades to ``True`` because its consumer is an unlink, and uncertainty must never delete. This one degrades to NO anchor because its only consumer is a write arm, and uncertainty must never write. Copying the other probe's degrade here reopens #735 through the repair itself. Nothing may escape. ``verify.GitError`` is a plain ``Exception`` and the attempt's net is ``(OSError, StateRootError)``, so a probe fault leaking out of here would replace an in-flight ``RunPaused`` in that ``finally`` with a secondary repair failure. """ if not task.baseline_commit: return _LedgerAnchor.NONE, None rel, fault = self._ledger_rel() if rel is None: if fault is not None: self.journal.append( "ledger-baseline-probe-failed", story_key=task.story_key, error=str(fault), ) return _LedgerAnchor.NONE, None # PROVEN external — it resolved cleanly and still fell outside the # root — which is determinate absence, not uncertainty: no revision # of this repo can name the path, so `reset --hard` cannot have # republished it. Same answer as a baseline commit that does not # carry the ledger, and for the same reason; the caller supplies the # anchor for a file git never had. Collapsing this into the fault # answer withholds the anchor from a SUPPORTED shape — an # `implementation_artifacts` dir configured outside the repo tree, # which `ProjectPaths.rebased` deliberately leaves put — and strands # the sweep's migration restore on an unprovable-anchor refusal that # the evidence does not support. return _LedgerAnchor.NO_RESET_CONTENT, None try: if verify.path_is_non_regular_at_revision( self.workspace.root, task.baseline_commit, rel ): # A symlink, gitlink or tree at the baseline is a path whose # CONTENTS the reset never republished: `reset --hard` restores # the link itself and cannot reach through it to revert what it # points at. Behind mode 120000 the blob is the TARGET PATHNAME, # so trusting it here would compare a pathname against ledger # text and leave the anchor silently never-true — the same # failure mode the newline normalization above exists to prevent, # and one that would escalate every failed migration over a # ledger symlinked into the repo. That shape is supported on # purpose: `atomic_write_text` follows symlinks by DEFAULT so # such a ledger keeps being a symlink. Determinate absence of # republished text, exactly like a proven-external ledger. return _LedgerAnchor.NO_RESET_CONTENT, None blob = verify.worktree_file_bytes_at_revision( self.workspace.root, task.baseline_commit, rel ) if blob is None: return _LedgerAnchor.BASELINE, None text = blob.decode("utf-8") except (verify.GitError, OSError, RuntimeError, UnicodeDecodeError) as e: self.journal.append( "ledger-baseline-probe-failed", story_key=task.story_key, error=str(e), ) return _LedgerAnchor.NONE, None return _LedgerAnchor.BASELINE, text.replace("\r\n", "\n").replace("\r", "\n") def _restore_ledger(self, task: StoryTask, snapshot: str | None) -> None: """Retract this attempt's engine ledger writes, without taking a concurrent writer's work with them (#286). The window being repaired spans ``_rollback_or_pause``'s git spawns, so a lock cannot cover it — :func:`deferredwork.ledger_lock` is contracted never to span a subprocess. Compare-and-set stands in, against two anchors, because this restore serves two different owners: * ``post_engine_ledger_digest`` — the bytes THIS engine last published. Matching it means the file on disk is the harvest append this restore exists to retract. * the ledger's committed blob at ``task.baseline_commit``, on a ledger git owns. That blob is exactly what ``reset --hard`` republished, it is nobody's concurrent write, and restoring the snapshot over it is what puts back the session's own ledger edits the reset erased. The anchor is read out of git rather than off the working tree because **a post-reset observation may justify a SKIP, never a WRITE**: a rival writing a tracked ledger inside the reset window would otherwise BE the observation this arm trusts, and the restore would overwrite it (#735). A probe that cannot answer withholds the anchor, so an unprovable baseline degrades to the same journaled skip rather than a write. Neither anchor holding means the text belongs to somebody else, and the restore degrades to a journaled skip rather than a write. **A retraction cannot be expressed as an append**, so there is no merge to fall back on the way :meth:`_restore_defer_ledger` has one. Skipping is safe by design: the harvest entries left standing are real findings rather than noise, ``append_entry``'s idempotence stops the next attempt filing them twice, and the attribution rebase at the call site reads a non-restored disk as "the ledger changed", which stands the harvest exclusion down and exposes MORE of the tree to the proof-of-work gate — the conservative direction (see :meth:`_harvest_gate_exclude`). The ``snapshot is None`` unlink is gated on the digest for the same reason, and that is a latent data loss being closed rather than a new guard: the code this replaces deleted whatever it found there, so a ledger a concurrent writer had created inside the window was removed along with the harvest that was supposed to be the only thing in it. Signature-stable on purpose — the direct-call unit tests drive this with an explicit snapshot. Write and lock faults propagate to the call site's net, which preserves an in-flight ``RunPaused`` rather than being replaced by a secondary repair failure. Both the write and the unlink are pinned to a unit mount (:meth:`_publish_restored_ledger`, :meth:`_retract_ledger`, DW-498): a mount swapped for a link refuses, as such a fault, rather than writing or deleting at the link's target. """ ledger = self.workspace.paths.deferred_work # Read IMMEDIATELY after `_rollback_or_pause` returned: only pure Python # runs between the reset and this line, so the compare window below is # file-I/O-only rather than spanning the rollback's git spawns. This # observation authorizes ONLY the skip that follows — declining to act is # safe whoever wrote those bytes. It is never a write anchor: it is taken # after the very reset it would attest to, so a rival that landed inside # that window becomes the observation itself (#735). observed = self._ledger_text(site="ledger-restore", story_key=task.story_key) if observed == snapshot: return # Probed BEFORE the lock: it spawns git, and `ledger_lock` may cover file # I/O only. It also journals its own degrades, which belong outside the # hold for the same reason. gits = self._ledger_is_gits_to_restore(task) if snapshot is None and gits: # A tracked ledger absent at snapshot time is not ours to delete — # reset restored its committed bytes. Deleting is the only thing a # None snapshot could do, so return before taking a lock no write # would ever use. return # The WRITE anchor derives from the committed blob, never from an # observation a rival could have authored (#735). Probed here, before the # lock, for the same reason as the one above: it spawns git, and # `ledger_lock` may cover file I/O only. Only a ledger git owns can be # reset-owned at all, so an untracked, ignored or external one skips the # spawn. A fault degrades to NO anchor — the inverse of the gits probe # above, whose consumer is an unlink; this one's is a write. anchor, expected = self._ledger_baseline_text(task) if gits else (_LedgerAnchor.NONE, None) diverged = False with deferredwork.ledger_lock(ledger): # PURE TEXT ONLY under the hold. Every `deferredwork` mutator takes # this same lock, and `ledger_lock` raises on the nesting rather than # deadlocking — that raise would abandon the repair half-done. current = self._ledger_text(site="ledger-restore-locked", story_key=task.story_key) if current == snapshot: return if isinstance(current, _DEGRADED_LEDGER): # Bytes nobody can read are nobody's to retract (DW-231, and the # OS-refused read of DW-258): either typed answer equals no # snapshot, is never digested as "ours" and never reaches the # unlink or the write below. The `observed == snapshot` probe # above is already False for it, so this is the arm that ends # every degraded read in the skip. diverged = True else: ours = _digest_of(current) == task.post_engine_ledger_digest # BASELINE only: `NO_RESET_CONTENT` carries `None` meaning "no # text to offer", so pairing it with a missing file would read a # rival's deletion as the reset's own work and write the snapshot # back over it. Observation may justify a skip, never a write. reset_owned = anchor is _LedgerAnchor.BASELINE and current == expected if snapshot is None: # `gits` is False on this arm — the guard above returned # otherwise — so the file is untracked, ignored or external # and `reset --hard` cannot have put it there. Deleting it is # therefore only defensible when the digest says these are # the bytes this engine itself published; the unguarded # unlink this replaces took a concurrent writer's ledger with # the harvest. if ours: self._retract_ledger(task, ledger) else: diverged = True elif ours or reset_owned: self._publish_restored_ledger(task, ledger, snapshot) else: diverged = True # Journaled outside the hold: the lock covers this ledger's # read-modify-write and nothing else. if diverged: self.journal.append( "ledger-restore-skipped-diverged", story_key=task.story_key, ledger=str(ledger), ) def _publish_restored_ledger(self, task: StoryTask, ledger: Path, text: str) -> None: """Publish a ledger restore's ``text`` at ``ledger`` — the one write both restores (:meth:`_restore_ledger`, :meth:`_restore_defer_ledger`) end in. Under worktree isolation the ledger lies in the unit mount, whose parent is session-writable, so the write is pinned there as DW-445's writers are (DW-498): `_mount_root_identity` pins ``workspace.paths.project``, a ledger under it has its parent created by `platform_util.make_dirs_confined` and is written by `atomic_write_text_confined` against that pin, and one configured outside it (not rebased, so not in the mount) is written as before after a `platform_util.require_root_pinned` pre-check — the spec writers' external arm. A mount swapped for a link refuses with an `UnconfinedWriteError` (an ``OSError``), which propagates to the call site's net like any other write fault, instead of landing at the link's target. The confined write replaces a link at the ledger itself rather than following it, as every mount writer does (#593). Unmounted (``None``) the write is unchanged: the operator's project is left unpinned by the pin rule, and its ledger keeps `atomic_write_text`'s symlink-following, mode-preserving default — a ledger or artifacts dir symlinked into the project is a supported shape the confined walk would refuse.""" root = self.workspace.paths.project root_identity = self._mount_root_identity(task, root) if root_identity is not None: require_root_pinned(root, root_identity) if ledger.is_relative_to(root): make_dirs_confined(ledger.parent, confine_root=root, root_identity=root_identity) atomic_write_text_confined( ledger, text, confine_root=root, root_identity=root_identity ) return ledger.parent.mkdir(parents=True, exist_ok=True) atomic_write_text(ledger, text) def _retract_ledger(self, task: StoryTask, ledger: Path) -> None: """Unlink the ledger this engine's harvest created — :meth:`_restore_ledger`'s ``snapshot is None`` arm — pinned as :meth:`_publish_restored_ledger`'s write is (DW-498): on a unit mount, the `require_root_pinned` pre-check refuses before anything is touched, and a ledger under the mount project is unlinked by `platform_util.unlink_confined`, so a link at the mount or below it never gets a same-named ledger deleted at its target. Unmounted, the by-path unlink is unchanged.""" root = self.workspace.paths.project root_identity = self._mount_root_identity(task, root) if root_identity is not None: require_root_pinned(root, root_identity) if ledger.is_relative_to(root): unlink_confined( ledger, confine_root=root, root_identity=root_identity, missing_ok=True ) return ledger.unlink(missing_ok=True) def _restore_persisted_ledger(self, task: StoryTask, *, replayed: bool) -> None: """Restore the snapshot durably armed before this attempt's engine writes. The arm is the captured flag, never the text: ``None`` is a real snapshot value meaning "no ledger existed", so an unarmed task and one armed over an absent ledger are different states that must not collapse. Delegates the compare-and-set to :meth:`_restore_ledger`, whose anchors both live on the task — which is what makes this safe across a crash replay, where the restore runs in a process that did not take the snapshot. A divergent ledger is skipped and journaled rather than overwritten, and the harvest-created file is unlinked only when the digest says the engine wrote it. """ if not task.pre_harvest_ledger_captured: if replayed: self.journal.append("ledger-snapshot-missing", story_key=task.story_key) return self._restore_ledger(task, task.pre_harvest_ledger) def _disarm_ledger_snapshot(self, task: StoryTask) -> None: """Drop the chain-scoped pre-harvest ledger snapshot and its CAS anchor.""" task.pre_harvest_ledger = None task.pre_harvest_ledger_captured = False # The anchor is meaningless without the snapshot it guards, and a stale # digest is worse than none: it could vouch for bytes a later attempt's # restore has no claim to retract. task.post_engine_ledger_digest = None def _harvest_gate_exclude(self, task: StoryTask) -> tuple[str, ...]: """Exclude only this attempt's engine-authored ledger append from proof of work. ``_harvest_spec_deferrals`` runs above the artifact gate. Without this exclusion a session that wrote no code can pass because the orchestrator itself changed the ledger. The persisted flag survives crash replay, when the repeated harvest dedupes and reports no newly-filed ids. The exclusion is path-granular, so it stands down when the ledger had already changed from the current attribution reference before harvesting. That preserves a session-authored ledger-only change as valid work even when the same attempt also records a frontmatter deferral. Standing down exposes more of the tree to the gate, which is the conservative direction. The relpath is derived against ``paths.repo_root``, the tree the gate invokes git in, NOT ``paths.project`` (#716). The two are the same object in every configuration but the `repo_root` override, and that override has TWO shapes whose wrong-root symptoms differ (DW-169): - DISJOINT (any root that is NOT an ancestor of `project` — a sibling checkout beside it is the example the fixtures build, but a descendant of `project`, or a root unrelated to it, lands here too): the ledger sits OUTSIDE the probed tree, where it cannot satisfy proof-of-work at all, so the ``ValueError`` arm's ``()`` is the right answer rather than a pathspec git would silently match nothing against. - NESTED / ANCESTOR (`repo_root` an ancestor of `project`, the monorepo shape): the ledger sits INSIDE the probed tree and the correct relpath keeps the project prefix (``app/_bmad-output/...``). Here ``()`` would be wrong and a ``paths.project`` spelling is not empty either — it drops the prefix and names a DIFFERENT, REAL file, the outer project's own ledger. That is the silently-wrong case: git matches something, just not this attempt's append, so the engine's own ledger write is left counting as session proof of work. Graded by these consumer-JOIN rows, which drive the tuple through a gate; the tuple's own value rows live alongside them in ``tests/test_engine.py``: ``tests/test_engine.py::test_harvest_gate_exclude_gates_the_nested_ledger_under_the_monorepo_shape``, ``tests/test_engine.py::test_accepted_park_observation_excludes_the_nested_ledger_under_the_monorepo_shape``, ``tests/test_stories_engine.py::test_accepted_plan_halt_observation_excludes_the_nested_ledger_under_the_monorepo_shape``, and ``tests/test_sweep.py::test_bundle_gate_excludes_the_nested_ledger_under_the_monorepo_shape``. """ if not task.harvest_wrote_ledger or task.ledger_changed_before_harvest: return () paths = self.workspace.paths root = paths.repo_root try: resolved_ledger = paths.deferred_work.resolve() resolved_root = root.resolve() except (OSError, RuntimeError, ValueError): # ProjectPaths are normalized when loaded. If filesystem resolution # nevertheless faults, keep a lexically in-tree ledger excluded: # uncertainty must not turn the engine's append into session proof. try: rel = paths.deferred_work.relative_to(root) except ValueError: return () else: try: rel = resolved_ledger.relative_to(resolved_root) except ValueError: # The proof-of-work gate only sees the code tree, so a ledger outside it # cannot satisfy the gate and needs no exclusion. return () return (rel.as_posix(),) def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): outcome = verify.verify_dev( task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled(), operator_park=self._operator_park_enabled(), engine_written=self._harvest_gate_exclude(task), ) # The record marks the WAIVED GATE, so it keys on the waiver itself # (`park_proof_skipped`) and never on what the probe managed to say. The # observation is a field on the record, not its trigger: `zero_diff` is # `true` when the waived gate would have found nothing it counts (the #676 # shape the skip exists for), `false` when it would have found something, # and JSON `null` when the probe could not answer — a `GitError`, a git # refusal, or an attempt with no baseline commit to measure from. Keying on # `park_zero_diff is not None` instead would drop exactly the unanswerable # case — a gate that WAS waived, silently, which is the silence this record # exists to end. An unknown answer is a truthful field value, not a reason # to withhold the record. And `false` is a fact about the TREE: the gate # this stands in for cannot attribute residue to a session (a shared # checkout may hold a commit from outside it), so neither can the record. # # What the record asserts is bounded at BOTH ends by this seam, and it is # narrower than "this park was accepted". The flag rides the `passed()` # return, so a waiver refused by a later check still inside `verify_dev` — # the sprint pair is the reachable one — records nothing. But everything # downstream of this method runs AFTER the append and can still reject the # attempt: the configured `[verify]` commands (`_dev_phase` replaces this # outcome with theirs a few lines later), decision routing, the review # loop, the pre-commit workflows and the commit itself. A retried or # deferred attempt therefore leaves a record too, one per attempt. So the # fact here is exactly "this attempt cleared the dev ARTIFACT gate with # proof-of-work waived" — never that the park committed. # # The terminal half of that question is `_finalize_commit_phase`'s # `story-awaiting-operator`, appended AFTER `finalize_commit` stamps # `task.commit_sha` and carrying that sha. Do NOT read # `_skip_review_and_commit`'s `review-skipped-awaiting-operator` as that # half: it is the FIRST statement of that method, ahead of # `_verify_review`, the repair loop, the pre-commit workflows and # `_commit`, so it exists just as much for a park those stages then # reject. It means "the park entered the commit path", never "the park # committed". # # A reader wanting "waived AND committed" correlates on `story_key` plus # journal ORDER: the committed park's waiver is the last # `park-proof-of-work-skipped` for that story preceding its # `story-awaiting-operator`. No attempt-level key is promised, and the # reason is structural rather than an omission — neither terminal event # carries `attempt`, and adding one would not help: `_fix_phase` # increments `task.attempt` and the park commit path calls it, so the # attempt current at commit can exceed the one on this record. A join # shaped like `(story_key, attempt)` would miss on exactly the # multi-attempt runs it exists for, which is worse than an honestly # coarser correlation. Nothing here persists past this outcome for the # same reason: the correlation is the journal's, not the task's. if outcome.park_proof_skipped: self.journal.append( "park-proof-of-work-skipped", story_key=task.story_key, attempt=task.attempt, zero_diff=outcome.park_zero_diff, ) return outcome def _review_command_sink(self, task: StoryTask) -> verify.CommandSink: """The sink a review gate hands its verifier results to, so a review-leg pass is journalled exactly like a dev or fix one. The same ``_journal_verify_command_results`` the dev side uses, bound to this task under ``"review"`` — so the records share one per-story ``verification_sequence`` with the dev and fix passes, and reading them in ordinal order replays the story's verifications in the order they ran. Also the ``post_review_verify`` capture point: the records land in ``self._review_verify_records`` for ``_review_verify_gate`` to publish. Captured here rather than threaded through ``_verify_review`` so the mode-specific ``_verify_review`` overrides (``StoriesEngine``, ``SweepEngine``), which all build their sink through this method, need no change of their own. Stored BEFORE the hard-stop check: an interrupted pass raises ``RunStopped`` out of the gate, so the wrapper never reaches its emit and the stored records are never published. WHICH gate ran is not on the record and is not meant to be: five engine call sites reach these gates, and the neighbouring ``review-result`` / ``review-skipped*`` / ``review-timeout-salvage*`` entries — plus the sequence ordering — already say which. A stage token per call site would be a second, drift-prone vocabulary for a fact the journal already carries. """ def sink(results: tuple[verify.CommandResult, ...]) -> None: sequence = self._journal_verify_command_results(task, "review", results) self._review_verify_records = VerifyCommandRecords( results=results, stage="review", sequence=sequence ) # Same boundary as the dev side: an interrupted review pass stops the # run before the gate classifies it (DW-353). self._stop_if_verify_interrupted(results) return sink def _review_probe_sink(self, task: StoryTask) -> verify.ProbeSink: """The sink a review gate hands its environment preflight to (DW-523): the pass core ran in ``paths.repo_root`` is recorded exactly as :meth:`_run_environment_probes` records the dev side's — a failure journals ``env-probe-failed`` with site ``"verify:review"``, a hard stop raises ``RunStopped``, a healthy pass marks the probes fresh.""" def sink(probe: verify.ProbeOutcome) -> None: self._observe_environment_probes( task, probe, site="verify:review", root=self.workspace.root ) return sink def _review_verify_gate( self, task: StoryTask, *, session_status: str | None = None, result_json: object = None, ) -> VerifyOutcome: """Evaluate the review verify gate and publish it to ``post_review_verify``. Every engine visit to the review gate goes through here (converged pass, budget-exhaustion rescue, review-timeout salvage, both passes of ``_skip_review_and_commit``), so the one emit covers the base engine and every ``_verify_review`` override alike. Observe-only: the outcome is returned untouched, and the caller routes on it exactly as before. ``session_status`` / ``result_json`` describe the review session whose product the gate verified; both stay ``None`` on the skip-review path, where no review session ran. The command records come from ``_review_command_sink`` — reset first, so a gate that fails before its command pass publishes ``NO_VERIFY_COMMANDS`` (stage/sequence ``None``, no results) rather than a previous gate's. A hard stop mid-pass raises ``RunStopped`` out of the sink, so an interrupted pass is never emitted. """ self._review_verify_records = NO_VERIFY_COMMANDS outcome = self._verify_review(task) records = self._review_verify_records self._emit( "post_review_verify", task, session_status=session_status, result_json=result_json, verify_reason=outcome.reason, command_results=records.results, verification_stage=records.stage, verification_sequence=records.sequence, ) return outcome def _verify_review(self, task: StoryTask): # `not _dev_review_enabled()` is exactly the case where _post_dev_state_sync # targeted "done" and verify_dev asserted the board got there, so a board # now short of done is a review revoking that sign-off, not a stage never # reached (#334). outcome = verify.verify_review( task, self.workspace.paths, self.policy, sprint_reached_done=not self._dev_review_enabled(), operator_park=self._operator_park_enabled(), on_results=self._review_command_sink(task), on_probes=self._review_probe_sink(task), ) if outcome.ok: self._accept_review_artifact_source(task) return outcome def _review_prompt(self, task: StoryTask) -> str: # Re-invoking bmad-build-auto on a `done` spec resets review_loop_iteration # and routes to step-04 for a fresh independent review pass (BMAD-METHOD # #2508) — so the follow-up review is just another dev-skill run, no # separate review skill. task.spec_file is set by verify_dev on success. # The ledger instruction is the prevention side of the reclose in # SweepEngine._verify_review: a review that rewrites deferred-work.md # from a stale snapshot clobbers orchestrator-recorded closures. # Existing entries are orchestrator-owned; new ones are simply not asked # for. Post-BMAD-METHOD#2640 the primitive records its own `defer` # findings in the spec frontmatter and `_harvest_spec_deferrals` files # them. An affirmative append instruction would therefore file each # finding twice. Keep this neutral for pre-#2640 skills, whose flat append # may still be the only record. `tests/test_engine.py` pins the ban as # `"append" not in prompt.lower()` over the WHOLE assembled prompt, board # clauses included — nothing injected below may spell that substring. # # Two separators live here, both plain spaces because every clause ends in # a full stop and the dev seam's em dash would render a `. — `: the # caller-owned one at the ledger↔board seam, and the join at the # board↔redirect seam. The `if tail else ""` guard is live, not defensive — # `StoriesEngine` inherits this method and empties both clauses. # # The park clause rides here only under `[operator] on_review_demotion = # "park"` (DW-383) — the one mode in which a review's `awaiting-operator` # finalization is accepted. It goes LAST, after the board clauses, the same # board-before-park order the dev seam keeps. Default prompt unchanged. # The environment-claim clause (DW-523, only while probes are configured) # sits just before it, as on the dev seam. park = self._operator_park_instruction() if self._review_demotion_parks() else "" clauses = [ c for c in ( self._sprint_board_instruction(), self._board_handback_redirect(), self._environment_claim_instruction(), park, ) if c ] tail = " ".join(clauses) return ( f"/{self._dev_skill('review')} {task.spec_file} — do NOT modify, " f"re-open, or rewrite existing deferred-work ledger entries; the " f"orchestrator owns their status and resolution." ) + (f" {tail}" if tail else "") def _render_commit_template(self, task: StoryTask) -> str | None: """The configured commit message template with {story_key}/{run_id}/ {story_title} substituted, or None when no template is set. Used by both the story and sweep-bundle commit paths so a filled-out template wins everywhere.""" template = self.policy.scm.commit_message_template.strip() if not template: return None # literal substitution (not str.format) so stray braces in the # template — e.g. a JSON trailer — don't raise. The spec read behind # {story_title} is skipped entirely for templates that don't ask for it. title = self._story_title(task) if "{story_title}" in template else "" # {story_title} substituted LAST: it is the only value here drawn from # agent-written spec prose, so a title that itself contains "{run_id}" # must land in the message as written instead of being re-substituted. return ( template.replace("{story_key}", task.story_key) .replace("{run_id}", self.state.run_id) .replace("{story_title}", title) ) def _story_title(self, task: StoryTask) -> str: """The story's human-readable title for {story_title}: the spec's ``title:`` frontmatter, falling back to a first **ATX** H1, then to the story key. Any leading ``Story :`` label is dropped either way (the template already carries the key, so the label would just repeat it). Frontmatter first because that is where a bmad-loop spec's title actually lives — `spec-template.md` opens with ``title:`` and writes no H1 at all. Keying on the heading alone left the placeholder inert on every canonical spec, silently rendering the story key in place of a title. The H1 branch stays for specs authored outside that template. ATX only, deliberately: the setext form (a line underlined by ``===``) is a valid CommonMark H1, but recognizing it would make *any* prose line sitting above a ``===`` divider the commit subject. That trades this method's one safe failure mode — falling back to the story key — for a confidently wrong title, so the narrower contract is the right one and this docstring is the place it is stated. Falls back to the story key when there is no spec, no title, or the spec is unreadable — the placeholder must never render empty, and a commit-time read failure must not fail the commit.""" if not task.spec_file: return task.story_key spec = Path(task.spec_file) try: # `read_frontmatter` already degrades a missing, undecodable or # malformed-YAML spec to {}; the guard below is for the reads that # still raise past it (EACCES here, a torn multi-byte spec in the # H1 fallback's own read_text). title = _story_label_stripped( verify.read_frontmatter(spec).get("title"), task.story_key ) if title: return title lines = spec.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeDecodeError): # UnicodeDecodeError is a ValueError, not an OSError, so a spec torn # mid-write through a multi-byte sequence would slip past an # except-OSError guard. This render happens before # _finalize_commit_phase's try, so an escape crashes the run rather # than escalating the story. return task.story_key # Skip a leading YAML frontmatter block (standalone --- delimiter lines, # same rule as frontmatter._split_frontmatter) so a YAML comment inside # it can't be mistaken for the H1. start = 0 if lines and lines[0].rstrip() == "---": for i in range(1, len(lines)): if lines[i].rstrip() == "---": start = i + 1 break # Fenced blocks are skipped, because `#` opens a comment in most of the # languages a spec quotes: a setup snippet whose first line is # "# Install the dependencies" is not this story's heading, and taking it # would render a confidently wrong commit subject rather than falling # back. An unclosed fence deliberately swallows the rest of the file # (CommonMark says it runs to EOF) — resetting at EOF to "rescue" a # heading would resurrect exactly the bug this skip exists to prevent. fence_char, fence_len = "", 0 for line in lines[start:]: # CommonMark allows up to three spaces of indentation before an ATX # heading; a fourth makes the line an indented code block, so that is # the bound rather than a plain lstrip. Getting this wrong only ever # costs a silent fall back to the story key, which is the same way # the placeholder was inert on every canonical spec before it read # `title:` — so the extraction stays as permissive as the syntax is. head = line.lstrip(" ") # A fence opens on 3+ backticks or tildes and closes only on a run of # the SAME character, at least as long, with nothing but whitespace # after it — so a ``` inside a ```` block does not close it early. run = 0 if len(line) - len(head) <= 3 and head[:1] in ("`", "~"): run = len(head) - len(head.lstrip(head[0])) if fence_char: if head[:1] == fence_char and run >= fence_len and not head[run:].strip(): fence_char, fence_len = "", 0 continue if run >= 3: fence_char, fence_len = head[0], run continue # The opener is `#` followed by a space OR A TAB — `#Title` is not a # heading at all, and `## Title` is an H2, so both stay rejected. if len(line) - len(head) <= 3 and head[:1] == "#" and head[1:2] in (" ", "\t"): # An ATX heading may carry an optional closing run of hashes, # which is syntax rather than title ("# Wire it ###" is "Wire # it"). Unlike the two misses above this one renders a *wrong* # subject rather than falling back, so it is the one worth # stripping. Whitespace before the run is what makes it a # closing sequence; "# C#" keeps its hash. title = _story_label_stripped(_ATX_CLOSING_RE.sub("", head[2:]), task.story_key) if title: return title break return task.story_key def _commit_message(self, task: StoryTask) -> str: # The park suffix is appended to a rendered template too. The template # governs the message's SHAPE; whether the story is finished is a fact # about the commit, and `git log` is where an operator looks for it long # after the notification scrolled past. A plugin that wants the suffix # gone can still rewrite the whole message at pre_commit. return self._base_commit_message(task) + ( " (awaiting operator)" if task.operator_actions else "" ) def _base_commit_message(self, task: StoryTask) -> str: rendered = self._render_commit_template(task) if rendered is not None: return rendered if self.policy.review.enabled: return f"story {task.story_key}: implemented and reviewed via bmad-loop" return f"story {task.story_key}: implemented via bmad-loop" # ------------------------------------------------------------- helpers def _session_end_extras(self, result: SessionResult, usage: TokenUsage | None = None) -> dict: """Timeout forensics for the session-end entry (#157). ``teardown_s`` is the timeout-fire → journal gap — the window the incident showed could silently stretch for hours. A tripped session-budget guard (#158) adds its sample plus the cap/mode it was judged against.""" extras: dict = {} if result.timeout_fired_at is not None: extras.update( fired_at=result.timeout_fired_at, teardown_s=max(0.0, round(time.time() - result.timeout_fired_at, 3)), expired_clock=result.timeout_expired_clock, ) if result.budget_weighted is not None: extras.update( budget_weighted=result.budget_weighted, budget=self.policy.limits.max_tokens_per_session, budget_mode=self.policy.limits.session_budget_mode, ) # transport-failure classification (#194): rides both session-end emit # sites so the evidence line is on the record wherever the session ended. if result.env_fault: extras["env_fault"] = True if result.env_fault_evidence: extras["env_fault_evidence"] = result.env_fault_evidence # overruled transport-failure match (#764): same present-only convention, # so a grep finds exactly the sessions whose log-tail provider error was # outweighed by measured token use and charged instead of paused. if result.env_fault_overruled: extras["env_fault_overruled"] = result.env_fault_overruled # lost-session diagnosis (#489): rides the same chokepoint so EVERY # role — dev, review, fix, migration, triage, injected workflows — # leaves the greppable record, not only the dev decision. The boolean # is not `has_session`'s weak False alone: the adapter confirms that # negative with a listing that proves the session gone (DW-459). if result.session_vanished: extras["session_vanished"] = True # no-work diagnosis (#727): same convention — present only when the # session ended non-completed without ever changing its pane after the # first frame, so a grep for the field finds exactly the parked sessions. if not result.produced_work: extras["produced_work"] = False # parked-session diagnosis (DW-348/DW-350): same present-only convention, # so a grep for the field finds exactly the sessions whose stall nudge # was withheld because the CLI was waiting on a human. if result.parked: extras["parked"] = True if result.parked_evidence: extras["parked_evidence"] = result.parked_evidence # usage-limit diagnosis (#610): same present-only convention — the line # the adapter saw live (claude's visible pane, or opencode's provider-error # frame for this session, DW-592), which exempted the env-fault verdict # from the token veto. if result.usage_limited: extras["usage_limited"] = result.usage_limited # the limit's reset, parsed from the same line (#610): ISO-8601, or the # raw clause when it did not resolve. if result.usage_limit_resets: extras["usage_limit_resets"] = result.usage_limit_resets # which usage-limit list matched (DW-591): `blocked` or `wait`, # present-only like the reset. if result.usage_limit_class: extras["usage_limit_class"] = result.usage_limit_class # usage-limit quota wait (DW-396): present-only — the seconds the session # sat behind a usage-window reset with its timeout frozen. if result.quota_wait_s > 0: extras["quota_wait_s"] = round(result.quota_wait_s, 1) # empty read-back diagnosis (#780): same present-only convention. The # last resultless-stop verdict lands here because nothing reads the # crumb file, so a spec nested out of the read-back's reach timed out # with no visible reason. if result.resultless_verdict: extras["resultless_verdict"] = result.resultless_verdict if result.resultless_detail: extras["resultless_detail"] = result.resultless_detail # skipped usage sources: same present-only convention, so a grep finds # exactly the sessions whose `tokens` is a lower bound. if usage is not None and usage.unread: extras["usage_unread"] = list(usage.unread) return extras @staticmethod def _session_timeout_s(default_s: float) -> float: """Per-session wall-clock budget in seconds. Normally ``limits.session_timeout_min * 60``; ``BMAD_LOOP_SESSION_TIMEOUT_S`` overrides it (test / override hook, à la ``BMAD_LOOP_PROCESS_HOST``). The policy floor is 1 minute — too coarse to exercise the #157 timeout-teardown path in a deterministic sub-minute E2E, and a real binary run can't be monkeypatched. A non-positive or unparseable override is ignored, so a fat-fingered value can never silently shorten a real run's budget.""" override = envvars.session_timeout_s() return override if override is not None else default_s def _run_session( self, task: StoryTask, role: str, prompt: str, seq: int, session_stage: str | None = None, label: str | None = None, spec_snapshot: SpecSnapshot | None = None, preserve_dispatched_spec_snapshot: bool = False, prelaunch_validator: Callable[[], None] | None = None, ) -> SessionResult: # A session may change the environment a probe pass vouched for, so no # pass survives a launch (DW-523). self._env_probes_fresh_root = None # ``label`` names a non-standard session (a plugin-provided workflow) so # its task_id stays distinct from the role's own dev/review attempts. task_id = _session_task_id(task.story_key, label if label else role, seq, task.generation) adapter = self.adapters[role] if self.workspace.root != self.paths.repo_root: # DW-341: Codex silently skips hooks it has not trusted for this exact # worktree path, so the session's Stop would never arrive. Checked here, # per session, because the `_finish_inflight` resume arms reopen a mounted # unit and drive sessions without passing through `run_isolated`'s # unit-entry gate. Path-based only, never live isolation policy: a run # paused under `worktree` and resumed under `none` still reopens its # mounted unit. Raises RunPaused (unit ESCALATED, worktree kept) before # env building, plugin gates, and the session-start journal; a no-op for # non-Codex adapters, and main-checkout sessions never reach it. self._worktree_flow.gate_codex_hook_trust(task, self.workspace.root, roles=(role,)) cfg = self.policy.adapter.resolved(role) env = { # The state root this process settled on, handed over rather than left # to inheritance — same rule, and same reason, as the events dir below. # A multiplexer may give a pane child none of our environment (psmux's # PSMUX_BARE_ENV=1 keeps a 14-name allowlist that drops both # BMAD_LOOP_STATE_DIR and the LOCALAPPDATA its default falls back to), # and a bmad-loop invoked inside the session would then answer with a # different state root: a different registry, so attach/stop/liveness # read this very session as gone. `{}` when none is derivable, which is # the one case there is nothing to say (see runs.pinned_state_env). **pinned_state_env(), "BMAD_LOOP_MODE": "1", "BMAD_LOOP_RUN_DIR": str(self.run_dir), # Where this session's hook relay writes its events (#494). The one # required producer site: every engine-driven session — dev/review, # sweep bundles, stories, injected plugin workflows — is dispatched # through this dict. The deliberate non-sites all fail closed without # it: `resolve.py` sets no BMAD_LOOP_TASK_ID, so the relay no-ops and # an interactive resolve session produces no events at all; `probe.py` # captures through BMAD_LOOP_PROBE_CAPTURE_DIR and its own probe relay; # `plugins/bus.py` spawns plain shells with no task id; the Unity # plugin's helper scripts are not CLI sessions. # # Keyed on the run's project and id, not on `self.run_dir`, so the # value cannot drift from the directory `runsetup.make_adapters` # pointed this run's SignalWatcher at — the producer and the consumer # of one channel, derived by one function from the same two inputs. "BMAD_LOOP_EVENTS_DIR": str(events_dir_for(self.paths.project, self.run_dir.name)), "BMAD_LOOP_TASK_ID": task_id, "BMAD_LOOP_STORY_KEY": task.story_key, } # engine-variant env seam: StoriesEngine adds BMAD_LOOP_SPEC_FOLDER so the # dev adapter resolves the story spec deterministically by id instead of # mtime-scanning. Base returns {} — sprint/sweep runs stay byte-identical. # ``label`` (set only for injected plugin-workflow sessions) is passed so the # variant can withhold that env from non-primary sessions. env.update(self._extra_session_env(task, role, label=label)) if task.dw_ids: # Deferred-work bundle: the orchestrator owns the bundle→dw-id binding # (the generic bmad-build-auto primitive knows nothing of dw ids). Export # them so the generic adapter can stamp them onto the synthesized # result.json, keeping verify_dev_bundle's dw_ids cross-check live. env["BMAD_LOOP_DW_IDS"] = ",".join(task.dw_ids) if role == "dev" and not self.policy.review.enabled: # signals that the orchestrator will run no follow-up review session. # bmad-build-auto always self-reviews inline (step-03 → step-04) and # commits regardless, so this is a no-op for it; kept for any future # dev skill that honors a skip-review mode (cf. the legacy seam). env["BMAD_LOOP_SKIP_REVIEW"] = "1" # plugin session hooks: a role-specific stage (pre_dev_session / fix / # migrate / ...) then the generic pre_session, both able to rewrite the # prompt + env or veto the session. A veto synthesizes a `vetoed` result # so the existing decide_dev/decide_review_session route it (retry → defer). prompt, env, sctx = self._emit_session_gate( task, role, prompt, env, session_stage or f"pre_{role}_session" ) if sctx is not None: veto = sctx.resolved_veto() if veto is not None: # A veto prevents adapter launch but does not undo executable # hook side effects. Callers whose durable launch authority is # bound to mutable input must validate that input before the # early return just as they do on the normal launch path. if prelaunch_validator is not None: prelaunch_validator() self.journal.append( "plugin-veto", stage=sctx.stage, action=veto.action, plugin=veto.plugin_id, reason=veto.reason, task_id=task_id, role=role, ) return SessionResult(status="vetoed") if label is None and role == "dev" and task.phase == Phase.DEV_RUNNING: # Session-gate hooks run after `_dev_phase`'s last observation and may # legitimately mutate the workspace. Refresh a fresh-baseline snapshot, # or only validate an already-armed fixable chain, after those hooks; # fail before `session-start` / adapter.run if the trusted regular file # vanished or became unreadable. Launching an explicit prompt without # recoverable input bytes would make a later rollback unable to # distinguish operator intent from child output. had_binding = task.dispatched_spec_file is not None snapshot_required = ( preserve_dispatched_spec_snapshot or had_binding or self._requires_dispatched_spec_snapshot(task, prompt) ) snapshot_ok = not snapshot_required if had_binding: if preserve_dispatched_spec_snapshot: snapshot_ok = self._validate_dispatched_spec_snapshot(task) else: snapshot_ok = self._refresh_dispatched_spec_snapshot( task, clear_on_failure=False ) elif snapshot_required and not preserve_dispatched_spec_snapshot: # A hook may introduce an explicit task-spec route into an # originally bare prompt. Bind it now because the actual child # prompt and the ownership record then agree. self._bind_dispatched_spec_for_attempt(task) snapshot_ok = ( task.dispatched_spec_file is not None and task.dispatched_spec_snapshot is not None ) if snapshot_required and not snapshot_ok: self._save() raise RuntimeError("attempt-owned spec became unreadable after pre-session hooks") if had_binding or snapshot_required: self._save() if label is not None: # Injected workflow session: name the sprint board's owner, then spell # out the completion-marker protocol and bound its stall nudges (see # WORKFLOW_BOARD_CONTRACT / WORKFLOW_COMPLETION_CONTRACT). # # Both are appended after the session-gate hooks so a # pre_workflow_session / pre_session prompt rewrite cannot strip them — # the property the dev/review seams do NOT have, because there the # orchestrator authors the whole prompt and the plugin body is the # rewrite. Here the prompt IS plugin text, so post-gate is the only # place the orchestrator can say anything at all. # # Board first, completion contract LAST: the marker protocol is the # load-bearing tail (a session that ends its turn without the marker # livelocks the orchestrator until session_timeout_min), and nothing may # come between its "end your turn" imperative and the end of the prompt. board_clause = self._sprint_board_instruction() if board_clause: prompt += WORKFLOW_BOARD_CONTRACT.format(clause=board_clause) # The marker path lands in the same implementation-artifacts dir the # dev adapter already searches — correct in place and under worktree # isolation alike, because spec.cwd is self.workspace.paths.project # either way. # This is the PRODUCER of the marker name. ``role``, not the default: # a workflow declares its own role (WORKFLOW_ROLES = dev | review) and # runs on THAT adapter, whose skill tree can be a different one at a # different era — dev=claude on .claude/skills post-rename, review=codex # on .agents/skills pre-rename. Resolving off the dev tree would name # the session a primitive its own tree does not carry. The reader still # accepts the legacy spelling because devcontract.FALLBACK_RESULT_PREFIXES # matches both eras unconditionally, so discovery survives either # resolution — the two halves agreeing, not a broken read-back. marker_path = ( self.workspace.paths.implementation_artifacts / f"{self._dev_skill(role)}-result-{task_id}.md" ) prompt += WORKFLOW_COMPLETION_CONTRACT.format(marker_path=marker_path) # Optional transaction boundary for callers whose durable launch # authority is tied to mutable workspace input. It deliberately runs # after every executable session hook and every prompt/snapshot repair, # but before either the session-start record or adapter launch. The # default keeps all existing callers byte-for-byte inert. if prelaunch_validator is not None: prelaunch_validator() spec = SessionSpec( task_id=task_id, role=role, prompt=prompt, # The mount project, not the checkout root: every coding CLI discovers # its hook config and skill tree from its cwd (or upward from it), and # provisioning and `init` put them in the project, which a nested # `repo_root:` puts BELOW the root — from there no probed CLI loaded the # Stop relay (DW-484). Code and git work still answer to the root. cwd=self.workspace.paths.project, env=env, model=cfg.model, effort=cfg.effort, timeout_s=self._session_timeout_s(self.policy.limits.session_timeout_min * 60), stall_nudges_cap=( self.policy.limits.workflow_stall_nudges_cap if label is not None else self.policy.limits.dev_stall_nudges_cap ), # mid-session token-budget guard (#158): every session the engine # drives (dev/review/labeled workflow) gets the same policy caps. token_budget=self.policy.limits.max_tokens_per_session, token_budget_mode=self.policy.limits.session_budget_mode, token_budget_grace_s=float(self.policy.limits.session_budget_grace_s), cache_read_weight=self.policy.limits.cache_read_weight, # usage-limit quota wait cap (DW-396): the timeout freezes while the # CLI waits out a usage-window reset, for at most this long in total. quota_wait_max_s=float(self.policy.limits.quota_wait_max_min * 60), # Launch-state snapshot of a review session's spec (#276 M1); None for # every other session and on a crash-resume (process-transient). spec_snapshot=spec_snapshot, # The spec this session is required to write, when already known (#261), # so the adapter reads THAT path back instead of mtime-scanning a # directory shared with every concurrent run. Same `_generic_dev()` guard # as the snapshot above, so the two can never disagree about whether the # devcontract read-back is in play. # # Pinned ONLY when the dispatched prompt NAMES that path — the read-back # may demand a file back solely because the session was told to write it. # Knowing a spec exists is not the same as having pointed a session at it: # a generic sprint re-drive names a recorded `task.spec_file` only when # `_dev_phase` also bound that regular file to the current attempt. A # fresh or stale-path task has no binding, a sweep bundle dispatches # `intent.md`, and StoriesEngine dispatches folder+id. Pinning any of # those modes would poll a path the session never promised to rewrite # and score its real output as "wrote nothing" — trading #261's unsafe # failure for a work-LOSING one, the exact trade this fix exists to avoid. # Testing the prompt keeps the pin and the contract that justifies it in # one place across all three engines (each builds its own prompt), and # reads the post-gate text, so a plugin rewrite cannot desynchronize them. # A miss simply falls back to the scan — the pre-#261 behavior. # # Withheld from an injected plugin-workflow session (`label` set — e.g. a # TEA pre_commit_gate): it runs the generic adapter but owes the # WORKFLOW_COMPLETION_CONTRACT marker above, not the story spec, so # pinning it to task.spec_file would point the read-back at the wrong # file. Same doctrine as StoriesEngine withholding BMAD_LOOP_SPEC_FOLDER # from labeled sessions. expected_spec=self._expected_spec_for(task, prompt, label), # The read-back's terminal vocabulary and fallback-result probe dirs: # the v6 defaults (`done`, none) for every base and stories session. success_statuses=self._session_success_statuses(role, label), fallback_result_dirs=self._session_fallback_dirs(role, label), ) self.journal.set_active_log(task_id) self.journal.append( "session-start", task_id=task_id, role=role, adapter=cfg.name, model=cfg.model, story_key=task.story_key, prompt=prompt, ) # Every session-start must be paired with a session-end, whatever path # leaves this method: on an abort (RunStopped / KeyboardInterrupt / a # transport error out of adapter.run) the top-level handlers record # run-stop/run-crash but know nothing of the open session, and the # journal would show it running forever (#157). result: SessionResult | None = None ended = False try: result = adapter.run(spec) # One shape read for the whole frame (DW-206): `result_json` is # parsed JSON off an adapter, so a non-mapping top level reached the # `.get`s below behind an `is not None` test alone and raised # `AttributeError` here — upstream of every guarded consumer, # including the sweep triage lane's own validator. It now refuses # through the empty-document channel each read already has. rj = result_mapping(result.result_json) # A post-kill rescue (#61) is otherwise indistinguishable from a normal # completion in the journal; leave a breadcrumb for forensics. if rj.get("post_kill_reconciled"): self.journal.append("session-rescued-post-kill", task_id=task_id, role=role) # Same forensics need for a missing-marker synthesis (#224): the # result is real, but the marker-append the skill owes was skipped. if rj.get("synthesized_from_frontmatter"): self.journal.append( "session-synthesized-from-frontmatter", task_id=task_id, role=role ) # #276 M3: the marker the skill owed was never appended. Repair the # on-disk spec (best-effort) so the next re-read is harvested on the # normal marker path. Covers live-Stop, crash-path, and post-kill # dead-window synthesis — every path that sets this flag. self._repair_spec_marker(task, rj) # Only dev/review sessions are resumable — `_resumable_session` matches # exactly those task ids under DEV_RUNNING/REVIEW_RUNNING. For everything # else (triage/sweep, labeled plugin-workflow sessions) the payload is # never consumed on resume, so persisting it is pure state.json bloat. # For resumable sessions, store a defensive copy: `result.result_json` # is mutated in place downstream (`_reconcile_generic_terminal_status`), # and the durable record must stay a stable snapshot of what the adapter # returned rather than aliasing a later, half-mutated dict. Shallow is # enough — reconcile only touches top-level keys. resumable = label is None and role in ("dev", "review") # Make a completed dev result and its proof-of-work attribution # durable in the same state save. A host death after this save makes # the result replayable, so leaving the comparison until _dev_phase # resumes would lose attempt identity when a prior retry already # latched harvest_wrote_ledger. The ordinary-path comparison remains # after post_session so later hook-side changes are still observed. if ( resumable and role == "dev" and result.status == "completed" and result.result_json is not None and task.baseline_ledger_digest is not None ): task.ledger_changed_before_harvest = self._ledger_changed_since_baseline(task) # A hard stop honored *inside* the session: the adapter's wait loop # saw a `mode: "hard"` stop-request.json, tore its window down and # returned this abort verdict. Position is load-bearing at both ends. # Inside the `try`, so the `finally` below journals the paired # session-end with status="aborted" — the same literal the exception # path writes there. Before `record_session`, so NO SessionRecord is # written: an abort is not a session outcome, and this matches the # signal-path hard stop, which interrupts inside `adapter.run()` and # records nothing either. "aborted" therefore never escapes this # method — no downstream status set (env-fault, retry, escalation) # needs to learn it. if result.status == "aborted": clear_graceful_stop(self.run_dir) raise RunStopped(via="stop-request") task.record_session( SessionRecord( task_id=task_id, role=role, status=result.status, adapter=cfg.name, model=cfg.model, session_id=result.session_id, transcript_path=result.transcript_path, result_json=( # `dict(rj)`, not `dict(result.result_json)`: a non-mapping # document raised out of `dict(...)` here too, one line # past the reads above. The `is not None` arm stays — it # is what keeps an empty document persisting as `{}` # rather than collapsing to `None`, which a truthiness # test on `rj` would silently do (DW-206). dict(rj) if resumable and result.result_json is not None else None ), label=label or "", ) ) # Make the completed session durable before the usage read, post-session # hooks, and follow-up verification. If the host kills the process in # that window, the resume path can see the session instead of a stale # dev-running task with no evidence; usage stays best-effort metadata, # not a durability gate. self._save() usage = adapter.read_usage(result) task.attach_session_usage(task_id, usage) self.journal.append( "session-end", task_id=task_id, status=result.status, tokens=usage.total if usage else None, # Weighted rides every usage-bearing session-end, not just # budget-tripped ones (#129): `tokens` alone is a bare scalar # from which the weighted figure cannot be recovered, so # per-session spend was unreconstructible after the fact. # `None`, never 0, when usage is untracked — a zeroed # TokenUsage weighs 0, and untracked != free (see tokens.py). # Distinct from `budget_weighted`, which the extras add only on # a trip and which means the guard's mid-session sample. tokens_weighted=( usage.weighted_total(self.state.cache_read_weight()) if usage else None ), **self._session_end_extras(result, usage), ) ended = True finally: if not ended: # Best-effort: a journal IO error here must never mask the # exception that is unwinding this frame. try: if result is not None: # A post-run step raised (e.g. read_usage): the session # itself finished — journal its real status, sans usage. # Both token fields are hardcoded None: `usage` may be # UNBOUND here (read_usage itself is a candidate raiser), # so referencing it would raise NameError on a path that # is already unwinding an exception. self.journal.append( "session-end", task_id=task_id, status=result.status, tokens=None, tokens_weighted=None, **self._session_end_extras(result), ) else: exc = sys.exc_info()[1] self.journal.append( "session-end", task_id=task_id, status="aborted", error=type(exc).__name__ if exc is not None else None, ) except Exception: # nosec B110 pass self._save() self._note_story_token_budget(task) # A hard stop request that raise site A could not see as an abort: it # landed in the gap after the wait loop's last poll, or the session DID # abort and `_post_kill_reconcile` rescued it back to `completed` (the # abort tore the window down before a landed Stop event was read). This # check fires regardless of status, and that rescue is exactly why: # without it a hard-stopped run would silently carry on into verify / # review / retry on the strength of a rescued result. The session is fully # recorded, saved and accounted for first, leaving the run byte-equivalent # to the replayable host-death-after-save state documented above — a # resume picks up from a complete session record, not a torn one. if read_stop_request_mode(self.run_dir) == "hard": clear_graceful_stop(self.run_dir) raise RunStopped(via="stop-request") # The same check against the *owning* run, for a nested auto-sweep child # whose own dir is empty because the operator stopped the parent. Without # it the fix above is inert on exactly the shape it exists for: the child's # adapter aborts off the parent's file, `_post_kill_reconcile` rescues that # `aborted` back to `completed`, so raise site A never fires — and the child # would carry on into verify/review on the strength of the rescued result. # Deliberately does NOT consume: the file is the parent's, and the parent's # own hard arm must still find it to record and attribute the stop. The # nested re-raise below hands this exception up before that arm consumes # anything, and `via` rides the exception rather than the file. if self._is_nested: owner = owner_run_dir() if owner is not None and read_stop_request_mode(owner) == "hard": raise RunStopped(via="stop-request") self._emit( "post_session", task, role=role, # Reaching here means `adapter.run` returned (a non-None SessionResult) # rather than raising past the finally, but pyright can't prove that # through the try/finally, so it keeps `result` widened to | None. session_status=result.status, # pyright: ignore[reportOptionalMemberAccess] result_json=result.result_json, # pyright: ignore[reportOptionalMemberAccess] ) # A post-session hook may be the session's last writer. The completed # result was checkpointed before hooks so it is replayable, but a retained # retry can already carry `harvest_wrote_ledger=True`; replay then # deliberately preserves the saved attribution instead of comparing an # engine-modified ledger again. Close that crash window immediately after # hooks finish. Save only when the comparison changed, keeping the # zero-plugin/no-ledger-change path free of a redundant state rewrite. if ( resumable and role == "dev" and result.status == "completed" # pyright: ignore[reportOptionalMemberAccess] and result.result_json is not None # pyright: ignore[reportOptionalMemberAccess] and task.baseline_ledger_digest is not None ): ledger_changed = self._ledger_changed_since_baseline(task) if ledger_changed != task.ledger_changed_before_harvest: task.ledger_changed_before_harvest = ledger_changed self._save() return result # pyright: ignore[reportReturnType] def _note_story_token_budget(self, task: StoryTask) -> None: """Warn once when a story's cost-weighted spend crosses ``limits.max_tokens_per_story``, at the session boundary that crossed it. The cap is advisory by contract (docs/reference/configuration.md) — this warns, it never terminates. Enforcement is the separate mid-session guard (#158, ``max_tokens_per_session``), a different cap with its own latch, untouched here. Placement is the whole point of #336. The predecessor check sat in ``_finalize_commit_phase`` past ``advance(task, Phase.DONE)``, so it saw only stories that committed and only after their last token was spent: the reported 4x overrun happened on a story that DEFERRED, which produced no record at all — total silence, not late silence. Every session of every run type funnels through ``_run_session``, so judging here covers the deferring and escalating stories too, and SweepEngine/StoriesEngine inherit it unchanged. Called after the tail ``_save()``, where ``task.tokens`` is both fresh (``attach_session_usage`` folded this session in above) and story-cumulative (it is the task's running total, not the session's). A session that aborted never reaches here — the exception propagates past the ``finally`` — so no story is judged on a usage read that did not happen. Weight comes from live ``self.policy``, not ``state.cache_read_weight()``: this is the enforcement side of the split documented at ``summary()`` — displays must reproduce from state.json, budgets bind at the policy the running process enforces. Emit order is load-bearing at both ends — see the comments below. The invariant: never latch a suppression bit before the record it suppresses is durable.""" if task.token_budget_warned: return budget = self.policy.limits.max_tokens_per_story weighted = task.tokens.weighted_total(self.policy.limits.cache_read_weight) if weighted <= budget: return self.journal.append( "token-budget-exceeded", story_key=task.story_key, weighted=weighted, total=task.tokens.total, budget=budget, ) # Latch only once the record it suppresses is durable. Unlike the phase # this method's siblings set before their own journal write # (`_record_defer`, `_escalate`), `token_budget_warned` is rendered on no # surface — it is a pure suppression bit, so "latch persisted, record # lost" is a story that overran in total silence, i.e. #336 itself. The # append above is unguarded IO (journal.py) and the run-level `finally` # in `_run_inner` persists whatever the task carries on EVERY abort arm, # so latching first would bury the overrun for good on an OSError there. # Leaving it unlatched costs at most a duplicate notice next boundary. task.token_budget_warned = True gates.notify( self.policy, self.run_dir, f"story token budget exceeded: {task.story_key}", f"{weighted} weighted tokens ({task.tokens.total} raw incl. cache reads) " f"past the {budget} `limits.max_tokens_per_story` cap — advisory: the " f"story continues. Stop the run with `bmad-loop stop` if the spend is " f"no longer worth it.", ) # ...and `_save()` stays AFTER the notice, the emit order every # record-a-decision site shares. That same run-level `finally` already # makes the latch durable on any in-process abort in this window, so only # a SIGKILL can re-warn — and at-least-once is the right bias for a # warning whose whole purpose is to not be silent: a duplicate ATTENTION # line is cosmetic, a lost one is the bug. Do not hoist this above the # emit pair; that trades the cosmetic failure for the silent one. self._save() def _dev_prompt(self, task: StoryTask, feedback: Path | None) -> str: return self._generic_dev_prompt(task, feedback) def _generic_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str: """Invocation for the generic `bmad-build-auto` dev skill, which has no `--feedback` flag: feedback is inlined as freeform intent pointing at the existing spec. On a repair re-invocation the spec is first re-opened (status → `in-progress`) so the skill's step-01 re-enters implement/review on it rather than ingesting a finalized spec as mere context. A patch-restore re-drive (#2564) must point at the spec explicitly: only step-01's spec-pointer intent check EARLY EXITs on the `in-review` status the re-arm set — and it exits before step-01's version-control sanity check, which would otherwise HALT `blocked` on the very diff `_restore_patch` just laid onto the tree. A bare story key takes the freeform/epic path instead, where that dirty-tree check runs first.""" # The injected clauses ride every leg, in this order (the environment-claim # clause only while probes are configured, DW-523) — the park clause # stays LAST because its docstring's backtick argument depends on nothing # following it. All are bare sentences, so this seam owns every separator: # an em dash after the bare story key (the one leg whose text carries no # terminal punctuation), a plain space after a sentence. A full stop # followed by an em dash is punctuation noise and must never be assembled. # # `if tail else ""` is unreachable on the one class that reaches here # (`Engine`, whose prohibition is unconditional; both subclasses override # `_dev_prompt`), unlike the live guard on the review seam. Kept for # symmetry and pinned with a monkeypatch. clauses = [ c for c in ( self._sprint_board_instruction(), self._environment_claim_instruction(), self._operator_park_instruction(), ) if c ] tail = " ".join(clauses) after_sentence = f" {tail}" if tail else "" after_key = f" — {tail}" if tail else "" if feedback is None: if task.restore_patch and task.spec_file: return ( f"/{self._dev_skill()} Resume review of the in-review spec at " f"`{task.spec_file}`. The attempted change was restored onto " f"the working tree after an intent-gap resolution; review it " f"against the amended spec." ) + after_sentence # The two fresh-baseline legs below may follow a rolled-back attempt: # point at its parked work in a paragraph of its own (#777). It sits # after the park clause, which stays last on the invocation line; these # legs carry no feedback-file pointer for its backticks to be read as. preserved = self._retry_preserve_notice(task) after_sentence += f"\n\n{preserved}" if preserved else "" after_key += f"\n\n{preserved}" if preserved else "" # The attempt binding was resolved in the active workspace immediately # before DEV_RUNNING became durable. A retained `spec_file` alone may # name a discarded unit worktree, so it cannot authorize this route or # the matching deterministic read-back pin. if task.spec_file and task.dispatched_spec_file: return ( f"/{self._dev_skill()} Resume the autonomous dev session on the " f"ready-for-dev spec at `{task.spec_file}`." ) + after_sentence return f"/{self._dev_skill()} {task.story_key}" + after_key self._reset_spec_for_repair(task) spec_ref = task.spec_file or task.story_key return ( f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress " f"spec at `{spec_ref}`. The previous session's work failed deterministic " f"verification; repair the working tree so verification passes without " f"changing the spec's frozen intent contract. Verification evidence is " f"in `{feedback}`." ) + after_sentence def _sprint_board_instruction(self) -> str: """The board-ownership PROHIBITION — never write the board, never revert it — injected into `_review_prompt` and `_generic_dev_prompt`, the two prompt seams `Engine` itself builds, the way the deferred-work sentence beside it is. It says nothing about what a session should do *instead*; that half is `_board_handback_redirect`, and it rides the review prompt only. Injection surface, exactly: story dev sessions (`_generic_dev_prompt`), the review sessions of sprint and sweep runs (both inherit `_review_prompt`), and every injected plugin-workflow session (`_run_session` wraps this in `WORKFLOW_BOARD_CONTRACT` post-gate — those run in the same window, at `post_dev_phase` / `post_review_result` / `pre_commit_gate`). `SweepEngine` and `StoriesEngine` override `_dev_prompt`, so no bundle or stories dev prompt reaches this; `StoriesEngine` overrides this method to "" as well, which drops it — the redirect gated on it, and the workflow section — from that mode's prompts too. Only the interactive resolve agent gets nothing from this seam, which costs nothing: `bmad-loop-resolve`'s own skill already forbids touching the board outright. The three injection points deliberately share ONE wording, so a reviewer, a dev session and a plugin workflow cannot be told three different things about who owns the board. They do NOT share unstrippability, and that asymmetry was weighed rather than overlooked. The workflow section is post-gate because it has to be — the prompt there is plugin text end to end, so there is no pre-gate string to inject into. The dev/review injections stay pre-gate: moving only this clause post-gate would assemble the review prompt in two files while the deferred-work sentence it was written to sit beside stayed strippable, and a plugin that rewrites `proposed_prompt` wholesale already discards the spec path, the ledger sentence and the #261 `expected_spec` pin with it — the codebase's existing reading is that such a rewrite means the plugin owns that session. If unstrippability is ever wanted here, move the whole orchestrator-authored appendix as one unit, not this sentence alone. `_post_dev_state_sync` advances sprint-status.yaml right after the dev session — to `done`, or to `awaiting-operator` on a park; those two values are exhaustive there — but the story's single commit lands only after the review loop, so every session dispatched in that window opens on an uncommitted, unattributed board change with nothing in the repo naming its author. A review session read the orchestrator's own write as a violation of its spec's Boundaries section, reverted it, and the #334 contradiction gate correctly escalated a story both sessions agreed was finished (#437). The row is called BOOKKEEPING and explicitly NOT proof of verification, because it is not: `_post_dev_state_sync` writes the board before `_verify_dev_artifacts` runs, and a repair session is dispatched precisely when that verification failed — it opens on a red tree under a `done` row. This reverses the "#334: review prompts are unchanged by design" rationale, which assumed forbidding the revert would let an unfinished story commit. #334's own code refutes it: `verify.verify_review` gates the SPEC frontmatter first and returns `retry` there, before it ever reads the board. A reviewer withholding sign-off through the spec already blocks the commit; the board revert was never the load-bearing channel. Returned as a bare sentence with NO leading separator, because the call sites need different ones. Deliberately backtick-free, for the reason `_operator_park_instruction` documents, and injected BEFORE that clause so the park contract stays the last thing a dev prompt says. Phrased as a prohibition rather than a goal so the skill's intent-alignment auditor cannot raise it as an `intent_gap`.""" return ( "sprint-status.yaml is owned by the orchestrator: never write it, and " "never revert a change to it. A row at done or awaiting-operator is the " "orchestrator's own bookkeeping — not a defect to fix, and not proof " "that the work is verified." ) def _board_handback_redirect(self) -> str: """Where a review session that disagrees with the board should go instead. Rides `_review_prompt` only, and only while the prohibition it follows is non-empty — the gate lives HERE rather than in the caller so the invariant is local and directly assertable (`StoriesEngine()._board_handback_redirect() == ""`), and `_review_prompt` stays structurally identical to the dev seam. `blocked` is named deliberately: it is the only spec status that both withholds the commit and reaches a human (`devcontract` synthesizes a CRITICAL from it and `decide_review_session` pauses). Any other non-terminal status merely retries until the cycle budget exhausts and `_defer` rolls the work back to baseline — honest dissent discarded in silence. "say why" is load-bearing: the `## Auto Run Result` detail becomes the escalation text a human reads. The trigger is deliberately NARROW — "cannot be finished without a human decision", not "looks unfinished". A review pass is itself a dev-primitive run whose job is to fix what it finds or defer it; a broad trigger would hand it a run-halting early exit on cycle 1 of 3. Review-only for the same reason it exists: that CRITICAL halts the whole run, which is the right trade for a review session and the wrong one for a dev session, where it would flatly contradict `_operator_park_instruction`'s "Never use the blocked status for this" a sentence later in the same prompt. The vocabulary overlap with the park clause ("a human decision" vs "actions only a HUMAN can perform outside the repo") is unreachable under the default policy, not merely unlikely: the two never co-occur because each rides a different builder, and more strongly a parked story is never dispatched a review session at all — `_review_and_commit` early-returns on `_park_awaiting_operator`. The residual is a story the dev pass should have parked and finalized `done` instead. By default this opens no new path there (`blocked` is the skill's native escape) and displaces nothing better: a review session is not offered the park, and its `awaiting-operator` finalization is not accepted. Pause-and-reach-a-human beats a false-green `done`. Under `[operator] on_review_demotion = "park"` (DW-383) the review prompt DOES carry the park clause after this one, deliberately: that is the residual's honest outcome, and the two triggers stay distinct — a human DECISION the story cannot proceed without (blocked, halts) versus human ACTIONS outside the repo on otherwise finished work (park, run continues). Bare sentence, no leading separator, backtick-free — same contract as the clause it follows.""" if not self._sprint_board_instruction(): return "" return ( "If the story cannot be finished without a human decision, finalize the " "spec to status: blocked and say why. That is the hand-back channel; " "the board is not." ) def _environment_claim_instruction(self) -> str: """The environment-fault claim contract (DW-523), injected while ``[environment] probes`` is configured; "" otherwise, so a run without probes keeps every prompt byte-identical. It invites a session to name a broken run environment in its final Auto Run Result. The line is a TRIGGER, not a verdict: ``devcontract`` reads it into ``env_fault_claim`` and the engine answers it by running its own probes (:meth:`_env_gate_claim`) — only a failed probe pauses, and a passing one leaves the session's outcome to decide as usual. So the clause also says the line changes nothing on its own, steering the session away from reporting blocked for an outage the probes may not see. Backtick-free and a bare sentence with no leading separator, for the reasons :meth:`_operator_park_instruction` gives; every caller places it BEFORE that clause, which stays last.""" if not self.policy.environment.probes: return "" return ( "If something outside the code blocks your work or its verification " "(a database, container, or service the tests need is down or " "unreachable), add one line reading Environment fault: followed by a " "short description of what is broken to the final Auto Run Result. " "The orchestrator answers that line by running its own environment " "probes, and only their result counts: the line alone changes nothing, " "so still report the story's real status." ) def _operator_park_instruction(self) -> str: """The park contract, injected into every dev prompt while ``[operator] enabled`` — and appended last to the review prompt under ``[operator] on_review_demotion = "park"`` (DW-383). "" when the feature is off. Engine-injected rather than skill-owned because the durable home for it is upstream — bmad-build-auto's spec template and step-03/04 finalize rules — and that PR is not landed. This is the shipped interim: the same words the skill will eventually carry, said by the orchestrator so the state is reachable now. When upstream lands, this method is what goes away. The "never blocked" clause is the load-bearing half. `blocked` is the skill's existing escape for "I cannot finish", and a human-only action is exactly the shape that tempts it — but blocked HALTS the run, which is the original defect: one story owing a DNS record stops every story behind it. Deliberately backtick-free. It is appended AFTER the repair prompt's feedback-file pointer, and the last backtick-wrapped token in a dev prompt is by convention that path — a backticked word here would quietly become the "feedback file" to anything reading the prompt back. Returned as a bare sentence with NO leading separator. The board clause now always precedes it and ends in a full stop, where the em dash this used to carry would render a `. — `; its sole caller owns every separator.""" if not self._operator_park_enabled(): return "" return ( "If this story's acceptance criteria include actions only a HUMAN " "can perform outside the repo (buy a domain, publish a DNS record, " "grant an API key, click through a vendor console): complete every " "part an agent CAN do, commit it, then finalize the spec frontmatter " "to status: awaiting-operator and enumerate what is owed under an " "operator_actions: key — a YAML list of strings, one imperative " "instruction each, non-empty. The final Auto Run Result must also " "report the matching status: awaiting-operator. Never use the blocked " "status for this: " "blocked halts the whole run, and this story is finished as far as " "you can take it." ) def _reset_spec_for_repair(self, task: StoryTask) -> None: """Re-open a generic-skill spec before a repair re-invocation. bmad-build-auto self-finalizes to `done` (or `in-review`); its step-01 routes such a spec to "ingest as context, do not resume," so a repair must flip the frontmatter `status` back to `in-progress` to re-enter implement/review in place against the frozen intent contract. No-op when no spec is recorded yet (the prompt then falls back to the story key). The stale terminal section is stripped too: `find_result_artifact` keys on its heading, so leaving it would let the re-driven session's first save of the spec read as a terminal result.""" if not task.spec_file: return spec_path = verify.resolve_spec_path(task.spec_file, self.workspace.paths) try: if spec_path.is_symlink(): raise RuntimeError("repair spec became a symlink") resolved = spec_path.resolve(strict=True) expected = spec_path.parent.resolve(strict=True) / spec_path.name if ( resolved != expected or not resolved.is_file() or not verify.spec_within_roots(resolved, self.workspace.paths) ): raise RuntimeError("repair spec is no longer a trusted regular file") except FileNotFoundError: # Preserve the existing missing-result behavior: there is no path to # mutate, and ownership-aware Sprint/Stories dispatch will still fail # its later explicit-route snapshot gate. Sweep deliberately keeps its # accepted-spec routing separate from snapshot ownership. return except (OSError, RuntimeError) as exc: raise RuntimeError( "recorded spec became unsafe before repair prompt construction" ) from exc # Repair-write doctrine: raising beats dispatching a repair at a charged # attempt against a spec still reading `done` — step-01 would ingest it as # context and not resume, re-wedging silently (cf. runs.rearm_escalation). confine_root = self.workspace.paths.project self._spec_status_write(task, resolved, "in-progress", "repair", confine_root=confine_root) devcontract.strip_auto_run_result( resolved, confine_root=confine_root, root_identity=self._mount_root_identity(task, confine_root), ) def _spec_status_write( self, task: StoryTask, spec: Path, target: str, reason: SpecWriteReason, *, confine_root: Path, ) -> bool: """The engine's one write path to a spec's frontmatter ``status``: every engine writer site calls it with its own ``reason`` (``tests/test_portability_guard.py::SPEC_STATUS_WRITERS`` pins the sites). The base makes each site's own write, unchanged: ``adoption`` sets an existing ``status`` line (``verify.set_frontmatter_status``), and the repair-family reasons fill or insert it (``devcontract.reset_spec_status``), both confined to ``confine_root`` and pinned to its mount identity. Returns the writer's "changed" answer; a fault raises, per the repair-write doctrine. A mode whose status writes go elsewhere overrides this, and may raise :class:`SpecStatusWriteRefused` for a write it does not allow.""" root_identity = self._mount_root_identity(task, confine_root) if reason == "adoption": return verify.set_frontmatter_status( spec, target, confine_root=confine_root, root_identity=root_identity ) return devcontract.reset_spec_status( spec, target, confine_root=confine_root, root_identity=root_identity ) def _reset_spec_for_review(self, task: StoryTask) -> SpecSnapshot | None: """Strip the prior pass's stale `## Auto Run Result` before a review launch, then capture a launch-state snapshot of the spec (#276 M1). A follow-up review session re-invokes bmad-build-auto on the FINALIZED spec, which still carries the dev pass's terminal `## Auto Run Result` section. The review's own step-04 entry write (it stamps the transient `in-review` status) bumps the spec's mtime past `find_result_artifact`'s launch floor, so the review's first result-less Stop reads that stale marker as this session's terminal result and kills the session mid-flight — the #109 stall grace can never arm on the review leg (issue #160). Cycle 2+ carries the PREVIOUS review pass's own marker, so this runs before every review launch, never on the crash-resume replay branch (no session launches there). Unlike `_reset_spec_for_repair` the frontmatter is left untouched: `status: done` is what routes the re-invocation's step-01 to a fresh step-04 review pass — the HARD CONSTRAINT that the review-launch frontmatter status is NEVER mutated (it is load-bearing skill routing), so every #276 mechanism observes only. Returns a `SpecSnapshot` of the on-disk spec as it stood at launch — its content hash, mtime, and normalized frontmatter status — so the generic adapter's missing-marker fallback can refuse to synthesize from a candidate whose bytes never changed this session (a `done` spec re-opened for review, never re-written). "Normalized" means through `status_of`, the same reading the adapter's `_observe_tick` compares against: a blank/YAML-null `status:` must be `""` on BOTH sides or the tick fabricates a transition off it (see that method's docstring — the two reads are one contract). Snapshot capture is best-effort: a torn/unreadable read degrades to `None` (journaled, `review-launch-snapshot`), and the fallback then keeps its conservative 2-observation fingerprint path. Only the capture is guarded — the strip keeps its raise-on-unreadable repair doctrine (see `devcontract.strip_auto_run_result`): skipping the strip recreates the exact #160 bug state, so it must surface. No-op (returns `None`) when the dev skill is not the generic one or no spec is recorded yet.""" if not self._generic_dev() or not task.spec_file: return None retained_authority = ( task.dispatched_spec_file is not None or task.dispatched_spec_snapshot is not None ) if ( self._retains_dispatched_spec_snapshot_on_repair() and retained_authority and not self._validate_dispatched_spec_snapshot(task) ): raise RuntimeError( "attempt-owned spec became unreadable before review prompt construction" ) spec_path = verify.resolve_spec_path(task.spec_file, self.workspace.paths) try: if spec_path.is_symlink(): raise RuntimeError("review spec became a symlink") resolved = spec_path.resolve(strict=True) expected = spec_path.parent.resolve(strict=True) / spec_path.name if ( resolved != expected or not resolved.is_file() or not verify.spec_within_roots(resolved, self.workspace.paths) ): raise RuntimeError("review spec is no longer a trusted regular file") except FileNotFoundError as exc: self._journal_spec_read_failed( spec_path, task.story_key, "review-launch-snapshot", exc, ) return None except (OSError, RuntimeError) as exc: raise RuntimeError( "recorded spec became unsafe before review prompt construction" ) from exc devcontract.strip_auto_run_result( resolved, confine_root=self.workspace.paths.project, root_identity=self._mount_root_identity(task, self.workspace.paths.project), ) try: raw = resolved.read_bytes() mtime_ns = resolved.stat().st_mtime_ns fm_status = verify.status_of(verify.read_frontmatter(resolved)) except OSError as e: self._journal_spec_read_failed(resolved, task.story_key, "review-launch-snapshot", e) return None return SpecSnapshot( path=str(resolved), mtime_ns=mtime_ns, sha256=hashlib.sha256(raw).hexdigest(), fm_status=fm_status, ) def _write_feedback(self, task: StoryTask, reason: str) -> Path: """Persist a verification failure where the next session can read it — deterministic evidence must reach the LLM, not just the journal.""" path = self.run_dir / "feedback" / f"{safe_segment(task.story_key)}-{len(task.sessions)}.md" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( f"# Verification feedback: {task.story_key}\n\n" "The previous session's work failed deterministic verification.\n" "Repair the working tree so verification passes, without violating\n" "the spec's frozen intent.\n\n" f"```\n{reason}\n```\n", encoding="utf-8", ) return path def _fix_phase(self, task: StoryTask, reason: str) -> Decision: """Feedback-driven repair after a clean review whose verify commands failed. Consumes the story's dev-attempt budget; returns PROCEED once commands pass, or terminal review routing when repair cannot continue.""" # Why the LAST attempt failed, when it failed without completing (#489). # Empty whenever the last attempt completed: the verify failure `reason` # already describes that, and the callers' own wording is the honest one. session_failure = "" while task.attempt < self.policy.limits.max_dev_attempts: preserve_chain_snapshot = self._preserves_dispatched_spec_snapshot_for_repair(task) task.attempt += 1 feedback = self._write_feedback(task, reason) advance(task, Phase.DEV_RUNNING) self._save() if preserve_chain_snapshot and not self._validate_dispatched_spec_snapshot(task): # The new attempt/phase is the durable recovery identity. Leaving # REVIEW_VERIFY here would replay the preceding review result on # resume instead of recovering this never-launched repair. raise RuntimeError( "attempt-owned spec became unreadable before repair prompt construction" ) result = self._run_session( task, role="dev", prompt=self._dev_prompt(task, feedback), seq=task.attempt, session_stage="pre_fix_session", preserve_dispatched_spec_snapshot=preserve_chain_snapshot, ) advance(task, Phase.DEV_VERIFY) outcome = None verified = NO_VERIFY_COMMANDS terminal = None if result.status == "completed": # A repair is another generic dev-primitive pass: it can leave # terminal prose ahead of frontmatter and record fresh deferred # findings just like the initial dev and review legs. Normalize # and harvest before accepting its verify-green tree, because the # next review pass may replace the frontmatter list. self._reconcile_generic_terminal_status(task, result.result_json) harvest_outcome = None for harvest_attempt in range(1, HARVEST_REPAIR_READ_ATTEMPTS + 1): harvest_outcome = self._harvest_spec_deferrals(task, result.result_json) if harvest_outcome is None: break self.journal.append( "fix-harvest-failed", story_key=task.story_key, attempt=task.attempt, reason=harvest_outcome.reason, env_fault=harvest_outcome.env_fault, harvest_attempt=harvest_attempt, ) if not harvest_outcome.retryable: break if harvest_outcome is not None and harvest_outcome.retryable: outcome = harvest_outcome terminal = review_exhausted( task, "fix-session deferral harvest remained unreadable after " f"{HARVEST_REPAIR_READ_ATTEMPTS} attempts: " f"{harvest_outcome.reason}", ) else: terminal = None if harvest_outcome is not None: outcome = harvest_outcome else: outcome, verified = self._verify_commands_with_results(task, "fix") if not outcome.ok: reason = outcome.reason ok = outcome is not None and outcome.ok session_failure = ( "" if result.status == "completed" else session_failure_reason("fix", result) ) self._emit( "post_dev_verify", task, session_status=result.status, result_json=result.result_json, verify_reason=(outcome.reason if outcome is not None else None), command_results=verified.results, # Stage "fix" is the only thing separating this emit from the dev # one: same stage, same DEV_VERIFY phase, same `attempt` counter. # Stays None when the harvest short-circuited above and the # commands never ran — `verify_reason` carries that reason. verification_stage=verified.stage, verification_sequence=verified.sequence, ) self.journal.append( "fix-decision", story_key=task.story_key, attempt=task.attempt, session_status=result.status, ok=ok, env_fault=bool((outcome is not None and outcome.env_fault) or result.env_fault), # parity with `dev-decision`: pair the diagnosis with the routing # it fed, so the fix path is greppable the same way (#489). session_vanished=result.session_vanished, # Parked on a human prompt (DW-348/DW-350): what routed a # non-completed fix result to the parked escalate below. parked=result.parked, parked_evidence=result.parked_evidence, # No work at all (#727): what routed it to the no-work escalate below. produced_work=result.produced_work, ) # CRITICAL routing, deliberately AFTER the emit and the journal record # above, and deliberately AHEAD of the env-fault/retryable arms below. # Both halves mirror `decide_dev`, which the dev leg reaches at the # same point in its own loop: it tests `critical_escalations` FIRST, # so a CRITICAL outranks an env fault there too, and its caller has # already emitted `post_dev_verify` and journalled `dev-decision` by # then. Escalating here before the emit — as this leg used to — made # one event class observable on the dev leg and invisible on the # repair leg: `_escalate` raises `RunPaused`, so a repair session # reporting CRITICAL fired no `post_dev_verify` at all, while a dev # session reporting the same thing fired one. The hook is named for # the verification, the verification ran, and a plugin correlating # verify passes cannot have half of them silently withheld. critical_reason = critical_session_reason("fix", result.result_json) if critical_reason is not None: self._escalate(task, critical_reason) if result.status != "completed" and result.env_fault: # A fix session whose CLI lost its API connection (#194) did no # repair work — another attempt cannot fix the run environment, so # pause (re-arm restores the budget) instead of burning the dev # budget. A completed-but-env-fault-grade verify failure is handled # by the retryable check just below (its own escalate path). self._escalate_session( task, env_fault_pause_reason("fix", result), usage_limit_pause(result), ) if result.status != "completed" and result.parked: # Parked on a human prompt (DW-348/DW-350): the adapter withheld the # stall nudge, so the repair never ran — another attempt would # relaunch into the same prompt. Pause (re-arm restores the budget). self._escalate_session( task, parked_pause_reason("fix", result), usage_limit_pause(result) ) if result.status != "completed" and not result.produced_work: # Never did anything (#727): another attempt would launch into the # same wall. Pause (re-arm restores the budget), after env_fault # and parked, which explain the silence better. self._escalate(task, no_work_pause_reason("fix", result)) if outcome is not None and not outcome.ok and not outcome.retryable: # escalate-grade failure (environment fault): another repair # session cannot fix the run environment — stop spending the # dev budget and pause for a human instead self._escalate_outcome(task, outcome, role="fix") if ok: # A green repair charges nothing, but a session claiming an # environment fault still forces a probe (DW-523) — ahead of the # acceptance below, as on the dev leg, so a confirmed claim # pauses without accepting the repair. claimed = self._env_gate_decision( task, Decision(Action.PROCEED), role="fix", result_json=result.result_json ) if claimed.action == Action.PAUSE: self._escalate_decision(task, claimed) # A verify-green repair supersedes the original accepted dev # record as the owner of the tree now parked at DEV_VERIFY. Make # that receipt durable in the same save as the fix decision so a # sweep crash resumes the repaired tree instead of restarting it. self._accept_current_dev_session(task) self._save() if terminal is not None: terminal = self._env_gate_decision( task, terminal, role="fix", result_json=result.result_json ) if terminal.action == Action.PAUSE and terminal.env_site is not None: self._escalate_decision(task, terminal) return terminal if ok: return Decision(Action.PROCEED) # A failed repair is charged — the next attempt, or the budget-spent # DEFER below — so re-probe the environment first (DW-523). budget_left = task.attempt < self.policy.limits.max_dev_attempts gated = self._env_gate_decision( task, Decision( Action.RETRY if budget_left else Action.DEFER, session_failure or reason, budget_exhausted=not budget_left, ), role="fix", result_json=result.result_json, ) if gated.action == Action.PAUSE: self._escalate_decision(task, gated) # Budget spent. Carry the last session's own failure so a repair the mux # destroyed is not filed as the verification failure that sent it here — # the callers substitute verify-centric text for an empty reason, which # is right only when the repair actually ran (#489). return Decision(Action.DEFER, session_failure) def _journal_review_budget_spent(self, task: StoryTask, damped: bool = False) -> None: """A *finalized, verify-green* story is being committed (not rolled back) while its review pass still recommended a follow-up; journal the spent budget. No ledger entry is filed — the DW-55/64/90 class showed such tickets re-litigate a converged story's review rather than record work anyone chose to defer, and a follow-up that matters resurfaces through review of later work, not through a process ticket. Two callers, distinguished by ``damped``: * ``damped=False`` — the review loop *exhausted* its ``max_review_cycles`` budget while still recommending a follow-up. A noteworthy event: always notify the human. * ``damped=True`` — the follow-up-review damping cap (``limits.max_followup_reviews``) was spent, so the orchestrator force-converged this finalized round instead of burning another cycle. The expected steady state: stay quiet (no ATTENTION notice) unless the re-review cap also fires. Re-review cap: if this story itself *originated* from a ``review-budget-followup`` entry (legacy and hand-filed rows still exist), the loud notice fires on both paths, so a second non-convergence reaches a human instead of slowly looping across sweeps.""" cycles = self.policy.limits.max_review_cycles cap = self.policy.limits.max_followup_reviews ledger = self.workspace.paths.deferred_work if damped: reason = ( f"The follow-up-review damping cap (limits.max_followup_reviews = {cap}) " f"was spent with the story finalized (status: done, verify green) while " f"the review pass still recommended an independent follow-up. The work " f"was committed by bmad-loop run {self.state.run_id}; the spent budget " f"is journaled and no ledger entry is filed." ) else: reason = ( f"Review budget ({cycles} cycles) was exhausted with the story finalized " f"(status: done, verify green) while the review pass kept recommending an " f"independent follow-up. The work was committed by bmad-loop run " f"{self.state.run_id}; the spent budget is journaled and no ledger entry " f"is filed." ) re_review = False if task.dw_ids: # OBSERVATION arm (DW-146): this read decides only the # `re_review_capped` flag on the journal row below — nothing is # written to the ledger here, and the story is already committed and # verify-green, so an unreadable ledger must not raise out of a # journaling helper. Degrade to `re_review = False` (the flag's own # default: no evidence this story came from a follow-up entry) and # record the attributed fault so the missing evidence is visible. text, fault = deferredwork.read_for_observation(ledger) if fault is not None: self.journal.append( "review-budget-ledger-unreadable", story_key=task.story_key, dw_ids=list(task.dw_ids), ledger=str(ledger), error=fault, ) entries = {e.id: e for e in deferredwork.parse_ledger(text)} re_review = any( i in entries and deferredwork.field_line_present(entries[i], "origin", "review-budget-followup") for i in task.dw_ids ) if damped: self.journal.append( "review-followup-damped", story_key=task.story_key, cycle=task.review_cycle, cap=cap, re_review_capped=re_review, ) else: self.journal.append( "review-budget-committed", story_key=task.story_key, cycles=cycles, re_review_capped=re_review, ) note = reason if re_review: note = ( f"{reason} This story already came from a review-budget follow-up and " f"still won't converge — a human should review whether the recommended " f"follow-up is real before sweeping it again." ) # Exhaustion always notifies. Damped convergence is the expected steady # state and stays quiet — EXCEPT when the re-review cap fires: a story that # itself originated from a review-budget-followup entry still won't converge # and must reach a human even under damping. if not damped or re_review: gates.notify( self.policy, self.run_dir, f"review budget reached, work committed: {task.story_key}", note, ) def _defer_recovery_note(self, task: StoryTask) -> str: """Message tail naming where the deferred attempt's work survives (#333). A bare reason leaves the operator to find the rolled-back work themselves (the reporter used `git log --all`). In-place, the auto-rollback parked the attempt on `task.preserve_ref`, which fast-forwards straight back. Isolated, *this* defer rolls nothing back — a kept-failed unit's branch stays mounted — but an EARLIER attempt's in-worktree dev-retry rollback parks on the same shared refs, so `preserve_ref` can be set here too. Both are named; the isolated arm deliberately prints no `merge --ff-only` line, because that ref is not fast-forwardable from either tree (the unit branch has since moved on with the deferred attempt's commits, and fast-forwarding it into the operator's checkout would land a *discarded* attempt on their branch). Empty when there is nothing to point at — a clean-tree defer that parked nothing, a commits-preserve failure, rollback off, or `keep_failed` off with no earlier ref — so the notice can never advertise a ref that was not created. A *worktree*-snapshot failure is the one case that still points somewhere: `preserve_partial` then downgrades the claim to the committed half instead of hiding a ref that does exist. The pointer is a name, not a promise: `scm.preserve_keep` prunes the oldest refs at a later run's start, and nothing here re-validates it (this must stay git-free — `status` reads `state.json` only). Selects on the same pair as `_defer` — live isolation OR a recorded mount — not on live policy alone. A run flipped `"worktree" -> "none"` while paused reaches the defer with the workspace swapped onto its mount, so the in-place arm would advertise `git -C merge --ff-only` against a directory `_integrate_unit` is about to delete (`keep_failed` off), and hide the kept branch when it is on.""" if self._isolated or task.worktree_path: note = "" if self.policy.scm.keep_failed and task.branch: note = f" — failed work kept on branch `{task.branch}`" if task.preserve_ref: half = " (commits only)" if task.preserve_partial else "" note += ( f" — an earlier rolled-back attempt is parked at `{task.preserve_ref}`{half}" ) return note if not task.preserve_ref: return "" recover = ( f'; recover with `git -C "{self.workspace.root}" merge --ff-only {task.preserve_ref}`' ) if task.preserve_partial: return ( f" — attempt COMMITS parked at `{task.preserve_ref}`{recover}; the " f"uncommitted changes could not be captured (journal: " f"attempt-worktree-preserve-failed) and did not survive the rollback" ) return f" — attempt work parked at `{task.preserve_ref}`{recover}" def _record_defer(self, task: StoryTask, reason: str, note: str | None = None) -> None: """Journal + notify + persist the defer decision — the one record shape all three emit sites share (#342). ``note`` overrides the recovery-note tail on the rollback-pause path, where the reset never ran and the standard note's parked/destroyed claims would be wrong.""" self.journal.append( "story-deferred", story_key=task.story_key, reason=reason, preserve_ref=task.preserve_ref or "", ) gates.notify( self.policy, self.run_dir, f"story deferred: {task.story_key}", reason + (self._defer_recovery_note(task) if note is None else note), ) self._save() def _defer(self, task: StoryTask, reason: str) -> None: task.defer_reason = reason # `self._isolated` is LIVE policy (`_worktree_flow.isolated` reads # `scm.isolation`), and it is not the whole question. `_finish_inflight` # decides its arms on `mounted = bool(task.worktree_path)` and reopens a # recorded mount REGARDLESS of live policy — an accepted continuation owns # the verified work in that tree, so it finishes there and merges. A run # flipped `"worktree" -> "none"` while paused therefore reaches this method # with the workspace swapped onto a mount, while `_isolated` answers False: # the in-place arm below then rolls the MAIN repo back and never carries the # harvest, and `_integrate_unit` deletes the mount on the way out. The # findings that unit harvested are gone, and a ledger row nothing will ever # re-file is invisible to every later sweep. # # `or task.worktree_path` and not `task.worktree_path` alone: isolation can be # live with no mount yet recorded (a defer before `run_isolated` stores the # path), and that shape must keep the isolated arm rather than fall through to # a main-repo reset. A restart in the other direction never reaches here with a # stale path — `_release_orphaned_mount` clears it before replacement work # begins, which is what keeps this test about the tree actually in hand. # # The same pair, spelled the same way, already decides `_run_story`'s arms. The # two are one question — does this task's work live in a mount — and a defer # that answered it differently from the dispatch that produced the work is the # shape this fixes. if self._isolated or task.worktree_path: # the failed work lives in the unit's worktree; the diff is captured # and the worktree kept/dropped by _integrate_unit. Don't touch the # tree here (no reset into the main repo — there's nothing to undo). # Harvested findings are the exception: re-file them into the main # checkout before the terminal save makes this defer durable. Keep # the pre-terminal phase until that repair write succeeds: if its git # commit fails, ordinary inflight recovery must re-enter this decision # and then finish defer recording + unit teardown/integration. self._carry_harvested_deferrals(task) advance(task, Phase.DEFERRED) self._record_defer(task, reason) return advance(task, Phase.DEFERRED) if task.baseline_commit: # The stash follows the rollback's decision (DW-528). Moving the spec out # first left an attempt-bound spec missing, so `rollback_or_pause` paused # on owned-spec recovery whatever `rollback_on_failure` said. Only a copy # is staged now — a reset that deletes the spec cannot take the work with # it — and the live spec stays for the rollback to judge. staged = self._stage_deferred_stash(task) deferred_work = self.workspace.paths.deferred_work # REPAIR/WRITE (DW-146), absence preserved: this snapshot is the input # to `_restore_defer_ledger`, so a snapshot taken from bytes nobody # could read would be republished over the real ledger. Undecodable # bytes (DW-231) and a read the OS refuses (DW-258) therefore DEGRADE # to no snapshot: the restore is skipped rather than the defer # crashed, since nothing here was about to publish and the reset # cannot lose what nobody could read — a tracked ledger's uncommitted # bytes are parked on the attempt's recovery ref by # `preserve_attempt_worktree` ahead of the reset, and an untracked # one is never reset at all. try: snapshot = deferredwork.read_for_write(deferred_work) except (deferredwork.LedgerReadError, OSError) as e: self.journal.append( "ledger-read-degraded", story_key=task.story_key, site="defer-snapshot", ledger=str(deferred_work), error=_ledger_fault_text(deferred_work, e), ) snapshot = None try: self._rollback_or_pause(task) except RunPaused: # Narrow, deliberate catch of the unwind-to-the-top pause (#342): # the pause already persisted Phase.DEFERRED (terminal), so resume # will never re-enter this method — the defer record is emitted # now or never. A pause fires before the reset the recovery note # describes, so its parked/destroyed claims would be wrong here — # the ACTION REQUIRED notice just above this one in ATTENTION is the # authoritative pointer. Re-raised untouched: the run still pauses. # The spec normally stays live, so the staged stash copy is dropped; # a pause after a post-reset spec restore is why the discard # compares bytes before dropping it. self._discard_deferred_stash(task, staged) self._record_defer( task, reason, note=" — the tree was NOT rolled back: the run paused for manual " "recovery first (see the ACTION REQUIRED notice for where the " "attempt's work is); if only the environment was broken, `bmad-loop " f"resolve {self.state.run_id} --reverify` re-verifies the kept work", ) raise except BaseException: self._discard_deferred_stash(task, staged) raise # The reset reverts a *tracked* ledger's uncommitted edits, so the # review-found entries it erased are real knowledge worth putting # back. The restore is compare-and-set against the ledger's committed # blob at the baseline — the text the reset republished, never an # observation of the tree a rival could have authored (#735) — and # gated on git owning the file; it merges rather than overwrites when # another writer interleaved. A foreign write # that landed BEFORE the reset is the reset's casualty, not the # restore's: the snapshot predates both, so nothing here can tell # that write apart from the session's own erased edits. try: if snapshot is not None: self._restore_defer_ledger(task, snapshot) # The restore deliberately keeps review-found ledger knowledge, but # it also replays this bundle's accepted close after the code was # discarded. Let the mode undo only the close it can identify as its # own; the base path has no bundle close and is a no-op. self._reopen_ledger_after_defer(task) except BaseException: # The reset already ran: the staged copy may be the attempt's only # version, so it must not stay behind as an unjournaled `.tmp`. self._discard_deferred_stash(task, staged) raise # Last, so a stash fault after the reset cannot cost the ledger repair # above: the snapshot it restores from exists only in memory. self._finish_deferred_stash(task, staged) self._record_defer(task, reason) def _restore_defer_ledger(self, task: StoryTask, snapshot: str) -> None: """Put back the ledger knowledge a defer's reset erased, without taking a concurrent writer's work with it (#286). The window being repaired spans ``_rollback_or_pause``'s git spawns, so a lock cannot cover it — :func:`deferredwork.ledger_lock` is contracted never to span a subprocess. Compare-and-set stands in, anchored on the ledger's committed blob at ``task.baseline_commit`` — the text the reset republished — and anything else found under the lock belongs to somebody else. Three refusals, in order of how much they know: * ``observed == snapshot`` — the reset changed nothing, so there is nothing to put back. Today's quiet path, byte-identical. * the ledger is not git's — ``reset --hard`` cannot have touched an untracked or external file, so the whole delta arrived from a live foreign writer and the correct restore is no write at all. The guard this replaces compared ``current != snapshot`` and overwrote on exactly that difference: it ARMED the lost update it reads like it prevents. * the text under the lock is not the one the reset republished. The anchor is read out of git rather than off the working tree because **a post-reset observation may justify a SKIP, never a WRITE**: a rival writing a tracked ledger inside the reset window would otherwise BE the observation this arm trusts, and the overwrite would take that rival's entries with it (#735). Every other case — a writer who landed inside the window, or a baseline no probe could read — republishes the snapshot by APPENDING the entries disk has since lost, never by overwriting what arrived. Unlike :meth:`_restore_ledger`, this site can degrade all the way to that merge instead of to a skip: appending cannot destroy anybody's write. Write and lock faults propagate, as the unguarded write here always did: a repair write that could not be serialized must fail loudly. Both writes (the overwrite and the merge) are pinned to a unit mount through :meth:`_publish_restored_ledger` (DW-498), so a mount swapped for a link is one such fault. """ ledger = self.workspace.paths.deferred_work # Read IMMEDIATELY after `_rollback_or_pause` returned: only pure Python # runs between the reset and this line, so the compare window below is # file-I/O-only rather than spanning the rollback's git spawns. This # observation authorizes ONLY the skip that follows — declining to act is # safe whoever wrote those bytes. It is never the write anchor: taken # after the very reset it would attest to, a rival that landed inside # that window becomes the observation itself (#735), which is what the # blob probe below exists to replace. observed = self._ledger_text(site="defer-ledger-restore", story_key=task.story_key) if observed == snapshot: return if not self._ledger_is_gits_to_restore(task): # The reset never reached an untracked or external ledger, so every # byte of the delta above is a live foreign write and there is # nothing of ours to restore over it. return # The WRITE anchor derives from the committed blob, never from an # observation a rival could have authored (#735). Probed here, before the # lock, because it spawns git and `ledger_lock` may cover file I/O only. # `gits` is already established above, so this only ever runs on a ledger # `reset --hard` could actually have republished. No anchor degrades to # the merge below, which is append-only and therefore cannot destroy a # rival's write — the reason this site can absorb a probe fault the way # `_restore_ledger`'s degrade-to-skip has to. That immunity covers a # rival's WRITE only; a rival's DELETION is refused at the merge itself. anchor, expected = self._ledger_baseline_text(task) merged: list[str] = [] collided: list[str] = [] flat_remainder = False with deferredwork.ledger_lock(ledger): # PURE TEXT ONLY under the hold. Every `deferredwork` mutator takes # this same lock, and `ledger_lock` raises on the nesting rather than # deadlocking — that raise would abandon the repair half-done. current = self._ledger_text( site="defer-ledger-restore-locked", story_key=task.story_key ) if current == snapshot: return # BASELINE only, for the reason `_restore_ledger` states: a # `NO_RESET_CONTENT` anchor plus a missing file is a rival's # deletion, not the reset's. The append-only merge below is the # right degrade — it cannot destroy a rival's write. if anchor is _LedgerAnchor.BASELINE and current == expected: self._publish_restored_ledger(task, ledger, snapshot) return # `isinstance`, not `is not None`: an UNDECODABLE ledger answers the # typed `_UndecodableLedger` (DW-231) and an OS-REFUSED one the typed # `_UnreadableLedger` (DW-258), neither of which the merge may see — # there is no text to append onto — so both fall through to the # divergence journal below exactly as a missing ledger does. if isinstance(current, str): restored, merged, flat_remainder, collided = self._merge_snapshot_entries( current, snapshot ) if restored is not None: self._publish_restored_ledger(task, ledger, restored) # A MISSING ledger falls straight through to the divergence journal. # The arm above already claimed the only absence that IS the reset's # own work (a baseline determinately lacking the ledger, where # `None == None` holds), so reaching here with no file means somebody # removed it after the reset — or through a symlink the reset cannot # reach at all. Merging `current or ""` would read that deletion as # "every entry is merely missing" and write them all back, recreating # the file: the append-only merge cannot destroy a rival's WRITE, but # it can resurrect what a rival DELETED, which is the same overwrite # wearing different clothes. # Only the divergent arm falls through to here. Journaled outside the # hold: the lock covers this ledger's read-modify-write and nothing else. self.journal.append( "defer-ledger-restore-diverged", story_key=task.story_key, ledger=str(ledger), dw_ids=merged, flat_remainder=flat_remainder, id_collisions=collided, ) def _merge_snapshot_entries( self, current: str, snapshot: str ) -> tuple[str | None, list[str], bool, list[str]]: """Republish the snapshot's lost entries onto `current` by APPENDING them. Returns the merged text — None when there was nothing to append — the ids appended, and whether the snapshot carried flat-appender content this merge could not account for. Append-only and keyed by id, deliberately: the divergent text is another writer's published state, so the only edit that cannot destroy it is adding back what it no longer carries. Bodies cross over verbatim, since re-rendering one would drop every field `parse_ledger` does not model, and they are joined by the same one-blank-line rule `deferredwork._apply_append` uses so a merged ledger is shaped like an appended one. Flat appender blocks belong to no canonical entry — `parse_ledger` truncates a span at :data:`deferredwork.FLAT_ENTRY_RE` rather than absorbing one — so no body can carry one across and guessing at their boundaries is exactly what PR #274 forbids. Each opener line in the snapshot is instead probed against the text about to be published, and a missing one is REPORTED for a human rather than merged. """ # Keyed by id AND body, not by id alone. `git reset --hard` can remove our # uncommitted `DW-n` and leave the text ending at `DW-(n-1)`, so a rival # appending into that window mints `DW-n` for an entry of its own. An # id-only membership test then reads our lost entry as "already present" # and drops it silently — the exact preservation this repair exists for, # failing quietly and reporting nothing moved. Re-appending is not the # answer either: it would publish a duplicate id, which the writer's own # `next_seq` and the sweep's duplicate refusal both treat as corruption. # So a same-id-different-body pair is REPORTED and left alone, the same # call the flat remainder below makes — a human is told, rather than a # boundary being guessed at. present = {entry.id: entry.body for entry in deferredwork.parse_ledger(current)} missing = [] collided: list[str] = [] for entry in deferredwork.parse_ledger(snapshot): held = present.get(entry.id) if held is None: missing.append(entry) elif held != entry.body: collided.append(entry.id) text = current for entry in missing: if text == "" or text.endswith("\n\n"): sep = "" elif text.endswith("\n"): sep = "\n" else: sep = "\n\n" text += sep + entry.body flat_remainder = False for m in deferredwork.FLAT_ENTRY_RE.finditer(snapshot): line_end = snapshot.find("\n", m.start()) opener = snapshot[m.start() : line_end if line_end != -1 else len(snapshot)] if opener not in text: flat_remainder = True break return (text if missing else None, [e.id for e in missing], flat_remainder, collided) def _reopen_ledger_after_defer(self, task: StoryTask) -> None: """Undo mode-owned ledger closes after a defer discarded their code. No-op on the base path; only ``SweepEngine`` writes reopenable bundle closes. This is reached on the in-place branch after a reset was attempted and the ledger was restored. An isolated defer returns earlier because the unit worktree, including its close, is discarded without merging — unless the ledger lives in a shared ticket store, where ``SweepEngine._defer`` calls this itself before the base defer. """ return def _carry_isolated_ledger_writes(self, task: StoryTask) -> None: """Apply Engine-owned ledger writes that an isolated merge may omit. APPENDS FIRST, THEN CLOSES — the ordering is a correctness contract, not a style. ``append_entry``'s idempotence scan is open-only, so a close that ran first would hide an already-filed row from it and mint a duplicate under a fresh id. That is why the harvest append leads, why the story close below trails it, and why ``SweepEngine``'s override runs its bundle close strictly after ``super()``. The two closes never coexist on one task — ``SweepEngine`` overrides the story producer to a no-op, and a story run has no bundle — so their relative order is unobservable. ``_carry_board_advance`` trails the ledger carries and is ordered freely: it writes sprint-status.yaml, which shares no state with the deferred-work ledger, so the appends-before-closes contract has nothing to say about it. """ self._carry_harvested_deferrals(task, terminal_composite=True) self._carry_story_deferred_closes(task) self._carry_board_advance(task) @property def store_topology(self) -> ticketstore.StoreTopology | None: """The ticket store this run recorded at its start. Base: None — a story run has no ticket store. Modes with a ticket store (``SweepEngine`` on v7, ``TicketsEngine``) override it to locate the recorded topology on the live project.""" return None def _harvest_carry_store_repo(self, ledger: Path) -> Path | None: """The run's store repository when ``ledger`` resolves inside it (T3/T4), else None: no recorded topology, an unversioned or in-checkout store (``store_repo`` None), a ledger outside the store repository, or a ``resolve()`` fault — which leaves the code-repo path, and its ``commit_paths`` uncertainty raise, in charge (DW-570).""" topology = self.store_topology if topology is None or topology.store_repo is None: return None try: inside = ledger.resolve().is_relative_to(topology.store_repo.resolve()) except (OSError, RuntimeError, ValueError): return None return topology.store_repo if inside else None def _store_publishes_harvest_carry(self, task: StoryTask, *, terminal_composite: bool) -> bool: """Whether a mode-owned store publisher commits the ledger right after this harvested carry, so the carry's store arm must not (DW-570): one publisher per write. Base: False — no publisher follows, so the carry commits its own harvest in the store repository.""" del task, terminal_composite return False def _commit_store_ledger( self, task: StoryTask, store_repo: Path, ledger: Path, message: str, *, code_commit: str | None, code_baseline: str | None = None, ) -> None: """The best-effort store commit the sweep ledger publishers and the harvested carry's store arm share: commit ``ledger`` alone in ``store_repo`` under ``message`` and journal the outcome — ``store-committed`` for a commit, ``store-commit-failed`` for a :class:`~bmad_loop.ticketstore.StoreCommitError`, ``store-commit-foreign-dirt`` for a staged foreign version, and nothing when the ledger is already at the store's HEAD.""" committer = ticketstore.StoreCommitter(store_repo, self.paths.repo_root) try: result = committer.commit( task.story_key, [ledger], message, code_commit=code_commit, code_baseline=code_baseline, ) except ticketstore.StoreCommitError as e: self.journal.append("store-commit-failed", story_key=task.story_key, error=str(e)) return if result.foreign: self.journal.append( "store-commit-foreign-dirt", story_key=task.story_key, count=len(result.foreign) ) return if result.sha is not None: self.journal.append("store-committed", story_key=task.story_key, commit=result.sha) def _harvest_carry_commit_may_degrade(self, ledger: Path) -> bool: """Whether a carry may remain uncommitted because git cannot own its path. Only a path proven external may degrade. Resolution uncertainty must keep the durable commit-pending latch set rather than guess that git cannot own a possibly tracked ledger and silently disable its retry. """ repo = self.paths.repo_root try: resolved_ledger = ledger.resolve() resolved_repo = repo.resolve() except (OSError, RuntimeError, ValueError): return False try: rel = resolved_ledger.relative_to(resolved_repo).as_posix() except ValueError: return True # a proven external ledger is an advisory artifact if verify.path_tracked(repo, rel): return False return rel not in verify.untracked_files(repo) def _harvest_carry_holds_only_this_carry(self, task: StoryTask, ledger: Path) -> bool: """Whether ``ledger`` holds HEAD's blob plus this task's harvested rows and no more (DW-355). The discrimination a REPLAY needs. An earlier pass appended the rows and its commit failed, so this pass dedupes to ``carried == []`` — or the host died between the latch save and the append, so this pass appends them again (DW-413) — and its commit would stage whatever the working-tree ledger holds now (the same shape arises on a FIRST pass whose novel rows the writer's cross-spec dedupe then dropped against a twin already on disk, and is proved the same way) — an operator's edit made while the run was down included, under a ``carry harvested findings`` subject. Refusing on DIRT alone would break the recovery the latch exists for: the crashed pass's own rows ARE uncommitted dirt on exactly this path, and committing them is the point. So the question is whether what is on disk is what this carry intends, recomputed from HEAD's blob through the carry's own spec builder (``_harvested_carry_specs``) and the writer's own fold (``deferredwork.appended_text``). A crashed pass's append, or this replay's own re-append over an unedited HEAD, matches, the fold being deterministic; an operator's edit does not. The sibling of :meth:`_board_carry_holds_only_this_advance`, and for its reasons. HEAD's blob, not a snapshot taken earlier in the run: the baseline has to predate every writer, and only git holds one that does. When the fold adds nothing — HEAD already carries every row, the operator having committed the whole ledger — the intended content IS HEAD's raw blob, so a checkout git calls clean passes whatever its line endings. Otherwise the fold's text is encoded the way the writer's text-mode publish encodes it, off HEAD's text newline-normalized the way the writer's ``read_text`` normalizes it. BOTH of the places git holds this path are proved, because ``commit_paths`` overwrites both: the working tree it copies into the commit, and the index it stages over. A staged operator edit distinct from HEAD and from the intended ledger survives neither, so proving the working tree alone would authorize destroying it. Sameness is git's question (``file_holds_content``), so a CRLF/LF twin of the intended bytes is not mistaken for foreign content. Answers True WITHOUT proving for two shapes, leaving the existing path untouched: a ledger outside the repo, which git cannot commit and ``_harvest_carry_commit_may_degrade`` already degrades; and a path HEAD does not carry, which the carry commits exactly as before — the #460 boundary :meth:`_board_carry_holds_only_this_advance` draws for the board, and drawing it elsewhere here would make the pair unreadable. A ``resolve()`` failure is NOT this proof's to answer either: it falls through to ``commit_paths``, whose own uncertainty raise keeps the latch. A git or OS fault, or a HEAD blob that does not decode as UTF-8, RAISES (``GitError``/``OSError``/``RuntimeError``/``ValueError``) rather than answering: the gate in :meth:`_carry_harvested_deferrals` fails closed on it but pauses with the fault named, because "I could not compute the intended content" is neither "the ledger is mine" nor "someone else wrote to it".""" repo = self.paths.repo_root try: resolved_ledger = ledger.resolve() resolved_repo = repo.resolve() except (OSError, RuntimeError, ValueError): return True # `commit_paths` raises its own uncertainty and keeps the latch try: rel = resolved_ledger.relative_to(resolved_repo).as_posix() except ValueError: return True # external: git cannot commit it, `may_degrade` owns it head = verify.file_bytes_at_revision(repo, "HEAD", rel) if head is None: return True # untracked: the #460 boundary, committed as before head_text = head.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") folded = deferredwork.appended_text(head_text, _harvested_carry_specs(task, head_text)) intended = head if folded == head_text else folded.replace("\n", os.linesep).encode("utf-8") if not verify.file_holds_content(repo, rel, ledger, intended): return False # The working tree is only half of what the commit overwrites: it stages # OVER the index, so a staged version distinct from both HEAD and the # intended ledger is destroyed rather than committed. return verify.index_holds_no_foreign_content(repo, rel, intended) def _pause_for_harvest_carry_foreign_dirt( self, task: StoryTask, ledger: Path, *, error: str | None = None ) -> NoReturn: """Pause the run over a replayed or latch-only harvested carry the ledger's content does not prove is its own (DW-355, DW-413). Mirrors :meth:`_pause_for_ledger_repair`'s shape — journal, ``ACTION REQUIRED`` notice, ``_save()``, ``RunPaused`` at ``PAUSE_ESCALATION`` — and leaves the phase and ``harvest_carry_commit_pending`` exactly where they are: the latch is still an obligation, since any rows this carry filed are on disk but in no commit, and a tracked ledger's in-place reset could otherwise drop them. A pause, not the board's best-effort refusal, because this carry holds that durable latch ("repair writes must raise"). Recovery needs no extra code: once the operator commits the whole ledger or moves the other changes out of it (or fixes the probe fault ``error`` names), the next resume re-runs the same carry — the ``defer_reason`` re-entry or ``_replay_unlatched_ledger_carries`` — and the proof accepts HEAD plus the rows (and commits them) or a HEAD that already holds them (a no-op commit). Invariant across call contexts; ``SweepEngine`` does not override it, its ``_pause_for_harvest_carry_repair`` route being for read faults. ``error`` is set when the proof itself FAULTED rather than failed: the row carries it and the notice asks for the fault to be fixed, not for changes that may not exist to be moved.""" # `error` only where the proof HAS fault text to attribute (the DW-237 rule). extra = {} if error is None else {"error": error} self.journal.append( "harvest-carry-foreign-dirt", story_key=task.story_key, ledger=str(ledger), **extra, ) resume = f"`bmad-loop resume {self.state.run_id}` to retry the carry commit." # The ledger path is folded to one segment of its line (DW-492), like # `error` below (DW-417); the row above keeps it raw. shown_ledger = gates.notice_line(str(ledger)) if error is not None: # The fault text is folded to one segment of its line (DW-417); the # row above keeps it raw. notice = ( "**ACTION REQUIRED — deferred-work ledger could not be verified**\n" f"Story **{task.story_key}** has harvested findings to commit into " f"`{shown_ledger}`, but the carry could not verify the ledger holds only its " f"own changes: {gates.notice_line(error)}. Any rows this carry filed are on disk but " "uncommitted, and nothing was committed.\n" f"Fix the fault, then run {resume}" ) else: notice = ( "**ACTION REQUIRED — deferred-work ledger holds changes the carry cannot " "prove are its own**\n" f"Story **{task.story_key}** has harvested findings to commit into " f"`{shown_ledger}`, but the ledger holds changes beyond HEAD plus those findings, " "in the working tree or the index (an operator edit, or a pre-commit hook " "that rewrote the ledger when it rejected the commit). Any rows this carry filed are on disk " "but uncommitted, and nothing was committed: committing now would have " "swept the other changes in the ledger into the carry commit.\n" "Commit the whole ledger (the carried rows included), or move the other " f"changes out of it, then run {resume}" ) subject = "could not be verified" if error is not None else "has foreign changes" gates.notify( self.policy, self.run_dir, f"ACTION REQUIRED: deferred-work ledger {subject} for {task.story_key}", notice, multiline=True, ) self._save() raise RunPaused(notice, PAUSE_ESCALATION, task.story_key) def _pause_for_harvest_carry_repair( self, task: StoryTask, ledger: Path, fault: deferredwork.LedgerReadError | OSError, *, site: str, terminal_composite: bool, ) -> NoReturn: """Dispatch a harvested-carry read refusal to its owning run route. The base route is deliberately invariant across call contexts: ordinary story runs pause at escalation. ``SweepEngine`` may use the context bit to redirect only the terminal post-merge composite carry; the direct pre-terminal carry from :meth:`_defer` must retain this route. The fault travels as the exception, not its text, so the sweep route can keep the OS-versus-decode classification (DW-279) its own row and notice split on; the base route attributes it here exactly as its other publish sites do. """ self._pause_for_ledger_repair(task, ledger, _ledger_fault_text(ledger, fault), site=site) def _carry_harvested_deferrals( self, task: StoryTask, *, terminal_composite: bool = False ) -> None: """Re-file an isolated unit's harvested findings into the main ledger. ``terminal_composite`` identifies the call from :meth:`_carry_isolated_ledger_writes`; direct defer and deferred-replay calls leave it false so subclasses cannot mistake a pre-terminal carry for the merged-unit recovery path. A REPLAY — the latch already set when this pass began, whether an earlier pass appended the rows and its commit failed or the host died between the latch save and the append (DW-413) — and a LATCH-ONLY commit (the latch set and nothing carried, which also covers a first pass whose novel rows the writer's cross-spec dedupe all dropped) first prove the ledger holds only HEAD plus this task's rows (:meth:`_harvest_carry_holds_only_this_carry`) and otherwise pause for the operator without committing or clearing the latch (DW-355). A fresh first pass that appends rows is not proved. A ledger inside the run's store repository (T3/T4, :meth:`_harvest_carry_store_repo`) takes the STORE ARM instead, after the same DW-237 refusal (DW-570): the code-repo commit is never attempted, since git there can only refuse the path. When a mode-owned store publisher commits the ledger right after this carry (:meth:`_store_publishes_harvest_carry`) the carry leaves the write to it; otherwise it commits the ledger alone in the store through :meth:`_commit_store_ledger` as ``chore(deferred-work): carry harvested findings from ``, naming the landed code commit and baseline on the terminal composite and no code commit elsewhere. Best effort, like every store publisher: a failed commit journals ``store-commit-failed``, a staged foreign version ``store-commit-foreign-dirt``; either way the latch clears and ``harvest-carried`` is journaled. The DW-355/413 replay proof is not run on the store arm: like every store publisher it publishes the whole file, and :class:`~bmad_loop.ticketstore.StoreCommitter` refuses only a staged foreign version. """ if not task.harvested_deferrals: return ledger = self.paths.deferred_work # REPAIR/WRITE (DW-146): this one fresh read is the whole on-disk guard # for the `append_entries` write below it. A PUBLISH site, so undecodable # bytes (DW-231) and a read the OS refuses (DW-258) pause the run for # repair — BEFORE the commit latch below, so nothing records an # obligation this call never took on. The phase is left where it is: the # `defer_reason` re-entry and `_replay_unlatched_ledger_carries` re-run # this carry on resume. try: text = deferredwork.read_for_write(ledger) or "" except (deferredwork.LedgerReadError, OSError) as e: self._pause_for_harvest_carry_repair( task, ledger, e, site="harvest-carry", terminal_composite=terminal_composite, ) specs = _harvested_carry_specs(task, text) # Read BEFORE the latch below can set it (DW-413): a latch this pass did not # set marks a replay, whose commit is proved even when it appends rows. latched_before = task.harvest_carry_commit_pending if specs and not latched_before: # Persist the commit obligation before the filesystem write. A host # loss after the append writes the rows but before it returns must # still make replay commit the now-deduplicated tracked/untracked row. # Latch only once a novel provenance is known: when every row already # arrived through the merge, committing here could sweep unrelated # operator edits to the same ledger into the carry commit. task.harvest_carry_commit_pending = True self._save() # The writer's own locked re-read (DW-259): bytes that went bad after the # pre-read above raise here, before any write, and take the same repair # pause. The commit latch above is already set, which is fine — a replay # retries the append and the latched commit with it. try: appended = deferredwork.append_entries(ledger, specs) except deferredwork.LedgerReadError as e: self._pause_for_harvest_carry_repair( task, ledger, e, site="harvest-carry-append-locked", terminal_composite=terminal_composite, ) carried = [dw_id for dw_id in appended if dw_id] commit_needed = bool(carried) or task.harvest_carry_commit_pending if commit_needed: # The pre-append latch also covers every git observation/write below. # A failed add/status/commit can leave the row staged or dirty; replay # must retry even when the provenance scan dedupes it to `carried == []`. # THE PUBLISHABLE-TARGET GUARD (DW-237), ahead of `may_degrade` because # that probe spawns git of its own and a refused publish must spawn NONE. # A refusal never RAISES, where a `GitError` on a git-ownable ledger # does: `may_degrade` asks whether git can own the path, and a refusal # answers a different question — the operand is not a publishable file at # all. Of the four causes, three are DURABLE (`target-absent`, # `target-not-a-file`, `target-undecodable`): a replay re-reads the same # shape and refuses again, so raising would cost the run its # `integrate_unit` with nothing left to retry. `target-unreadable` is the # one TRANSIENT cause, which does NOT share that argument, and it is # filtered out just below. refusal = _publication_refusal(ledger, "ledger") if refusal is not None and refusal[0] == "target-unreadable": # UNCERTAINTY, not a refusal, and only at THIS site: alone among the # five publishers this one carries a durable commit latch, and # DW-195/#552 pinned the rule that an unreadable ledger must not # become an advisory success there. That cause is whatever a probe # RAISED — an EACCES parent, or the WinError 64 a # registered-but-not-serving UNC provider gives — so a replay may # find it gone, where a directory, an absence or undecodable bytes # are still there. Handing it back to `commit_paths` keeps the # pre-DW-237 path exactly: git is asked, it raises, and `may_degrade` # decides whether the run keeps the latch and retries or degrades. # The three DURABLE causes take the refusal arm below, where there is # nothing to retry — `target-undecodable` among them, because git # accepts any bytes and the fall-through would otherwise commit the # corrupt ledger (the guard splits that cause from this one for # exactly this site; DW-237). A ledger in a store repository takes # the store arm below instead (DW-570): best effort, the latch # clears, as its `may_degrade` degrade did before. refusal = None if refusal is not None: cause, error = refusal # `error` only where the refusal HAS fault text to attribute; an # absent operand has none, and an empty string would read as one. extra = {} if error is None else {"error": error} self.journal.append( "harvest-carry-refused", story_key=task.story_key, dw_ids=carried, refuse_cause=cause, **extra, ) elif (store_repo := self._harvest_carry_store_repo(ledger)) is not None: # THE STORE ARM (DW-570): the ledger lives in a ticket store with # its own repository (T3/T4), which `commit_paths` in the code repo # can never own. Exactly one publisher per write: a mode-owned # store publisher that commits right after this carry takes it # (the sweep's `_publish_deferred_ledger` and # `_publish_bundle_close`, the tickets `_commit_marked_plan`); # otherwise the carry commits its harvest in the store itself. # Best effort: never a raise, never a pause, and the latch clears # below. if not self._store_publishes_harvest_carry( task, terminal_composite=terminal_composite ): self._commit_store_ledger( task, store_repo, ledger, f"chore(deferred-work): carry harvested findings from {task.story_key}", code_commit=( self._landed_code_commit(task) if terminal_composite else None ), code_baseline=task.baseline_commit if terminal_composite else None, ) else: if latched_before or not carried: # Stages whatever the working-tree ledger holds now, so it is # proved on two shapes. A REPLAY (DW-413): the latch was set # before this pass — an earlier pass appended the rows and its # commit failed (this pass then dedupes to `carried == []`), or # the host died between the latch save and the append (this pass # appends them again). Either way an operator may have edited # the ledger while the run was down. A LATCH-ONLY commit # (DW-355): nothing carried, which inside `commit_needed` means # the latch is set — also a first pass whose novel rows the # writer's cross-spec dedupe all dropped. A FRESH first pass that # appends rows is NOT asked and still commits the whole # working-tree ledger, as before (the decided DW-413 boundary). # Proved before `may_degrade`, which spawns git of its own, and # after the refusal above, which spawns none. A probe fault # fails closed, but pauses with its error named. try: owned = self._harvest_carry_holds_only_this_carry(task, ledger) except (verify.GitError, OSError, RuntimeError, ValueError) as e: self._pause_for_harvest_carry_foreign_dirt( task, ledger, error=f"{type(e).__name__}: {e}" ) if not owned: self._pause_for_harvest_carry_foreign_dirt(task, ledger) may_degrade = self._harvest_carry_commit_may_degrade(ledger) try: verify.commit_paths( self.paths.repo_root, f"chore(deferred-work): carry harvested findings from {task.story_key}", [ledger], ) except verify.GitError as e: if not may_degrade: raise self.journal.append( "harvest-carry-uncommitted", story_key=task.story_key, dw_ids=carried, error=str(e), ) # Unchanged by a refusal: the latch it clears covers a COMMIT # obligation, and there is no commit left owing for an operand no # replay of this frame would publish either. task.harvest_carry_commit_pending = False self._save() self.journal.append("harvest-carried", story_key=task.story_key, dw_ids=carried) def _carry_story_deferred_closes(self, task: StoryTask) -> None: """Re-apply a story's declared ledger CLOSES to the main checkout (#458). The last producer in the ``git add -A`` family. ``_close_declared_deferred`` writes the ACTIVE workspace's ledger, so under isolation a gitignored one is flipped inside a unit worktree, skipped by ``finalize_commit``'s ``git add -A`` in silence, and deleted with the worktree — while the run has already journaled ``story-deferred-closed``. Left uncarried the entry stays ``open``, so ``deferredwork.open_ids`` re-bundles resolved work on every later sweep: unbounded re-triage, not a one-time drop. ``mark_done_many_reopenable``, the same variant the commit-boundary close now uses, under the same ``_story_close_operation_id`` and the same ``_story_close_note``: byte-identity between a carried row and one the merge delivered is the point, and both halves of that comparison carry the ``resolution-undo:`` line since #286 made the story close entry-scoped. This paragraph used to say the opposite — no operation id, no undo marker — which was the byte-identity argument against the old close, and inverts with it. Only the date can differ, and only across a midnight boundary — the same accepted drift the park record carries. Unconditional and idempotent, with no tracked/ignored predicate. Idempotence here is stronger than the appends': ``_apply_done`` returns None for a row that is absent OR already ``done``, and ``_mark_done_many`` then writes nothing at all — not even a byte-identical rewrite. So a tracked ledger, whose close rode the merge, yields an empty ``carried`` and no commit, and a replay re-applying a landed carry is a no-op. ``story-deferred-close-carried`` with ``dw_ids == []`` is an ordinary outcome. Best effort, like ``SweepEngine``'s close and unlike ``_carry_harvested_deferrals`` — because the commit can only ever FAIL here rather than because failure is rare. Of the three shapes the main ledger can take, a tracked one is already closed by the merge; a gitignored one reaches ``commit_paths`` and ``git add`` refuses that ignored path with rc 1 every time, so a commit-pending latch would only retry a refusal. An untracked-but-not-ignored one used to be unreachable — ``clean_incoming_collisions`` refused the merge over it — and since #460 it reaches here too, where ``git add`` accepts it and the carry commits it, which is the leg that issue reported as never happening. ``self.paths``, not ``self.workspace.paths``, states the intent — the MAIN checkout's ledger. The two are the same path at every call site that reaches here: the workspace is swapped back before ``integrate_unit`` runs the hook, and before the resume replay does. So no test can tell the two apart, and none pretends to; the explicit form is kept because the intent stops being obvious the moment that stops holding. """ if not task.story_closes_intended: return ledger = self.paths.deferred_work # A PUBLISH site with no pre-read of its own: the close's locked # `read_for_write` is the only read, so undecodable bytes raise from the # call itself, ahead of any write, and take the repair pause (DW-259). A # pause leaves `isolated_ledger_carried` False, so # `_replay_unlatched_ledger_carries` re-runs the whole carry hook. try: carried = deferredwork.mark_done_many_reopenable( ledger, task.story_closes_intended, self._today(), self._story_close_note(task), self._story_close_operation_id(task), ) except deferredwork.LedgerReadError as e: self._pause_for_ledger_repair( task, ledger, _ledger_fault_text(ledger, e), site="story-close-carry-locked" ) if carried: # The DW-237 guard, before any git: `commit_paths` hands its operand to # `git add` as a LITERAL pathspec, so a ledger replaced by a directory # would be staged RECURSIVELY under this `chore(deferred-work):` message. # Every cause refuses here, `target-unreadable` included: this carry holds # no commit latch (its flips are idempotent and its commit was already # best effort), so it has no retry to protect the way # `_carry_harvested_deferrals` does. refusal = _publication_refusal(ledger, "ledger") if refusal is not None: cause, error = refusal extra = {} if error is None else {"error": error} self.journal.append( "story-deferred-close-carry-refused", story_key=task.story_key, dw_ids=carried, refuse_cause=cause, **extra, ) else: try: verify.commit_paths( self.paths.repo_root, f"chore(deferred-work): close {task.story_key}'s declared ids", [ledger], ) except verify.GitError as e: self.journal.append( "story-deferred-close-carry-uncommitted", story_key=task.story_key, dw_ids=carried, error=str(e), ) self.journal.append( "story-deferred-close-carried", story_key=task.story_key, dw_ids=carried ) def _board_carry_must_prove_ownership(self, board: Path) -> bool: """Whether anyone other than this pass may already have written ``board``. Asked by ``_carry_board_advance`` BEFORE its own advance, which is the whole of why it is a separate frame: a moment later this run's write is on the path and "was anybody else here" has stopped being answerable. ``dirty_paths`` — git's own answer — and nothing else decides whether EITHER comparison below runs at all. That ordering is load-bearing rather than an optimization: it is what keeps them from being asked about a board nobody has written, where the only honest answer is git's. It is NOT what makes the byte comparison safe on a repo that normalizes line endings — ``file_holds_content`` hashes both sides through the path's clean filter for that, so no eol domain has to be guessed at either end. Fail CLOSED. A probe that could not run has not ruled an operator out, and the writes it gates are the ones that leave no trace of what they took. What the conservative answer costs depends on which check then answers, and neither cost is the destructive one: the sibling guarding the COMMIT costs a no-op commit, ``advance`` having already put the status on disk; the row check that PRECEDES ``advance`` costs the carry itself, and with it the next run re-picking the story — the #350 behavior, minus the false claim that it was fixed. A board outside the repo is the one False the failure paths do not share: git cannot commit it either way, so there is nothing here to protect and no baseline for either comparison below, and answering True would trade a no-op commit for a real refusal. A GITIGNORED board is the ceiling, and it is git's rather than this probe's. ``dirty_paths`` never reports one — ``git status --porcelain`` needs ``--ignored`` to spell it ``!!`` at all — so both comparisons are skipped here. Reporting it would change NOTHING, which is why the probe is left alone: an ignored board is untracked, HEAD carries no blob for it, and both comparisons accept a path HEAD does not carry precisely because there is no baseline to compare against (the #460 boundary). Measured. Nor can the COMMIT half of the hazard arise there — ``git add`` refuses an ignored path with rc 1 every time. What is genuinely unprotected is the ADVANCE: a replayed carry can still overwrite a row an operator edited on an ignored board while the host was down, and nothing git holds could prove otherwise.""" repo = self.paths.repo_root try: resolved_board = board.resolve() resolved_repo = repo.resolve() except (OSError, RuntimeError, ValueError): return True try: rel = resolved_board.relative_to(resolved_repo).as_posix() except ValueError: return False # external board — never git's to commit in the first place try: return rel in verify.dirty_paths(repo) except (verify.GitError, OSError, RuntimeError): return True def _board_carry_foreign_row_status( self, board: Path, story_key: str, target: str ) -> str | None: """The status ``story_key``'s row holds for somebody OTHER than this pass. ``None`` means the row is this pass's to write. Anything else is a status ``advance`` would overwrite that this run did not put there, and it is handed back rather than a bare False because it is the whole of what the refusal has to report: nothing lands, so there is no ``landed`` to journal in its place. The one question the sibling below cannot be asked in time. That one guards the COMMIT and runs after ``advance``, which for the story's OWN row is after the evidence is gone — ``advance`` has replaced the operator's status with the target, so the board then holds precisely HEAD's bytes plus this advance and the proof rightly says so. Nor would refusing the commit at that point have saved anything: the status on disk is the value ``_pick_next`` schedules from. Hence a check that runs BEFORE the write. ADDITIVE, and about one ROW rather than the board. A stray on some OTHER row is not this write's to refuse — ``advance`` cannot reach it, and today's outcome there (the advance lands, the sibling declines the commit, ``_pick_next`` stays honest) is the right one. Refusing on a whole-board difference would trade that for a finished story re-picked by every run until a human intervenes. Two shapes are this pass's own. A row still holding HEAD's status was written by nobody since the commit ``advance`` recomputes from. A row already AT or PAST ``target`` is the replay leg's reason to exist — a crashed pass's landed advance — and never-regress means ``advance`` writes nothing over it either way, so that shape is settled first and without asking git anything, which keeps a replay off the fail-closed path entirely. An ABSENT row accepts too, and is not this frame's to judge: ``advance`` returns None over it and writes nothing, and ``board-advance-carry-failed`` already names that outcome. Fail CLOSED on git, like both siblings and for their reason. The board's own parse is deliberately NOT caught: for a board that carries no ``development_status`` map (or is not YAML at all) the live read here raises ``SprintStatusError`` exactly as ``advance`` would have raised it one call later, and quietly converting that into a refusal would dress a corrupt board as an operator's edit. A MISSING board is a different case and never reaches this frame: over one, ``advance`` returns None where this read's ``load`` raises — the two disagree, which is why the caller refuses the shape up front (``board-advance-carry-failed``) rather than letting the probe die on a file the writer would have shrugged at. A path HEAD does not carry accepts — the #460 boundary the sibling draws, drawn once for both. """ live = sprint_story_status(board, story_key) if live is None or _at_or_past(live, target): return None repo = self.paths.repo_root try: rel = board.resolve().relative_to(repo.resolve()).as_posix() head = verify.file_bytes_at_revision(repo, "HEAD", rel) if head is None: return None return None if live == sprint_status_in_bytes(head, story_key) else live except (verify.GitError, OSError, RuntimeError, ValueError, SprintStatusError): return live def _board_carry_holds_only_this_advance( self, board: Path, story_key: str, target: str ) -> bool: """Whether ``board``'s bytes are HEAD's plus this pass's advance and no more. The discrimination the replay leg needs. Refusing on DIRT alone would break the recovery that leg exists for: a crashed pass's own advance IS uncommitted dirt on exactly this path, and finishing it is the point. So the question asked is not whether the board is dirty but whether what is on it is what this pass intends — recomputed from HEAD's blob through ``advance`` itself, then compared byte for byte. A crashed pass's write matches, ``advance`` being deterministic and never-regressing, so replaying a landed one lands on the same bytes; an operator's edit does not. HEAD's blob, not a snapshot taken earlier in the run: the baseline has to predate every writer, and only git holds one that does. A path HEAD does not carry answers True, leaving an untracked board committed exactly as before (#460). That is the boundary ``merge_local`` already draws — ``_carried_artifact_rels`` protects the board only when it is TRACKED (and ``board_advance_intended`` is set, DW-354), because protecting an untracked artifact would halt every run whose project never committed its board — and a second frame drawing it elsewhere would make the pair unreadable. BOTH of the places git holds this path are proved, because `commit_paths` overwrites both: the working tree it copies into the commit, and the index it stages over. A staged edit distinct from HEAD and from this advance survives neither, so proving the working tree alone would authorize destroying it. Sameness is GIT's question here, not a byte compare's (``file_holds_content``). The baseline is HEAD's raw blob, and the board on disk may be its CRLF twin or its LF one depending on nothing the run controls — git calls the tree clean either way, so a byte compare would have to guess, and either guess refuses a pristine board on the hosts the other guess serves. Both sides hashed through the path's clean filter answers the only question worth asking, and still parts an operator's added row from this pass's advance. Fail CLOSED, like its sibling and for its reason, and that covers ``advanced_bytes`` returning None: a row missing from HEAD's board leaves nothing to compare against, and "I could not compute the intended content" must not read as "the tree is mine". A row the writer refuses to rewrite fails closed the same way: ``advanced_bytes`` raises ``SprintStatusWriteRefused`` for it (#842) where it used to hand HEAD's bytes back unchanged, and an untouched — or someone else's — board compared against those would be accepted as an advance no board holds. A HEAD blob that does not parse (``SprintStatusError``) has no intended content either.""" repo = self.paths.repo_root try: rel = board.resolve().relative_to(repo.resolve()).as_posix() head = verify.file_bytes_at_revision(repo, "HEAD", rel) if head is None: return True intended = sprint_advanced_bytes(head, story_key, target) if intended is None or not verify.file_holds_content(repo, rel, board, intended): return False # The working tree is only half of what the carry overwrites: `commit_paths` # stages it OVER the index, so a staged version distinct from both HEAD and # this advance is destroyed rather than committed. return verify.index_holds_no_foreign_content(repo, rel, intended) except (verify.GitError, OSError, RuntimeError, ValueError, SprintStatusError): return False def _carry_board_advance(self, task: StoryTask) -> None: """Re-apply the story's sprint-board advance to the main checkout (#350). The one member of the ``git add -A`` family that is not about the deferred-work ledger. ``_post_dev_state_sync`` advances ``self.workspace.paths.sprint_status``, which under isolation is the unit worktree's board: for a GITIGNORED board that is ``_board_seed``'s copy, shielded from ``finalize_commit``'s ``git add -A`` with every other seeded rel, so the advance never rides the unit branch and dies with the worktree at teardown. Uncarried, the main board keeps the story at ``ready-for-dev`` — inside ``ACTIONABLE_STATUSES`` — and ``_pick_next``, which reads the MAIN board, re-picks finished work on the next run. Only this story's own advance is carried. Any other row a session edits in a gitignored worktree board is lost with the worktree at teardown. Through ``sprintstatus.advance``, the orchestrator's sole write path to this file, never a copy of the worktree's: that keeps the never-regress rule and the comment-preserving line edit in one place, and a copy would also overwrite rows the worktree's board knows nothing about. ``self.paths``, not ``self.workspace.paths`` — the MAIN checkout's board, for the reason spelled out in ``_carry_story_deferred_closes``: the workspace is swapped back before every call site reaches here, so no test can tell them apart, and the explicit form states an intent that stops being obvious the moment that stops holding. Unconditional and idempotent, with no tracked/ignored predicate. ``advance`` never regresses, so re-applying a landed carry is a no-op, and so is the whole call for a TRACKED board: the worktree's flip is an ordinary modification that no ignore rule masks, so it rides the merge and the main board is already at the target when this runs. ``board-advance-carried`` naming a status the carry did not itself write is an ordinary outcome. No ``now=``: refreshing ``last_updated`` is ``bmad-loop confirm``'s to do, and passing it here would rewrite a second line — a whole-file relay on a usually-tracked file (#576) — for bookkeeping the story's own advance already declined to touch. Best effort, like the two carries above it, and for their reason: of the board shapes that reach this frame, a gitignored one is the only one with anything to write, and ``git add`` refuses an ignored path with rc 1 every time — a commit-pending latch would only retry a refusal. The status on disk is the value that keeps ``_pick_next`` honest; the commit is bookkeeping. The commit is not gated on evidence of a WRITE, because ``advance`` cannot report whether it wrote (a never-regress echo returns the target too) — and it does not need to be: an unchanged board simply gives ``commit_paths`` nothing to commit. What the commit must NOT be given is somebody else's bytes, and on the LIVE merge path ``clean_incoming_collisions`` has already accounted for those: inside the branch's incoming set unrelated dirt was restored, and outside that set it REFUSED the merge, this frame among everything else it precedes (the board is one of the two paths ``merge_local`` passes as ``protected``, precisely because the pathspec stage below would otherwise commit it). That pre-flight does not precede every caller. ``_replay_unlatched_ledger_carries`` falls straight through to the carry for a unit whose ``unit-merged`` was already journaled — it re-runs no merge on that leg, so no pre-flight runs on it either — and an edit the operator made while the host was down would ride out under this method's own message, tree clean behind it. Hence ``_board_carry_must_prove_ownership``, which asks there what the pre-flight asks here. Ownership is then asked TWICE, on either side of ``advance``, because the two questions have different deadlines. What the COMMIT must not be handed is answerable afterwards, about the whole board. What ``advance`` ITSELF must not overwrite is answerable only before it, and only about this story's row — so ``_board_carry_foreign_row_status`` leads, and a refusal there returns without journaling ``board-advance-carried``: nothing reached the disk, and that event's claim is precisely that the status did. What ``advance`` CAN report is that the row did not REACH ``target``, and that is a different question from whether it wrote — the one this method has to ask before naming its outcome ``board-advance-carried``. It reports below-target in two shapes, both of them a carry that did not happen: `None` when the story's row is gone (deleted or renamed while the isolated session held its own copy, or before a merge-to-carry replay), and a raised ``SprintStatusWriteRefused`` when the row is there but the line edit cannot rewrite it — a quoted key, an alias, a block scalar, which ``story_status``'s full YAML parse resolves and the writer then refuses (#842). The refusal is caught here and journaled with its ``status`` and ``refuse_cause`` (the writer's stable reason token) rather than raised: uncaught, it would end finalization, and every resume that replays the carry, on a row a retry cannot repair. A whole board that is gone is the shape ``advance`` cannot be allowed to answer for at all: it returns None over a missing file, but the pre-advance row probe's own read raises ``SprintStatusError`` there — so the caller refuses it before either runs, on the same journal row. Latching any of these as carried would file a success for a main board still sitting in ``ACTIONABLE_STATUSES``, and the run would tear the worktree holding the advanced copy down on the strength of that record. It journals ``board-advance-carry-failed`` instead and skips the commit, which has nothing to carry. Still best effort, not a raise: the shapes that get here are ones a retry cannot repair (a board that is gone stays gone), and the cost of the honest record is the next run re-picking the story — the #350 behavior, minus the false claim that it was fixed. """ target = task.board_advance_intended if not target: return board = self.paths.sprint_status if not board.is_file(): # A board that is GONE is refused here, before the ownership probes: # `advance` answers None over a missing file, but the foreign-row # probe's own live read (`sprint_story_status` → `load`) raises # `SprintStatusError` for it — and a deleted TRACKED board is exactly # the shape that turns proving on (` D` in `dirty_paths`), so on the # replay leg that raise escaped `_replay_unlatched_ledger_carries` # and killed every resume before `_loop()`. The is_file-to-advance # window this leaves is #686's TOCTOU, not this guard's. self.journal.append( "board-advance-carry-failed", story_key=task.story_key, target=target, status=None, ) return prove = self._board_carry_must_prove_ownership(board) if prove: # `is not None`, not truthiness: an empty status is still somebody's edit. foreign = self._board_carry_foreign_row_status(board, task.story_key, target) if foreign is not None: self.journal.append( "board-advance-carry-foreign-dirt", story_key=task.story_key, target=target, status=foreign, ) return try: landed = sprint_advance(board, task.story_key, target) except SprintStatusWriteRefused as refused: # The row is there and below target, and the writer wrote nothing: # the same failed carry, with the writer's reason token beside it. self.journal.append( "board-advance-carry-failed", story_key=task.story_key, target=target, status=refused.current, refuse_cause=refused.reason, ) return if not _at_or_past(landed, target): self.journal.append( "board-advance-carry-failed", story_key=task.story_key, target=target, status=landed, ) return if prove and not self._board_carry_holds_only_this_advance(board, task.story_key, target): self.journal.append( "board-advance-carry-foreign-dirt", story_key=task.story_key, target=target, status=landed, ) else: # The DW-237 guard on the BOARD, declared `"store"` — the family names # the validation POLICY, not the file's role: `"ledger"` is the leg that # additionally asks `deferredwork.read_for_write`, and a board wants only # "a regular file is there". A third family would mint a second spelling # for one policy and weaken `unpublishable_target`'s `assert_never`. # Every cause refuses here for the reason the story-close carry gives: # no latch, so no retry to keep alive for a transient `target-unreadable`. refusal = _publication_refusal(board, "store") if refusal is not None: cause, error = refusal extra = {} if error is None else {"error": error} self.journal.append( "board-advance-carry-refused", story_key=task.story_key, target=target, refuse_cause=cause, **extra, ) else: try: verify.commit_paths( self.paths.repo_root, f"chore(sprint-status): carry {task.story_key} to {target}", [board], ) except verify.GitError as e: self.journal.append( "board-advance-carry-uncommitted", story_key=task.story_key, target=target, error=str(e), ) self.journal.append( "board-advance-carried", story_key=task.story_key, target=target, status=landed, ) def _stage_deferred_stash(self, task: StoryTask) -> _StagedStash | None: """First half of moving a deferred story's spec out of the artifacts dir into the run dir: a leftover in-review spec would confuse the next attempt, but the work in it is worth keeping for the human. None when there is no spec. Only a copy is made, beside the stash target, and the live spec and any earlier stash of it stay untouched. `_defer` stages before its rollback, so a reset that deletes the spec cannot take the work with it, then hands the copy to `_finish_deferred_stash` once the rollback completes or to `_discard_deferred_stash` when it pauses (DW-528). Staging inside the stash dir keeps the later replace same-filesystem, preserving `shutil.move`'s cross-device tolerance.""" if not task.spec_file: return None spec_path = Path(task.spec_file) if not spec_path.is_file(): return None target = deferred_stash_path(self.run_dir, task.story_key, spec_path.name) target.parent.mkdir(parents=True, exist_ok=True) tmp = deferred_stash_staged_path(target) shutil.copy2(spec_path, tmp) return _StagedStash(spec_path, tmp, target) def _land_deferred_stash(self, task: StoryTask, staged: _StagedStash) -> bytes: """`atomic_replace` the staged copy onto its stash target, journal it, and return the stashed bytes. A story that defers twice re-stashes the same filename, so the target may exist. `shutil.move` survived that on Windows only by accident: `os.rename` raises FileExistsError over an existing target, `move` catches *any* OSError and falls back to `copy2` + `unlink`. Two real hazards ride on that fallback (#101) — it re-fails outright when an AV/indexer handle turns the rename into a sharing violation (WinError 5/32) and `copy2` then cannot open the same locked target, and it is non-atomic, so a crash mid-copy leaves a truncated stash. The replace overwrites in one step and carries #98's win32 retry. On failure the staged copy is dropped only while the live spec still holds the same work: after a reset it may be the only copy left, and a stash must be able to leave a duplicate spec, never a hole where the work was. A kept copy is what `runs.rearm_for_reverify` restores the spec from when the landed stash is absent.""" try: stashed = staged.staged.read_bytes() atomic_replace(staged.staged, staged.target) except BaseException: with contextlib.suppress(OSError): if staged.spec.read_bytes() == staged.staged.read_bytes(): staged.staged.unlink(missing_ok=True) raise self.journal.append( "deferred-artifacts-stashed", story_key=task.story_key, stashed_to=str(staged.target), ) return stashed def _finish_deferred_stash(self, task: StoryTask, staged: _StagedStash | None) -> None: """Land a staged copy and take the attempt's spec out of the artifacts dir. The live spec is unlinked only while it still holds the staged bytes. After a rollback, different bytes are something the rollback put back (a tracked spec reset to its baseline, or an attempt-owned spec restored to its pre-launch snapshot), and those stay. An unchanged spec that equals the attempt's pre-launch snapshot is the attempt's input, not its output, so it stays too. The unlink is retried like the replace: Windows denies a delete against an open handle just as it denies a rename-over.""" if staged is None: return stashed = self._land_deferred_stash(task, staged) try: live = staged.spec.read_bytes() except FileNotFoundError: live = None if live == stashed and live != task.dispatched_spec_snapshot: retrying_unlink(staged.spec) def _discard_deferred_stash(self, task: StoryTask, staged: _StagedStash | None) -> None: """Drop a staged copy whose spec is staying live: the rollback paused, or the defer failed, before the stash could land. Only while the live spec still holds the staged bytes — a pause can follow a byte-exact restore of an attempt-owned spec, and a fault can follow the reset, and then the copy is the attempt's only version, so it is landed on the stash target (and journaled) instead. Best-effort: it runs while a pause or fault is propagating, and a leftover copy beside the stash costs nothing.""" if staged is None: return with contextlib.suppress(OSError): if staged.spec.read_bytes() == staged.staged.read_bytes(): retrying_unlink(staged.staged) return with contextlib.suppress(OSError): self._land_deferred_stash(task, staged) def _escalate(self, task: StoryTask, reason: str) -> None: advance(task, Phase.ESCALATED) task.adopt_pending = False # an escalation spends any adoption (DW-386) # Two literal writes rather than a conditional `**` splat, so the journal # field guard reads every name; `env_fault_site` appears only when set. if task.env_fault_site is None: self.journal.append("story-escalated", story_key=task.story_key, reason=reason) else: self.journal.append( "story-escalated", story_key=task.story_key, reason=reason, env_fault_site=task.env_fault_site, ) displayed = display_critical_reason(reason, task.spec_file) # An environment fault that left a verifiable product can keep it (DW-522). reverify = ( f"; or fix the environment and keep the work with " f"`bmad-loop resolve {self.state.run_id} --reverify`" if env_fault_site_reverifiable(task) else "" ) gates.notify( self.policy, self.run_dir, pause_title(reason, task.story_key, env_fault_site=task.env_fault_site), f"{displayed} — resolve, then `bmad-loop resume {self.state.run_id}`{reverify}", ) self._save() raise RunPaused(reason, PAUSE_ESCALATION, task.story_key) def _escalate_env(self, task: StoryTask, reason: str, *, site: str) -> None: """Escalate an environment fault, recording where it was detected (``StoryTask.env_fault_site``, DW-523) before :meth:`_escalate` journals and saves it. The site is set on the task rather than threaded through ``_escalate`` so the sweep engine's override keeps its signature.""" task.env_fault_site = site self._escalate(task, reason) def _escalate_outcome(self, task: StoryTask, outcome: VerifyOutcome, *, role: str) -> None: """Escalate a failed verify outcome: an environment fault records site ``verify:`` (DW-523); anything else escalates plainly.""" if outcome.env_fault: self._escalate_env(task, outcome.reason, site=f"verify:{role}") else: self._escalate(task, outcome.reason) def _escalate_decision(self, task: StoryTask, decision: Decision) -> None: """Escalate a PAUSE decision, recording its ``env_site`` when the environment seam (:meth:`_env_gate_decision`) produced it, else its usage-limit facts (:meth:`_escalate_session`).""" if decision.env_site is not None: self._escalate_env(task, decision.reason, site=decision.env_site) else: self._escalate_session(task, decision.reason, decision.usage_limit) def _escalate_session( self, task: StoryTask, reason: str, usage_limit: UsageLimitPause | None ) -> None: """Escalate a pause whose reason came from a session result, attaching that result's usage-limit facts (``escalation.usage_limit_pause``, None unless the reason is the usage-limit reason) to the ``RunPaused`` so ``run()`` records them with the pause (DW-591). Attached on the way out rather than threaded through ``_escalate``, so the sweep engine's override keeps its signature.""" try: self._escalate(task, reason) except RunPaused as pause: pause.usage_limit = usage_limit raise def _record_sweep_refusal(self, trigger: str, reason: str) -> None: """Record, durably, that this trigger's auto-sweep did not deliver. The remedy for #501's closing note: every refusal below was journal-only, so a run whose one sweep trigger was refused finished looking exactly like a run that swept — nothing in ``summary().render()``, ``status`` or ``status --json`` said otherwise, and under ``auto = "run-end"`` there is no later ask to notice the gap. ``reason`` must be one of the ``SWEEP_REFUSED_*`` slugs, never a formatted exception — see their definition in ``model.py`` for why a free-form string breaks ``bmad-loop diagnose`` outright rather than being redacted. Written before the arm's journal/notify calls, mirroring ``latch``: the durable record is the point, and it must not be lost to an OSError from a journal append.""" self.state.sweeps_refused[trigger] = reason self._save() def _maybe_auto_sweep(self, kind: str, trigger: str) -> None: """Run a child deferred-work sweep when policy [sweep].auto matches. The child is its own resumable run; a paused or failed child is journaled + notified but never interrupts this run — "failed" including a ``SystemExit`` (#600). A stop or a ``KeyboardInterrupt`` delivered through the child is the deliberate exception and propagates to the owner (#601); the arms below carry why, in both directions. ``state.sweeps_triggered`` spends the trigger only once a child sweep has actually started. The ``started`` thunk handed to the factory (:class:`SweepFactory`) fires at ``compose_sweep``'s success boundary, and a plain return latches too — so the thunk's only job is to classify a *raise*, and a raise that never reached that boundary leaves the trigger unspent. What that is worth, stated precisely, because the intuitive answer is wrong. It is NOT "the trigger gets retried later": at both call sites the retry closes within a few statements of an un-latched return. - ``run-end`` fires from :meth:`_loop`, whose return lands directly on ``self.state.finished = True`` in :meth:`_run_inner`; a finished run is refused outright by ``cli._resume_paused_run``. There is no later ask. - ``per-epic`` fires from :meth:`_epic_boundary`, and that boundary is detected as ``state.current_epic != story.epic`` — a field that advances within the same frame, either before the gate's ``RunPaused`` or on return into ``_loop``. Nothing in between can pause the run: ``gates`` notification never raises (it swallows its own ``OSError``), and :meth:`_emit` isolates both hook kinds (``_HookError`` for a declarative hook, ``except Exception`` for an in-process one) and *returns* vetoes rather than raising them — which this caller does not even resolve. So the retry survives only in a crash window: a process death, or a ``BaseException``, between the un-latched return and the state write that closes the boundary. Do not widen that claim in a comment or a changelog. What the ordering buys on every non-crashing run is that ``sweeps_triggered`` is *true* — it is durable run state, rendered by ``bmad-loop diagnose`` (``diagnostics.py``), and a record's whole value is that it does not claim work that never happened. The worktree check therefore sits AHEAD of the latch rather than behind it. ``verify.worktree_clean`` fails closed on a ``GitError`` and ``_run_git`` maps a ``subprocess.TimeoutExpired`` onto exactly that, so a `git status` that merely timed out used to spend the run's one and only sweep trigger, permanently and silently. Both refusals carry a ``reason`` so the journal separates a genuinely dirty tree from a git fault. That contract is stated over "a paused or failed child" while the guard below was written over ``Exception``, and the two sets differ in BOTH directions — which is why the arms are shaped the way they are: - ``SystemExit`` is a failed child the guard did not cover. It is a ``BaseException``, so it was missed here, by every arm of :meth:`_run_inner`, and by ``cli.main`` — it unwound through the ``finally`` (persisting this trigger's already-burned latch) and killed the process at exit 1, leaving the parent neither ``finished`` nor ``crashed``, with no ``run-complete`` and an orphaned session. ``runsetup.make_adapters`` raises it, reachably: the unusable-mux refusal sits behind ``mux_usable``, a live uncached ``shutil.which`` re-run on every call. - ``RunStopped`` is the reverse — an ``Exception`` that is not a failure at all. The child's hard-stop arm re-raises it precisely so the owner records the stop; swallowing it as ``sweep-auto-failed`` let the parent run on to ``finished`` AND left it unstoppable, since the signal handler latches ``_stopping`` before raising and every later SIGTERM then returns early. Deliberately NOT ``BaseException``: ``KeyboardInterrupt`` must keep escaping, because the nested-child re-raise in :meth:`_run_inner` depends on it reaching the owner.""" if self.policy.sweep.auto != kind or self.sweep_factory is None: return if trigger in self.state.sweeps_triggered: return # already fired before a pause/resume of this run if graceful_stop_requested(self.run_dir): # A pending graceful stop suppresses new child sweeps. Return (not # raise): at the run-end call site the story queue is already empty, # so finishing this run as `finished` is truthful — the finally clears # the superseded control file. The trigger stays unspent, which is # honest bookkeeping rather than a deferral: see the docstring's # crash-window verdict, which covers this return like the two below. self.journal.append("sweep-auto-suppressed", trigger=trigger) return try: clean = verify.worktree_clean(self.workspace.root, project=self.workspace.paths.project) except verify.GitError as e: # Fails closed — but ahead of the latch, because unlike the dirty-tree # arm this one is transient-reachable: `_run_git` reports a # `subprocess.TimeoutExpired` as GitError (verify.py), so a slow # `git status` used to permanently spend this run's sweep trigger. self._record_sweep_refusal(trigger, SWEEP_REFUSED_DIRTY) self.journal.append( "sweep-auto-skipped-dirty", trigger=trigger, reason="git-error", error=str(e) ) return if not clean: # should not happen at these call sites (everything committed or # reset); refuse rather than sweep on top of stray changes self._record_sweep_refusal(trigger, SWEEP_REFUSED_DIRTY) self.journal.append("sweep-auto-skipped-dirty", trigger=trigger, reason="dirty") return latched = False def latch() -> None: """Spend this run's trigger. Idempotent, so the factory may call it without knowing whether the plain-return arm below already will. The in-memory flag and the state list are set BEFORE the write: a `_save` that fails must not re-open a trigger whose child is already composed and resumable.""" nonlocal latched if latched: return latched = True # Clear any refusal this trigger carries from an earlier ask, so the # two records cannot contradict each other. Reachable only through the # narrow crash window the docstring above bounds — a per-epic trigger # refused, the process dying before the boundary closed, and a resume # re-asking it. Not a retry path; a guard against a stale claim if one # happens. The `failed` arm re-records after this, by design. self.state.sweeps_refused.pop(trigger, None) self.state.sweeps_triggered.append(trigger) self._save() self.journal.append("sweep-auto-trigger", trigger=trigger) try: self.sweep_factory(trigger, started=latch) except RunStopped: raise # a stop is not a failed child — let the owner record it except (Exception, SystemExit) as e: # child must never break the parent if latched: self._record_sweep_refusal(trigger, SWEEP_REFUSED_FAILED) self.journal.append("sweep-auto-failed", trigger=trigger, error=str(e)) gates.notify(self.policy, self.run_dir, "auto sweep failed", f"{trigger}: {e}") else: # The raise beat composition, so there is no child run dir and # nothing to resume — recording the trigger would be a claim about # work that does not exist. Still notified, and with its own # wording rather than none: the loudest raise that lands here is # the #461 config-integrity refusal, a security event that must not # go quiet just because it stopped being permanent. self._record_sweep_refusal(trigger, SWEEP_REFUSED_NOT_STARTED) self.journal.append("sweep-auto-not-started", trigger=trigger, error=str(e)) gates.notify( self.policy, self.run_dir, "auto sweep did not start", f"{trigger}: {e}" ) else: # A plain return is a child that ran, whether or not the factory # bothered with the thunk — the thunk exists to classify raises. # Outside the try on purpose: `latch` writes the PARENT's state, and a # failure there is this run's, not a child failure to swallow. latch() self.journal.append("sweep-auto-finished", trigger=trigger) def _skip_auto_retro( self, epic: int, reason: str, error: str | None = None, *, epic_ref: str | None = None ) -> None: """Journal a pre-dispatch refusal of the auto retrospective and, unless the retro is simply already done, tell the operator to run it by hand. ``epic_ref`` names a slug-keyed epic (tickets mode, ``epic`` 0): it rides the row and the notice names it.""" extra: dict[str, str] = {} if error is None else {"error": error} if epic_ref is not None: extra["epic_ref"] = epic_ref self.journal.append("retro-auto-skipped", epic=epic, reason=reason, **extra) label = f"epic {epic}" if epic_ref is None else epic_ref if reason != "already-done": gates.notify( self.policy, self.run_dir, f"auto retrospective skipped for {label}", f"{reason}: run /bmad-retrospective for {label} by hand", ) def _retro_docs(self, epic: int) -> list[Path]: """``epic``'s retrospective docs on disk, sorted — the source's :meth:`~bmad_loop.worksource.WorkSource.retro_docs`. Raises ``OSError`` when their directory cannot be listed.""" return self.source.retro_docs(epic) def _verify_retro( self, epic: int, result: SessionResult | None ) -> tuple[list[str], list[Path]]: """Deterministic success checks for an auto-retro session (DW-389): every failed check as one ``errors`` line, plus the retro docs found. Success is an empty error list — a completed session, a ``result.json`` saying ``done``, the board reading ``epic-N-retrospective: done``, and at least one retro doc on disk. Each is re-read here; no session prose is consulted. ``result`` is ``None`` when the session raised (the caller supplies that error itself).""" errors: list[str] = [] if result is not None: if result.status != "completed": errors.append(f"session status {result.status}, not completed") elif result_mapping(result.result_json).get("status") != "done": errors.append("result.json status is not done") # the board and doc checks belong to the source; they follow the session's artifact_errors, docs = self.source.retro_artifact_errors(epic) errors.extend(artifact_errors) return errors, docs def _retro_verdict(self, docs: list[Path]) -> tuple[str, Path | None]: """The acceptance verdict of the newest retro doc (DW-487), as ``(verdict, doc)``: one of ``RETRO_VERDICTS`` or ``RETRO_VERDICT_UNKNOWN``, plus the doc it was read from (``None`` when none could be picked). The newest by ``lstat`` mtime (ties by name) is the one this session wrote: a board already reading ``done`` never dispatches, so an older doc is a leftover. Read through ``verify.read_frontmatter``, the spec-frontmatter reader, which already degrades an undecodable or unparseable block to ``{}``; the value is stripped and lowercased, then matched against the skill's closed vocabulary. Every miss — including a doc that cannot be stat'd or read — is ``unknown``, so the caller can never mistake an unreadable verdict for an accepted one, and no raw doc text is returned.""" try: doc = max(docs, key=lambda d: (d.lstat().st_mtime_ns, d.name), default=None) except OSError: return RETRO_VERDICT_UNKNOWN, None if doc is None: return RETRO_VERDICT_UNKNOWN, None try: raw = verify.read_frontmatter(doc).get("verdict") except OSError: return RETRO_VERDICT_UNKNOWN, doc verdict = raw.strip().lower() if isinstance(raw, str) else "" return (verdict if verdict in RETRO_VERDICTS else RETRO_VERDICT_UNKNOWN), doc def _repo_relpath(self, path: Path) -> str | None: """``path`` as the repo-relative posix spelling ``verify.dirty_paths`` uses, or None when it lies outside the repo (a sibling artifacts tree).""" try: return path.resolve().relative_to(self.paths.repo_root.resolve()).as_posix() except (OSError, RuntimeError, ValueError): return None def _settle_retro_tree( self, epic: int, ok: bool, docs: list[Path], *, epic_ref: str | None = None ) -> None: """Leave the tree clean after an auto-retro session, or pause. On success, commit exactly the dirty members of {sprint-status, the retro docs} — the retro's own output, nothing the session happened to touch besides. A commit ``GitError`` degrades to ``retro-auto-uncommitted`` (the paths then stay dirty and the check below pauses on them). Then, success or not, ANY dirty path left pauses the run at the epic boundary without advancing ``state.current_epic``: the next story's rollback resets to its baseline, so an uncommitted retro doc would be destroyed or swept into that story's commit, and an LLM session's stray write must not enter history silently. Resume re-enters this boundary; ``already-done`` keeps a succeeded retro from running twice. ``epic_ref`` names a slug-keyed epic (tickets mode, ``epic`` 0) on every row, the commit and the pause.""" repo = self.paths.repo_root label = f"epic {epic}" if epic_ref is None else epic_ref ref: dict[str, str] = {} if epic_ref is None else {"epic_ref": epic_ref} try: dirty = verify.dirty_paths(repo) if ok: expected = self.source.retro_commit_paths(docs) wanted = {rel for p in expected if (rel := self._repo_relpath(p)) is not None} to_commit = sorted(rel for rel in dirty if rel in wanted) if to_commit: try: verify.commit_paths( repo, f"chore(retro): {label} retrospective", [repo / rel for rel in to_commit], ) except verify.GitError as e: self.journal.append( "retro-auto-uncommitted", epic=epic, error=str(e), **ref ) dirty = verify.dirty_paths(repo) except verify.GitError as e: self.journal.append("retro-auto-dirty", epic=epic, paths=[], error=str(e), **ref) raise RunPaused( f"{label} boundary — could not read the worktree after the auto " f"retrospective ({e}); check it, then `bmad-loop resume {self.state.run_id}`", PAUSE_EPIC_BOUNDARY, ) from e if not dirty: return leftover = sorted(dirty) self.journal.append( "retro-auto-dirty", epic=epic, paths=leftover, count=len(leftover), **ref ) shown = ", ".join(leftover[:_RETRO_DIRTY_REASON_PATHS]) more = len(leftover) - _RETRO_DIRTY_REASON_PATHS if more > 0: shown += f" (+{more} more)" raise RunPaused( f"{label} boundary — the auto retrospective left uncommitted changes: " f"{shown}; commit or clean them, then `bmad-loop resume {self.state.run_id}`", PAUSE_EPIC_BOUNDARY, ) def _maybe_auto_retro(self, epic: int) -> None: """Run the headless ``/bmad-retrospective -H `` session when ``gates.retrospective = "auto"`` (DW-389). Deterministic end to end: the session completes only through the retro adapter's Stop-with-``result.json`` / window-death path, and success is judged by :meth:`_verify_retro` against the on-disk artifacts. The engine never writes sprint-status here — the skill does, and it is only read back. Refusals (journaled ``retro-auto-skipped`` with a closed ``reason``) return without dispatching: ``already-done``, ``board-unreadable``, ``stop-requested``, ``dirty`` / ``git-error`` and ``skill-missing``. The session rides a synthetic ``StoryTask`` that is NEVER put in ``state.tasks``, so ``--max-stories``, ``_pick_next`` and ``_finish_inflight`` stay blind to it. Only a stop, a pause and a ``KeyboardInterrupt`` escape the session; any other exception is a failed retro. A failed retro is not retried within one pass of the boundary; a resume after a ``retro-auto-dirty`` pause re-enters the boundary and re-dispatches unless the board already reads done. The tree is settled afterwards either way (:meth:`_settle_retro_tree`). A succeeded retro's acceptance verdict (:meth:`_retro_verdict`) rides ``retro-auto-finished`` and the done notice; a ``rejected`` one also sends an ATTENTION line. Neither pauses: the verdict is surfaced, not gated (DW-487).""" if self.policy.gates.retrospective != "auto": return try: retro_done = self.source.retro_done(epic) except SourceUnreadable as e: self._skip_auto_retro(epic, "board-unreadable", str(e)) return if retro_done: self._skip_auto_retro(epic, "already-done") return if graceful_stop_requested(self.run_dir): self._skip_auto_retro(epic, "stop-requested") return try: clean = verify.worktree_clean(self.workspace.root, project=self.workspace.paths.project) except verify.GitError as e: self._skip_auto_retro(epic, "git-error", str(e)) return if not clean: self._skip_auto_retro(epic, "dirty") return adapter = self.adapters["retro"] tree = getattr(getattr(adapter, "profile", None), "skill_tree", None) if tree and not (self.workspace.paths.project / tree / RETRO_SKILL / "SKILL.md").is_file(): self._skip_auto_retro(epic, "skill-missing") return task = StoryTask(story_key=f"epic-{epic}-retrospective", epic=epic) self.journal.append("retro-auto-start", epic=epic) result: SessionResult | None = None raised: Exception | None = None try: result = self._run_session( task, role="retro", prompt=_retro_auto_prompt(self.source.retro_target(epic)), seq=1 ) except (RunStopped, RunPaused): raise except Exception as e: # a failed retro, never a failed run raised = e errors, docs = self._verify_retro(epic, result) if raised is not None: errors.insert(0, f"session raised {type(raised).__name__}") ok = not errors if ok: names = [d.name for d in docs] verdict, verdict_doc = self._retro_verdict(docs) self.journal.append("retro-auto-finished", epic=epic, docs=names, verdict=verdict) gates.notify( self.policy, self.run_dir, f"epic {epic} retrospective done", f"retrospective written: {', '.join(names)}; verdict: {verdict}", ) if verdict == RETRO_VERDICT_REJECTED and verdict_doc is not None: self._notify_retro_rejected(epic, verdict_doc) else: extra = {} if raised is None else {"error": str(raised)} self.journal.append("retro-auto-failed", epic=epic, errors=errors, **extra) gates.notify( self.policy, self.run_dir, "auto retrospective failed", f"epic {epic}: {'; '.join(errors)} — run /bmad-retrospective for epic " f"{epic} by hand", ) self._settle_retro_tree(epic, ok, docs) def _notify_retro_rejected(self, epic: int, doc: Path) -> None: """The ATTENTION line for a retrospective that REJECTED its epic (DW-487). Notify-only by decision: the run is not paused, it goes on to the next epic, so the line says so and points at the doc that holds the evidence. The doc name is a session-written filename, so it is folded through ``gates.notice_line`` (DW-417/419) to stay one segment of the line.""" gates.notify( self.policy, self.run_dir, f"epic {epic} retrospective rejected", f"the auto retrospective's verdict for epic {epic} is rejected (see " f"{gates.notice_line(doc.name)}); the run continues with the next epic — " f"review its Acceptance verdict and Action items before relying on epic " f"{epic}'s work", ) def _nudge_retrospective(self, epic: int) -> None: """The ``gates.retrospective = "notify"`` half of the retrospective gate: one "retrospective suggested" nudge for a finished epic. ``auto`` is :meth:`_maybe_auto_retro`; each half checks its own mode, so a caller fires the gate by calling both and never switches on the mode itself.""" if self.policy.gates.retrospective == "notify": gates.notify( self.policy, self.run_dir, f"epic {epic} stories complete", "retrospective suggested: run /bmad-retrospective when convenient", ) def _run_end_retrospective(self) -> None: """Fire the retrospective gate for the run's last epic at run end (DW-488). :meth:`_epic_boundary` fires only when a story of a *different* epic is picked, so the final epic of a run (every epic of a single-epic run) never crossed one. Called from both of :meth:`_loop`'s run-end returns: the exhausted queue, ahead of the run-end sweep, and ``--max-stories`` — a run that stops there is recorded ``finished`` too, and a later run starts with no ``current_epic``, so no boundary would ever fire for that epic. A pause, stop or crash unwinds past both returns and never reaches this. Fires only when ``state.current_epic`` (the epic of the last story this run dispatched) reads complete on the board — at least one story of it, every one ``done`` — and ``epic-N-retrospective`` is not already ``done``: a truncated run with the epic's stories left fires nothing. Each refusal is journaled ``retro-run-end-skipped`` with a closed ``reason`` (``epic-incomplete``, ``already-done``, ``board-unreadable``); an unreadable board is a refusal, never read as a complete epic. The gate itself is the epic boundary's, unchanged: ``auto`` runs :meth:`_maybe_auto_retro` (its failures notify and never fail the run; a dirty tree after it pauses at ``PAUSE_EPIC_BOUNDARY``, and resume re-enters here, where an already-done retro is skipped) and ``notify`` sends :meth:`_nudge_retrospective`'s nudge. A source without epics (``has_epics`` false) never crosses an epic boundary, so it never fires this gate either.""" if not self.source.has_epics: return epic = self.state.current_epic if epic is None or self.policy.gates.retrospective == "never": return # no epic dispatched, or no gate to fire: leave the board unread try: progress = self.source.epic_progress(epic) except SourceUnreadable as e: self.journal.append( "retro-run-end-skipped", epic=epic, reason="board-unreadable", error=str(e) ) return if not progress.complete: self.journal.append("retro-run-end-skipped", epic=epic, reason="epic-incomplete") return if progress.retro_done: self.journal.append("retro-run-end-skipped", epic=epic, reason="already-done") return self.journal.append("retro-run-end", epic=epic) self._maybe_auto_retro(epic) self._nudge_retrospective(epic) def _epic_boundary(self, finished_epic: int, next_epic: int) -> None: self.journal.append("epic-boundary", finished=finished_epic, next=next_epic) self._emit("pre_epic_boundary", epic=finished_epic) # Before the per-epic sweep, so the sweep starts on a clean tree and sees # the retro's committed action items (DW-388 ingests them). self._maybe_auto_retro(finished_epic) self._maybe_auto_sweep("per-epic", self.source.epic_label(finished_epic)) self._nudge_retrospective(finished_epic) self._emit("post_epic_boundary", epic=finished_epic) if gates.pause_at_epic_boundary(self.policy): self.state.current_epic = next_epic # don't re-trigger this gate on resume self._save() raise RunPaused( f"epic {finished_epic} boundary — `bmad-loop resume {self.state.run_id}` " f"to continue with epic {next_epic}", PAUSE_EPIC_BOUNDARY, ) def _save(self) -> None: save_state(self.run_dir, self.state)