From cb3bb3d64ac2e49028399a33f58ceb1040c92e0d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 19:27:05 -0700 Subject: [PATCH 01/45] fix(verify): require a dispatch-time expectation before a park skips proof-of-work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parked leg of `verify_dev` selected the proof-of-work skip from state a fresh session can inherit: the `[operator] enabled` policy flag plus the spec's own `awaiting-operator` status. A spec an earlier attempt left parked still reads parked to the next session, so a re-drive that did nothing selected #676's relaxation and verified green on someone else's declaration. The skip is now `parked and park_eligible`, giving it the two-sided shape plan-halt already has. `Engine._park_eligible_at_dispatch` records, on the fresh entry into `_dev_phase` and from the same instant as `baseline_commit`, whether the story's bound spec was already at `awaiting-operator`. Anchoring it to the phase rather than the attempt keeps a fixable repair of a malformed park eligible — that retry deliberately inherits the previous session's tree. Eligibility gates the skip and nothing else: the status pair, actions list, workflow tag, baseline match and sprint pair all still select on the observed status, so an inherited park carrying a real diff passes as before. When the skip does fire on an accepted park, the shared gate now runs its proof-of-work probe as an observation — from its own `proof_baseline`, so a commit that reached a shared checkout from outside the session cannot be credited — and the answer travels out on the return value to `_verify_dev_artifacts`, which journals `park-proof-of-work-skipped` with `zero_diff`. A park that wrote code and a park that wrote nothing are no longer indistinguishable after the fact. --- CHANGELOG.md | 16 ++ docs/FEATURES.md | 6 +- src/bmad_loop/engine.py | 110 +++++++++++++- src/bmad_loop/model.py | 61 +++++++- src/bmad_loop/verify.py | 317 ++++++++++++++++++++++++++++++--------- tests/test_engine.py | 322 ++++++++++++++++++++++++++++++++++++++++ tests/test_model.py | 45 ++++++ tests/test_verify.py | 233 ++++++++++++++++++++++++++++- 8 files changed, 1026 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b711d728..fbf75018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,6 +180,22 @@ breaking changes may land in a minor release. ### Fixed +- Require the orchestrator's own dispatch-time expectation before an `awaiting-operator` + park skips the dev gate's proof-of-work check (#335, #676). The skip was selected by the + policy flag plus the spec's own status, both of which a fresh session inherits, so a + re-drive over a spec an earlier attempt had already parked verified green having done + nothing. The expectation is captured once per dev phase, on the same anchor as the + attempt baseline, so a fixable repair of a malformed park still passes. An inherited park + that did real work is unaffected — only the skip narrows, not the park. One upgrade + note: the expectation defaults to "not eligible" for state written before it existed, so + a run interrupted mid-park and resumed after upgrading holds that in-flight park to + proof-of-work — if it produced no code, it is retried and may defer rather than parking. + Re-running the story is enough; nothing is lost. +- Journal `park-proof-of-work-skipped` with a `zero_diff` flag whenever an accepted park's + gate is waived (#676), so a park that wrote nothing and a park that committed real code + stop being indistinguishable after the fact. The probe is an observation only: when it + cannot answer — a git fault, or no recorded baseline — the flag is `null` and the outcome + is unchanged. - Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run owns. Under isolation both resolved against the main checkout, so the review modals showed that copy of the spec and the replan reset it — reporting success while the run's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 31735b18..e0e0d2f9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -47,7 +47,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Verification (trust-nothing gate) -- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. +- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park this attempt newly elected — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. - Runs _your_ commands (`[verify].commands`, e.g. `pytest -q`, `ruff check .`) in the git root the code lives in (`repo_root`): your project dir by default, the mounted per-unit worktree under `[scm] isolation = "worktree"`, and an explicit `repo_root:` when you set one under `isolation = "none"`. The gate's own artifact READS — the spec's frontmatter, the sprint board, the deferred-work ledger — stay project-rooted either way (#695). What follows the code, besides the command `cwd`, is every git question the gate asks about it: the recorded baseline is written in the git root, so the commit-identity lookup, both ancestry checks and the non-empty-diff probe are all asked there, and the pathspecs they exclude are spelled relative to that same root (#716). Anchoring those on the project dir meant a correct attempt could be refused forever under an explicit `repo_root:`. A broken build never reaches review or commit. ### Adversarial review (review stage) @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). That skip covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement — a re-drive that does nothing no longer verifies green on someone else's park. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so every ACCEPTED park's waived gate is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got in. (A park that waived the gate and then failed a later one is refused, and records nothing — the log answers which parks were accepted without proving work.) Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. @@ -172,7 +172,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). +- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per accepted `awaiting-operator` park whose proof-of-work gate was waived, carrying `zero_diff` — `true` when the park's whole residue was its own spec plus the board (the shape the waiver exists for), `false` when it also committed real code, `null` when the probe could not answer — a git fault, or an attempt with no recorded baseline to measure from — and the gate was waived anyway — so a waived gate always leaves a trace instead of being indistinguishable from one that ran); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 5c9891e0..7240c119 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2299,6 +2299,15 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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() + # Whether this phase may newly ELECT a park, on the same anchor and + # for the same reason as the baseline above: the proof-of-work skip + # this authorizes is measured from that baseline, so the expectation + # and the diff it guards have to be captured at one instant. A fixable + # repair therefore inherits the phase's answer (it deliberately keeps + # the previous session's tree, park declaration included, so + # re-observing per attempt would make every repair of a malformed park + # ineligible), and a crash-replayed attempt keeps the persisted one. + task.park_eligible = self._park_eligible_at_dispatch(task) feedback: Path | None = None while True: replayed = resume_result is not None @@ -3550,6 +3559,76 @@ def _operator_park_enabled(self) -> bool: branch happens to sit.""" return self.policy.operator.enabled + def _park_eligible_at_dispatch(self, task: StoryTask) -> bool: + """Whether the attempt about to be dispatched could newly ELECT a park — + the orchestrator-side half of :func:`verify.verify_dev`'s two-part + proof-of-work skip selector (#335, #676). + + The skip used to be selected entirely by state a fresh session can + INHERIT: ``operator_park`` (a policy flag) plus the spec's own + ``awaiting-operator`` status, which an earlier attempt may already have + written. A re-drive over such a spec therefore selected #676's relaxation + while having done nothing at all, and verified green on someone else's + park declaration. This is the fact that cannot be inherited: at the moment + the phase is dispatched, was the story's bound spec ALREADY parked? + + ``False`` when parking is off (the skip is unreachable anyway, so this + costs no read), when the bound spec already reads ``awaiting-operator``, + and on the two genuinely unobservable shapes: a recorded ``spec_file`` + that no longer resolves to a trusted regular file, and one whose read + raises ``OSError`` (journaled ``spec-read-failed``). Those fail closed onto + the ordinary gated path, where an honest park with a real diff still + passes. + + An UNPARSEABLE spec is deliberately not in that list, and the distinction + is worth stating because it looks like a gap. ``read_frontmatter`` + degrades malformed YAML and non-UTF-8 to ``{}`` rather than raising, so + ``status_of`` reads ``""`` and this returns True. That is correct rather + than merely tolerated: an unparseable spec demonstrably does not say + "parked", and ``verify_dev``'s own gate reads the very same ``{}``, so + ``parked`` is False there too and the skip is unreachable on that leg no + matter what this answers. Only OSError and an unresolvable binding are + uncertainty about a spec that *does* say something. + + ``True`` when nothing is bound at all — the ordinary case, not a fallback. + Note precisely what that tests: ``task.spec_file`` is an IN-RUN binding, + set only after a session returns and its artifacts verify, so "unbound" + means "this task object has no binding", NOT "no earlier park exists on + disk". A story whose spec was parked by a previous RUN, or edited into the + park status out of band, presents as unbound here and is eligible. The + residual is recorded as a deferred finding on this change's spec rather + than closed silently; closing it means keying eligibility on the spec the + story resolves to rather than on the task's binding, which is a wider + change than the one this gate makes. + + Called only from ``_dev_phase``'s ``resume_result is None`` block, beside + the baseline capture — see the comment there for why the anchor is the + PHASE and not the attempt. Reuses ``_dispatched_spec_for_attempt`` for the + symlink/roots checks rather than re-deriving them: a second, laxer + resolution here would be a second answer to "which file is this attempt's + spec", and recovery already owns that question. + + Consequence worth knowing before touching either caller: that resolver is + now invoked TWICE per dev phase — once here at phase entry, and once by + the binder inside the attempt loop. They are two observations of the same + path at different instants and neither may be folded into the other (this + one must precede the first attempt; the binder's must be the one that + promotes). Any test that counts calls to it has to say which observation + it means — ``test_transient_initial_binding_fault_does_not_promote_after_bare_prompt`` + pins this one out for exactly that reason. + """ + if not self._operator_park_enabled(): + return False + if not task.spec_file: + return True + bound = self._dispatched_spec_for_attempt(task) + if bound is None: + return False + fm = self._observed_frontmatter(Path(bound), task.story_key, "park-eligibility") + if fm is None: + return False + return verify.status_of(fm) != verify.AWAITING_OPERATOR + 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 @@ -5200,14 +5279,43 @@ def _harvest_gate_exclude(self, task: StoryTask) -> tuple[str, ...]: return (rel.as_posix(),) def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): - return verify.verify_dev( + outcome = verify.verify_dev( task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled(), operator_park=self._operator_park_enabled(), + # The dispatch-time half of the park's proof-of-work skip selector, + # read from the task rather than re-observed: it was captured on this + # phase's fresh entry, and re-deriving it now would answer about the + # spec the session just finished writing (#676). + park_eligible=task.park_eligible, 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 session's whole residue was the spec and the board (the + # #676 shape the skip exists for), `false` when it also carried real code, + # and JSON `null` when the probe could not answer — a git fault, 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. + # + # Only ACCEPTED parks reach here with the flag set: it rides the `passed()` + # return, so a park that waived proof-of-work and then failed the sprint + # pair records nothing. That is the intended scope — the question this + # answers is which parks got IN without proving work. + 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 _verify_review(self, task: StoryTask): # `not _dev_review_enabled()` is exactly the case where _post_dev_state_sync diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index c9f784c2..da64c8fd 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -294,6 +294,20 @@ class StoryTask: # owes, and nothing re-derives it once the session that wrote the spec is # gone). operator_actions: list[str] = field(default_factory=list) + # Whether THIS dev phase was in a position to newly elect a park: captured + # once, on the fresh entry into `Engine._dev_phase` (`resume_result is None`), + # from the same instant and the same condition as `baseline_commit` — so the + # expectation and the diff it guards share one anchor. False when the bound + # spec was ALREADY at `awaiting-operator` on entry (an earlier attempt's park + # is on disk, so a park observed afterwards may be inherited rather than + # elected), when parking is disabled, or when the spec could not be read at + # all (fail closed). It gates exactly one thing: `verify_dev`'s proof-of-work + # skip on the park leg (#335, #676). Every other park gate still selects on the + # observed status alone, so an ineligible park with a real diff still passes. + # Deliberately per-PHASE, not per-attempt: a fixable repair keeps the previous + # session's tree, so re-observing would make every repair of a malformed park + # ineligible and fail it on the gate it just re-armed. + park_eligible: bool = False defer_reason: str | None = None # the recovery ref this attempt's work was parked on by the last auto-rollback # — an `attempt-preserve/*` branch (commits above baseline) or, when the tree @@ -442,6 +456,7 @@ def to_dict(self) -> dict[str, Any]: ), "commit_sha": self.commit_sha, "operator_actions": self.operator_actions, + "park_eligible": self.park_eligible, "defer_reason": self.defer_reason, "preserve_ref": self.preserve_ref, "preserve_partial": self.preserve_partial, @@ -629,6 +644,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot=dispatched_spec_snapshot, commit_sha=d.get("commit_sha"), operator_actions=[str(a) for a in d.get("operator_actions", [])], + park_eligible=bool(d.get("park_eligible", False)), defer_reason=d.get("defer_reason"), preserve_ref=d.get("preserve_ref"), preserve_partial=bool(d.get("preserve_partial", False)), @@ -868,10 +884,51 @@ class VerifyOutcome: # time): no further session can reconcile it, so it routes to a pause with # both sides named rather than to another cycle (#334) contradiction: bool = False + # Whether this ACCEPTED outcome waived the dev gate's proof-of-work check on + # the park leg (`verify_dev`'s two-part park selector fired). The fact of the + # waiver, not its result: `Engine._verify_dev_artifacts` journals exactly the + # attempts this is True for, so an accepted park's waived gate always leaves a + # trace (#676). + # + # Scoped to ACCEPTED deliberately, and it is the whole guarantee: this rides + # only the `passed()` return, so a leg that waived proof-of-work and then + # failed a LATER gate — the sprint pair is the reachable one — records nothing. + # That is the intended bound, not a gap: the record answers "which accepted + # parks got in without proving work", and a park that was refused did not get + # in. Anything wider would need the flag on the failing constructors too. + park_proof_skipped: bool = False + # An OBSERVATION, never a gate: on that same waived leg, whether the tree was + # in fact free of code residue since the attempt's baseline. `True` = the + # accepted park wrote nothing beyond what proof-of-work already excludes, + # `False` = it carried a real diff, `None` = the probe could not answer. Two + # things produce that `None`: a git fault (it degrades rather than escalating) + # and an attempt with no `baseline_commit` to measure from. Nothing branches + # on it. Note also that `False` is the weaker of the two definite answers: + # the probe inherits `has_changes_since`'s fail-open, so a git REFUSAL (rc 128, + # e.g. an unresolvable baseline) reads as "there are changes" rather than + # raising, and is recorded as `False`. + # + # The two fields are deliberately separate, and collapsing them is the bug + # this pair exists to prevent: one says a gate was waived, the other says what + # that gate would have found. Keyed on the observation alone, an unanswerable + # probe is indistinguishable from no waiver at all — so a park whose probe + # faulted would go unrecorded, re-creating exactly the silence this pair ends. + # A waived gate is recorded whatever the probe managed to say; `None` is a + # truthful field value, not a reason to withhold the record. + park_zero_diff: bool | None = None @classmethod - def passed(cls) -> "VerifyOutcome": - return cls(ok=True) + def passed( + cls, + *, + park_proof_skipped: bool = False, + park_zero_diff: bool | None = None, + ) -> "VerifyOutcome": + return cls( + ok=True, + park_proof_skipped=park_proof_skipped, + park_zero_diff=park_zero_diff, + ) @classmethod def retry(cls, reason: str, fixable: bool = False) -> "VerifyOutcome": diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 702e38ef..d97733df 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3292,6 +3292,29 @@ def _gate_frontmatter(spec_path: Path) -> dict[str, Any] | VerifyOutcome: return VerifyOutcome.retry(f"spec unreadable ({e.__class__.__name__}: {e}): {spec_path}") +@dataclass(frozen=True) +class _SharedGateResult: + """What :func:`_verify_shared_gates` answers: the failing outcome (``None`` + when every gate passed and the caller may run its mode-specific tail), plus + whatever the gate OBSERVED on the way through that no gate acted on. + + ``skipped_proof_zero_diff`` is the second kind: on a leg that skipped + proof-of-work and asked to be told anyway (``observe_skipped_proof``), it is + ``True`` when the tree held no changes the gate would have counted, ``False`` + when it held some, and ``None`` when nothing was observed — no skip, no + request, no baseline, or a git fault. It is deliberately a return value and + not a gate input: the observation must be made HERE because the baseline it + measures from is derived here (the newer-claim branch can re-anchor + ``proof_baseline`` and drop untracked evidence), and no caller can reproduce + that derivation. A caller re-probing from ``task.baseline_commit`` would count + a commit that arrived in a shared ``isolation = "none"`` checkout from outside + the session as this attempt's work — the exact false negative the observation + exists to expose.""" + + outcome: VerifyOutcome | None = None + skipped_proof_zero_diff: bool | None = None + + def _verify_shared_gates( spec_path: Path, rj: dict[str, Any], @@ -3300,15 +3323,17 @@ def _verify_shared_gates( *, expected_status: str, extra_exclude: tuple[str, ...] | None, + observe_skipped_proof: tuple[str, ...] | None = None, allow_ancestor_baseline: bool = False, fm: dict[str, Any] | None = None, -) -> VerifyOutcome | None: +) -> _SharedGateResult: """The workflow-tag, expected-status, baseline-match, and proof-of-work gates shared verbatim by :func:`verify_dev`, :func:`verify_dev_bundle`, and :func:`verify_dev_stories` — factored out so the sprint-mode and stories-mode gates can't silently drift. Reads frontmatter once; a caller that had to read it first to *choose* ``expected_status`` passes what it read as ``fm`` so the - single-read contract still holds (no caller re-reads it). Returns a failing + single-read contract still holds (no caller re-reads it). Returns a + :class:`_SharedGateResult` whose ``outcome`` is a failing :class:`VerifyOutcome`, or ``None`` when every gate passes and the caller may run its mode-specific tail. @@ -3325,22 +3350,54 @@ def _verify_shared_gates( leg produced only its own spec (structurally spec-only), and a park may legitimately have produced no code at all because its remaining work is a human's (#676). Both mean "there is no diff to demand here"; neither - generalizes to the other's leg, so keep them named separately.""" + generalizes to the other's leg, so keep them named separately. + + ``observe_skipped_proof`` is the same exclusion tuple the caller WOULD have + passed as ``extra_exclude`` had it not skipped the gate. When set on a skipped + leg the probe still runs — against the baseline derived above, not the raw + ``task.baseline_commit`` — purely to answer whether there was in fact a diff, + and the answer rides out on ``_SharedGateResult.skipped_proof_zero_diff``. + Nothing branches on it here: a fault degrades to ``None`` rather than + escalating, and the leg's outcome is identical either way. It exists so an + accepted park's skipped gate stops being silent (#676) — a park that wrote + code and a park that wrote nothing are otherwise indistinguishable after the + fact. + + Exactly one of the two skipping legs asks for it, and the asymmetry is + deliberate rather than an omission: only sprint mode's PARK passes it. + ``verify_dev_stories``' plan halt skips the gate and observes nothing, because + it already has an independent cross-check a park has no equivalent for — a + clean plan-halt carries ``devcontract``'s ``plan_halt`` marker in its + result.json (``rj.get("plan_halt") is not True`` refuses the leg outright), so + a died-mid-flight ``ready-for-dev`` cannot reach the skip in the first place. A + park's status is self-asserted with no such marker, which is why it is the leg + that needs a record of what the waived gate would have found. + + The two parameters are MUTUALLY EXCLUSIVE by construction: ``extra_exclude`` + gates and ``observe_skipped_proof`` observes, and the arms below are ``if`` / + ``elif`` on that order. Passing both is not a richer mode, it is a caller + error that silently drops the observation — the gate arm wins and the leg was + never skipped, so there was nothing to observe. Pass ``extra_exclude`` OR + ``observe_skipped_proof``, never both.""" workflow = rj.get("workflow") if workflow != DEV_WORKFLOW: - return VerifyOutcome.retry( - f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}" + return _SharedGateResult( + VerifyOutcome.retry( + f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}" + ) ) if fm is None: read = _gate_frontmatter(spec_path) if isinstance(read, VerifyOutcome): - return read + return _SharedGateResult(read) fm = read status = status_of(fm) if status != expected_status: - return VerifyOutcome.retry( - f"spec status is {status!r}, expected {expected_status!r}: {spec_path}" + return _SharedGateResult( + VerifyOutcome.retry( + f"spec status is {status!r}, expected {expected_status!r}: {spec_path}" + ) ) # The generic bmad-build-auto skill stamps `baseline_revision`, never @@ -3376,11 +3433,13 @@ def _verify_shared_gates( try: canonical_claimed = _canonical_commit_oid(paths.repo_root, claimed_baseline) except GitError as e: - return VerifyOutcome.escalate(str(e)) + return _SharedGateResult(VerifyOutcome.escalate(str(e))) if canonical_claimed is None: - return VerifyOutcome.retry( - f"spec baseline {claimed_baseline[:12]} does not match " - f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + return _SharedGateResult( + VerifyOutcome.retry( + f"spec baseline {claimed_baseline[:12]} does not match " + f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + ) ) if canonical_claimed != task.baseline_commit: # A deferred-work bundle may legitimately adopt a pre-existing story @@ -3412,34 +3471,70 @@ def _verify_shared_gates( proof_baseline = canonical_claimed if newer_ok else proof_baseline include_untracked_proof = not newer_ok if not (older_ok or newer_ok): - return VerifyOutcome.retry( - f"spec baseline {claimed_baseline[:12]} does not match " - f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + return _SharedGateResult( + VerifyOutcome.retry( + f"spec baseline {claimed_baseline[:12]} does not match " + f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + ) ) - if extra_exclude is not None and task.baseline_commit: - # The exclude pathspecs are rooted where git is invoked: `repo_root` here - # and `repo_root` in every producer that composes into `extra_exclude` - # (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative - # to a different root is not merely wrong, it is SILENTLY wrong — git - # matches nothing and the exclusion evaporates. - exclude = ( - verify_dev_exclude_relpaths(paths, spec_path, task.restore_patch, root=paths.repo_root) - + extra_exclude + def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: + """The one place proof-of-work is measured, called by BOTH arms below. + + The gate arm and the observation arm differ in exactly one input — which + mode-supplied tuple composes onto the gate's own exclusions — and in + nothing else. They were briefly two spelled-out copies of the same five + arguments, and every property the docstrings claim for the observation + (that it excludes the mode's paths, that it keeps the newer-claim + ``proof_baseline``, that it inherits ``include_untracked_proof``) was + silently droppable in the copy while the gate stayed correct and the suite + stayed green. A shared body makes the two unable to disagree by + construction, which is stronger than any test over the copies: divergence + is no longer a thing a reader can express here. + + The exclude pathspecs are rooted where git is invoked: `repo_root` here + and `repo_root` in every producer that composes into them + (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative + to a different root is not merely wrong, it is SILENTLY wrong — git + matches nothing and the exclusion evaporates. + """ + return has_changes_since( + paths.repo_root, + proof_baseline, + exclude=verify_dev_exclude_relpaths( + paths, spec_path, task.restore_patch, root=paths.repo_root + ) + + mode_exclude, + baseline_untracked=task.baseline_untracked, + include_untracked=include_untracked_proof, ) + + if extra_exclude is not None and task.baseline_commit: try: - if not has_changes_since( - paths.repo_root, - proof_baseline, - exclude=exclude, - baseline_untracked=task.baseline_untracked, - include_untracked=include_untracked_proof, - ): - return VerifyOutcome.retry("no changes in worktree since baseline commit") + if not proof_of_work_probe(extra_exclude): + return _SharedGateResult( + VerifyOutcome.retry("no changes in worktree since baseline commit") + ) except GitError as e: - return VerifyOutcome.escalate(str(e)) + return _SharedGateResult(VerifyOutcome.escalate(str(e))) + elif observe_skipped_proof is not None and task.baseline_commit: + # The gate was skipped; run its probe anyway and report, never refuse. + # Only `GitError` is caught, so a non-git bug still surfaces — but that is + # a narrower guarantee than "an unanswerable probe records None". The + # observation inherits `has_changes_since`'s deliberate fail-open: any + # non-zero rc reads as "there are changes", and only timeout, spawn and + # decode faults raise `GitError` at all. So a git REFUSAL — an unresolvable + # baseline, rc 128 — is recorded as `zero_diff: False`, "this park + # committed real code". The bias is toward the less alarming record, which + # is the right direction for a field nothing gates on, but it means a + # `False` here is weaker evidence than a `True`. + try: + skipped_proof_zero_diff = not proof_of_work_probe(observe_skipped_proof) + except GitError: + skipped_proof_zero_diff = None + return _SharedGateResult(None, skipped_proof_zero_diff) - return None + return _SharedGateResult() # The terminal spec status of a story whose agent-doable work is finished but @@ -3481,6 +3576,7 @@ def verify_dev( review_enabled: bool = True, *, operator_park: bool = False, + park_eligible: bool = False, engine_written: tuple[str, ...] = (), ) -> VerifyOutcome: """Verify a dev session's on-disk artifacts against its result.json claims. @@ -3502,9 +3598,15 @@ def verify_dev( a terminal the gate knows, so it fails the ordinary status check and the session is retried with that mismatch as feedback. - On the park leg the proof-of-work gate is skipped, the same way the plan-halt - leg of :func:`verify_dev_stories` skips it and by the same ``extra_exclude=None`` - spelling: a park's whole output can legitimately be its own spec's park + The proof-of-work gate is skipped on a park that this attempt was in a + position to newly ELECT — ``skip_proof = parked and park_eligible``, a + two-part selector. ``parked`` is what the session left behind (the observed + spec status, plus the policy flag); ``park_eligible`` is what the orchestrator + knew at dispatch (:meth:`Engine._park_eligible_at_dispatch`, captured on the + fresh entry into ``Engine._dev_phase`` from the same instant and the same + condition as ``task.baseline_commit``): the story's bound spec did NOT already + read ``awaiting-operator``. Both halves are load-bearing. The skip exists + because a park's whole output can legitimately be its own spec's park declaration plus the board sync, both of which proof-of-work already excludes, so demanding a diff read a correct park as "no changes since baseline commit" and refused it (#676) — costing the attempt, and with it the park declaration: @@ -3515,27 +3617,76 @@ def verify_dev( gate passes — not the session's own work: ``bmad-build-auto`` commits each iteration, so a skill commit chain usually already sits above baseline (``Engine._finalize_commit_phase``), and a reset discards that too, onto an - ``attempt-preserve/*`` ref. Nothing else relaxes — the - ``operator_actions`` gate above still refuses a park that enumerates nothing, - and the workflow-tag, status, baseline-match and sprint-pair gates all still - run. Two of those four are not independent evidence on this leg, and saying so - is the point: the status check is tautological here (the same ``fm`` that - selected ``parked`` is threaded in as ``fm=fm``, so the shared gate compares it - against an ``expected_status`` derived from itself), and the sprint pair was - written from that same frontmatter by ``Engine._post_dev_state_sync`` a dozen - lines before this gate runs, so it confirms the orchestrator's own write landed - rather than anything the session did. What still binds a park to the attempt - the orchestrator actually launched is the workflow tag, the baseline match, and - a non-empty actions list — and the middle one is weaker on this leg than its - name suggests. Baseline-match also accepts a claim NEWER than the recorded - baseline whenever it is a HEAD-reachable descendant, and the comment guarding - that branch names the compensating control: such a commit "may have arrived in - the shared checkout from outside the session", so the check re-anchors - proof-of-work onto the claimed commit rather than trusting the match alone. - Proof-of-work is precisely what this leg skips, so on a park that re-anchoring - is inert and the newer-claim branch tightens nothing. The trade is recorded rather than hidden: the skip - covers EVERY park, including one that wrote nothing and listed plausible - actions, because the actions gate tests list non-emptiness and never content. + ``attempt-preserve/*`` ref. + + What the eligibility half defends is narrow and worth naming exactly. Before + it, the relaxation was selected entirely by state a fresh session could + INHERIT rather than produce: a spec an earlier attempt left at + ``awaiting-operator`` still reads ``awaiting-operator`` to the next session + that does nothing at all, so a re-drive over that spec selected the skip and + verified green on someone else's declaration, relaxing #676's skip for an + attempt that produced nothing. Requiring the + orchestrator's own dispatch-time answer means the leg that skips proof-of-work + is the leg that actually authored the park. It does NOT defend against a + session that elects a park it did not earn — one that writes the frontmatter, + lists plausible actions and implements nothing is eligible by construction and + still passes, because the actions gate tests list non-emptiness and never + content. It is a check on WHICH ATTEMPT owns the park, not on whether the park + is honest, and it is captured per PHASE rather than per attempt: a fixable + repair deliberately keeps the previous session's tree, so re-observing would + make every repair of a malformed park ineligible and fail it on the gate it + just re-armed. + + An INELIGIBLE park is not refused — it is merely held to proof-of-work like + any other terminal. The park's status pair, ``operator_actions`` + non-emptiness, workflow tag, baseline match and sprint pair all keep selecting + on the observed status alone, so an inherited park carrying a real diff passes + exactly as before; only the residue-free one now owes the diff it never + produced. + + Nothing else relaxes on the eligible leg either — the ``operator_actions`` + gate above still refuses a park that enumerates nothing, and the workflow-tag, + status, baseline-match and sprint-pair gates all still run. Two of those four + are not independent evidence on this leg, and saying so is the point: the + status check is tautological here (the same ``fm`` that selected ``parked`` is + threaded in as ``fm=fm``, so the shared gate compares it against an + ``expected_status`` derived from itself), and the sprint pair was written from + that same frontmatter by ``Engine._post_dev_state_sync`` a dozen lines before + this gate runs, so it confirms the orchestrator's own write landed rather than + anything the session did. What still binds a park to the attempt the + orchestrator actually launched is the workflow tag, the baseline match, the + non-empty actions list — and now the dispatch-time eligibility, which is the + only one of the four the session cannot influence at all. Baseline-match also + accepts a claim NEWER than the recorded baseline whenever it is a + HEAD-reachable descendant, and the comment guarding that branch names the + compensating control: such a commit "may have arrived in the shared checkout + from outside the session", so the check re-anchors proof-of-work onto the + claimed commit rather than trusting the match alone. Proof-of-work is precisely + what this leg skips, so on a park that re-anchoring still gates nothing — but + it is no longer inert: the observation below inherits it, so a foreign commit + cannot be credited as this attempt's work in the record either. + + The accepted skip is no longer silent, and it is recorded on TWO fields + because one cannot carry both facts. ``VerifyOutcome.park_proof_skipped`` is + the waiver itself — ``skip_proof``, ``False`` on every other leg. When it + fires, the shared gate additionally runs the proof-of-work probe as a pure + OBSERVATION (``observe_skipped_proof=engine_written``) and what that probe + found rides out on ``VerifyOutcome.park_zero_diff``: ``True`` for a park with + no code residue, ``False`` for one carrying a real diff, ``None`` when the + probe could not answer. What separates "unknown" from "no skip happened" is + ``park_proof_skipped``, not this field — collapsing the two into + ``park_zero_diff is not None`` would make a park whose probe faulted look like + a leg that never waived anything, and it would go unrecorded — the silence + this record exists to end. ``None`` has exactly two causes now, both of them + "the probe could not answer": a git fault, and an attempt carrying no + ``task.baseline_commit`` to measure from (the shared gate runs neither arm + without one). Neither field changes an outcome: a git fault degrades to + ``None`` rather than escalating, and an eligible park verifies identically + either way. Their consumer is + :meth:`Engine._verify_dev_artifacts`, which journals + ``park-proof-of-work-skipped`` for every waived gate and carries the + observation as that record's ``zero_diff`` field, so a park that wrote code + and a park that wrote nothing stop being indistinguishable afterwards (#676). ``engine_written`` names paths the orchestrator itself wrote above this gate during the attempt, relative to ``paths.repo_root`` — the tree the gate invokes @@ -3543,9 +3694,11 @@ def verify_dev( must share (#716). They compose with the mode's normal proof-of-work exclusions so engine bookkeeping cannot masquerade as session work; see :meth:`Engine._harvest_gate_exclude`, which is their producer and states what a - ledger outside the code tree resolves to. On the parked leg they are not passed - at all — proof-of-work is skipped there, so there is no exclusion set left for - them to compose with. + ledger outside the code tree resolves to. On the skipped park leg they are + passed as ``observe_skipped_proof`` instead of ``extra_exclude``: no gate + consumes them there, but the zero-diff observation must exclude exactly what + the gate would have, or the orchestrator's own bookkeeping writes would be + recorded as the park's code residue. """ rj = result_json or {} spec_file = rj.get("spec_file") @@ -3563,6 +3716,12 @@ def verify_dev( actions = _operator_actions_gate(fm, task.story_key) if actions is not None: return actions + # The two-part selector: the session's observed park AND the orchestrator's + # dispatch-time answer that this phase could newly elect one. Deliberately a + # separate name from `parked` — every other park gate below still keys on + # `parked` alone, and collapsing the two would silently widen this expectation + # from "may skip proof-of-work" to "may park at all" (#335, #676). + skip_proof = parked and park_eligible # With review disabled, the dev session runs its own internal review and # finalizes straight to done; otherwise it hands off at in-review. A park @@ -3575,16 +3734,20 @@ def verify_dev( expected_status=( AWAITING_OPERATOR if parked else ("in-review" if review_enabled else "done") ), - # Proof-of-work is the one gate the parked leg skips (``extra_exclude=None``, - # the callee-blessed spelling): a park's whole residue can legitimately be - # the spec and the board, both already excluded (#676). The park paragraph + # Proof-of-work is the one gate an ELECTED park skips (``extra_exclude=None``, + # the callee-blessed spelling): such a park's whole residue can legitimately + # be the spec and the board, both already excluded (#676). The park paragraph # in this function's docstring carries the reasoning and, more importantly, - # what the skip does NOT relax. - extra_exclude=None if parked else engine_written, + # what the skip does NOT relax. An inherited park (`park_eligible=False`) + # takes the ordinary arm and owes a diff like every other terminal. + extra_exclude=None if skip_proof else engine_written, + # Same tuple, no gate: when the skip fires the probe still runs, purely so + # the accepted park's zero-diff answer can be journaled (#676). + observe_skipped_proof=engine_written if skip_proof else None, fm=fm, ) - if gate is not None: - return gate + if gate.outcome is not None: + return gate.outcome expected_sprint = AWAITING_OPERATOR if parked else ("review" if review_enabled else "done") sprint = story_status(paths.sprint_status, task.story_key) @@ -3594,7 +3757,15 @@ def verify_dev( ) task.spec_file = str(spec_path) - return VerifyOutcome.passed() + # Two facts, deliberately on two fields: `park_proof_skipped` says this leg + # WAIVED proof-of-work (False on every other leg), `park_zero_diff` says what + # the waived gate would have found — and `None` there now means only "the + # probe could not answer", because the first field already carries the waiver. + # Both are carried to the journal; neither is a gate (#676). + return VerifyOutcome.passed( + park_proof_skipped=skip_proof, + park_zero_diff=gate.skipped_proof_zero_diff, + ) def verify_dev_bundle( @@ -3633,8 +3804,8 @@ def verify_dev_bundle( extra_exclude=engine_written, allow_ancestor_baseline=True, ) - if gate is not None: - return gate + if gate.outcome is not None: + return gate.outcome claimed_ids = {str(i) for i in (rj.get("dw_ids") or [])} if claimed_ids and claimed_ids != set(task.dw_ids): @@ -3752,8 +3923,8 @@ def verify_dev_stories( else _stories_relpaths(paths.repo_root, spec_folder) + engine_written ), ) - if gate is not None: - return gate + if gate.outcome is not None: + return gate.outcome task.spec_file = str(spec_path) return VerifyOutcome.passed() diff --git a/tests/test_engine.py b/tests/test_engine.py index 8824060a..0864e15b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1369,6 +1369,13 @@ def transient_first_fault(bound_task): return real_resolve(bound_task) monkeypatch.setattr(engine, "_dispatched_spec_for_attempt", transient_first_fault) + # The phase-entry park-eligibility read is a SECOND, unrelated consumer of the + # same resolver (`_park_eligible_at_dispatch`, DW-1) and would otherwise absorb + # the injected fault, handing the binder a clean second observation and + # inverting exactly what this row measures. Pin it out so `observations` counts + # the binder alone — this test is about prompt construction and recovery + # ownership, not about whether the story could newly elect a park. + monkeypatch.setattr(engine, "_park_eligible_at_dispatch", lambda _task: False) assert engine._dev_phase(task) @@ -2765,6 +2772,321 @@ def test_park_without_usable_actions_is_repaired_not_committed(project): assert "story-awaiting-operator" not in kinds and "story-done" in kinds +def test_dispatch_over_an_already_parked_spec_is_not_park_eligible(project): + """DW-1's engine half: the proof-of-work skip is authorized by an expectation + the orchestrator records at dispatch, and a story whose bound spec ALREADY + reads `awaiting-operator` cannot newly elect a park — whatever the session + that runs next leaves behind, the declaration on disk when it launched was + someone else's. + + The answer is captured on the fresh entry into `_dev_phase`, on the same + `resume_result is None` condition as `baseline_commit`, and persisted, so a + crash-replayed attempt reads back the same expectation rather than + re-deriving one from the tree the replayed session already wrote. + + Ablation: move the capture out of the `resume_result is None` block (or drop + the `!= AWAITING_OPERATOR` test) and this fails — the re-drive becomes + eligible and #676's relaxation applies to a session that inherited its park.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [dev_effect(project, "1-1-a")], policy=_park_policy()) + recorded = spec_path(project, "1-1-a") + write_spec( + recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS + ) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + engine.state.tasks[task.story_key] = task + + engine._dev_phase(task) + + assert task.park_eligible is False + assert load_state(engine.run_dir).tasks["1-1-a"].park_eligible is False + + +def test_inherited_park_is_refused_end_to_end_through_the_engine(project): + """The JOIN, which both halves being pinned separately does not cover: that + `_verify_dev_artifacts` actually forwards `task.park_eligible` into + `verify_dev`. Its sibling row stops at the flag, and every refusal row in + `test_verify.py` hand-passes `park_eligible=False` straight into the gate — so + the one wiring point between them was untested, and the whole fix could be + reverted there with the suite green. + + Driven through the engine's own binding lifecycle: the story's spec_file is + bound to a spec ALREADY at `awaiting-operator`, so eligibility is reached via + the bound branch (every other `engine.run()`-level park row reaches it + unbound, and therefore eligible). The re-driven session writes no code and + re-declares the same park — the inherited-park shape — and must NOT verify + green. + + Ablation: replace `park_eligible=task.park_eligible` with the literal `True` + in `_verify_dev_artifacts` and this row fails; without it that mutation passes + the entire suite.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "awaiting-operator"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=False, + ) + ] + * 3, + policy=_park_policy(), + ) + recorded = spec_path(project, "1-1-a") + write_spec( + recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS + ) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + engine.state.tasks[task.story_key] = task + + # The refusal is non-fixable, so the attempt is rolled back and the phase ends + # in the pause its unrecoverable binding forces. The PAUSE is the point for + # this row's purposes — "did not verify green" — and the journal below names + # the cause. Under the mutation this row exists to catch, the park verifies, + # commits, and nothing raises at all. + with pytest.raises(RunPaused): + engine._dev_phase(task) + + assert task.park_eligible is False + reasons = [e["reason"] for e in engine.journal.entries() if e["kind"] == "dev-decision"] + assert reasons and all(r == "no changes in worktree since baseline commit" for r in reasons) + # the waiver never fired, so nothing was journaled as a skipped gate + assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] + + +def test_dispatch_with_no_bound_spec_is_park_eligible(project): + """The ordinary case, not a fallback: a story's first attempt has no + `spec_file` yet, so there is no earlier declaration for it to inherit and the + #676 relaxation must remain available. Fail-CLOSED applies to uncertainty + about a spec that exists, not to the absence of one.""" + engine, _ = make_engine(project, [], policy=_park_policy()) + + assert engine._park_eligible_at_dispatch(StoryTask(story_key="1-1-a", epic=1)) is True + + +def test_park_eligibility_fails_closed_on_an_unresolvable_binding(project): + """The OTHER fail-closed arm, and a genuinely separate one: this is the + `bound is None` refusal from `_dispatched_spec_for_attempt` (a symlinked + binding, the shape it exists to refuse), not the later `fm is None` OSError + arm its sibling row covers. A spec_file that will not resolve to a trusted + regular file is a spec whose status the orchestrator does not know, and an + unknown status must not authorize waiving proof-of-work. + + Ablation: invert this arm to `return True` and this row fails while the whole + rest of the suite stays green — nothing else reaches it, which is why it + needed its own row rather than sharing the unreadable-spec one.""" + engine, _ = make_engine(project, [], policy=_park_policy()) + real = spec_path(project, "1-1-a") + write_spec(real, "ready-for-dev", rev_parse_head(project.project)) + link = real.parent / "spec-1-1-a-symlink.md" + link.symlink_to(real) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(link)) + + # the binding resolves to nothing usable, even though the TARGET is a + # perfectly readable non-parked spec — it is the binding that is untrusted + assert engine._dispatched_spec_for_attempt(task) is None + assert engine._park_eligible_at_dispatch(task) is False + + +def test_park_eligibility_fails_closed_on_an_unreadable_spec(project): + """Observation degrades, and here degrading means denying the relaxation: a + bound spec the orchestrator cannot read is a spec whose status it does not + know, and an unknown status must not authorize skipping proof-of-work. The + skip is what would be lost, not the park — an honest park with a real diff + still passes the ordinary gate. + + Silent it is not: the read goes through `_observed_frontmatter`, so the skip + lands a `spec-read-failed` entry naming this site.""" + engine, _ = make_engine(project, [], policy=_park_policy()) + recorded = spec_path(project, "1-1-a") + write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + + def boom(_path): + raise OSError("spec vanished mid-read") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(verify, "read_frontmatter", boom) + assert engine._park_eligible_at_dispatch(task) is False + + failures = [e for e in engine.journal.entries() if e["kind"] == "spec-read-failed"] + assert [e["site"] for e in failures] == ["park-eligibility"] + + +def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): + """A fixable repair deliberately keeps the previous session's tree, so the + malformed park it is repairing is on disk when it launches. Re-observing + eligibility per ATTEMPT would therefore make every such repair ineligible, + and its fix — one frontmatter block, which proof-of-work already excludes — + would fail the gate it just re-armed. The expectation is anchored to the + phase, on the same `resume_result is None` condition as `baseline_commit`, + precisely so the expectation and the diff it guards cannot disagree. + + Both sessions run with `write_src=False`, which is what makes this row + evidence: the tree never holds any code residue, so the ONLY thing that can + carry the repair past proof-of-work is the retained eligibility. + + Ablation (measured, not assumed): move `task.park_eligible = ...` out of the + `resume_result is None` block and into `_dev_phase`'s per-attempt branch, and + attempt 2 re-observes the parked spec attempt 1 left behind, turns ineligible, + and its `dev-decision` reads exactly `no changes in worktree since baseline + commit` -> DEFER. Note what the row then fails ON: the defer's spec-restore + finds the binding unusable and raises `RunPaused`, so the visible surface is a + pause, not the assertion below. The refusal is the cause and the journal + records it; the pause is its consequence.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, adapter = make_engine( + project, + [ + # attempt 1: parks, but declares nothing -> fixable + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=[], + write_src=False, + ), + # the repair: a well-formed park, still with no code of its own + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=False, + ), + ], + policy=_park_policy(), + ) + recorded = spec_path(project, "1-1-a") + write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + engine.state.tasks[task.story_key] = task + + assert engine._dev_phase(task) is True + + assert task.park_eligible is True + assert len(adapter.sessions) == 2 # the malformed park, then its repair + # the repair's park was ACCEPTED with the gate waived, on a tree that holds no + # code at all — the whole point of retaining the phase's answer + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert [(e["attempt"], e["zero_diff"]) for e in records] == [(2, True)] + + +@pytest.mark.parametrize( + "write_src, zero_diff", + [(False, True), (True, False)], + ids=["residue-free", "with-code"], +) +def test_accepted_park_records_whether_the_skipped_gate_would_have_passed( + project, write_src, zero_diff +): + """DW-6: the skip stops being silent. Proof-of-work is waived for every + ELECTED park, so afterwards a park that wrote real code and one that wrote + nothing at all were indistinguishable — the same green outcome, no trace of + which gate was waived or what it would have said. + + The record carries the discriminator ON the entry rather than in its kind, + because its readers are out-of-process: `zero_diff` is `true` when the whole + residue was the spec and the board (the #676 shape the relaxation exists for) + and `false` when the session also committed real work and simply happened not + to need the waiver. One kind, one attempt, one answer. + + The probe runs inside the shared gate on purpose — it measures from the + baseline that gate derived, so a commit the newer-claim branch re-anchored + past cannot be credited to this attempt. + + Ablation: drop the `park_zero_diff is not None` journal in + `_verify_dev_artifacts` and both legs fail on the empty record list; hardcode + the observation to `True` and only the `with-code` leg reddens, which is why + both are parametrized here rather than only the zero-diff one.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=write_src, + ) + ], + policy=_park_policy(), + ) + + summary = engine.run() + + assert summary.awaiting_operator == 1 + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert len(records) == 1 + assert records[0]["story_key"] == "1-1-a" and records[0]["attempt"] == 1 + assert records[0]["zero_diff"] is zero_diff + + +def test_accepted_park_still_records_when_the_zero_diff_probe_faults(project): + """The record marks the WAIVED GATE, not the probe's success. A git fault + leaves the observation unanswerable, but the gate was waived all the same — + and that is precisely the case DW-6 must not lose, because it is the one where + nothing else on disk says proof-of-work was skipped. + + So the entry is still written and `zero_diff` carries JSON `null`: an unknown + answer is a truthful field value, not a reason to withhold the record. The + park is unaffected — the observation degrades and never escalates. + + Ablation: key the journal on `park_zero_diff is not None` (the collapsed + single-field form) instead of on `park_proof_skipped` and this row fails on an + empty record list, while every other park row here stays green — they all have + an answerable probe, so only this one can tell the two spellings apart.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, "1-1-a", final_status="awaiting-operator", operator_actions=ACTIONS + ) + ], + policy=_park_policy(), + ) + real = verify.has_changes_since + + def fault_the_observation(*args, **kwargs): + raise verify.GitError("git diff exploded") + + # NOTE the patch is module-GLOBAL, not narrowed to the observation arm — this + # row works because the park path reaches no other `has_changes_since` caller, + # not because the fault was targeted. `zero_diff is None` is what proves the + # observation arm is the one that swallowed it: only its `except GitError` + # produces that value. + with pytest.MonkeyPatch.context() as mp: + mp.setattr(verify, "has_changes_since", fault_the_observation) + summary = engine.run() + + # the context manager UNDID the patch — this says nothing about its breadth + assert verify.has_changes_since is real + assert summary.awaiting_operator == 1 + assert engine.state.tasks["1-1-a"].phase == Phase.AWAITING_OPERATOR + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert len(records) == 1 + assert records[0]["attempt"] == 1 + assert records[0]["zero_diff"] is None + + +def test_no_park_record_when_the_gate_actually_ran(project): + """The control: the record marks a WAIVED gate, so an ordinary story that + cleared proof-of-work on its own must leave none. Without this the record + would be indistinguishable from "a dev session verified", and the DW-6 + inventory would count every story as a skipped park.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [generic_dev_effect(project, "1-1-a")], policy=_park_policy()) + + engine.run() + + assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] + + def test_park_disabled_by_policy_never_commits_the_token(project): """`[operator] enabled = false` does not reinterpret the token — it makes it unknown. The gate rejects it, the attempt budget runs out, and the story diff --git a/tests/test_model.py b/tests/test_model.py index 3bb4fca2..1f896e9d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -14,6 +14,7 @@ SessionRecord, StoryTask, TokenUsage, + VerifyOutcome, ) @@ -187,6 +188,50 @@ def test_followup_review_recommended_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).followup_review_recommended is False +def test_park_eligible_round_trips(): + """The dispatch-time expectation gating the park's proof-of-work skip is + captured once per dev phase, so it has to survive the crash/resume boundary — + a replayed attempt that re-derived it would answer about the spec the session + it is replaying already parked.""" + task = StoryTask(story_key="1-1-a", epic=1, park_eligible=True) + assert StoryTask.from_dict(task.to_dict()).park_eligible is True + + +def test_park_eligible_defaults_false_for_legacy_state(): + """And it defaults to the FAIL-CLOSED value, which is the load-bearing half: a + run resumed from a state.json written before the field existed has no recorded + answer, and the absent one must deny the skip rather than grant it. Defaulting + True would make every legacy resume the exact DW-1 hole this field closes.""" + doc = StoryTask(story_key="1-1-a", epic=1).to_dict() + del doc["park_eligible"] # state.json from before the field existed + assert StoryTask.from_dict(doc).park_eligible is False + + +def test_verify_outcome_park_fields_are_absent_by_default(): + """Both park fields are opt-in on the one leg that waives proof-of-work, and + every other outcome must leave them at the inert pair — `park_proof_skipped` + is what `Engine._verify_dev_artifacts` journals on, so a default of True + anywhere would file every ordinary story as a waived gate. + + They are asserted TOGETHER because the whole point of splitting them is that + `park_zero_diff is None` no longer means "no waiver": on a waived leg whose + probe faulted it means "unknown", and only `park_proof_skipped` separates the + two.""" + assert VerifyOutcome.passed().park_proof_skipped is False + assert VerifyOutcome.passed().park_zero_diff is None + assert VerifyOutcome.retry("nope").park_proof_skipped is False + assert VerifyOutcome.retry("nope").park_zero_diff is None + assert VerifyOutcome.escalate("boom").park_proof_skipped is False + assert VerifyOutcome.escalate("boom").park_zero_diff is None + + # settable, and independently: the waived-but-unanswerable pair is a real + # state, not an unreachable combination + waived = VerifyOutcome.passed(park_proof_skipped=True, park_zero_diff=True) + assert waived.park_proof_skipped is True and waived.park_zero_diff is True + unknown = VerifyOutcome.passed(park_proof_skipped=True) + assert unknown.park_proof_skipped is True and unknown.park_zero_diff is None + + def test_followup_reviews_spent_round_trips(): task = StoryTask(story_key="1-1-a", epic=1, followup_reviews_spent=2) assert StoryTask.from_dict(task.to_dict()).followup_reviews_spent == 2 diff --git a/tests/test_verify.py b/tests/test_verify.py index 490f5ac0..5f4a7889 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -835,26 +835,41 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): passes `False`, and the skip is now the only thing standing between a park and this gate. A park short-circuits both terminals — the pair demanded is (awaiting-operator, awaiting-operator) either way — so the flag must not reach - the outcome, and the `True` leg is what would catch a future edit that let it.""" + the outcome, and the `True` leg is what would catch a future edit that let it. + + `park_eligible=True` is the engine-side half of the selector the skip now + needs: the orchestrator's answer, recorded at dispatch, that this phase could + newly ELECT a park rather than inherit one (DW-1). Without it this row fails + on proof-of-work — which is exactly what + `test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work` asserts. + `park_zero_diff` is the accepted skip's record: the tree really was residue-free, + and the outcome says so instead of the skip passing silently (DW-6).""" task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) out = verify.verify_dev( - task, project, dev_result(sp), review_enabled=review_enabled, operator_park=True + task, + project, + dev_result(sp), + review_enabled=review_enabled, + operator_park=True, + park_eligible=True, ) assert out.ok assert task.spec_file == str(sp) + assert out.park_proof_skipped is True and out.park_zero_diff is True +@pytest.mark.parametrize("park_eligible", [False, True]) @pytest.mark.parametrize("operator_park", [False, True]) @pytest.mark.parametrize( "status, sprint, review_enabled", [("in-review", "review", True), ("done", "done", False)], ) def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( - project, status, sprint, review_enabled, operator_park + project, status, sprint, review_enabled, operator_park, park_eligible ): """The control for the row above, and the reason that row proves anything: the SAME residue-free tree at an ordinary terminal must still be refused. Without @@ -875,6 +890,14 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( `test_engine.py` that are about harvest, not about park. A run with parking enabled but a session that finished ordinarily must still owe a diff. + `park_eligible` is parametrized for the identical reason, one selector later: + the skip is now `parked and park_eligible`, so the engine-side half is the + other input that could widen it past the park. Rewriting it as + `None if park_eligible` — the dispatch-time expectation alone, ignoring the + observed status — is green everywhere without this dimension, and it would let + every ordinary session on a story that had never parked skip proof-of-work + entirely. Neither half selects the skip on its own. + Ablation: delete the `if extra_exclude is not None and task.baseline_commit:` proof-of-work block in `_verify_shared_gates` and all four rows fail on `assert not out.ok` — the residue-free tree then verifies clean at every @@ -887,10 +910,189 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( dev_result(sp), review_enabled=review_enabled, operator_park=operator_park, + park_eligible=park_eligible, ) assert not out.ok and out.retryable assert out.reason == "no changes in worktree since baseline commit" + # neither half of the record: no gate was waived, so there is nothing observed + assert out.park_proof_skipped is False and out.park_zero_diff is None + + +def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): + """DW-1, and the reason the row above needs its new argument: the skip used to + be selected entirely by state a fresh session can INHERIT — the policy flag + plus the spec's own status. A spec an earlier attempt left at + `awaiting-operator` still reads `awaiting-operator` to a session that did + nothing at all, so a re-drive over it selected #676's relaxation and verified + green on someone else's park declaration. + + `park_eligible=False` is the orchestrator saying "the bound spec was ALREADY + parked when I dispatched this". The park is not refused for being inherited — + it is merely held to proof-of-work like every other terminal, and this tree has + none to show. Note the reason: the ordinary proof-of-work message, not a + park-specific refusal, because the eligibility flag gates the SKIP and nothing + else. + + This row and `test_verify_dev_park_with_no_code_residue_passes` differ in + exactly one argument over byte-identical state, which is what makes either one + evidence. Ablation: rewrite the selector as `skip_proof = parked` (drop the + `and park_eligible`) and this fails on `assert not out.ok` while its twin stays + green — the pre-DW-1 behavior exactly.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=False, + ) + + assert not out.ok and out.retryable + assert out.reason == "no changes in worktree since baseline commit" + # no gate was waived here, so neither field carries anything — and the pair is + # asserted in both directions, because `park_zero_diff is None` alone is also + # what a WAIVED gate whose probe faulted looks like + assert out.park_proof_skipped is False and out.park_zero_diff is None + + +def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): + """The bound on DW-1: ineligibility gates the proof-of-work SKIP, never the + park itself. An inherited park that carried real work satisfies proof-of-work + on its own and passes — status pair, actions list, workflow tag, baseline match + and sprint pair all still select on the OBSERVED status exactly as before. + + This is the row that would catch the over-correction: making `park_eligible` + select the park's status pair as well (rather than only the skip) turns a + legitimate repair-then-park into a status mismatch, and refuses work that was + actually done. `park_zero_diff` stays None because no skip fired — a passing + park is not automatically a recorded one.""" + task, sp = _park(project) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=False, + ) + + assert out.ok + assert task.spec_file == str(sp) + # a PASSING park that owed and produced its diff: no waiver, nothing observed + assert out.park_proof_skipped is False and out.park_zero_diff is None + + +def test_verify_dev_elected_park_with_code_residue_records_a_non_zero_diff(project): + """DW-6's discriminator, and the half a zero-diff-only record could never + prove: the skip fires for EVERY elected park, including one that wrote real + code, and the record has to tell the two apart. `_park` writes `src.txt`, so + the waived gate would have passed — and the observation says so. + + Ablation: make the observation arm return a constant `True` and this row fails + while `test_verify_dev_park_with_no_code_residue_passes` stays green, because + that one cannot distinguish a real probe from a hardcoded answer.""" + task, sp = _park(project) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) + + assert out.ok + assert out.park_proof_skipped is True and out.park_zero_diff is False + + +def test_verify_dev_park_zero_diff_observation_degrades_to_unknown(project, monkeypatch): + """The observation must never change an outcome. `has_changes_since` can raise + `GitError`, and on the gated legs that escalates the attempt — here the same + fault has to leave the park accepted and the answer honestly unknown. + + Load-bearing because the probe fails OPEN (`rc != 0` -> "there are changes"), + so a fault swallowed at the wrong level would be recorded as a confident + `False` — a zero-diff park filed as one that wrote code, which is worse than no + record at all. + + This is the row that separates the two reasons `park_zero_diff` can be `None`: + the probe could not answer, versus no gate was ever waived. They are different + facts and they live on different fields — `park_proof_skipped` stays True here. + Collapsing them would make this park look like an ordinary leg and drop its + journal record, which is the exact silence DW-6 exists to end. + + Ablation: drop the `except GitError` in the observation arm and this fails with + the GitError propagating out of `verify_dev`, turning a bookkeeping probe into + a failed attempt.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + + def boom(*_a, **_kw): + raise verify.GitError("git diff exploded") + + monkeypatch.setattr(verify, "has_changes_since", boom) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) + + assert out.ok + assert task.spec_file == str(sp) + assert out.park_zero_diff is None + # the gate WAS waived — unknown is not the same fact as "no waiver" + assert out.park_proof_skipped is True + + +def test_verify_dev_park_zero_diff_excludes_the_orchestrators_own_writes(project): + """The observation must exclude exactly what the waived gate would have, and + this is the misattribution most likely to be audited: the orchestrator appends + a harvested deferral to the ledger DURING the attempt, so a park whose session + wrote nothing still leaves that file changed. Counted, the record would read + `zero_diff: false` — "this park committed real code" — about a diff the + orchestrator itself produced, and an audit of which parks got in without + proving work would quietly exonerate exactly the wrong ones. + + `engine_written` is what `Engine._harvest_gate_exclude` supplies for this, and + on the waived leg it is routed to `observe_skipped_proof` rather than + `extra_exclude` — same tuple, no gate. + + Ablation: drop `+ mode_exclude` from `proof_of_work_probe`'s exclusion (or + stop passing `observe_skipped_proof` at the call site) and this fails with + `park_zero_diff is False`, while every other park row stays green — they have + no orchestrator residue to misattribute.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + (project.repo_root / "ledger.md").write_text("- DW-9 harvested by the orchestrator\n") + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + engine_written=("ledger.md",), + ) + + assert out.ok + assert out.park_proof_skipped is True + # the ONLY residue is the orchestrator's own write, so the park really is + # zero-diff and the record has to say so + assert out.park_zero_diff is True def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): @@ -911,7 +1113,11 @@ def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): ) rj = {"workflow": "quick-dev", "spec_file": str(sp)} - out = verify.verify_dev(task, project, rj, review_enabled=False, operator_park=True) + # park_eligible=True so the skip really is in place: without it proof-of-work + # would also refuse this tree and the row would pass for a compound reason. + out = verify.verify_dev( + task, project, rj, review_enabled=False, operator_park=True, park_eligible=True + ) assert not out.ok and out.retryable assert "auto-dev" in out.reason @@ -938,7 +1144,17 @@ def test_verify_dev_park_still_faces_the_baseline_match_gate(project): baseline="deadbeef" * 5, ) - out = verify.verify_dev(task, project, dev_result(sp), review_enabled=False, operator_park=True) + # Same reason as the workflow-tag row above: with park_eligible left False the + # tree would also owe proof-of-work, and baseline-match would stop being the + # only thing that could refuse here. + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) assert not out.ok and out.retryable assert "does not match" in out.reason @@ -6348,6 +6564,13 @@ def test_engine_written_is_keyword_only_on_all_dev_verifiers(): parameter = inspect.signature(fn).parameters["engine_written"] assert parameter.kind is inspect.Parameter.KEYWORD_ONLY assert "operator_park" in inspect.signature(verify.verify_dev).parameters + # The park skip's second selector (DW-1). Keyword-only for the same reason + # `engine_written` is: `verify_dev`'s positional tail is `review_enabled`, and + # a positional eligibility flag would be one transposed argument away from + # silently authorizing the skip on every leg. + park_eligible = inspect.signature(verify.verify_dev).parameters["park_eligible"] + assert park_eligible.kind is inspect.Parameter.KEYWORD_ONLY + assert park_eligible.default is False # --------------------------------------------------- the git support floor (GIT_FLOOR) From b15408a1c55e98bd340b1536b65699973f3bd2a8 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 19:58:12 -0700 Subject: [PATCH 02/45] Revert "fix(verify): require a dispatch-time expectation before a park skips proof-of-work" This reverts commit cb3bb3d64ac2e49028399a33f58ceb1040c92e0d. --- CHANGELOG.md | 16 -- docs/FEATURES.md | 6 +- src/bmad_loop/engine.py | 110 +------------- src/bmad_loop/model.py | 61 +------- src/bmad_loop/verify.py | 317 +++++++++------------------------------ tests/test_engine.py | 322 ---------------------------------------- tests/test_model.py | 45 ------ tests/test_verify.py | 233 +---------------------------- 8 files changed, 84 insertions(+), 1026 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf75018..b711d728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,22 +180,6 @@ breaking changes may land in a minor release. ### Fixed -- Require the orchestrator's own dispatch-time expectation before an `awaiting-operator` - park skips the dev gate's proof-of-work check (#335, #676). The skip was selected by the - policy flag plus the spec's own status, both of which a fresh session inherits, so a - re-drive over a spec an earlier attempt had already parked verified green having done - nothing. The expectation is captured once per dev phase, on the same anchor as the - attempt baseline, so a fixable repair of a malformed park still passes. An inherited park - that did real work is unaffected — only the skip narrows, not the park. One upgrade - note: the expectation defaults to "not eligible" for state written before it existed, so - a run interrupted mid-park and resumed after upgrading holds that in-flight park to - proof-of-work — if it produced no code, it is retried and may defer rather than parking. - Re-running the story is enough; nothing is lost. -- Journal `park-proof-of-work-skipped` with a `zero_diff` flag whenever an accepted park's - gate is waived (#676), so a park that wrote nothing and a park that committed real code - stop being indistinguishable after the fact. The probe is an observation only: when it - cannot answer — a git fault, or no recorded baseline — the flag is `null` and the outcome - is unchanged. - Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run owns. Under isolation both resolved against the main checkout, so the review modals showed that copy of the spec and the replan reset it — reporting success while the run's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e0e0d2f9..31735b18 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -47,7 +47,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Verification (trust-nothing gate) -- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park this attempt newly elected — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. +- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. - Runs _your_ commands (`[verify].commands`, e.g. `pytest -q`, `ruff check .`) in the git root the code lives in (`repo_root`): your project dir by default, the mounted per-unit worktree under `[scm] isolation = "worktree"`, and an explicit `repo_root:` when you set one under `isolation = "none"`. The gate's own artifact READS — the spec's frontmatter, the sprint board, the deferred-work ledger — stay project-rooted either way (#695). What follows the code, besides the command `cwd`, is every git question the gate asks about it: the recorded baseline is written in the git root, so the commit-identity lookup, both ancestry checks and the non-empty-diff probe are all asked there, and the pathspecs they exclude are spelled relative to that same root (#716). Anchoring those on the project dir meant a correct attempt could be refused forever under an explicit `repo_root:`. A broken build never reaches review or commit. ### Adversarial review (review stage) @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement — a re-drive that does nothing no longer verifies green on someone else's park. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so every ACCEPTED park's waived gate is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got in. (A park that waived the gate and then failed a later one is refused, and records nothing — the log answers which parks were accepted without proving work.) Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). That skip covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. @@ -172,7 +172,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per accepted `awaiting-operator` park whose proof-of-work gate was waived, carrying `zero_diff` — `true` when the park's whole residue was its own spec plus the board (the shape the waiver exists for), `false` when it also committed real code, `null` when the probe could not answer — a git fault, or an attempt with no recorded baseline to measure from — and the gate was waived anyway — so a waived gate always leaves a trace instead of being indistinguishable from one that ran); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). +- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 7240c119..5c9891e0 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2299,15 +2299,6 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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() - # Whether this phase may newly ELECT a park, on the same anchor and - # for the same reason as the baseline above: the proof-of-work skip - # this authorizes is measured from that baseline, so the expectation - # and the diff it guards have to be captured at one instant. A fixable - # repair therefore inherits the phase's answer (it deliberately keeps - # the previous session's tree, park declaration included, so - # re-observing per attempt would make every repair of a malformed park - # ineligible), and a crash-replayed attempt keeps the persisted one. - task.park_eligible = self._park_eligible_at_dispatch(task) feedback: Path | None = None while True: replayed = resume_result is not None @@ -3559,76 +3550,6 @@ def _operator_park_enabled(self) -> bool: branch happens to sit.""" return self.policy.operator.enabled - def _park_eligible_at_dispatch(self, task: StoryTask) -> bool: - """Whether the attempt about to be dispatched could newly ELECT a park — - the orchestrator-side half of :func:`verify.verify_dev`'s two-part - proof-of-work skip selector (#335, #676). - - The skip used to be selected entirely by state a fresh session can - INHERIT: ``operator_park`` (a policy flag) plus the spec's own - ``awaiting-operator`` status, which an earlier attempt may already have - written. A re-drive over such a spec therefore selected #676's relaxation - while having done nothing at all, and verified green on someone else's - park declaration. This is the fact that cannot be inherited: at the moment - the phase is dispatched, was the story's bound spec ALREADY parked? - - ``False`` when parking is off (the skip is unreachable anyway, so this - costs no read), when the bound spec already reads ``awaiting-operator``, - and on the two genuinely unobservable shapes: a recorded ``spec_file`` - that no longer resolves to a trusted regular file, and one whose read - raises ``OSError`` (journaled ``spec-read-failed``). Those fail closed onto - the ordinary gated path, where an honest park with a real diff still - passes. - - An UNPARSEABLE spec is deliberately not in that list, and the distinction - is worth stating because it looks like a gap. ``read_frontmatter`` - degrades malformed YAML and non-UTF-8 to ``{}`` rather than raising, so - ``status_of`` reads ``""`` and this returns True. That is correct rather - than merely tolerated: an unparseable spec demonstrably does not say - "parked", and ``verify_dev``'s own gate reads the very same ``{}``, so - ``parked`` is False there too and the skip is unreachable on that leg no - matter what this answers. Only OSError and an unresolvable binding are - uncertainty about a spec that *does* say something. - - ``True`` when nothing is bound at all — the ordinary case, not a fallback. - Note precisely what that tests: ``task.spec_file`` is an IN-RUN binding, - set only after a session returns and its artifacts verify, so "unbound" - means "this task object has no binding", NOT "no earlier park exists on - disk". A story whose spec was parked by a previous RUN, or edited into the - park status out of band, presents as unbound here and is eligible. The - residual is recorded as a deferred finding on this change's spec rather - than closed silently; closing it means keying eligibility on the spec the - story resolves to rather than on the task's binding, which is a wider - change than the one this gate makes. - - Called only from ``_dev_phase``'s ``resume_result is None`` block, beside - the baseline capture — see the comment there for why the anchor is the - PHASE and not the attempt. Reuses ``_dispatched_spec_for_attempt`` for the - symlink/roots checks rather than re-deriving them: a second, laxer - resolution here would be a second answer to "which file is this attempt's - spec", and recovery already owns that question. - - Consequence worth knowing before touching either caller: that resolver is - now invoked TWICE per dev phase — once here at phase entry, and once by - the binder inside the attempt loop. They are two observations of the same - path at different instants and neither may be folded into the other (this - one must precede the first attempt; the binder's must be the one that - promotes). Any test that counts calls to it has to say which observation - it means — ``test_transient_initial_binding_fault_does_not_promote_after_bare_prompt`` - pins this one out for exactly that reason. - """ - if not self._operator_park_enabled(): - return False - if not task.spec_file: - return True - bound = self._dispatched_spec_for_attempt(task) - if bound is None: - return False - fm = self._observed_frontmatter(Path(bound), task.story_key, "park-eligibility") - if fm is None: - return False - return verify.status_of(fm) != verify.AWAITING_OPERATOR - 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 @@ -5279,43 +5200,14 @@ def _harvest_gate_exclude(self, task: StoryTask) -> tuple[str, ...]: return (rel.as_posix(),) def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): - outcome = verify.verify_dev( + return verify.verify_dev( task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled(), operator_park=self._operator_park_enabled(), - # The dispatch-time half of the park's proof-of-work skip selector, - # read from the task rather than re-observed: it was captured on this - # phase's fresh entry, and re-deriving it now would answer about the - # spec the session just finished writing (#676). - park_eligible=task.park_eligible, 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 session's whole residue was the spec and the board (the - # #676 shape the skip exists for), `false` when it also carried real code, - # and JSON `null` when the probe could not answer — a git fault, 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. - # - # Only ACCEPTED parks reach here with the flag set: it rides the `passed()` - # return, so a park that waived proof-of-work and then failed the sprint - # pair records nothing. That is the intended scope — the question this - # answers is which parks got IN without proving work. - 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 _verify_review(self, task: StoryTask): # `not _dev_review_enabled()` is exactly the case where _post_dev_state_sync diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index da64c8fd..c9f784c2 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -294,20 +294,6 @@ class StoryTask: # owes, and nothing re-derives it once the session that wrote the spec is # gone). operator_actions: list[str] = field(default_factory=list) - # Whether THIS dev phase was in a position to newly elect a park: captured - # once, on the fresh entry into `Engine._dev_phase` (`resume_result is None`), - # from the same instant and the same condition as `baseline_commit` — so the - # expectation and the diff it guards share one anchor. False when the bound - # spec was ALREADY at `awaiting-operator` on entry (an earlier attempt's park - # is on disk, so a park observed afterwards may be inherited rather than - # elected), when parking is disabled, or when the spec could not be read at - # all (fail closed). It gates exactly one thing: `verify_dev`'s proof-of-work - # skip on the park leg (#335, #676). Every other park gate still selects on the - # observed status alone, so an ineligible park with a real diff still passes. - # Deliberately per-PHASE, not per-attempt: a fixable repair keeps the previous - # session's tree, so re-observing would make every repair of a malformed park - # ineligible and fail it on the gate it just re-armed. - park_eligible: bool = False defer_reason: str | None = None # the recovery ref this attempt's work was parked on by the last auto-rollback # — an `attempt-preserve/*` branch (commits above baseline) or, when the tree @@ -456,7 +442,6 @@ def to_dict(self) -> dict[str, Any]: ), "commit_sha": self.commit_sha, "operator_actions": self.operator_actions, - "park_eligible": self.park_eligible, "defer_reason": self.defer_reason, "preserve_ref": self.preserve_ref, "preserve_partial": self.preserve_partial, @@ -644,7 +629,6 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot=dispatched_spec_snapshot, commit_sha=d.get("commit_sha"), operator_actions=[str(a) for a in d.get("operator_actions", [])], - park_eligible=bool(d.get("park_eligible", False)), defer_reason=d.get("defer_reason"), preserve_ref=d.get("preserve_ref"), preserve_partial=bool(d.get("preserve_partial", False)), @@ -884,51 +868,10 @@ class VerifyOutcome: # time): no further session can reconcile it, so it routes to a pause with # both sides named rather than to another cycle (#334) contradiction: bool = False - # Whether this ACCEPTED outcome waived the dev gate's proof-of-work check on - # the park leg (`verify_dev`'s two-part park selector fired). The fact of the - # waiver, not its result: `Engine._verify_dev_artifacts` journals exactly the - # attempts this is True for, so an accepted park's waived gate always leaves a - # trace (#676). - # - # Scoped to ACCEPTED deliberately, and it is the whole guarantee: this rides - # only the `passed()` return, so a leg that waived proof-of-work and then - # failed a LATER gate — the sprint pair is the reachable one — records nothing. - # That is the intended bound, not a gap: the record answers "which accepted - # parks got in without proving work", and a park that was refused did not get - # in. Anything wider would need the flag on the failing constructors too. - park_proof_skipped: bool = False - # An OBSERVATION, never a gate: on that same waived leg, whether the tree was - # in fact free of code residue since the attempt's baseline. `True` = the - # accepted park wrote nothing beyond what proof-of-work already excludes, - # `False` = it carried a real diff, `None` = the probe could not answer. Two - # things produce that `None`: a git fault (it degrades rather than escalating) - # and an attempt with no `baseline_commit` to measure from. Nothing branches - # on it. Note also that `False` is the weaker of the two definite answers: - # the probe inherits `has_changes_since`'s fail-open, so a git REFUSAL (rc 128, - # e.g. an unresolvable baseline) reads as "there are changes" rather than - # raising, and is recorded as `False`. - # - # The two fields are deliberately separate, and collapsing them is the bug - # this pair exists to prevent: one says a gate was waived, the other says what - # that gate would have found. Keyed on the observation alone, an unanswerable - # probe is indistinguishable from no waiver at all — so a park whose probe - # faulted would go unrecorded, re-creating exactly the silence this pair ends. - # A waived gate is recorded whatever the probe managed to say; `None` is a - # truthful field value, not a reason to withhold the record. - park_zero_diff: bool | None = None @classmethod - def passed( - cls, - *, - park_proof_skipped: bool = False, - park_zero_diff: bool | None = None, - ) -> "VerifyOutcome": - return cls( - ok=True, - park_proof_skipped=park_proof_skipped, - park_zero_diff=park_zero_diff, - ) + def passed(cls) -> "VerifyOutcome": + return cls(ok=True) @classmethod def retry(cls, reason: str, fixable: bool = False) -> "VerifyOutcome": diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index d97733df..702e38ef 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3292,29 +3292,6 @@ def _gate_frontmatter(spec_path: Path) -> dict[str, Any] | VerifyOutcome: return VerifyOutcome.retry(f"spec unreadable ({e.__class__.__name__}: {e}): {spec_path}") -@dataclass(frozen=True) -class _SharedGateResult: - """What :func:`_verify_shared_gates` answers: the failing outcome (``None`` - when every gate passed and the caller may run its mode-specific tail), plus - whatever the gate OBSERVED on the way through that no gate acted on. - - ``skipped_proof_zero_diff`` is the second kind: on a leg that skipped - proof-of-work and asked to be told anyway (``observe_skipped_proof``), it is - ``True`` when the tree held no changes the gate would have counted, ``False`` - when it held some, and ``None`` when nothing was observed — no skip, no - request, no baseline, or a git fault. It is deliberately a return value and - not a gate input: the observation must be made HERE because the baseline it - measures from is derived here (the newer-claim branch can re-anchor - ``proof_baseline`` and drop untracked evidence), and no caller can reproduce - that derivation. A caller re-probing from ``task.baseline_commit`` would count - a commit that arrived in a shared ``isolation = "none"`` checkout from outside - the session as this attempt's work — the exact false negative the observation - exists to expose.""" - - outcome: VerifyOutcome | None = None - skipped_proof_zero_diff: bool | None = None - - def _verify_shared_gates( spec_path: Path, rj: dict[str, Any], @@ -3323,17 +3300,15 @@ def _verify_shared_gates( *, expected_status: str, extra_exclude: tuple[str, ...] | None, - observe_skipped_proof: tuple[str, ...] | None = None, allow_ancestor_baseline: bool = False, fm: dict[str, Any] | None = None, -) -> _SharedGateResult: +) -> VerifyOutcome | None: """The workflow-tag, expected-status, baseline-match, and proof-of-work gates shared verbatim by :func:`verify_dev`, :func:`verify_dev_bundle`, and :func:`verify_dev_stories` — factored out so the sprint-mode and stories-mode gates can't silently drift. Reads frontmatter once; a caller that had to read it first to *choose* ``expected_status`` passes what it read as ``fm`` so the - single-read contract still holds (no caller re-reads it). Returns a - :class:`_SharedGateResult` whose ``outcome`` is a failing + single-read contract still holds (no caller re-reads it). Returns a failing :class:`VerifyOutcome`, or ``None`` when every gate passes and the caller may run its mode-specific tail. @@ -3350,54 +3325,22 @@ def _verify_shared_gates( leg produced only its own spec (structurally spec-only), and a park may legitimately have produced no code at all because its remaining work is a human's (#676). Both mean "there is no diff to demand here"; neither - generalizes to the other's leg, so keep them named separately. - - ``observe_skipped_proof`` is the same exclusion tuple the caller WOULD have - passed as ``extra_exclude`` had it not skipped the gate. When set on a skipped - leg the probe still runs — against the baseline derived above, not the raw - ``task.baseline_commit`` — purely to answer whether there was in fact a diff, - and the answer rides out on ``_SharedGateResult.skipped_proof_zero_diff``. - Nothing branches on it here: a fault degrades to ``None`` rather than - escalating, and the leg's outcome is identical either way. It exists so an - accepted park's skipped gate stops being silent (#676) — a park that wrote - code and a park that wrote nothing are otherwise indistinguishable after the - fact. - - Exactly one of the two skipping legs asks for it, and the asymmetry is - deliberate rather than an omission: only sprint mode's PARK passes it. - ``verify_dev_stories``' plan halt skips the gate and observes nothing, because - it already has an independent cross-check a park has no equivalent for — a - clean plan-halt carries ``devcontract``'s ``plan_halt`` marker in its - result.json (``rj.get("plan_halt") is not True`` refuses the leg outright), so - a died-mid-flight ``ready-for-dev`` cannot reach the skip in the first place. A - park's status is self-asserted with no such marker, which is why it is the leg - that needs a record of what the waived gate would have found. - - The two parameters are MUTUALLY EXCLUSIVE by construction: ``extra_exclude`` - gates and ``observe_skipped_proof`` observes, and the arms below are ``if`` / - ``elif`` on that order. Passing both is not a richer mode, it is a caller - error that silently drops the observation — the gate arm wins and the leg was - never skipped, so there was nothing to observe. Pass ``extra_exclude`` OR - ``observe_skipped_proof``, never both.""" + generalizes to the other's leg, so keep them named separately.""" workflow = rj.get("workflow") if workflow != DEV_WORKFLOW: - return _SharedGateResult( - VerifyOutcome.retry( - f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}" - ) + return VerifyOutcome.retry( + f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}" ) if fm is None: read = _gate_frontmatter(spec_path) if isinstance(read, VerifyOutcome): - return _SharedGateResult(read) + return read fm = read status = status_of(fm) if status != expected_status: - return _SharedGateResult( - VerifyOutcome.retry( - f"spec status is {status!r}, expected {expected_status!r}: {spec_path}" - ) + return VerifyOutcome.retry( + f"spec status is {status!r}, expected {expected_status!r}: {spec_path}" ) # The generic bmad-build-auto skill stamps `baseline_revision`, never @@ -3433,13 +3376,11 @@ def _verify_shared_gates( try: canonical_claimed = _canonical_commit_oid(paths.repo_root, claimed_baseline) except GitError as e: - return _SharedGateResult(VerifyOutcome.escalate(str(e))) + return VerifyOutcome.escalate(str(e)) if canonical_claimed is None: - return _SharedGateResult( - VerifyOutcome.retry( - f"spec baseline {claimed_baseline[:12]} does not match " - f"orchestrator-recorded baseline {task.baseline_commit[:12]}" - ) + return VerifyOutcome.retry( + f"spec baseline {claimed_baseline[:12]} does not match " + f"orchestrator-recorded baseline {task.baseline_commit[:12]}" ) if canonical_claimed != task.baseline_commit: # A deferred-work bundle may legitimately adopt a pre-existing story @@ -3471,70 +3412,34 @@ def _verify_shared_gates( proof_baseline = canonical_claimed if newer_ok else proof_baseline include_untracked_proof = not newer_ok if not (older_ok or newer_ok): - return _SharedGateResult( - VerifyOutcome.retry( - f"spec baseline {claimed_baseline[:12]} does not match " - f"orchestrator-recorded baseline {task.baseline_commit[:12]}" - ) + return VerifyOutcome.retry( + f"spec baseline {claimed_baseline[:12]} does not match " + f"orchestrator-recorded baseline {task.baseline_commit[:12]}" ) - def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: - """The one place proof-of-work is measured, called by BOTH arms below. - - The gate arm and the observation arm differ in exactly one input — which - mode-supplied tuple composes onto the gate's own exclusions — and in - nothing else. They were briefly two spelled-out copies of the same five - arguments, and every property the docstrings claim for the observation - (that it excludes the mode's paths, that it keeps the newer-claim - ``proof_baseline``, that it inherits ``include_untracked_proof``) was - silently droppable in the copy while the gate stayed correct and the suite - stayed green. A shared body makes the two unable to disagree by - construction, which is stronger than any test over the copies: divergence - is no longer a thing a reader can express here. - - The exclude pathspecs are rooted where git is invoked: `repo_root` here - and `repo_root` in every producer that composes into them - (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative - to a different root is not merely wrong, it is SILENTLY wrong — git - matches nothing and the exclusion evaporates. - """ - return has_changes_since( - paths.repo_root, - proof_baseline, - exclude=verify_dev_exclude_relpaths( - paths, spec_path, task.restore_patch, root=paths.repo_root - ) - + mode_exclude, - baseline_untracked=task.baseline_untracked, - include_untracked=include_untracked_proof, - ) - if extra_exclude is not None and task.baseline_commit: + # The exclude pathspecs are rooted where git is invoked: `repo_root` here + # and `repo_root` in every producer that composes into `extra_exclude` + # (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative + # to a different root is not merely wrong, it is SILENTLY wrong — git + # matches nothing and the exclusion evaporates. + exclude = ( + verify_dev_exclude_relpaths(paths, spec_path, task.restore_patch, root=paths.repo_root) + + extra_exclude + ) try: - if not proof_of_work_probe(extra_exclude): - return _SharedGateResult( - VerifyOutcome.retry("no changes in worktree since baseline commit") - ) + if not has_changes_since( + paths.repo_root, + proof_baseline, + exclude=exclude, + baseline_untracked=task.baseline_untracked, + include_untracked=include_untracked_proof, + ): + return VerifyOutcome.retry("no changes in worktree since baseline commit") except GitError as e: - return _SharedGateResult(VerifyOutcome.escalate(str(e))) - elif observe_skipped_proof is not None and task.baseline_commit: - # The gate was skipped; run its probe anyway and report, never refuse. - # Only `GitError` is caught, so a non-git bug still surfaces — but that is - # a narrower guarantee than "an unanswerable probe records None". The - # observation inherits `has_changes_since`'s deliberate fail-open: any - # non-zero rc reads as "there are changes", and only timeout, spawn and - # decode faults raise `GitError` at all. So a git REFUSAL — an unresolvable - # baseline, rc 128 — is recorded as `zero_diff: False`, "this park - # committed real code". The bias is toward the less alarming record, which - # is the right direction for a field nothing gates on, but it means a - # `False` here is weaker evidence than a `True`. - try: - skipped_proof_zero_diff = not proof_of_work_probe(observe_skipped_proof) - except GitError: - skipped_proof_zero_diff = None - return _SharedGateResult(None, skipped_proof_zero_diff) + return VerifyOutcome.escalate(str(e)) - return _SharedGateResult() + return None # The terminal spec status of a story whose agent-doable work is finished but @@ -3576,7 +3481,6 @@ def verify_dev( review_enabled: bool = True, *, operator_park: bool = False, - park_eligible: bool = False, engine_written: tuple[str, ...] = (), ) -> VerifyOutcome: """Verify a dev session's on-disk artifacts against its result.json claims. @@ -3598,15 +3502,9 @@ def verify_dev( a terminal the gate knows, so it fails the ordinary status check and the session is retried with that mismatch as feedback. - The proof-of-work gate is skipped on a park that this attempt was in a - position to newly ELECT — ``skip_proof = parked and park_eligible``, a - two-part selector. ``parked`` is what the session left behind (the observed - spec status, plus the policy flag); ``park_eligible`` is what the orchestrator - knew at dispatch (:meth:`Engine._park_eligible_at_dispatch`, captured on the - fresh entry into ``Engine._dev_phase`` from the same instant and the same - condition as ``task.baseline_commit``): the story's bound spec did NOT already - read ``awaiting-operator``. Both halves are load-bearing. The skip exists - because a park's whole output can legitimately be its own spec's park + On the park leg the proof-of-work gate is skipped, the same way the plan-halt + leg of :func:`verify_dev_stories` skips it and by the same ``extra_exclude=None`` + spelling: a park's whole output can legitimately be its own spec's park declaration plus the board sync, both of which proof-of-work already excludes, so demanding a diff read a correct park as "no changes since baseline commit" and refused it (#676) — costing the attempt, and with it the park declaration: @@ -3617,76 +3515,27 @@ def verify_dev( gate passes — not the session's own work: ``bmad-build-auto`` commits each iteration, so a skill commit chain usually already sits above baseline (``Engine._finalize_commit_phase``), and a reset discards that too, onto an - ``attempt-preserve/*`` ref. - - What the eligibility half defends is narrow and worth naming exactly. Before - it, the relaxation was selected entirely by state a fresh session could - INHERIT rather than produce: a spec an earlier attempt left at - ``awaiting-operator`` still reads ``awaiting-operator`` to the next session - that does nothing at all, so a re-drive over that spec selected the skip and - verified green on someone else's declaration, relaxing #676's skip for an - attempt that produced nothing. Requiring the - orchestrator's own dispatch-time answer means the leg that skips proof-of-work - is the leg that actually authored the park. It does NOT defend against a - session that elects a park it did not earn — one that writes the frontmatter, - lists plausible actions and implements nothing is eligible by construction and - still passes, because the actions gate tests list non-emptiness and never - content. It is a check on WHICH ATTEMPT owns the park, not on whether the park - is honest, and it is captured per PHASE rather than per attempt: a fixable - repair deliberately keeps the previous session's tree, so re-observing would - make every repair of a malformed park ineligible and fail it on the gate it - just re-armed. - - An INELIGIBLE park is not refused — it is merely held to proof-of-work like - any other terminal. The park's status pair, ``operator_actions`` - non-emptiness, workflow tag, baseline match and sprint pair all keep selecting - on the observed status alone, so an inherited park carrying a real diff passes - exactly as before; only the residue-free one now owes the diff it never - produced. - - Nothing else relaxes on the eligible leg either — the ``operator_actions`` - gate above still refuses a park that enumerates nothing, and the workflow-tag, - status, baseline-match and sprint-pair gates all still run. Two of those four - are not independent evidence on this leg, and saying so is the point: the - status check is tautological here (the same ``fm`` that selected ``parked`` is - threaded in as ``fm=fm``, so the shared gate compares it against an - ``expected_status`` derived from itself), and the sprint pair was written from - that same frontmatter by ``Engine._post_dev_state_sync`` a dozen lines before - this gate runs, so it confirms the orchestrator's own write landed rather than - anything the session did. What still binds a park to the attempt the - orchestrator actually launched is the workflow tag, the baseline match, the - non-empty actions list — and now the dispatch-time eligibility, which is the - only one of the four the session cannot influence at all. Baseline-match also - accepts a claim NEWER than the recorded baseline whenever it is a - HEAD-reachable descendant, and the comment guarding that branch names the - compensating control: such a commit "may have arrived in the shared checkout - from outside the session", so the check re-anchors proof-of-work onto the - claimed commit rather than trusting the match alone. Proof-of-work is precisely - what this leg skips, so on a park that re-anchoring still gates nothing — but - it is no longer inert: the observation below inherits it, so a foreign commit - cannot be credited as this attempt's work in the record either. - - The accepted skip is no longer silent, and it is recorded on TWO fields - because one cannot carry both facts. ``VerifyOutcome.park_proof_skipped`` is - the waiver itself — ``skip_proof``, ``False`` on every other leg. When it - fires, the shared gate additionally runs the proof-of-work probe as a pure - OBSERVATION (``observe_skipped_proof=engine_written``) and what that probe - found rides out on ``VerifyOutcome.park_zero_diff``: ``True`` for a park with - no code residue, ``False`` for one carrying a real diff, ``None`` when the - probe could not answer. What separates "unknown" from "no skip happened" is - ``park_proof_skipped``, not this field — collapsing the two into - ``park_zero_diff is not None`` would make a park whose probe faulted look like - a leg that never waived anything, and it would go unrecorded — the silence - this record exists to end. ``None`` has exactly two causes now, both of them - "the probe could not answer": a git fault, and an attempt carrying no - ``task.baseline_commit`` to measure from (the shared gate runs neither arm - without one). Neither field changes an outcome: a git fault degrades to - ``None`` rather than escalating, and an eligible park verifies identically - either way. Their consumer is - :meth:`Engine._verify_dev_artifacts`, which journals - ``park-proof-of-work-skipped`` for every waived gate and carries the - observation as that record's ``zero_diff`` field, so a park that wrote code - and a park that wrote nothing stop being indistinguishable afterwards (#676). + ``attempt-preserve/*`` ref. Nothing else relaxes — the + ``operator_actions`` gate above still refuses a park that enumerates nothing, + and the workflow-tag, status, baseline-match and sprint-pair gates all still + run. Two of those four are not independent evidence on this leg, and saying so + is the point: the status check is tautological here (the same ``fm`` that + selected ``parked`` is threaded in as ``fm=fm``, so the shared gate compares it + against an ``expected_status`` derived from itself), and the sprint pair was + written from that same frontmatter by ``Engine._post_dev_state_sync`` a dozen + lines before this gate runs, so it confirms the orchestrator's own write landed + rather than anything the session did. What still binds a park to the attempt + the orchestrator actually launched is the workflow tag, the baseline match, and + a non-empty actions list — and the middle one is weaker on this leg than its + name suggests. Baseline-match also accepts a claim NEWER than the recorded + baseline whenever it is a HEAD-reachable descendant, and the comment guarding + that branch names the compensating control: such a commit "may have arrived in + the shared checkout from outside the session", so the check re-anchors + proof-of-work onto the claimed commit rather than trusting the match alone. + Proof-of-work is precisely what this leg skips, so on a park that re-anchoring + is inert and the newer-claim branch tightens nothing. The trade is recorded rather than hidden: the skip + covers EVERY park, including one that wrote nothing and listed plausible + actions, because the actions gate tests list non-emptiness and never content. ``engine_written`` names paths the orchestrator itself wrote above this gate during the attempt, relative to ``paths.repo_root`` — the tree the gate invokes @@ -3694,11 +3543,9 @@ def verify_dev( must share (#716). They compose with the mode's normal proof-of-work exclusions so engine bookkeeping cannot masquerade as session work; see :meth:`Engine._harvest_gate_exclude`, which is their producer and states what a - ledger outside the code tree resolves to. On the skipped park leg they are - passed as ``observe_skipped_proof`` instead of ``extra_exclude``: no gate - consumes them there, but the zero-diff observation must exclude exactly what - the gate would have, or the orchestrator's own bookkeeping writes would be - recorded as the park's code residue. + ledger outside the code tree resolves to. On the parked leg they are not passed + at all — proof-of-work is skipped there, so there is no exclusion set left for + them to compose with. """ rj = result_json or {} spec_file = rj.get("spec_file") @@ -3716,12 +3563,6 @@ def verify_dev( actions = _operator_actions_gate(fm, task.story_key) if actions is not None: return actions - # The two-part selector: the session's observed park AND the orchestrator's - # dispatch-time answer that this phase could newly elect one. Deliberately a - # separate name from `parked` — every other park gate below still keys on - # `parked` alone, and collapsing the two would silently widen this expectation - # from "may skip proof-of-work" to "may park at all" (#335, #676). - skip_proof = parked and park_eligible # With review disabled, the dev session runs its own internal review and # finalizes straight to done; otherwise it hands off at in-review. A park @@ -3734,20 +3575,16 @@ def verify_dev( expected_status=( AWAITING_OPERATOR if parked else ("in-review" if review_enabled else "done") ), - # Proof-of-work is the one gate an ELECTED park skips (``extra_exclude=None``, - # the callee-blessed spelling): such a park's whole residue can legitimately - # be the spec and the board, both already excluded (#676). The park paragraph + # Proof-of-work is the one gate the parked leg skips (``extra_exclude=None``, + # the callee-blessed spelling): a park's whole residue can legitimately be + # the spec and the board, both already excluded (#676). The park paragraph # in this function's docstring carries the reasoning and, more importantly, - # what the skip does NOT relax. An inherited park (`park_eligible=False`) - # takes the ordinary arm and owes a diff like every other terminal. - extra_exclude=None if skip_proof else engine_written, - # Same tuple, no gate: when the skip fires the probe still runs, purely so - # the accepted park's zero-diff answer can be journaled (#676). - observe_skipped_proof=engine_written if skip_proof else None, + # what the skip does NOT relax. + extra_exclude=None if parked else engine_written, fm=fm, ) - if gate.outcome is not None: - return gate.outcome + if gate is not None: + return gate expected_sprint = AWAITING_OPERATOR if parked else ("review" if review_enabled else "done") sprint = story_status(paths.sprint_status, task.story_key) @@ -3757,15 +3594,7 @@ def verify_dev( ) task.spec_file = str(spec_path) - # Two facts, deliberately on two fields: `park_proof_skipped` says this leg - # WAIVED proof-of-work (False on every other leg), `park_zero_diff` says what - # the waived gate would have found — and `None` there now means only "the - # probe could not answer", because the first field already carries the waiver. - # Both are carried to the journal; neither is a gate (#676). - return VerifyOutcome.passed( - park_proof_skipped=skip_proof, - park_zero_diff=gate.skipped_proof_zero_diff, - ) + return VerifyOutcome.passed() def verify_dev_bundle( @@ -3804,8 +3633,8 @@ def verify_dev_bundle( extra_exclude=engine_written, allow_ancestor_baseline=True, ) - if gate.outcome is not None: - return gate.outcome + if gate is not None: + return gate claimed_ids = {str(i) for i in (rj.get("dw_ids") or [])} if claimed_ids and claimed_ids != set(task.dw_ids): @@ -3923,8 +3752,8 @@ def verify_dev_stories( else _stories_relpaths(paths.repo_root, spec_folder) + engine_written ), ) - if gate.outcome is not None: - return gate.outcome + if gate is not None: + return gate task.spec_file = str(spec_path) return VerifyOutcome.passed() diff --git a/tests/test_engine.py b/tests/test_engine.py index 0864e15b..8824060a 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1369,13 +1369,6 @@ def transient_first_fault(bound_task): return real_resolve(bound_task) monkeypatch.setattr(engine, "_dispatched_spec_for_attempt", transient_first_fault) - # The phase-entry park-eligibility read is a SECOND, unrelated consumer of the - # same resolver (`_park_eligible_at_dispatch`, DW-1) and would otherwise absorb - # the injected fault, handing the binder a clean second observation and - # inverting exactly what this row measures. Pin it out so `observations` counts - # the binder alone — this test is about prompt construction and recovery - # ownership, not about whether the story could newly elect a park. - monkeypatch.setattr(engine, "_park_eligible_at_dispatch", lambda _task: False) assert engine._dev_phase(task) @@ -2772,321 +2765,6 @@ def test_park_without_usable_actions_is_repaired_not_committed(project): assert "story-awaiting-operator" not in kinds and "story-done" in kinds -def test_dispatch_over_an_already_parked_spec_is_not_park_eligible(project): - """DW-1's engine half: the proof-of-work skip is authorized by an expectation - the orchestrator records at dispatch, and a story whose bound spec ALREADY - reads `awaiting-operator` cannot newly elect a park — whatever the session - that runs next leaves behind, the declaration on disk when it launched was - someone else's. - - The answer is captured on the fresh entry into `_dev_phase`, on the same - `resume_result is None` condition as `baseline_commit`, and persisted, so a - crash-replayed attempt reads back the same expectation rather than - re-deriving one from the tree the replayed session already wrote. - - Ablation: move the capture out of the `resume_result is None` block (or drop - the `!= AWAITING_OPERATOR` test) and this fails — the re-drive becomes - eligible and #676's relaxation applies to a session that inherited its park.""" - write_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, [dev_effect(project, "1-1-a")], policy=_park_policy()) - recorded = spec_path(project, "1-1-a") - write_spec( - recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS - ) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - engine.state.tasks[task.story_key] = task - - engine._dev_phase(task) - - assert task.park_eligible is False - assert load_state(engine.run_dir).tasks["1-1-a"].park_eligible is False - - -def test_inherited_park_is_refused_end_to_end_through_the_engine(project): - """The JOIN, which both halves being pinned separately does not cover: that - `_verify_dev_artifacts` actually forwards `task.park_eligible` into - `verify_dev`. Its sibling row stops at the flag, and every refusal row in - `test_verify.py` hand-passes `park_eligible=False` straight into the gate — so - the one wiring point between them was untested, and the whole fix could be - reverted there with the suite green. - - Driven through the engine's own binding lifecycle: the story's spec_file is - bound to a spec ALREADY at `awaiting-operator`, so eligibility is reached via - the bound branch (every other `engine.run()`-level park row reaches it - unbound, and therefore eligible). The re-driven session writes no code and - re-declares the same park — the inherited-park shape — and must NOT verify - green. - - Ablation: replace `park_eligible=task.park_eligible` with the literal `True` - in `_verify_dev_artifacts` and this row fails; without it that mutation passes - the entire suite.""" - write_sprint(project, {"epic-1": "backlog", "1-1-a": "awaiting-operator"}) - engine, _ = make_engine( - project, - [ - generic_dev_effect( - project, - "1-1-a", - final_status="awaiting-operator", - operator_actions=ACTIONS, - write_src=False, - ) - ] - * 3, - policy=_park_policy(), - ) - recorded = spec_path(project, "1-1-a") - write_spec( - recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS - ) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - engine.state.tasks[task.story_key] = task - - # The refusal is non-fixable, so the attempt is rolled back and the phase ends - # in the pause its unrecoverable binding forces. The PAUSE is the point for - # this row's purposes — "did not verify green" — and the journal below names - # the cause. Under the mutation this row exists to catch, the park verifies, - # commits, and nothing raises at all. - with pytest.raises(RunPaused): - engine._dev_phase(task) - - assert task.park_eligible is False - reasons = [e["reason"] for e in engine.journal.entries() if e["kind"] == "dev-decision"] - assert reasons and all(r == "no changes in worktree since baseline commit" for r in reasons) - # the waiver never fired, so nothing was journaled as a skipped gate - assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] - - -def test_dispatch_with_no_bound_spec_is_park_eligible(project): - """The ordinary case, not a fallback: a story's first attempt has no - `spec_file` yet, so there is no earlier declaration for it to inherit and the - #676 relaxation must remain available. Fail-CLOSED applies to uncertainty - about a spec that exists, not to the absence of one.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - - assert engine._park_eligible_at_dispatch(StoryTask(story_key="1-1-a", epic=1)) is True - - -def test_park_eligibility_fails_closed_on_an_unresolvable_binding(project): - """The OTHER fail-closed arm, and a genuinely separate one: this is the - `bound is None` refusal from `_dispatched_spec_for_attempt` (a symlinked - binding, the shape it exists to refuse), not the later `fm is None` OSError - arm its sibling row covers. A spec_file that will not resolve to a trusted - regular file is a spec whose status the orchestrator does not know, and an - unknown status must not authorize waiving proof-of-work. - - Ablation: invert this arm to `return True` and this row fails while the whole - rest of the suite stays green — nothing else reaches it, which is why it - needed its own row rather than sharing the unreadable-spec one.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - real = spec_path(project, "1-1-a") - write_spec(real, "ready-for-dev", rev_parse_head(project.project)) - link = real.parent / "spec-1-1-a-symlink.md" - link.symlink_to(real) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(link)) - - # the binding resolves to nothing usable, even though the TARGET is a - # perfectly readable non-parked spec — it is the binding that is untrusted - assert engine._dispatched_spec_for_attempt(task) is None - assert engine._park_eligible_at_dispatch(task) is False - - -def test_park_eligibility_fails_closed_on_an_unreadable_spec(project): - """Observation degrades, and here degrading means denying the relaxation: a - bound spec the orchestrator cannot read is a spec whose status it does not - know, and an unknown status must not authorize skipping proof-of-work. The - skip is what would be lost, not the park — an honest park with a real diff - still passes the ordinary gate. - - Silent it is not: the read goes through `_observed_frontmatter`, so the skip - lands a `spec-read-failed` entry naming this site.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - recorded = spec_path(project, "1-1-a") - write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - - def boom(_path): - raise OSError("spec vanished mid-read") - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(verify, "read_frontmatter", boom) - assert engine._park_eligible_at_dispatch(task) is False - - failures = [e for e in engine.journal.entries() if e["kind"] == "spec-read-failed"] - assert [e["site"] for e in failures] == ["park-eligibility"] - - -def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): - """A fixable repair deliberately keeps the previous session's tree, so the - malformed park it is repairing is on disk when it launches. Re-observing - eligibility per ATTEMPT would therefore make every such repair ineligible, - and its fix — one frontmatter block, which proof-of-work already excludes — - would fail the gate it just re-armed. The expectation is anchored to the - phase, on the same `resume_result is None` condition as `baseline_commit`, - precisely so the expectation and the diff it guards cannot disagree. - - Both sessions run with `write_src=False`, which is what makes this row - evidence: the tree never holds any code residue, so the ONLY thing that can - carry the repair past proof-of-work is the retained eligibility. - - Ablation (measured, not assumed): move `task.park_eligible = ...` out of the - `resume_result is None` block and into `_dev_phase`'s per-attempt branch, and - attempt 2 re-observes the parked spec attempt 1 left behind, turns ineligible, - and its `dev-decision` reads exactly `no changes in worktree since baseline - commit` -> DEFER. Note what the row then fails ON: the defer's spec-restore - finds the binding unusable and raises `RunPaused`, so the visible surface is a - pause, not the assertion below. The refusal is the cause and the journal - records it; the pause is its consequence.""" - write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) - engine, adapter = make_engine( - project, - [ - # attempt 1: parks, but declares nothing -> fixable - generic_dev_effect( - project, - "1-1-a", - final_status="awaiting-operator", - operator_actions=[], - write_src=False, - ), - # the repair: a well-formed park, still with no code of its own - generic_dev_effect( - project, - "1-1-a", - final_status="awaiting-operator", - operator_actions=ACTIONS, - write_src=False, - ), - ], - policy=_park_policy(), - ) - recorded = spec_path(project, "1-1-a") - write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - engine.state.tasks[task.story_key] = task - - assert engine._dev_phase(task) is True - - assert task.park_eligible is True - assert len(adapter.sessions) == 2 # the malformed park, then its repair - # the repair's park was ACCEPTED with the gate waived, on a tree that holds no - # code at all — the whole point of retaining the phase's answer - records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] - assert [(e["attempt"], e["zero_diff"]) for e in records] == [(2, True)] - - -@pytest.mark.parametrize( - "write_src, zero_diff", - [(False, True), (True, False)], - ids=["residue-free", "with-code"], -) -def test_accepted_park_records_whether_the_skipped_gate_would_have_passed( - project, write_src, zero_diff -): - """DW-6: the skip stops being silent. Proof-of-work is waived for every - ELECTED park, so afterwards a park that wrote real code and one that wrote - nothing at all were indistinguishable — the same green outcome, no trace of - which gate was waived or what it would have said. - - The record carries the discriminator ON the entry rather than in its kind, - because its readers are out-of-process: `zero_diff` is `true` when the whole - residue was the spec and the board (the #676 shape the relaxation exists for) - and `false` when the session also committed real work and simply happened not - to need the waiver. One kind, one attempt, one answer. - - The probe runs inside the shared gate on purpose — it measures from the - baseline that gate derived, so a commit the newer-claim branch re-anchored - past cannot be credited to this attempt. - - Ablation: drop the `park_zero_diff is not None` journal in - `_verify_dev_artifacts` and both legs fail on the empty record list; hardcode - the observation to `True` and only the `with-code` leg reddens, which is why - both are parametrized here rather than only the zero-diff one.""" - write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) - engine, _ = make_engine( - project, - [ - generic_dev_effect( - project, - "1-1-a", - final_status="awaiting-operator", - operator_actions=ACTIONS, - write_src=write_src, - ) - ], - policy=_park_policy(), - ) - - summary = engine.run() - - assert summary.awaiting_operator == 1 - records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] - assert len(records) == 1 - assert records[0]["story_key"] == "1-1-a" and records[0]["attempt"] == 1 - assert records[0]["zero_diff"] is zero_diff - - -def test_accepted_park_still_records_when_the_zero_diff_probe_faults(project): - """The record marks the WAIVED GATE, not the probe's success. A git fault - leaves the observation unanswerable, but the gate was waived all the same — - and that is precisely the case DW-6 must not lose, because it is the one where - nothing else on disk says proof-of-work was skipped. - - So the entry is still written and `zero_diff` carries JSON `null`: an unknown - answer is a truthful field value, not a reason to withhold the record. The - park is unaffected — the observation degrades and never escalates. - - Ablation: key the journal on `park_zero_diff is not None` (the collapsed - single-field form) instead of on `park_proof_skipped` and this row fails on an - empty record list, while every other park row here stays green — they all have - an answerable probe, so only this one can tell the two spellings apart.""" - write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) - engine, _ = make_engine( - project, - [ - generic_dev_effect( - project, "1-1-a", final_status="awaiting-operator", operator_actions=ACTIONS - ) - ], - policy=_park_policy(), - ) - real = verify.has_changes_since - - def fault_the_observation(*args, **kwargs): - raise verify.GitError("git diff exploded") - - # NOTE the patch is module-GLOBAL, not narrowed to the observation arm — this - # row works because the park path reaches no other `has_changes_since` caller, - # not because the fault was targeted. `zero_diff is None` is what proves the - # observation arm is the one that swallowed it: only its `except GitError` - # produces that value. - with pytest.MonkeyPatch.context() as mp: - mp.setattr(verify, "has_changes_since", fault_the_observation) - summary = engine.run() - - # the context manager UNDID the patch — this says nothing about its breadth - assert verify.has_changes_since is real - assert summary.awaiting_operator == 1 - assert engine.state.tasks["1-1-a"].phase == Phase.AWAITING_OPERATOR - records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] - assert len(records) == 1 - assert records[0]["attempt"] == 1 - assert records[0]["zero_diff"] is None - - -def test_no_park_record_when_the_gate_actually_ran(project): - """The control: the record marks a WAIVED gate, so an ordinary story that - cleared proof-of-work on its own must leave none. Without this the record - would be indistinguishable from "a dev session verified", and the DW-6 - inventory would count every story as a skipped park.""" - write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, [generic_dev_effect(project, "1-1-a")], policy=_park_policy()) - - engine.run() - - assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] - - def test_park_disabled_by_policy_never_commits_the_token(project): """`[operator] enabled = false` does not reinterpret the token — it makes it unknown. The gate rejects it, the attempt budget runs out, and the story diff --git a/tests/test_model.py b/tests/test_model.py index 1f896e9d..3bb4fca2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -14,7 +14,6 @@ SessionRecord, StoryTask, TokenUsage, - VerifyOutcome, ) @@ -188,50 +187,6 @@ def test_followup_review_recommended_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).followup_review_recommended is False -def test_park_eligible_round_trips(): - """The dispatch-time expectation gating the park's proof-of-work skip is - captured once per dev phase, so it has to survive the crash/resume boundary — - a replayed attempt that re-derived it would answer about the spec the session - it is replaying already parked.""" - task = StoryTask(story_key="1-1-a", epic=1, park_eligible=True) - assert StoryTask.from_dict(task.to_dict()).park_eligible is True - - -def test_park_eligible_defaults_false_for_legacy_state(): - """And it defaults to the FAIL-CLOSED value, which is the load-bearing half: a - run resumed from a state.json written before the field existed has no recorded - answer, and the absent one must deny the skip rather than grant it. Defaulting - True would make every legacy resume the exact DW-1 hole this field closes.""" - doc = StoryTask(story_key="1-1-a", epic=1).to_dict() - del doc["park_eligible"] # state.json from before the field existed - assert StoryTask.from_dict(doc).park_eligible is False - - -def test_verify_outcome_park_fields_are_absent_by_default(): - """Both park fields are opt-in on the one leg that waives proof-of-work, and - every other outcome must leave them at the inert pair — `park_proof_skipped` - is what `Engine._verify_dev_artifacts` journals on, so a default of True - anywhere would file every ordinary story as a waived gate. - - They are asserted TOGETHER because the whole point of splitting them is that - `park_zero_diff is None` no longer means "no waiver": on a waived leg whose - probe faulted it means "unknown", and only `park_proof_skipped` separates the - two.""" - assert VerifyOutcome.passed().park_proof_skipped is False - assert VerifyOutcome.passed().park_zero_diff is None - assert VerifyOutcome.retry("nope").park_proof_skipped is False - assert VerifyOutcome.retry("nope").park_zero_diff is None - assert VerifyOutcome.escalate("boom").park_proof_skipped is False - assert VerifyOutcome.escalate("boom").park_zero_diff is None - - # settable, and independently: the waived-but-unanswerable pair is a real - # state, not an unreachable combination - waived = VerifyOutcome.passed(park_proof_skipped=True, park_zero_diff=True) - assert waived.park_proof_skipped is True and waived.park_zero_diff is True - unknown = VerifyOutcome.passed(park_proof_skipped=True) - assert unknown.park_proof_skipped is True and unknown.park_zero_diff is None - - def test_followup_reviews_spent_round_trips(): task = StoryTask(story_key="1-1-a", epic=1, followup_reviews_spent=2) assert StoryTask.from_dict(task.to_dict()).followup_reviews_spent == 2 diff --git a/tests/test_verify.py b/tests/test_verify.py index 5f4a7889..490f5ac0 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -835,41 +835,26 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): passes `False`, and the skip is now the only thing standing between a park and this gate. A park short-circuits both terminals — the pair demanded is (awaiting-operator, awaiting-operator) either way — so the flag must not reach - the outcome, and the `True` leg is what would catch a future edit that let it. - - `park_eligible=True` is the engine-side half of the selector the skip now - needs: the orchestrator's answer, recorded at dispatch, that this phase could - newly ELECT a park rather than inherit one (DW-1). Without it this row fails - on proof-of-work — which is exactly what - `test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work` asserts. - `park_zero_diff` is the accepted skip's record: the tree really was residue-free, - and the outcome says so instead of the skip passing silently (DW-6).""" + the outcome, and the `True` leg is what would catch a future edit that let it.""" task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=review_enabled, - operator_park=True, - park_eligible=True, + task, project, dev_result(sp), review_enabled=review_enabled, operator_park=True ) assert out.ok assert task.spec_file == str(sp) - assert out.park_proof_skipped is True and out.park_zero_diff is True -@pytest.mark.parametrize("park_eligible", [False, True]) @pytest.mark.parametrize("operator_park", [False, True]) @pytest.mark.parametrize( "status, sprint, review_enabled", [("in-review", "review", True), ("done", "done", False)], ) def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( - project, status, sprint, review_enabled, operator_park, park_eligible + project, status, sprint, review_enabled, operator_park ): """The control for the row above, and the reason that row proves anything: the SAME residue-free tree at an ordinary terminal must still be refused. Without @@ -890,14 +875,6 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( `test_engine.py` that are about harvest, not about park. A run with parking enabled but a session that finished ordinarily must still owe a diff. - `park_eligible` is parametrized for the identical reason, one selector later: - the skip is now `parked and park_eligible`, so the engine-side half is the - other input that could widen it past the park. Rewriting it as - `None if park_eligible` — the dispatch-time expectation alone, ignoring the - observed status — is green everywhere without this dimension, and it would let - every ordinary session on a story that had never parked skip proof-of-work - entirely. Neither half selects the skip on its own. - Ablation: delete the `if extra_exclude is not None and task.baseline_commit:` proof-of-work block in `_verify_shared_gates` and all four rows fail on `assert not out.ok` — the residue-free tree then verifies clean at every @@ -910,189 +887,10 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( dev_result(sp), review_enabled=review_enabled, operator_park=operator_park, - park_eligible=park_eligible, ) assert not out.ok and out.retryable assert out.reason == "no changes in worktree since baseline commit" - # neither half of the record: no gate was waived, so there is nothing observed - assert out.park_proof_skipped is False and out.park_zero_diff is None - - -def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): - """DW-1, and the reason the row above needs its new argument: the skip used to - be selected entirely by state a fresh session can INHERIT — the policy flag - plus the spec's own status. A spec an earlier attempt left at - `awaiting-operator` still reads `awaiting-operator` to a session that did - nothing at all, so a re-drive over it selected #676's relaxation and verified - green on someone else's park declaration. - - `park_eligible=False` is the orchestrator saying "the bound spec was ALREADY - parked when I dispatched this". The park is not refused for being inherited — - it is merely held to proof-of-work like every other terminal, and this tree has - none to show. Note the reason: the ordinary proof-of-work message, not a - park-specific refusal, because the eligibility flag gates the SKIP and nothing - else. - - This row and `test_verify_dev_park_with_no_code_residue_passes` differ in - exactly one argument over byte-identical state, which is what makes either one - evidence. Ablation: rewrite the selector as `skip_proof = parked` (drop the - `and park_eligible`) and this fails on `assert not out.ok` while its twin stays - green — the pre-DW-1 behavior exactly.""" - task, sp = _residue_free( - project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR - ) - - out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=False, - operator_park=True, - park_eligible=False, - ) - - assert not out.ok and out.retryable - assert out.reason == "no changes in worktree since baseline commit" - # no gate was waived here, so neither field carries anything — and the pair is - # asserted in both directions, because `park_zero_diff is None` alone is also - # what a WAIVED gate whose probe faulted looks like - assert out.park_proof_skipped is False and out.park_zero_diff is None - - -def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): - """The bound on DW-1: ineligibility gates the proof-of-work SKIP, never the - park itself. An inherited park that carried real work satisfies proof-of-work - on its own and passes — status pair, actions list, workflow tag, baseline match - and sprint pair all still select on the OBSERVED status exactly as before. - - This is the row that would catch the over-correction: making `park_eligible` - select the park's status pair as well (rather than only the skip) turns a - legitimate repair-then-park into a status mismatch, and refuses work that was - actually done. `park_zero_diff` stays None because no skip fired — a passing - park is not automatically a recorded one.""" - task, sp = _park(project) - - out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=False, - operator_park=True, - park_eligible=False, - ) - - assert out.ok - assert task.spec_file == str(sp) - # a PASSING park that owed and produced its diff: no waiver, nothing observed - assert out.park_proof_skipped is False and out.park_zero_diff is None - - -def test_verify_dev_elected_park_with_code_residue_records_a_non_zero_diff(project): - """DW-6's discriminator, and the half a zero-diff-only record could never - prove: the skip fires for EVERY elected park, including one that wrote real - code, and the record has to tell the two apart. `_park` writes `src.txt`, so - the waived gate would have passed — and the observation says so. - - Ablation: make the observation arm return a constant `True` and this row fails - while `test_verify_dev_park_with_no_code_residue_passes` stays green, because - that one cannot distinguish a real probe from a hardcoded answer.""" - task, sp = _park(project) - - out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=False, - operator_park=True, - park_eligible=True, - ) - - assert out.ok - assert out.park_proof_skipped is True and out.park_zero_diff is False - - -def test_verify_dev_park_zero_diff_observation_degrades_to_unknown(project, monkeypatch): - """The observation must never change an outcome. `has_changes_since` can raise - `GitError`, and on the gated legs that escalates the attempt — here the same - fault has to leave the park accepted and the answer honestly unknown. - - Load-bearing because the probe fails OPEN (`rc != 0` -> "there are changes"), - so a fault swallowed at the wrong level would be recorded as a confident - `False` — a zero-diff park filed as one that wrote code, which is worse than no - record at all. - - This is the row that separates the two reasons `park_zero_diff` can be `None`: - the probe could not answer, versus no gate was ever waived. They are different - facts and they live on different fields — `park_proof_skipped` stays True here. - Collapsing them would make this park look like an ordinary leg and drop its - journal record, which is the exact silence DW-6 exists to end. - - Ablation: drop the `except GitError` in the observation arm and this fails with - the GitError propagating out of `verify_dev`, turning a bookkeeping probe into - a failed attempt.""" - task, sp = _residue_free( - project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR - ) - - def boom(*_a, **_kw): - raise verify.GitError("git diff exploded") - - monkeypatch.setattr(verify, "has_changes_since", boom) - - out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=False, - operator_park=True, - park_eligible=True, - ) - - assert out.ok - assert task.spec_file == str(sp) - assert out.park_zero_diff is None - # the gate WAS waived — unknown is not the same fact as "no waiver" - assert out.park_proof_skipped is True - - -def test_verify_dev_park_zero_diff_excludes_the_orchestrators_own_writes(project): - """The observation must exclude exactly what the waived gate would have, and - this is the misattribution most likely to be audited: the orchestrator appends - a harvested deferral to the ledger DURING the attempt, so a park whose session - wrote nothing still leaves that file changed. Counted, the record would read - `zero_diff: false` — "this park committed real code" — about a diff the - orchestrator itself produced, and an audit of which parks got in without - proving work would quietly exonerate exactly the wrong ones. - - `engine_written` is what `Engine._harvest_gate_exclude` supplies for this, and - on the waived leg it is routed to `observe_skipped_proof` rather than - `extra_exclude` — same tuple, no gate. - - Ablation: drop `+ mode_exclude` from `proof_of_work_probe`'s exclusion (or - stop passing `observe_skipped_proof` at the call site) and this fails with - `park_zero_diff is False`, while every other park row stays green — they have - no orchestrator residue to misattribute.""" - task, sp = _residue_free( - project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR - ) - (project.repo_root / "ledger.md").write_text("- DW-9 harvested by the orchestrator\n") - - out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=False, - operator_park=True, - park_eligible=True, - engine_written=("ledger.md",), - ) - - assert out.ok - assert out.park_proof_skipped is True - # the ONLY residue is the orchestrator's own write, so the park really is - # zero-diff and the record has to say so - assert out.park_zero_diff is True def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): @@ -1113,11 +911,7 @@ def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): ) rj = {"workflow": "quick-dev", "spec_file": str(sp)} - # park_eligible=True so the skip really is in place: without it proof-of-work - # would also refuse this tree and the row would pass for a compound reason. - out = verify.verify_dev( - task, project, rj, review_enabled=False, operator_park=True, park_eligible=True - ) + out = verify.verify_dev(task, project, rj, review_enabled=False, operator_park=True) assert not out.ok and out.retryable assert "auto-dev" in out.reason @@ -1144,17 +938,7 @@ def test_verify_dev_park_still_faces_the_baseline_match_gate(project): baseline="deadbeef" * 5, ) - # Same reason as the workflow-tag row above: with park_eligible left False the - # tree would also owe proof-of-work, and baseline-match would stop being the - # only thing that could refuse here. - out = verify.verify_dev( - task, - project, - dev_result(sp), - review_enabled=False, - operator_park=True, - park_eligible=True, - ) + out = verify.verify_dev(task, project, dev_result(sp), review_enabled=False, operator_park=True) assert not out.ok and out.retryable assert "does not match" in out.reason @@ -6564,13 +6348,6 @@ def test_engine_written_is_keyword_only_on_all_dev_verifiers(): parameter = inspect.signature(fn).parameters["engine_written"] assert parameter.kind is inspect.Parameter.KEYWORD_ONLY assert "operator_park" in inspect.signature(verify.verify_dev).parameters - # The park skip's second selector (DW-1). Keyword-only for the same reason - # `engine_written` is: `verify_dev`'s positional tail is `review_enabled`, and - # a positional eligibility flag would be one transposed argument away from - # silently authorizing the skip on every leg. - park_eligible = inspect.signature(verify.verify_dev).parameters["park_eligible"] - assert park_eligible.kind is inspect.Parameter.KEYWORD_ONLY - assert park_eligible.default is False # --------------------------------------------------- the git support floor (GIT_FLOOR) From b6b4f0861a3e1a4f7d090375c9bff38efc4b8c7b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 21:36:59 -0700 Subject: [PATCH 03/45] fix(verify): require a dispatch-time expectation before a park skips proof-of-work Give the parked leg's proof-of-work skip a two-part selector: the session's observed `awaiting-operator` status AND an expectation the orchestrator records at dev-phase dispatch (the story's bound spec was not already parked). The expectation is captured once per phase, on the same anchor as the attempt baseline, so a fixable repair still passes; only the skip narrows, so an inherited park with a real diff passes as before (DW-1). Return the waiver upward on `VerifyOutcome` and journal `park-proof-of-work-skipped` with a `zero_diff` flag at `Engine._verify_dev_artifacts`, so a park that wrote nothing and one that wrote real code stop being indistinguishable. The observation runs through the shared gate's own probe, so it cannot drift from the baseline the skipped gate would have used (DW-6). --- CHANGELOG.md | 24 +++ docs/FEATURES.md | 6 +- src/bmad_loop/engine.py | 121 ++++++++++++++- src/bmad_loop/model.py | 65 +++++++- src/bmad_loop/verify.py | 323 ++++++++++++++++++++++++++++++--------- tests/test_engine.py | 327 ++++++++++++++++++++++++++++++++++++++++ tests/test_model.py | 45 ++++++ tests/test_verify.py | 270 ++++++++++++++++++++++++++++++++- 8 files changed, 1097 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b711d728..4449ad14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,6 +180,30 @@ breaking changes may land in a minor release. ### Fixed +- Require the orchestrator's own dispatch-time expectation before an `awaiting-operator` + park skips the dev gate's proof-of-work check (#335, #676). The skip was selected by the + policy flag plus the spec's own status, both of which a fresh session inherits, so a + re-drive over a spec an earlier attempt had already parked verified green having done + nothing. The expectation is captured once per dev phase, on the same anchor as the + attempt baseline, so a fixable repair of a malformed park still passes. An inherited park + that did real work is unaffected — only the skip narrows, not the park. The expectation is + read from the run's own binding for the story, which bounds what it catches: a park + inherited from a previous run, written out of band, or re-armed out of an escalation + (re-arming reopens the spec without clearing `operator_actions:`) still reaches a + dispatch that is eligible. Both residuals are tracked as deferred work. One upgrade + note: the expectation defaults to "not eligible" for state written before it existed, so + a run interrupted mid-park and resumed after upgrading holds that in-flight park to + proof-of-work — if it produced no code, it is retried and may defer rather than parking. + Re-running the story is enough; nothing is lost. +- Journal `park-proof-of-work-skipped` with a `zero_diff` flag for every attempt that clears + the dev artifact gate on a park with proof-of-work waived (#676), so a park that wrote + nothing and a park that wrote real code stop being indistinguishable after the fact. The + record is scoped to that gate: a waiver refused by a later check inside it leaves no + entry, while the stages after it (your `[verify]` commands, the review loop, the commit) + can still reject the attempt with its entry already written — join + `review-skipped-awaiting-operator` on the story key for the parks that reached commit. + The probe is an observation only: when it cannot answer — a git fault, or no recorded + baseline — the flag is `null` and the outcome is unchanged. - Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run owns. Under isolation both resolved against the main checkout, so the review modals showed that copy of the spec and the replan reset it — reporting success while the run's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 31735b18..11e08ed7 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -47,7 +47,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Verification (trust-nothing gate) -- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. +- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park this attempt newly elected — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. - Runs _your_ commands (`[verify].commands`, e.g. `pytest -q`, `ruff check .`) in the git root the code lives in (`repo_root`): your project dir by default, the mounted per-unit worktree under `[scm] isolation = "worktree"`, and an explicit `repo_root:` when you set one under `isolation = "none"`. The gate's own artifact READS — the spec's frontmatter, the sprint board, the deferred-work ledger — stay project-rooted either way (#695). What follows the code, besides the command `cwd`, is every git question the gate asks about it: the recorded baseline is written in the git root, so the commit-identity lookup, both ancestry checks and the non-empty-diff probe are all asked there, and the pathspecs they exclude are spelled relative to that same root (#716). Anchoring those on the project dir meant a correct attempt could be refused forever under an explicit `repo_root:`. A broken build never reaches review or commit. ### Adversarial review (review stage) @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). That skip covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement, so a re-drive that does nothing does not verify green on the park it inherited. That expectation is read from the run's own binding for the story, which bounds what it catches: a park inherited from a **previous run**, or one written into the spec out of band, reaches a dispatch with nothing bound and is eligible; so does a story re-armed out of an escalation, since re-arming reopens the spec at `ready-for-dev` without clearing its `operator_actions:`. Both remain open and are tracked as deferred work. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so a park that clears this artifact gate with the waiver in force is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got through. Read that record for exactly what it says: a waiver refused by a later check inside the same gate leaves no entry, but the stages **after** it — your `[verify]` commands, the review loop, the commit — can still reject the attempt, and the entry stands regardless. It answers "which parks cleared the artifact gate without proving work", never "which parks committed"; the second question is the `review-skipped-awaiting-operator` record below, which fires for every park that reaches commit. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. @@ -172,7 +172,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). +- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the park's whole residue was its own spec plus the board (the shape the waiver exists for), `false` when it also wrote real code, `null` when the probe could not answer — a git fault, or an attempt with no recorded baseline to measure from — and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 5c9891e0..14fae01a 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2299,6 +2299,15 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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() + # Whether this phase may newly ELECT a park, on the same anchor and + # for the same reason as the baseline above: the proof-of-work skip + # this authorizes is measured from that baseline, so the expectation + # and the diff it guards have to be captured at one instant. A fixable + # repair therefore inherits the phase's answer (it deliberately keeps + # the previous session's tree, park declaration included, so + # re-observing per attempt would make every repair of a malformed park + # ineligible), and a crash-replayed attempt keeps the persisted one. + task.park_eligible = self._park_eligible_at_dispatch(task) feedback: Path | None = None while True: replayed = resume_result is not None @@ -3550,6 +3559,76 @@ def _operator_park_enabled(self) -> bool: branch happens to sit.""" return self.policy.operator.enabled + def _park_eligible_at_dispatch(self, task: StoryTask) -> bool: + """Whether the attempt about to be dispatched could newly ELECT a park — + the orchestrator-side half of :func:`verify.verify_dev`'s two-part + proof-of-work skip selector (#335, #676). + + The skip used to be selected entirely by state a fresh session can + INHERIT: ``operator_park`` (a policy flag) plus the spec's own + ``awaiting-operator`` status, which an earlier attempt may already have + written. A re-drive over such a spec therefore selected #676's relaxation + while having done nothing at all, and verified green on someone else's + park declaration. This is the fact that cannot be inherited: at the moment + the phase is dispatched, was the story's bound spec ALREADY parked? + + ``False`` when parking is off (the skip is unreachable anyway, so this + costs no read), when the bound spec already reads ``awaiting-operator``, + and on the two genuinely unobservable shapes: a recorded ``spec_file`` + that no longer resolves to a trusted regular file, and one whose read + raises ``OSError`` (journaled ``spec-read-failed``). Those fail closed onto + the ordinary gated path, where an honest park with a real diff still + passes. + + An UNPARSEABLE spec is deliberately not in that list, and the distinction + is worth stating because it looks like a gap. ``read_frontmatter`` + degrades malformed YAML and non-UTF-8 to ``{}`` rather than raising, so + ``status_of`` reads ``""`` and this returns True. That is correct rather + than merely tolerated: an unparseable spec demonstrably does not say + "parked", and ``verify_dev``'s own gate reads the very same ``{}``, so + ``parked`` is False there too and the skip is unreachable on that leg no + matter what this answers. Only OSError and an unresolvable binding are + uncertainty about a spec that *does* say something. + + ``True`` when nothing is bound at all — the ordinary case, not a fallback. + Note precisely what that tests: ``task.spec_file`` is an IN-RUN binding, + set only after a session returns and its artifacts verify, so "unbound" + means "this task object has no binding", NOT "no earlier park exists on + disk". A story whose spec was parked by a previous RUN, or edited into the + park status out of band, presents as unbound here and is eligible. The + residual is recorded as a deferred finding on this change's spec rather + than closed silently; closing it means keying eligibility on the spec the + story resolves to rather than on the task's binding, which is a wider + change than the one this gate makes. + + Called only from ``_dev_phase``'s ``resume_result is None`` block, beside + the baseline capture — see the comment there for why the anchor is the + PHASE and not the attempt. Reuses ``_dispatched_spec_for_attempt`` for the + symlink/roots checks rather than re-deriving them: a second, laxer + resolution here would be a second answer to "which file is this attempt's + spec", and recovery already owns that question. + + Consequence worth knowing before touching either caller: that resolver is + now invoked TWICE per dev phase — once here at phase entry, and once by + the binder inside the attempt loop. They are two observations of the same + path at different instants and neither may be folded into the other (this + one must precede the first attempt; the binder's must be the one that + promotes). Any test that counts calls to it has to say which observation + it means — ``test_transient_initial_binding_fault_does_not_promote_after_bare_prompt`` + pins this one out for exactly that reason. + """ + if not self._operator_park_enabled(): + return False + if not task.spec_file: + return True + bound = self._dispatched_spec_for_attempt(task) + if bound is None: + return False + fm = self._observed_frontmatter(Path(bound), task.story_key, "park-eligibility") + if fm is None: + return False + return verify.status_of(fm) != verify.AWAITING_OPERATOR + 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 @@ -5200,14 +5279,54 @@ def _harvest_gate_exclude(self, task: StoryTask) -> tuple[str, ...]: return (rel.as_posix(),) def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): - return verify.verify_dev( + outcome = verify.verify_dev( task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled(), operator_park=self._operator_park_enabled(), + # The dispatch-time half of the park's proof-of-work skip selector, + # read from the task rather than re-observed: it was captured on this + # phase's fresh entry, and re-deriving it now would answer about the + # spec the session just finished writing (#676). + park_eligible=task.park_eligible, 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 session's whole residue was the spec and the board (the + # #676 shape the skip exists for), `false` when it also carried real code, + # and JSON `null` when the probe could not answer — a git fault, 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. + # + # 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 `_skip_review_and_commit`'s + # `review-skipped-awaiting-operator`, which fires for every park that + # reaches commit; a reader wanting "waived AND committed" joins the two on + # the story key. + 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 _verify_review(self, task: StoryTask): # `not _dev_review_enabled()` is exactly the case where _post_dev_state_sync diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index c9f784c2..e10142a8 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -294,6 +294,20 @@ class StoryTask: # owes, and nothing re-derives it once the session that wrote the spec is # gone). operator_actions: list[str] = field(default_factory=list) + # Whether THIS dev phase was in a position to newly elect a park: captured + # once, on the fresh entry into `Engine._dev_phase` (`resume_result is None`), + # from the same instant and the same condition as `baseline_commit` — so the + # expectation and the diff it guards share one anchor. False when the bound + # spec was ALREADY at `awaiting-operator` on entry (an earlier attempt's park + # is on disk, so a park observed afterwards may be inherited rather than + # elected), when parking is disabled, or when the spec could not be read at + # all (fail closed). It gates exactly one thing: `verify_dev`'s proof-of-work + # skip on the park leg (#335, #676). Every other park gate still selects on the + # observed status alone, so an ineligible park with a real diff still passes. + # Deliberately per-PHASE, not per-attempt: a fixable repair keeps the previous + # session's tree, so re-observing would make every repair of a malformed park + # ineligible and fail it on the gate it just re-armed. + park_eligible: bool = False defer_reason: str | None = None # the recovery ref this attempt's work was parked on by the last auto-rollback # — an `attempt-preserve/*` branch (commits above baseline) or, when the tree @@ -442,6 +456,7 @@ def to_dict(self) -> dict[str, Any]: ), "commit_sha": self.commit_sha, "operator_actions": self.operator_actions, + "park_eligible": self.park_eligible, "defer_reason": self.defer_reason, "preserve_ref": self.preserve_ref, "preserve_partial": self.preserve_partial, @@ -629,6 +644,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot=dispatched_spec_snapshot, commit_sha=d.get("commit_sha"), operator_actions=[str(a) for a in d.get("operator_actions", [])], + park_eligible=bool(d.get("park_eligible", False)), defer_reason=d.get("defer_reason"), preserve_ref=d.get("preserve_ref"), preserve_partial=bool(d.get("preserve_partial", False)), @@ -868,10 +884,55 @@ class VerifyOutcome: # time): no further session can reconcile it, so it routes to a pause with # both sides named rather than to another cycle (#334) contradiction: bool = False + # Whether this PASSING outcome waived the dev gate's proof-of-work check on + # the park leg (`verify_dev`'s two-part park selector fired). The fact of the + # waiver, not its result: `Engine._verify_dev_artifacts` journals exactly the + # attempts this is True for, so a park that got past the dev ARTIFACT gate + # without proving work always leaves a trace there (#676). + # + # Scoped to that gate at both ends, and the bound is worth stating exactly. + # This rides only the `passed()` return, so a leg that waived proof-of-work + # and then failed a later check INSIDE `verify_dev` — the sprint pair is the + # reachable one — records nothing; anything wider would need the flag on the + # failing constructors too. It says nothing at all about the stages AFTER that + # gate: the configured `[verify]` commands, decision routing, the review loop, + # the pre-commit workflows and the commit all run later and may still reject + # the attempt, which is then retried or deferred with its record already + # written. So a True here means "the artifact gate was cleared with + # proof-of-work waived", never "this park committed". + park_proof_skipped: bool = False + # An OBSERVATION, never a gate: on that same waived leg, whether the tree was + # in fact free of code residue since the attempt's baseline. `True` = the + # accepted park wrote nothing beyond what proof-of-work already excludes, + # `False` = it carried a real diff, `None` = the probe could not answer. Two + # things produce that `None`: a git fault (it degrades rather than escalating) + # and an attempt with no `baseline_commit` to measure from. Nothing branches + # on it. Note also that `False` is the weaker of the two definite answers: + # the probe inherits `has_changes_since`'s fail-open, so a git REFUSAL (rc 128, + # e.g. an unresolvable baseline) reads as "there are changes" rather than + # raising, and is recorded as `False`. + # + # The two fields are deliberately separate, and collapsing them is the bug + # this pair exists to prevent: one says a gate was waived, the other says what + # that gate would have found. Keyed on the observation alone, an unanswerable + # probe is indistinguishable from no waiver at all — so a park whose probe + # faulted would go unrecorded, re-creating exactly the silence this pair ends. + # A waived gate is recorded whatever the probe managed to say; `None` is a + # truthful field value, not a reason to withhold the record. + park_zero_diff: bool | None = None @classmethod - def passed(cls) -> "VerifyOutcome": - return cls(ok=True) + def passed( + cls, + *, + park_proof_skipped: bool = False, + park_zero_diff: bool | None = None, + ) -> "VerifyOutcome": + return cls( + ok=True, + park_proof_skipped=park_proof_skipped, + park_zero_diff=park_zero_diff, + ) @classmethod def retry(cls, reason: str, fixable: bool = False) -> "VerifyOutcome": diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 702e38ef..d04c2719 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3292,6 +3292,29 @@ def _gate_frontmatter(spec_path: Path) -> dict[str, Any] | VerifyOutcome: return VerifyOutcome.retry(f"spec unreadable ({e.__class__.__name__}: {e}): {spec_path}") +@dataclass(frozen=True) +class _SharedGateResult: + """What :func:`_verify_shared_gates` answers: the failing outcome (``None`` + when every gate passed and the caller may run its mode-specific tail), plus + whatever the gate OBSERVED on the way through that no gate acted on. + + ``skipped_proof_zero_diff`` is the second kind: on a leg that skipped + proof-of-work and asked to be told anyway (``observe_skipped_proof``), it is + ``True`` when the tree held no changes the gate would have counted, ``False`` + when it held some, and ``None`` when nothing was observed — no skip, no + request, no baseline, or a git fault. It is deliberately a return value and + not a gate input: the observation must be made HERE because the baseline it + measures from is derived here (the newer-claim branch can re-anchor + ``proof_baseline`` and drop untracked evidence), and no caller can reproduce + that derivation. A caller re-probing from ``task.baseline_commit`` would count + a commit that arrived in a shared ``isolation = "none"`` checkout from outside + the session as this attempt's work — the exact false negative the observation + exists to expose.""" + + outcome: VerifyOutcome | None = None + skipped_proof_zero_diff: bool | None = None + + def _verify_shared_gates( spec_path: Path, rj: dict[str, Any], @@ -3300,15 +3323,17 @@ def _verify_shared_gates( *, expected_status: str, extra_exclude: tuple[str, ...] | None, + observe_skipped_proof: tuple[str, ...] | None = None, allow_ancestor_baseline: bool = False, fm: dict[str, Any] | None = None, -) -> VerifyOutcome | None: +) -> _SharedGateResult: """The workflow-tag, expected-status, baseline-match, and proof-of-work gates shared verbatim by :func:`verify_dev`, :func:`verify_dev_bundle`, and :func:`verify_dev_stories` — factored out so the sprint-mode and stories-mode gates can't silently drift. Reads frontmatter once; a caller that had to read it first to *choose* ``expected_status`` passes what it read as ``fm`` so the - single-read contract still holds (no caller re-reads it). Returns a failing + single-read contract still holds (no caller re-reads it). Returns a + :class:`_SharedGateResult` whose ``outcome`` is a failing :class:`VerifyOutcome`, or ``None`` when every gate passes and the caller may run its mode-specific tail. @@ -3325,22 +3350,54 @@ def _verify_shared_gates( leg produced only its own spec (structurally spec-only), and a park may legitimately have produced no code at all because its remaining work is a human's (#676). Both mean "there is no diff to demand here"; neither - generalizes to the other's leg, so keep them named separately.""" + generalizes to the other's leg, so keep them named separately. + + ``observe_skipped_proof`` is the same exclusion tuple the caller WOULD have + passed as ``extra_exclude`` had it not skipped the gate. When set on a skipped + leg the probe still runs — against the baseline derived above, not the raw + ``task.baseline_commit`` — purely to answer whether there was in fact a diff, + and the answer rides out on ``_SharedGateResult.skipped_proof_zero_diff``. + Nothing branches on it here: a fault degrades to ``None`` rather than + escalating, and the leg's outcome is identical either way. It exists so an + accepted park's skipped gate stops being silent (#676) — a park that wrote + code and a park that wrote nothing are otherwise indistinguishable after the + fact. + + Exactly one of the two skipping legs asks for it, and the asymmetry is + deliberate rather than an omission: only sprint mode's PARK passes it. + ``verify_dev_stories``' plan halt skips the gate and observes nothing, because + it already has an independent cross-check a park has no equivalent for — a + clean plan-halt carries ``devcontract``'s ``plan_halt`` marker in its + result.json (``rj.get("plan_halt") is not True`` refuses the leg outright), so + a died-mid-flight ``ready-for-dev`` cannot reach the skip in the first place. A + park's status is self-asserted with no such marker, which is why it is the leg + that needs a record of what the waived gate would have found. + + The two parameters are MUTUALLY EXCLUSIVE by construction: ``extra_exclude`` + gates and ``observe_skipped_proof`` observes, and the arms below are ``if`` / + ``elif`` on that order. Passing both is not a richer mode, it is a caller + error that silently drops the observation — the gate arm wins and the leg was + never skipped, so there was nothing to observe. Pass ``extra_exclude`` OR + ``observe_skipped_proof``, never both.""" workflow = rj.get("workflow") if workflow != DEV_WORKFLOW: - return VerifyOutcome.retry( - f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}" + return _SharedGateResult( + VerifyOutcome.retry( + f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}" + ) ) if fm is None: read = _gate_frontmatter(spec_path) if isinstance(read, VerifyOutcome): - return read + return _SharedGateResult(read) fm = read status = status_of(fm) if status != expected_status: - return VerifyOutcome.retry( - f"spec status is {status!r}, expected {expected_status!r}: {spec_path}" + return _SharedGateResult( + VerifyOutcome.retry( + f"spec status is {status!r}, expected {expected_status!r}: {spec_path}" + ) ) # The generic bmad-build-auto skill stamps `baseline_revision`, never @@ -3376,11 +3433,13 @@ def _verify_shared_gates( try: canonical_claimed = _canonical_commit_oid(paths.repo_root, claimed_baseline) except GitError as e: - return VerifyOutcome.escalate(str(e)) + return _SharedGateResult(VerifyOutcome.escalate(str(e))) if canonical_claimed is None: - return VerifyOutcome.retry( - f"spec baseline {claimed_baseline[:12]} does not match " - f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + return _SharedGateResult( + VerifyOutcome.retry( + f"spec baseline {claimed_baseline[:12]} does not match " + f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + ) ) if canonical_claimed != task.baseline_commit: # A deferred-work bundle may legitimately adopt a pre-existing story @@ -3412,34 +3471,70 @@ def _verify_shared_gates( proof_baseline = canonical_claimed if newer_ok else proof_baseline include_untracked_proof = not newer_ok if not (older_ok or newer_ok): - return VerifyOutcome.retry( - f"spec baseline {claimed_baseline[:12]} does not match " - f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + return _SharedGateResult( + VerifyOutcome.retry( + f"spec baseline {claimed_baseline[:12]} does not match " + f"orchestrator-recorded baseline {task.baseline_commit[:12]}" + ) ) - if extra_exclude is not None and task.baseline_commit: - # The exclude pathspecs are rooted where git is invoked: `repo_root` here - # and `repo_root` in every producer that composes into `extra_exclude` - # (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative - # to a different root is not merely wrong, it is SILENTLY wrong — git - # matches nothing and the exclusion evaporates. - exclude = ( - verify_dev_exclude_relpaths(paths, spec_path, task.restore_patch, root=paths.repo_root) - + extra_exclude + def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: + """The one place proof-of-work is measured, called by BOTH arms below. + + The gate arm and the observation arm differ in exactly one input — which + mode-supplied tuple composes onto the gate's own exclusions — and in + nothing else. They were briefly two spelled-out copies of the same five + arguments, and every property the docstrings claim for the observation + (that it excludes the mode's paths, that it keeps the newer-claim + ``proof_baseline``, that it inherits ``include_untracked_proof``) was + silently droppable in the copy while the gate stayed correct and the suite + stayed green. A shared body makes the two unable to disagree by + construction, which is stronger than any test over the copies: divergence + is no longer a thing a reader can express here. + + The exclude pathspecs are rooted where git is invoked: `repo_root` here + and `repo_root` in every producer that composes into them + (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative + to a different root is not merely wrong, it is SILENTLY wrong — git + matches nothing and the exclusion evaporates. + """ + return has_changes_since( + paths.repo_root, + proof_baseline, + exclude=verify_dev_exclude_relpaths( + paths, spec_path, task.restore_patch, root=paths.repo_root + ) + + mode_exclude, + baseline_untracked=task.baseline_untracked, + include_untracked=include_untracked_proof, ) + + if extra_exclude is not None and task.baseline_commit: try: - if not has_changes_since( - paths.repo_root, - proof_baseline, - exclude=exclude, - baseline_untracked=task.baseline_untracked, - include_untracked=include_untracked_proof, - ): - return VerifyOutcome.retry("no changes in worktree since baseline commit") + if not proof_of_work_probe(extra_exclude): + return _SharedGateResult( + VerifyOutcome.retry("no changes in worktree since baseline commit") + ) except GitError as e: - return VerifyOutcome.escalate(str(e)) + return _SharedGateResult(VerifyOutcome.escalate(str(e))) + elif observe_skipped_proof is not None and task.baseline_commit: + # The gate was skipped; run its probe anyway and report, never refuse. + # Only `GitError` is caught, so a non-git bug still surfaces — but that is + # a narrower guarantee than "an unanswerable probe records None". The + # observation inherits `has_changes_since`'s deliberate fail-open: any + # non-zero rc reads as "there are changes", and only timeout, spawn and + # decode faults raise `GitError` at all. So a git REFUSAL — an unresolvable + # baseline, rc 128 — is recorded as `zero_diff: False`, "this park + # committed real code". The bias is toward the less alarming record, which + # is the right direction for a field nothing gates on, but it means a + # `False` here is weaker evidence than a `True`. + try: + skipped_proof_zero_diff = not proof_of_work_probe(observe_skipped_proof) + except GitError: + skipped_proof_zero_diff = None + return _SharedGateResult(None, skipped_proof_zero_diff) - return None + return _SharedGateResult() # The terminal spec status of a story whose agent-doable work is finished but @@ -3481,6 +3576,7 @@ def verify_dev( review_enabled: bool = True, *, operator_park: bool = False, + park_eligible: bool = False, engine_written: tuple[str, ...] = (), ) -> VerifyOutcome: """Verify a dev session's on-disk artifacts against its result.json claims. @@ -3502,9 +3598,15 @@ def verify_dev( a terminal the gate knows, so it fails the ordinary status check and the session is retried with that mismatch as feedback. - On the park leg the proof-of-work gate is skipped, the same way the plan-halt - leg of :func:`verify_dev_stories` skips it and by the same ``extra_exclude=None`` - spelling: a park's whole output can legitimately be its own spec's park + The proof-of-work gate is skipped on a park that this attempt was in a + position to newly ELECT — ``skip_proof = parked and park_eligible``, a + two-part selector. ``parked`` is what the session left behind (the observed + spec status, plus the policy flag); ``park_eligible`` is what the orchestrator + knew at dispatch (:meth:`Engine._park_eligible_at_dispatch`, captured on the + fresh entry into ``Engine._dev_phase`` from the same instant and the same + condition as ``task.baseline_commit``): the story's bound spec did NOT already + read ``awaiting-operator``. Both halves are load-bearing. The skip exists + because a park's whole output can legitimately be its own spec's park declaration plus the board sync, both of which proof-of-work already excludes, so demanding a diff read a correct park as "no changes since baseline commit" and refused it (#676) — costing the attempt, and with it the park declaration: @@ -3515,27 +3617,82 @@ def verify_dev( gate passes — not the session's own work: ``bmad-build-auto`` commits each iteration, so a skill commit chain usually already sits above baseline (``Engine._finalize_commit_phase``), and a reset discards that too, onto an - ``attempt-preserve/*`` ref. Nothing else relaxes — the - ``operator_actions`` gate above still refuses a park that enumerates nothing, - and the workflow-tag, status, baseline-match and sprint-pair gates all still - run. Two of those four are not independent evidence on this leg, and saying so - is the point: the status check is tautological here (the same ``fm`` that - selected ``parked`` is threaded in as ``fm=fm``, so the shared gate compares it - against an ``expected_status`` derived from itself), and the sprint pair was - written from that same frontmatter by ``Engine._post_dev_state_sync`` a dozen - lines before this gate runs, so it confirms the orchestrator's own write landed - rather than anything the session did. What still binds a park to the attempt - the orchestrator actually launched is the workflow tag, the baseline match, and - a non-empty actions list — and the middle one is weaker on this leg than its - name suggests. Baseline-match also accepts a claim NEWER than the recorded - baseline whenever it is a HEAD-reachable descendant, and the comment guarding - that branch names the compensating control: such a commit "may have arrived in - the shared checkout from outside the session", so the check re-anchors - proof-of-work onto the claimed commit rather than trusting the match alone. - Proof-of-work is precisely what this leg skips, so on a park that re-anchoring - is inert and the newer-claim branch tightens nothing. The trade is recorded rather than hidden: the skip - covers EVERY park, including one that wrote nothing and listed plausible - actions, because the actions gate tests list non-emptiness and never content. + ``attempt-preserve/*`` ref. + + What the eligibility half defends is narrow and worth naming exactly. Before + it, the relaxation was selected entirely by state a fresh session could + INHERIT rather than produce: a spec an earlier attempt left at + ``awaiting-operator`` still reads ``awaiting-operator`` to the next session + that does nothing at all, so a re-drive over that spec selected the skip and + verified green on someone else's declaration, relaxing #676's skip for an + attempt that produced nothing. Requiring the + orchestrator's own dispatch-time answer means the leg that skips proof-of-work + is the leg that actually authored the park. It does NOT defend against a + session that elects a park it did not earn — one that writes the frontmatter, + lists plausible actions and implements nothing is eligible by construction and + still passes, because the actions gate tests list non-emptiness and never + content. It is a check on WHICH ATTEMPT owns the park, not on whether the park + is honest, and it is captured per PHASE rather than per attempt: a fixable + repair deliberately keeps the previous session's tree, so re-observing would + make every repair of a malformed park ineligible and fail it on the gate it + just re-armed. + + An INELIGIBLE park is not refused — it is merely held to proof-of-work like + any other terminal. The park's status pair, ``operator_actions`` + non-emptiness, workflow tag, baseline match and sprint pair all keep selecting + on the observed status alone, so an inherited park carrying a real diff passes + exactly as before; only the residue-free one now owes the diff it never + produced. + + Nothing else relaxes on the eligible leg either — the ``operator_actions`` + gate above still refuses a park that enumerates nothing, and the workflow-tag, + status, baseline-match and sprint-pair gates all still run. Two of those four + are not independent evidence on this leg, and saying so is the point: the + status check is tautological here (the same ``fm`` that selected ``parked`` is + threaded in as ``fm=fm``, so the shared gate compares it against an + ``expected_status`` derived from itself), and the sprint pair was written from + that same frontmatter by ``Engine._post_dev_state_sync`` a dozen lines before + this gate runs, so it confirms the orchestrator's own write landed rather than + anything the session did. What still binds a park to the attempt the + orchestrator actually launched is the workflow tag, the baseline match, the + non-empty actions list — and now the dispatch-time eligibility, which is the + only one of the four the session cannot influence at all. Baseline-match also + accepts a claim NEWER than the recorded baseline whenever it is a + HEAD-reachable descendant, and the comment guarding that branch names the + compensating control: such a commit "may have arrived in the shared checkout + from outside the session", so the check re-anchors proof-of-work onto the + claimed commit rather than trusting the match alone. Proof-of-work is precisely + what this leg skips, so on a park that re-anchoring still gates nothing — but + it is no longer inert: the observation below inherits it, so a foreign commit + cannot be credited as this attempt's work in the record either. + + The accepted skip is no longer silent, and it is recorded on TWO fields + because one cannot carry both facts. ``VerifyOutcome.park_proof_skipped`` is + the waiver itself — ``skip_proof``, ``False`` on every other leg. When it + fires, the shared gate additionally runs the proof-of-work probe as a pure + OBSERVATION (``observe_skipped_proof=engine_written``) and what that probe + found rides out on ``VerifyOutcome.park_zero_diff``: ``True`` for a park with + no code residue, ``False`` for one carrying a real diff, ``None`` when the + probe could not answer. What separates "unknown" from "no skip happened" is + ``park_proof_skipped``, not this field — collapsing the two into + ``park_zero_diff is not None`` would make a park whose probe faulted look like + a leg that never waived anything, and it would go unrecorded — the silence + this record exists to end. ``None`` has exactly two causes now, both of them + "the probe could not answer": a git fault, and an attempt carrying no + ``task.baseline_commit`` to measure from (the shared gate runs neither arm + without one). Neither field changes an outcome: a git fault degrades to + ``None`` rather than escalating, and an eligible park verifies identically + either way. Their consumer is + :meth:`Engine._verify_dev_artifacts`, which journals + ``park-proof-of-work-skipped`` for a waived gate that this function then + PASSED, and carries the observation as that record's ``zero_diff`` field, so a + park that wrote code and a park that wrote nothing stop being + indistinguishable afterwards (#676). Both ends of that scope are set here: a + waiver refused by a later check in this function (the sprint pair) never + reaches the record, and a record that IS written asserts only that this gate + was cleared with proof-of-work waived — the configured ``[verify]`` commands, + the review loop and the commit all run afterwards and may still reject the + attempt, which is then retried or deferred with its record already written. ``engine_written`` names paths the orchestrator itself wrote above this gate during the attempt, relative to ``paths.repo_root`` — the tree the gate invokes @@ -3543,9 +3700,11 @@ def verify_dev( must share (#716). They compose with the mode's normal proof-of-work exclusions so engine bookkeeping cannot masquerade as session work; see :meth:`Engine._harvest_gate_exclude`, which is their producer and states what a - ledger outside the code tree resolves to. On the parked leg they are not passed - at all — proof-of-work is skipped there, so there is no exclusion set left for - them to compose with. + ledger outside the code tree resolves to. On the skipped park leg they are + passed as ``observe_skipped_proof`` instead of ``extra_exclude``: no gate + consumes them there, but the zero-diff observation must exclude exactly what + the gate would have, or the orchestrator's own bookkeeping writes would be + recorded as the park's code residue. """ rj = result_json or {} spec_file = rj.get("spec_file") @@ -3563,6 +3722,12 @@ def verify_dev( actions = _operator_actions_gate(fm, task.story_key) if actions is not None: return actions + # The two-part selector: the session's observed park AND the orchestrator's + # dispatch-time answer that this phase could newly elect one. Deliberately a + # separate name from `parked` — every other park gate below still keys on + # `parked` alone, and collapsing the two would silently widen this expectation + # from "may skip proof-of-work" to "may park at all" (#335, #676). + skip_proof = parked and park_eligible # With review disabled, the dev session runs its own internal review and # finalizes straight to done; otherwise it hands off at in-review. A park @@ -3575,16 +3740,20 @@ def verify_dev( expected_status=( AWAITING_OPERATOR if parked else ("in-review" if review_enabled else "done") ), - # Proof-of-work is the one gate the parked leg skips (``extra_exclude=None``, - # the callee-blessed spelling): a park's whole residue can legitimately be - # the spec and the board, both already excluded (#676). The park paragraph + # Proof-of-work is the one gate an ELECTED park skips (``extra_exclude=None``, + # the callee-blessed spelling): such a park's whole residue can legitimately + # be the spec and the board, both already excluded (#676). The park paragraph # in this function's docstring carries the reasoning and, more importantly, - # what the skip does NOT relax. - extra_exclude=None if parked else engine_written, + # what the skip does NOT relax. An inherited park (`park_eligible=False`) + # takes the ordinary arm and owes a diff like every other terminal. + extra_exclude=None if skip_proof else engine_written, + # Same tuple, no gate: when the skip fires the probe still runs, purely so + # the accepted park's zero-diff answer can be journaled (#676). + observe_skipped_proof=engine_written if skip_proof else None, fm=fm, ) - if gate is not None: - return gate + if gate.outcome is not None: + return gate.outcome expected_sprint = AWAITING_OPERATOR if parked else ("review" if review_enabled else "done") sprint = story_status(paths.sprint_status, task.story_key) @@ -3594,7 +3763,15 @@ def verify_dev( ) task.spec_file = str(spec_path) - return VerifyOutcome.passed() + # Two facts, deliberately on two fields: `park_proof_skipped` says this leg + # WAIVED proof-of-work (False on every other leg), `park_zero_diff` says what + # the waived gate would have found — and `None` there now means only "the + # probe could not answer", because the first field already carries the waiver. + # Both are carried to the journal; neither is a gate (#676). + return VerifyOutcome.passed( + park_proof_skipped=skip_proof, + park_zero_diff=gate.skipped_proof_zero_diff, + ) def verify_dev_bundle( @@ -3633,8 +3810,8 @@ def verify_dev_bundle( extra_exclude=engine_written, allow_ancestor_baseline=True, ) - if gate is not None: - return gate + if gate.outcome is not None: + return gate.outcome claimed_ids = {str(i) for i in (rj.get("dw_ids") or [])} if claimed_ids and claimed_ids != set(task.dw_ids): @@ -3752,8 +3929,8 @@ def verify_dev_stories( else _stories_relpaths(paths.repo_root, spec_folder) + engine_written ), ) - if gate is not None: - return gate + if gate.outcome is not None: + return gate.outcome task.spec_file = str(spec_path) return VerifyOutcome.passed() diff --git a/tests/test_engine.py b/tests/test_engine.py index 8824060a..c6e4d219 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1369,6 +1369,13 @@ def transient_first_fault(bound_task): return real_resolve(bound_task) monkeypatch.setattr(engine, "_dispatched_spec_for_attempt", transient_first_fault) + # The phase-entry park-eligibility read is a SECOND, unrelated consumer of the + # same resolver (`_park_eligible_at_dispatch`, DW-1) and would otherwise absorb + # the injected fault, handing the binder a clean second observation and + # inverting exactly what this row measures. Pin it out so `observations` counts + # the binder alone — this test is about prompt construction and recovery + # ownership, not about whether the story could newly elect a park. + monkeypatch.setattr(engine, "_park_eligible_at_dispatch", lambda _task: False) assert engine._dev_phase(task) @@ -2765,6 +2772,326 @@ def test_park_without_usable_actions_is_repaired_not_committed(project): assert "story-awaiting-operator" not in kinds and "story-done" in kinds +def test_dispatch_over_an_already_parked_spec_is_not_park_eligible(project): + """DW-1's engine half: the proof-of-work skip is authorized by an expectation + the orchestrator records at dispatch, and a story whose bound spec ALREADY + reads `awaiting-operator` cannot newly elect a park — whatever the session + that runs next leaves behind, the declaration on disk when it launched was + someone else's. + + The answer is captured on the fresh entry into `_dev_phase`, on the same + `resume_result is None` condition as `baseline_commit`, and persisted, so a + crash-replayed attempt reads back the same expectation rather than + re-deriving one from the tree the replayed session already wrote. + + Ablation (measured): drop the `!= AWAITING_OPERATOR` test and this fails — + the re-drive becomes eligible and #676's relaxation applies to a session that + inherited its park. Note what this row does NOT detect: moving the capture out + of the `resume_result is None` block leaves it green, because the parked spec + is on disk before the phase starts and attempt 1 therefore observes the same + status either way. The anchor is pinned one row down, by + `test_park_eligibility_is_captured_once_per_phase_not_per_attempt`, which is + the row that reddens on that mutation.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [dev_effect(project, "1-1-a")], policy=_park_policy()) + recorded = spec_path(project, "1-1-a") + write_spec( + recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS + ) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + engine.state.tasks[task.story_key] = task + + engine._dev_phase(task) + + assert task.park_eligible is False + assert load_state(engine.run_dir).tasks["1-1-a"].park_eligible is False + + +def test_inherited_park_is_refused_end_to_end_through_the_engine(project): + """The JOIN, which both halves being pinned separately does not cover: that + `_verify_dev_artifacts` actually forwards `task.park_eligible` into + `verify_dev`. Its sibling row stops at the flag, and every refusal row in + `test_verify.py` hand-passes `park_eligible=False` straight into the gate — so + the one wiring point between them was untested, and the whole fix could be + reverted there with the suite green. + + Driven through the engine's own binding lifecycle: the story's spec_file is + bound to a spec ALREADY at `awaiting-operator`, so eligibility is reached via + the bound branch (every other `engine.run()`-level park row reaches it + unbound, and therefore eligible). The re-driven session writes no code and + re-declares the same park — the inherited-park shape — and must NOT verify + green. + + Ablation: replace `park_eligible=task.park_eligible` with the literal `True` + in `_verify_dev_artifacts` and this row fails; without it that mutation passes + the entire suite.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "awaiting-operator"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=False, + ) + ] + * 3, + policy=_park_policy(), + ) + recorded = spec_path(project, "1-1-a") + write_spec( + recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS + ) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + engine.state.tasks[task.story_key] = task + + # The refusal is non-fixable, so the attempt is rolled back and the phase ends + # in the pause its unrecoverable binding forces. The PAUSE is the point for + # this row's purposes — "did not verify green" — and the journal below names + # the cause. Under the mutation this row exists to catch, the park verifies, + # commits, and nothing raises at all. + with pytest.raises(RunPaused): + engine._dev_phase(task) + + assert task.park_eligible is False + reasons = [e["reason"] for e in engine.journal.entries() if e["kind"] == "dev-decision"] + assert reasons and all(r == "no changes in worktree since baseline commit" for r in reasons) + # the waiver never fired, so nothing was journaled as a skipped gate + assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] + + +def test_dispatch_with_no_bound_spec_is_park_eligible(project): + """The ordinary case, not a fallback: a story's first attempt has no + `spec_file` yet, so there is no earlier declaration for it to inherit and the + #676 relaxation must remain available. Fail-CLOSED applies to uncertainty + about a spec that exists, not to the absence of one.""" + engine, _ = make_engine(project, [], policy=_park_policy()) + + assert engine._park_eligible_at_dispatch(StoryTask(story_key="1-1-a", epic=1)) is True + + +def test_park_eligibility_fails_closed_on_an_unresolvable_binding(project): + """The OTHER fail-closed arm, and a genuinely separate one: this is the + `bound is None` refusal from `_dispatched_spec_for_attempt` (a symlinked + binding, the shape it exists to refuse), not the later `fm is None` OSError + arm its sibling row covers. A spec_file that will not resolve to a trusted + regular file is a spec whose status the orchestrator does not know, and an + unknown status must not authorize waiving proof-of-work. + + Ablation: invert this arm to `return True` and this row fails while the whole + rest of the suite stays green — nothing else reaches it, which is why it + needed its own row rather than sharing the unreadable-spec one.""" + engine, _ = make_engine(project, [], policy=_park_policy()) + real = spec_path(project, "1-1-a") + write_spec(real, "ready-for-dev", rev_parse_head(project.project)) + link = real.parent / "spec-1-1-a-symlink.md" + link.symlink_to(real) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(link)) + + # the binding resolves to nothing usable, even though the TARGET is a + # perfectly readable non-parked spec — it is the binding that is untrusted + assert engine._dispatched_spec_for_attempt(task) is None + assert engine._park_eligible_at_dispatch(task) is False + + +def test_park_eligibility_fails_closed_on_an_unreadable_spec(project): + """Observation degrades, and here degrading means denying the relaxation: a + bound spec the orchestrator cannot read is a spec whose status it does not + know, and an unknown status must not authorize skipping proof-of-work. The + skip is what would be lost, not the park — an honest park with a real diff + still passes the ordinary gate. + + Silent it is not: the read goes through `_observed_frontmatter`, so the skip + lands a `spec-read-failed` entry naming this site.""" + engine, _ = make_engine(project, [], policy=_park_policy()) + recorded = spec_path(project, "1-1-a") + write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + + def boom(_path): + raise OSError("spec vanished mid-read") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(verify, "read_frontmatter", boom) + assert engine._park_eligible_at_dispatch(task) is False + + failures = [e for e in engine.journal.entries() if e["kind"] == "spec-read-failed"] + assert [e["site"] for e in failures] == ["park-eligibility"] + + +def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): + """A fixable repair deliberately keeps the previous session's tree, so the + malformed park it is repairing is on disk when it launches. Re-observing + eligibility per ATTEMPT would therefore make every such repair ineligible, + and its fix — one frontmatter block, which proof-of-work already excludes — + would fail the gate it just re-armed. The expectation is anchored to the + phase, on the same `resume_result is None` condition as `baseline_commit`, + precisely so the expectation and the diff it guards cannot disagree. + + Both sessions run with `write_src=False`, which is what makes this row + evidence: the tree never holds any code residue, so the ONLY thing that can + carry the repair past proof-of-work is the retained eligibility. + + Ablation (measured, not assumed): move `task.park_eligible = ...` out of the + `resume_result is None` block and into `_dev_phase`'s per-attempt branch, and + attempt 2 re-observes the parked spec attempt 1 left behind, turns ineligible, + and its `dev-decision` reads exactly `no changes in worktree since baseline + commit` -> DEFER. Note what the row then fails ON: the defer's spec-restore + finds the binding unusable and raises `RunPaused`, so the visible surface is a + pause, not the assertion below. The refusal is the cause and the journal + records it; the pause is its consequence.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, adapter = make_engine( + project, + [ + # attempt 1: parks, but declares nothing -> fixable + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=[], + write_src=False, + ), + # the repair: a well-formed park, still with no code of its own + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=False, + ), + ], + policy=_park_policy(), + ) + recorded = spec_path(project, "1-1-a") + write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) + engine.state.tasks[task.story_key] = task + + assert engine._dev_phase(task) is True + + assert task.park_eligible is True + assert len(adapter.sessions) == 2 # the malformed park, then its repair + # the repair's park was ACCEPTED with the gate waived, on a tree that holds no + # code at all — the whole point of retaining the phase's answer + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert [(e["attempt"], e["zero_diff"]) for e in records] == [(2, True)] + + +@pytest.mark.parametrize( + "write_src, zero_diff", + [(False, True), (True, False)], + ids=["residue-free", "with-code"], +) +def test_accepted_park_records_whether_the_skipped_gate_would_have_passed( + project, write_src, zero_diff +): + """DW-6: the skip stops being silent. Proof-of-work is waived for every + ELECTED park, so afterwards a park that wrote real code and one that wrote + nothing at all were indistinguishable — the same green outcome, no trace of + which gate was waived or what it would have said. + + The record carries the discriminator ON the entry rather than in its kind, + because its readers are out-of-process: `zero_diff` is `true` when the whole + residue was the spec and the board (the #676 shape the relaxation exists for) + and `false` when the session also committed real work and simply happened not + to need the waiver. One kind, one attempt, one answer. + + The probe runs inside the shared gate on purpose — it measures from the + baseline that gate derived, so a commit the newer-claim branch re-anchored + past cannot be credited to this attempt. + + Ablation: drop the `if outcome.park_proof_skipped:` journal in + `_verify_dev_artifacts` and both legs fail on the empty record list; hardcode + the observation to `True` and only the `with-code` leg reddens, which is why + both are parametrized here rather than only the zero-diff one.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=write_src, + ) + ], + policy=_park_policy(), + ) + + summary = engine.run() + + assert summary.awaiting_operator == 1 + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert len(records) == 1 + assert records[0]["story_key"] == "1-1-a" and records[0]["attempt"] == 1 + assert records[0]["zero_diff"] is zero_diff + + +def test_accepted_park_still_records_when_the_zero_diff_probe_faults(project): + """The record marks the WAIVED GATE, not the probe's success. A git fault + leaves the observation unanswerable, but the gate was waived all the same — + and that is precisely the case DW-6 must not lose, because it is the one where + nothing else on disk says proof-of-work was skipped. + + So the entry is still written and `zero_diff` carries JSON `null`: an unknown + answer is a truthful field value, not a reason to withhold the record. The + park is unaffected — the observation degrades and never escalates. + + Ablation: key the journal on `park_zero_diff is not None` (the collapsed + single-field form) instead of on `park_proof_skipped` and this row fails on an + empty record list, while every other park row here stays green — they all have + an answerable probe, so only this one can tell the two spellings apart.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, "1-1-a", final_status="awaiting-operator", operator_actions=ACTIONS + ) + ], + policy=_park_policy(), + ) + real = verify.has_changes_since + + def fault_the_observation(*args, **kwargs): + raise verify.GitError("git diff exploded") + + # NOTE the patch is module-GLOBAL, not narrowed to the observation arm — this + # row works because the park path reaches no other `has_changes_since` caller, + # not because the fault was targeted. `zero_diff is None` is what proves the + # observation arm is the one that swallowed it: only its `except GitError` + # produces that value. + with pytest.MonkeyPatch.context() as mp: + mp.setattr(verify, "has_changes_since", fault_the_observation) + summary = engine.run() + + # the context manager UNDID the patch — this says nothing about its breadth + assert verify.has_changes_since is real + assert summary.awaiting_operator == 1 + assert engine.state.tasks["1-1-a"].phase == Phase.AWAITING_OPERATOR + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert len(records) == 1 + assert records[0]["attempt"] == 1 + assert records[0]["zero_diff"] is None + + +def test_no_park_record_when_the_gate_actually_ran(project): + """The control: the record marks a WAIVED gate, so an ordinary story that + cleared proof-of-work on its own must leave none. Without this the record + would be indistinguishable from "a dev session verified", and the DW-6 + inventory would count every story as a skipped park.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [generic_dev_effect(project, "1-1-a")], policy=_park_policy()) + + engine.run() + + assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] + + def test_park_disabled_by_policy_never_commits_the_token(project): """`[operator] enabled = false` does not reinterpret the token — it makes it unknown. The gate rejects it, the attempt budget runs out, and the story diff --git a/tests/test_model.py b/tests/test_model.py index 3bb4fca2..1f896e9d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -14,6 +14,7 @@ SessionRecord, StoryTask, TokenUsage, + VerifyOutcome, ) @@ -187,6 +188,50 @@ def test_followup_review_recommended_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).followup_review_recommended is False +def test_park_eligible_round_trips(): + """The dispatch-time expectation gating the park's proof-of-work skip is + captured once per dev phase, so it has to survive the crash/resume boundary — + a replayed attempt that re-derived it would answer about the spec the session + it is replaying already parked.""" + task = StoryTask(story_key="1-1-a", epic=1, park_eligible=True) + assert StoryTask.from_dict(task.to_dict()).park_eligible is True + + +def test_park_eligible_defaults_false_for_legacy_state(): + """And it defaults to the FAIL-CLOSED value, which is the load-bearing half: a + run resumed from a state.json written before the field existed has no recorded + answer, and the absent one must deny the skip rather than grant it. Defaulting + True would make every legacy resume the exact DW-1 hole this field closes.""" + doc = StoryTask(story_key="1-1-a", epic=1).to_dict() + del doc["park_eligible"] # state.json from before the field existed + assert StoryTask.from_dict(doc).park_eligible is False + + +def test_verify_outcome_park_fields_are_absent_by_default(): + """Both park fields are opt-in on the one leg that waives proof-of-work, and + every other outcome must leave them at the inert pair — `park_proof_skipped` + is what `Engine._verify_dev_artifacts` journals on, so a default of True + anywhere would file every ordinary story as a waived gate. + + They are asserted TOGETHER because the whole point of splitting them is that + `park_zero_diff is None` no longer means "no waiver": on a waived leg whose + probe faulted it means "unknown", and only `park_proof_skipped` separates the + two.""" + assert VerifyOutcome.passed().park_proof_skipped is False + assert VerifyOutcome.passed().park_zero_diff is None + assert VerifyOutcome.retry("nope").park_proof_skipped is False + assert VerifyOutcome.retry("nope").park_zero_diff is None + assert VerifyOutcome.escalate("boom").park_proof_skipped is False + assert VerifyOutcome.escalate("boom").park_zero_diff is None + + # settable, and independently: the waived-but-unanswerable pair is a real + # state, not an unreachable combination + waived = VerifyOutcome.passed(park_proof_skipped=True, park_zero_diff=True) + assert waived.park_proof_skipped is True and waived.park_zero_diff is True + unknown = VerifyOutcome.passed(park_proof_skipped=True) + assert unknown.park_proof_skipped is True and unknown.park_zero_diff is None + + def test_followup_reviews_spent_round_trips(): task = StoryTask(story_key="1-1-a", epic=1, followup_reviews_spent=2) assert StoryTask.from_dict(task.to_dict()).followup_reviews_spent == 2 diff --git a/tests/test_verify.py b/tests/test_verify.py index 490f5ac0..6d791f5a 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -835,26 +835,41 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): passes `False`, and the skip is now the only thing standing between a park and this gate. A park short-circuits both terminals — the pair demanded is (awaiting-operator, awaiting-operator) either way — so the flag must not reach - the outcome, and the `True` leg is what would catch a future edit that let it.""" + the outcome, and the `True` leg is what would catch a future edit that let it. + + `park_eligible=True` is the engine-side half of the selector the skip now + needs: the orchestrator's answer, recorded at dispatch, that this phase could + newly ELECT a park rather than inherit one (DW-1). Without it this row fails + on proof-of-work — which is exactly what + `test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work` asserts. + `park_zero_diff` is the accepted skip's record: the tree really was residue-free, + and the outcome says so instead of the skip passing silently (DW-6).""" task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) out = verify.verify_dev( - task, project, dev_result(sp), review_enabled=review_enabled, operator_park=True + task, + project, + dev_result(sp), + review_enabled=review_enabled, + operator_park=True, + park_eligible=True, ) assert out.ok assert task.spec_file == str(sp) + assert out.park_proof_skipped is True and out.park_zero_diff is True +@pytest.mark.parametrize("park_eligible", [False, True]) @pytest.mark.parametrize("operator_park", [False, True]) @pytest.mark.parametrize( "status, sprint, review_enabled", [("in-review", "review", True), ("done", "done", False)], ) def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( - project, status, sprint, review_enabled, operator_park + project, status, sprint, review_enabled, operator_park, park_eligible ): """The control for the row above, and the reason that row proves anything: the SAME residue-free tree at an ordinary terminal must still be refused. Without @@ -875,6 +890,14 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( `test_engine.py` that are about harvest, not about park. A run with parking enabled but a session that finished ordinarily must still owe a diff. + `park_eligible` is parametrized for the identical reason, one selector later: + the skip is now `parked and park_eligible`, so the engine-side half is the + other input that could widen it past the park. Rewriting it as + `None if park_eligible` — the dispatch-time expectation alone, ignoring the + observed status — is green everywhere without this dimension, and it would let + every ordinary session on a story that had never parked skip proof-of-work + entirely. Neither half selects the skip on its own. + Ablation: delete the `if extra_exclude is not None and task.baseline_commit:` proof-of-work block in `_verify_shared_gates` and all four rows fail on `assert not out.ok` — the residue-free tree then verifies clean at every @@ -887,10 +910,226 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( dev_result(sp), review_enabled=review_enabled, operator_park=operator_park, + park_eligible=park_eligible, + ) + + assert not out.ok and out.retryable + assert out.reason == "no changes in worktree since baseline commit" + # neither half of the record: no gate was waived, so there is nothing observed + assert out.park_proof_skipped is False and out.park_zero_diff is None + + +def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): + """DW-1, and the reason the row above needs its new argument: the skip used to + be selected entirely by state a fresh session can INHERIT — the policy flag + plus the spec's own status. A spec an earlier attempt left at + `awaiting-operator` still reads `awaiting-operator` to a session that did + nothing at all, so a re-drive over it selected #676's relaxation and verified + green on someone else's park declaration. + + `park_eligible=False` is the orchestrator saying "the bound spec was ALREADY + parked when I dispatched this". The park is not refused for being inherited — + it is merely held to proof-of-work like every other terminal, and this tree has + none to show. Note the reason: the ordinary proof-of-work message, not a + park-specific refusal, because the eligibility flag gates the SKIP and nothing + else. + + This row and `test_verify_dev_park_with_no_code_residue_passes` differ in + exactly one argument over byte-identical state, which is what makes either one + evidence. Ablation: rewrite the selector as `skip_proof = parked` (drop the + `and park_eligible`) and this fails on `assert not out.ok` while its twin stays + green — the pre-DW-1 behavior exactly.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=False, ) assert not out.ok and out.retryable assert out.reason == "no changes in worktree since baseline commit" + # no gate was waived here, so neither field carries anything — and the pair is + # asserted in both directions, because `park_zero_diff is None` alone is also + # what a WAIVED gate whose probe faulted looks like + assert out.park_proof_skipped is False and out.park_zero_diff is None + + +def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): + """The bound on DW-1: ineligibility gates the proof-of-work SKIP, never the + park itself. An inherited park that carried real work satisfies proof-of-work + on its own and passes — status pair, actions list, workflow tag, baseline match + and sprint pair all still select on the OBSERVED status exactly as before. + + This is the row that would catch the over-correction: making `park_eligible` + select the park's status pair as well (rather than only the skip) turns a + legitimate repair-then-park into a status mismatch, and refuses work that was + actually done. `park_zero_diff` stays None because no skip fired — a passing + park is not automatically a recorded one.""" + task, sp = _park(project) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=False, + ) + + assert out.ok + assert task.spec_file == str(sp) + # a PASSING park that owed and produced its diff: no waiver, nothing observed + assert out.park_proof_skipped is False and out.park_zero_diff is None + + +def test_verify_dev_elected_park_with_code_residue_records_a_non_zero_diff(project): + """DW-6's discriminator, and the half a zero-diff-only record could never + prove: the skip fires for EVERY elected park, including one that wrote real + code, and the record has to tell the two apart. `_park` writes `src.txt`, so + the waived gate would have passed — and the observation says so. + + Ablation: make the observation arm return a constant `True` and this row fails + while `test_verify_dev_park_with_no_code_residue_passes` stays green, because + that one cannot distinguish a real probe from a hardcoded answer.""" + task, sp = _park(project) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) + + assert out.ok + assert out.park_proof_skipped is True and out.park_zero_diff is False + + +def test_verify_dev_park_zero_diff_observation_degrades_to_unknown(project, monkeypatch): + """The observation must never change an outcome. `has_changes_since` can raise + `GitError`, and on the gated legs that escalates the attempt — here the same + fault has to leave the park accepted and the answer honestly unknown. + + Load-bearing because the probe fails OPEN (`rc != 0` -> "there are changes"), + so a fault swallowed at the wrong level would be recorded as a confident + `False` — a zero-diff park filed as one that wrote code, which is worse than no + record at all. + + This is the row that separates the two reasons `park_zero_diff` can be `None`: + the probe could not answer, versus no gate was ever waived. They are different + facts and they live on different fields — `park_proof_skipped` stays True here. + Collapsing them would make this park look like an ordinary leg and drop its + journal record, which is the exact silence DW-6 exists to end. + + Ablation: drop the `except GitError` in the observation arm and this fails with + the GitError propagating out of `verify_dev`, turning a bookkeeping probe into + a failed attempt.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + + def boom(*_a, **_kw): + raise verify.GitError("git diff exploded") + + monkeypatch.setattr(verify, "has_changes_since", boom) + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) + + assert out.ok + assert task.spec_file == str(sp) + assert out.park_zero_diff is None + # the gate WAS waived — unknown is not the same fact as "no waiver" + assert out.park_proof_skipped is True + + +def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(project): + """The SECOND documented cause of `zero_diff: null`, and the one a reader is + likeliest to mistake for the first: not a git fault, but an attempt carrying + no `baseline_commit` at all. The shared gate runs neither proof arm without + one, so there is nothing to measure from and the observation never happens — + yet the waiver did, and the record still has to say so. + + Both causes are named in `verify_dev`'s docstring, in `VerifyOutcome`'s field + comment and in `docs/FEATURES.md`; its sibling row above covers the git fault, + and this one covers the missing baseline, so neither claim rests on prose. + + Ablation: drop `and task.baseline_commit` from the observation arm's guard and + this fails with `park_zero_diff is False` — the probe runs against an empty + baseline, `has_changes_since` fails OPEN on the resulting git error, and an + attempt with nothing to measure gets filed as one that wrote real code.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + # the spec keeps its real `baseline_revision` claim; what is missing is the + # ORCHESTRATOR's recorded baseline, which is what both proof arms measure from + task.baseline_commit = "" + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) + + assert out.ok + # the gate was waived and is recorded as such; only the observation is unknown + assert out.park_proof_skipped is True + assert out.park_zero_diff is None + + +def test_verify_dev_park_zero_diff_excludes_the_orchestrators_own_writes(project): + """The observation must exclude exactly what the waived gate would have, and + this is the misattribution most likely to be audited: the orchestrator appends + a harvested deferral to the ledger DURING the attempt, so a park whose session + wrote nothing still leaves that file changed. Counted, the record would read + `zero_diff: false` — "this park committed real code" — about a diff the + orchestrator itself produced, and an audit of which parks got in without + proving work would quietly exonerate exactly the wrong ones. + + `engine_written` is what `Engine._harvest_gate_exclude` supplies for this, and + on the waived leg it is routed to `observe_skipped_proof` rather than + `extra_exclude` — same tuple, no gate. + + Ablation: drop `+ mode_exclude` from `proof_of_work_probe`'s exclusion (or + stop passing `observe_skipped_proof` at the call site) and this fails with + `park_zero_diff is False`, while every other park row stays green — they have + no orchestrator residue to misattribute.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR + ) + (project.repo_root / "ledger.md").write_text("- DW-9 harvested by the orchestrator\n") + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + engine_written=("ledger.md",), + ) + + assert out.ok + assert out.park_proof_skipped is True + # the ONLY residue is the orchestrator's own write, so the park really is + # zero-diff and the record has to say so + assert out.park_zero_diff is True def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): @@ -911,7 +1150,11 @@ def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): ) rj = {"workflow": "quick-dev", "spec_file": str(sp)} - out = verify.verify_dev(task, project, rj, review_enabled=False, operator_park=True) + # park_eligible=True so the skip really is in place: without it proof-of-work + # would also refuse this tree and the row would pass for a compound reason. + out = verify.verify_dev( + task, project, rj, review_enabled=False, operator_park=True, park_eligible=True + ) assert not out.ok and out.retryable assert "auto-dev" in out.reason @@ -938,7 +1181,17 @@ def test_verify_dev_park_still_faces_the_baseline_match_gate(project): baseline="deadbeef" * 5, ) - out = verify.verify_dev(task, project, dev_result(sp), review_enabled=False, operator_park=True) + # Same reason as the workflow-tag row above: with park_eligible left False the + # tree would also owe proof-of-work, and baseline-match would stop being the + # only thing that could refuse here. + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) assert not out.ok and out.retryable assert "does not match" in out.reason @@ -6348,6 +6601,13 @@ def test_engine_written_is_keyword_only_on_all_dev_verifiers(): parameter = inspect.signature(fn).parameters["engine_written"] assert parameter.kind is inspect.Parameter.KEYWORD_ONLY assert "operator_park" in inspect.signature(verify.verify_dev).parameters + # The park skip's second selector (DW-1). Keyword-only for the same reason + # `engine_written` is: `verify_dev`'s positional tail is `review_enabled`, and + # a positional eligibility flag would be one transposed argument away from + # silently authorizing the skip on every leg. + park_eligible = inspect.signature(verify.verify_dev).parameters["park_eligible"] + assert park_eligible.kind is inspect.Parameter.KEYWORD_ONLY + assert park_eligible.default is False # --------------------------------------------------- the git support floor (GIT_FLOOR) From b0676427d1562dc2406ae7578b737f141f12c9a5 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 23:57:56 -0700 Subject: [PATCH 04/45] sweep dw-park-skip-expectation-and-record: DW-1, DW-6 via bmad-loop --- CHANGELOG.md | 32 +--- docs/FEATURES.md | 4 +- src/bmad_loop/engine.py | 41 +++-- src/bmad_loop/model.py | 45 ++++-- src/bmad_loop/verify.py | 170 ++++++++++++++------ tests/test_engine.py | 336 ++++++++++++++++++++++++++++++++++++++-- tests/test_events.py | 3 + tests/test_model.py | 34 ++++ tests/test_verify.py | 202 ++++++++++++++++++++---- 9 files changed, 731 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4449ad14..268b3e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,30 +180,14 @@ breaking changes may land in a minor release. ### Fixed -- Require the orchestrator's own dispatch-time expectation before an `awaiting-operator` - park skips the dev gate's proof-of-work check (#335, #676). The skip was selected by the - policy flag plus the spec's own status, both of which a fresh session inherits, so a - re-drive over a spec an earlier attempt had already parked verified green having done - nothing. The expectation is captured once per dev phase, on the same anchor as the - attempt baseline, so a fixable repair of a malformed park still passes. An inherited park - that did real work is unaffected — only the skip narrows, not the park. The expectation is - read from the run's own binding for the story, which bounds what it catches: a park - inherited from a previous run, written out of band, or re-armed out of an escalation - (re-arming reopens the spec without clearing `operator_actions:`) still reaches a - dispatch that is eligible. Both residuals are tracked as deferred work. One upgrade - note: the expectation defaults to "not eligible" for state written before it existed, so - a run interrupted mid-park and resumed after upgrading holds that in-flight park to - proof-of-work — if it produced no code, it is retried and may defer rather than parking. - Re-running the story is enough; nothing is lost. -- Journal `park-proof-of-work-skipped` with a `zero_diff` flag for every attempt that clears - the dev artifact gate on a park with proof-of-work waived (#676), so a park that wrote - nothing and a park that wrote real code stop being indistinguishable after the fact. The - record is scoped to that gate: a waiver refused by a later check inside it leaves no - entry, while the stages after it (your `[verify]` commands, the review loop, the commit) - can still reject the attempt with its entry already written — join - `review-skipped-awaiting-operator` on the story key for the parks that reached commit. - The probe is an observation only: when it cannot answer — a git fault, or no recorded - baseline — the flag is `null` and the outcome is unchanged. +- Require a dispatch-time expectation before an `awaiting-operator` park skips proof-of-work, + so a re-drive cannot verify green by inheriting an earlier in-run park; inherited parks with + real changes still pass (#335, #676). Journal each waived artifact-gate pass as + `park-proof-of-work-skipped`, with `zero_diff` reporting no non-excluded residue (`true`), + residue (`false`), or an unanswerable probe (`null`). The record does not mean the park + committed; use the later `story-awaiting-operator` event for that. Cross-run, out-of-band, + and re-armed parks remain deferred. On upgrade, an in-flight legacy park defaults ineligible + and may retry; re-running the story is sufficient. - Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run owns. Under isolation both resolved against the main checkout, so the review modals showed that copy of the spec and the replan reset it — reporting success while the run's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 11e08ed7..8847e148 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement, so a re-drive that does nothing does not verify green on the park it inherited. That expectation is read from the run's own binding for the story, which bounds what it catches: a park inherited from a **previous run**, or one written into the spec out of band, reaches a dispatch with nothing bound and is eligible; so does a story re-armed out of an escalation, since re-arming reopens the spec at `ready-for-dev` without clearing its `operator_actions:`. Both remain open and are tracked as deferred work. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so a park that clears this artifact gate with the waiver in force is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got through. Read that record for exactly what it says: a waiver refused by a later check inside the same gate leaves no entry, but the stages **after** it — your `[verify]` commands, the review loop, the commit — can still reject the attempt, and the entry stands regardless. It answers "which parks cleared the artifact gate without proving work", never "which parks committed"; the second question is the `review-skipped-awaiting-operator` record below, which fires for every park that reaches commit. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement, so a re-drive that does nothing does not verify green on the park it inherited. That expectation is read from the run's own binding for the story, which bounds what it catches: a park inherited from a **previous run**, or one written into the spec out of band, reaches a dispatch with nothing bound and is eligible; so does a story re-armed out of an escalation, since re-arming reopens the spec at `ready-for-dev` without clearing its `operator_actions:`. Both remain open and are tracked as deferred work. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so a park that clears this artifact gate with the waiver in force is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got through. Read that record for exactly what it says: a waiver refused by a later check inside the same gate leaves no entry, but the stages **after** it — your `[verify]` commands, deterministic review verification and repair, pre-commit workflows, and the commit — can still reject the attempt, and the entry stands regardless. It answers "which attempts cleared the artifact gate without proving work", never "which parks committed". The committed half is the post-commit `story-awaiting-operator` journal record, appended only after the commit lands and carrying its sha; correlate the two on the story key plus journal order — a committed park's waiver is the last `park-proof-of-work-skipped` for that story before that event. The waiver record does carry `attempt`; the terminal event does not, which is exactly why no attempt-keyed join is promised — and adding one would not help, since the attempt current at commit can be higher than the one on the waiver. Do **not** read `review-skipped-awaiting-operator` as that half: it is written when a park _enters_ the commit path, ahead of the review verification, the repair loop, the pre-commit workflows and the commit itself, every one of which can still reject it. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. @@ -172,7 +172,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the park's whole residue was its own spec plus the board (the shape the waiver exists for), `false` when it also wrote real code, `null` when the probe could not answer — a git fault, or an attempt with no recorded baseline to measure from — and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). +- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 14fae01a..2f4b9c75 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -5295,14 +5295,16 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # 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 session's whole residue was the spec and the board (the - # #676 shape the skip exists for), `false` when it also carried real code, - # and JSON `null` when the probe could not answer — a git fault, or an - # attempt with no baseline commit to measure from. Keying on + # `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. + # 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()` @@ -5314,11 +5316,30 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # 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 `_skip_review_and_commit`'s - # `review-skipped-awaiting-operator`, which fires for every park that - # reaches commit; a reader wanting "waived AND committed" joins the two on - # the story key. + # 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", diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index e10142a8..b16e7b9e 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -644,7 +644,19 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot=dispatched_spec_snapshot, commit_sha=d.get("commit_sha"), operator_actions=[str(a) for a in d.get("operator_actions", [])], - park_eligible=bool(d.get("park_eligible", False)), + # `is True`, not `bool(...)`, and this is the one field on this task + # where the difference is load-bearing. Every sibling bool above + # merely restores bookkeeping; this one AUTHORIZES a gate to be + # waived, so its failure direction is not symmetric — a wrong False + # costs one retryable proof-of-work refusal, a wrong True re-opens + # the inheritance hole the field exists to close (#335, #676). Under + # `bool()` every truthy non-boolean grants the waiver, and the + # likeliest one is the string "false" (a hand-edited state.json, a + # bridge that stringifies JSON scalars): `bool("false")` is True. + # Only a real JSON `true` may authorize; anything else — absent, + # null, a string, a number — fails closed onto the ordinary gated + # path, where an honest park with a real diff still passes. + park_eligible=d.get("park_eligible") is True, defer_reason=d.get("defer_reason"), preserve_ref=d.get("preserve_ref"), preserve_partial=bool(d.get("preserve_partial", False)), @@ -901,16 +913,27 @@ class VerifyOutcome: # written. So a True here means "the artifact gate was cleared with # proof-of-work waived", never "this park committed". park_proof_skipped: bool = False - # An OBSERVATION, never a gate: on that same waived leg, whether the tree was - # in fact free of code residue since the attempt's baseline. `True` = the - # accepted park wrote nothing beyond what proof-of-work already excludes, - # `False` = it carried a real diff, `None` = the probe could not answer. Two - # things produce that `None`: a git fault (it degrades rather than escalating) - # and an attempt with no `baseline_commit` to measure from. Nothing branches - # on it. Note also that `False` is the weaker of the two definite answers: - # the probe inherits `has_changes_since`'s fail-open, so a git REFUSAL (rc 128, - # e.g. an unresolvable baseline) reads as "there are changes" rather than - # raising, and is recorded as `False`. + # An OBSERVATION, never a gate: on that same waived leg, what the skipped + # proof-of-work gate WOULD have found, measured from the same baseline and + # under the same exclusions it would have used. `True` = nothing it counts — + # the residue was confined to what proof-of-work already excludes, the #676 + # shape the waiver exists for. `False` = it would have found changes. `None` = + # the probe could not answer. + # + # `False` is a statement about the TREE, not about a session, and the wording + # matters because the tempting shorthand ("the park wrote real code") is a + # claim this seam cannot make: the gate it stands in for cannot attribute + # residue to a session either — under `isolation = "none"` a commit that + # arrived in the shared checkout from outside the session satisfies it — so + # the observation inherits exactly that limit rather than improving on it. + # + # Three things produce `None`, all of them "the probe could not answer": a + # `GitError` (it degrades rather than escalating), a git REFUSAL such as an + # unresolvable baseline (any rc that is not one of git's two real answers, rc + # 128 being the everyday one — `_changes_since` reports it as unknown instead + # of letting the gate's fail-open record a confident `False`), and an + # attempt with no `baseline_commit` to measure from. Nothing branches on any + # of the three. # # The two fields are deliberately separate, and collapsing them is the bug # this pair exists to prevent: one says a gate was waived, the other says what diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index d04c2719..a3cfe538 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -749,8 +749,60 @@ def has_changes_since( "work happened" (a pre-snapshot run must not have its gate silently weakened into never seeing new files), while a rollback gate must fail open toward "nothing to remove" (never delete a file it cannot prove this attempt - created). Keep it that way.""" + created). Keep it that way. + + Every non-zero `git diff` result reads as "changed" here, INCLUDING a refusal + (rc 128 — an unresolvable baseline, a repo git will not read). That is the + fail-open above, and it is deliberate for a gate. A caller that needs to tell + "git said there are changes" from "git would not answer" calls + :func:`_changes_since`, whose tri-state this function collapses; the collapse + lives in one place so the gate and any observer share one body.""" + answer = _changes_since( + repo, + baseline, + exclude, + baseline_untracked=baseline_untracked, + include_untracked=include_untracked, + ) + # unanswerable -> the stricter reading for a gate: assume work happened + return True if answer is None else answer + + +def _changes_since( + repo: Path, + baseline: str, + exclude: tuple[str, ...] = (), + *, + baseline_untracked: list[str] | None = None, + include_untracked: bool = True, +) -> bool | None: + """:func:`has_changes_since` before its fail-open is applied: ``True`` / + ``False`` when git answered, and ``None`` when git REFUSED to answer at all. + + `git diff --quiet` reports "no differences" as rc 0 and "differences" as rc 1; + anything else is the command failing rather than answering (rc 128 for a + baseline it cannot resolve or a directory that is not a repository). The gate + above cannot act on that distinction — uncertainty there must keep the + stricter path — but a pure OBSERVATION must, because recording an + unanswerable probe as a confident ``False`` (`_verify_shared_gates`' + ``observe_skipped_proof`` arm) files "the gate would have found changes" + about a question git never answered. + + This is the body BOTH proof arms reach, and by only one route: the + `proof_of_work_probe` closure in :func:`_verify_shared_gates`, which is what + actually makes "the observation measures exactly what the gate would have" + structural. The guarantee is the closure's, not this function's — one closure + over one `proof_baseline` / `include_untracked_proof` / exclusion set, so the + gate arm and the observation arm cannot be given different inputs. All this + body decides is what an unanswerable git call looks like; each arm then reads + that `None` under its own policy. + + :func:`has_changes_since` is the fail-open COLLAPSE of this tri-state, kept for + the gates that want it — it folds `None` into `True` and is what a caller + should reach for unless it can act on "git would not answer".""" rc, _ = _git(repo, "diff", "--quiet", baseline, "--", ".", *_exclude_specs(exclude)) + if rc not in (0, 1): + return None if rc != 0: return True if not include_untracked: @@ -778,8 +830,9 @@ def path_changed_since( counting every ordinary untracked path. Ignored paths are absent from :func:`untracked_files` and therefore cannot become proof of work here. - Any non-zero diff result fails open toward "changed", matching the - authoritative :func:`has_changes_since` gate. The literal pathspec is + Any non-zero diff result fails open toward "changed", matching what the + proof-of-work gate does with :func:`_changes_since`'s unanswerable `None` (and + what :func:`has_changes_since` collapses it to). The literal pathspec is required for operator-configured ledger paths containing Git wildmatch characters. """ @@ -1033,14 +1086,15 @@ def _exclude_specs(dirs: tuple[str, ...]) -> list[str]: `literal` for the same reason as :func:`_literal_specs` — git reads a positional operand as a PATHSPEC, so `[`, `]`, `*` and `?` in an operator-configured dir are wildmatch metacharacters — but the harm here runs the other way: an over-matching - exclusion HIDES a diff instead of exposing a file. `has_changes_since` and + exclusion HIDES a diff instead of exposing a file. `_changes_since` (the + proof-of-work probe's body, which `has_changes_since` collapses) and `attempt_dirty` both spend these on `diff --quiet . :(exclude)`, so a dir whose name carries a `*` excludes a sibling tree as well and the attempt reads CLEAN when it changed — the same false "no changes" that a dev attempt's dirtiness check exists to prevent (#423 item 3). It also realigns this half with :func:`_path_under_any`, the Python `startswith` - that filters the untracked half of the very same `has_changes_since` call. The two + that filters the untracked half of the very same `_changes_since` call. The two disagreed on exactly the shapes that glob (#423 item 4): the tracked half excluded a path the untracked half still counted, so one function's two branches answered differently about what "under the artifact dir" means. Literal is the reading @@ -1068,7 +1122,7 @@ def _path_under_any(path: str, prefixes: tuple[str, ...]) -> bool: The literal reading of "under", and since #423 item 4 the one `_exclude_specs` agrees with — the two filter the tracked and untracked halves of a single - `has_changes_since` answer and must not disagree.""" + `_changes_since` answer and must not disagree.""" return any(path == p or path.startswith(p.rstrip("/") + "/") for p in prefixes) @@ -1123,7 +1177,7 @@ def path_tracked(repo: Path, rel: str) -> bool: operator-named `implementation_artifacts` (`bmadconfig._resolve` takes that key verbatim, metacharacters and all) outlived the rollback that discarded the code it described. Not the global `--literal-pathspecs` / `GIT_LITERAL_PATHSPECS` form, - which would also disarm the `:(exclude)` magic `worktree_clean`, `has_changes_since` + which would also disarm the `:(exclude)` magic `worktree_clean`, `_changes_since` and `attempt_dirty` are built on; the per-operand prefix is scoped to this call. It costs the callers nothing: that same literal comparison is what matches a DIRECTORY prefix, so `_bmad/render` still lists everything beneath it (`cmd_validate`'s @@ -3148,7 +3202,8 @@ def verify_dev_exclude_relpaths( root: Path, ) -> tuple[str, ...]: """Repo-relative posix paths the dev/bundle proof-of-work gate excludes from - `has_changes_since` — file-granularity, unlike `artifact_relpaths`' whole-folder + its probe (`_changes_since`, via `_verify_shared_gates.proof_of_work_probe`) — + file-granularity, unlike `artifact_relpaths`' whole-folder exclusion. `artifact_relpaths` has NO production caller left: rollback protection builds its own list in `recovery_flow.protected_relpaths` against `workspace.root`, and `Engine._protected_relpaths` merely delegates there. Do @@ -3184,7 +3239,7 @@ def verify_dev_exclude_relpaths( ``root`` is the tree the resulting pathspecs are relative to, and MUST be the same root the caller invokes git against — `paths.repo_root` for the - proof-of-work gate, which is where `has_changes_since` runs. REQUIRED, with no + proof-of-work gate, which is where the probe runs. REQUIRED, with no default: an implicit `paths.project` anchor is #716's own root cause, and the two roots collapse in every configuration but the `repo_root` override, so a defaulted caller would look correct everywhere it was tested and be wrong only @@ -3302,7 +3357,12 @@ class _SharedGateResult: proof-of-work and asked to be told anyway (``observe_skipped_proof``), it is ``True`` when the tree held no changes the gate would have counted, ``False`` when it held some, and ``None`` when nothing was observed — no skip, no - request, no baseline, or a git fault. It is deliberately a return value and + request, no baseline, or a probe that could not answer (a ``GitError``, or a + git refusal such as an unresolvable baseline). Note what ``False`` does and + does not say: the gate would have found changes it counts, measured under the + gate's own exclusions. It does not say who wrote them — in a shared checkout + the gate itself cannot attribute residue to a session, and this observation + inherits exactly that limit. It is deliberately a return value and not a gate input: the observation must be made HERE because the baseline it measures from is derived here (the newer-claim branch can re-anchor ``proof_baseline`` and drop untracked evidence), and no caller can reproduce @@ -3359,9 +3419,9 @@ def _verify_shared_gates( and the answer rides out on ``_SharedGateResult.skipped_proof_zero_diff``. Nothing branches on it here: a fault degrades to ``None`` rather than escalating, and the leg's outcome is identical either way. It exists so an - accepted park's skipped gate stops being silent (#676) — a park that wrote - code and a park that wrote nothing are otherwise indistinguishable after the - fact. + accepted park's skipped gate stops being silent (#676) — a park the waived + gate would have passed and one it would have refused are otherwise + indistinguishable after the fact. Exactly one of the two skipping legs asks for it, and the asymmetry is deliberate rather than an omission: only sprint mode's PARK passes it. @@ -3422,8 +3482,10 @@ def _verify_shared_gates( # `bmadconfig.worktree_isolation_conflict` refuses the other) the session's cwd # IS the code tree, so a `project`-anchored probe judged a tree the session never # touched. WHICH probe burned the attempt depends on the layout, and the burn is - # not `has_changes_since` in both: it fails OPEN (`rc != 0` -> True), so wherever - # `project` is not a checkout the failing git call PASSES that gate. Nested + # not the proof-of-work probe in both: `_changes_since` answers `None` when git + # will not run, and the gate arm below accepts anything that is not a positive + # "nothing changed" (`is False`), so wherever `project` is not a checkout the + # failing git call PASSES that gate. Nested # (`project` a subdirectory of the code tree) the call succeeds but is scoped to # that subdirectory, and the "no changes" forever-burn is real. Disjoint # (`project` beside the checkout) git fails and the burn moves to the probes that @@ -3478,7 +3540,7 @@ def _verify_shared_gates( ) ) - def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: + def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool | None: """The one place proof-of-work is measured, called by BOTH arms below. The gate arm and the observation arm differ in exactly one input — which @@ -3497,8 +3559,16 @@ def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative to a different root is not merely wrong, it is SILENTLY wrong — git matches nothing and the exclusion evaporates. + + Tri-state on purpose: ``None`` means git REFUSED to answer — any rc outside + the two that ARE answers, rc 128 being the everyday one — which the two arms + below must read differently. The gate treats it as the + stricter "there are changes" — exactly `has_changes_since`'s fail-open, + which this function used to call and whose behavior the gate arm keeps + byte-for-byte — while the observation arm records it as unknown rather + than as a confident answer it never got. """ - return has_changes_since( + return _changes_since( paths.repo_root, proof_baseline, exclude=verify_dev_exclude_relpaths( @@ -3511,7 +3581,11 @@ def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: if extra_exclude is not None and task.baseline_commit: try: - if not proof_of_work_probe(extra_exclude): + # `is False` is the gate's fail-open spelled out: only a probe that + # positively answered "nothing changed" refuses the attempt, so a git + # REFUSAL (`None`) keeps the stricter path exactly as it did when this + # arm called `has_changes_since` and let that function collapse it. + if proof_of_work_probe(extra_exclude) is False: return _SharedGateResult( VerifyOutcome.retry("no changes in worktree since baseline commit") ) @@ -3519,20 +3593,21 @@ def proof_of_work_probe(mode_exclude: tuple[str, ...]) -> bool: return _SharedGateResult(VerifyOutcome.escalate(str(e))) elif observe_skipped_proof is not None and task.baseline_commit: # The gate was skipped; run its probe anyway and report, never refuse. - # Only `GitError` is caught, so a non-git bug still surfaces — but that is - # a narrower guarantee than "an unanswerable probe records None". The - # observation inherits `has_changes_since`'s deliberate fail-open: any - # non-zero rc reads as "there are changes", and only timeout, spawn and - # decode faults raise `GitError` at all. So a git REFUSAL — an unresolvable - # baseline, rc 128 — is recorded as `zero_diff: False`, "this park - # committed real code". The bias is toward the less alarming record, which - # is the right direction for a field nothing gates on, but it means a - # `False` here is weaker evidence than a `True`. + # + # Unanswerable is recorded as unanswerable, in BOTH of the ways a probe + # can fail to answer: a `GitError` (timeout, spawn or decode fault) and a + # git REFUSAL (any rc that is not one of the two real answers — rc 128 for + # an unresolvable baseline is the everyday one), which the tri-state + # probe reports as `None` rather than collapsing into the gate's + # fail-open. Collapsing it would file "the gate would have found changes" + # about a question git never answered — the one reading a reader cannot + # correct, because nothing downstream re-asks. A non-git bug still + # surfaces: only `GitError` is caught. try: - skipped_proof_zero_diff = not proof_of_work_probe(observe_skipped_proof) + observed = proof_of_work_probe(observe_skipped_proof) except GitError: - skipped_proof_zero_diff = None - return _SharedGateResult(None, skipped_proof_zero_diff) + observed = None + return _SharedGateResult(None, None if observed is None else not observed) return _SharedGateResult() @@ -3671,23 +3746,30 @@ def verify_dev( the waiver itself — ``skip_proof``, ``False`` on every other leg. When it fires, the shared gate additionally runs the proof-of-work probe as a pure OBSERVATION (``observe_skipped_proof=engine_written``) and what that probe - found rides out on ``VerifyOutcome.park_zero_diff``: ``True`` for a park with - no code residue, ``False`` for one carrying a real diff, ``None`` when the - probe could not answer. What separates "unknown" from "no skip happened" is + found rides out on ``VerifyOutcome.park_zero_diff``: ``True`` when the waived + gate would have found nothing it counts, ``False`` when it would have found + something, ``None`` when the probe could not answer. Read ``False`` as exactly + that and no further — the residue the gate counts is not attributed to a + session, here or in the gate itself, because under a shared checkout it cannot + be (see the newer-claim paragraph above, and `docs/FEATURES.md` on + ``isolation``). What separates "unknown" from "no skip happened" is ``park_proof_skipped``, not this field — collapsing the two into ``park_zero_diff is not None`` would make a park whose probe faulted look like a leg that never waived anything, and it would go unrecorded — the silence - this record exists to end. ``None`` has exactly two causes now, both of them - "the probe could not answer": a git fault, and an attempt carrying no - ``task.baseline_commit`` to measure from (the shared gate runs neither arm - without one). Neither field changes an outcome: a git fault degrades to - ``None`` rather than escalating, and an eligible park verifies identically - either way. Their consumer is + this record exists to end. ``None`` means "the probe could not answer", and + reaches here three ways: a ``GitError`` (timeout, spawn or decode fault), a + git REFUSAL such as an unresolvable baseline (any rc that is not one of git's + two real answers, rc 128 being the everyday one — the gate arm folds that into + its fail-open, the observation arm keeps it as unknown), and an attempt + carrying no ``task.baseline_commit`` to measure from (the shared gate runs + neither arm without one). Neither field changes an outcome: an unanswerable + probe degrades rather than escalating, and an eligible park verifies + identically either way. Their consumer is :meth:`Engine._verify_dev_artifacts`, which journals ``park-proof-of-work-skipped`` for a waived gate that this function then PASSED, and carries the observation as that record's ``zero_diff`` field, so a - park that wrote code and a park that wrote nothing stop being - indistinguishable afterwards (#676). Both ends of that scope are set here: a + park the waived gate would have passed and one it would have refused stop + being indistinguishable afterwards (#676). Both ends of that scope are set here: a waiver refused by a later check in this function (the sprint pair) never reaches the record, and a record that IS written asserts only that this gate was cleared with proof-of-work waived — the configured ``[verify]`` commands, @@ -3704,7 +3786,7 @@ def verify_dev( passed as ``observe_skipped_proof`` instead of ``extra_exclude``: no gate consumes them there, but the zero-diff observation must exclude exactly what the gate would have, or the orchestrator's own bookkeeping writes would be - recorded as the park's code residue. + counted as residue on the park's record. """ rj = result_json or {} spec_file = rj.get("spec_file") @@ -4538,8 +4620,8 @@ def resolve_restore_path(raw: str, root: Path) -> Path: `model.StoryTask.restore_patch` documents the field as repo-relative-or-absolute, and every consumer must resolve it against the base it actually reads the tree from — the engine's live workspace root (the unit worktree under isolation), - `paths.repo_root` for the proof-of-work exclude (which is where - `has_changes_since` runs, so the latch has to name a path in that tree; #716), + `paths.repo_root` for the proof-of-work exclude (which is where the gate's own + probe runs, so the latch has to name a path in that tree; #716), the CLI's `--project`. Hence the caller-supplied `root` rather than one baked-in base. diff --git a/tests/test_engine.py b/tests/test_engine.py index c6e4d219..be76e553 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -2980,6 +2980,251 @@ def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): assert [(e["attempt"], e["zero_diff"]) for e in records] == [(2, True)] +def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): + """Crash replay: the host died after the dev session finished and before its + result was consumed, so `_finish_inflight` resets the task to PENDING and + re-enters `_dev_phase` with the recorded result instead of a session + (`engine.py`'s `resumable` arm). The fresh-entry block is skipped wholesale on + that path, which is exactly why eligibility is captured there — a replayed + attempt must read back the answer the DEAD phase recorded, never derive a new + one from the tree the session it is replaying already wrote. + + The setup makes the two answers differ: the spec on disk is ALREADY parked + (the replayed session's own work), so a re-observation at this point returns + False and the residue-free tree would then owe proof-of-work it cannot show. + The persisted `True` is the only thing that carries the park through. + + Ablation: move `task.park_eligible = self._park_eligible_at_dispatch(task)` + out of `_dev_phase`'s `if resume_result is None:` block and this fails — the + replay re-observes its own parked spec, turns ineligible, and the park is + refused for "no changes in worktree since baseline commit". This is the row + the sibling capture-once test explicitly does NOT cover: that one measures a + second ATTEMPT inside a live phase, this one a replayed phase with no + attempt of its own.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, adapter = make_engine(project, [], policy=_park_policy()) + baseline = rev_parse_head(project.project) + # captured in production order: the phase's snapshot predates the session that + # wrote the park below + untracked = sorted(verify.untracked_files(project.project)) + sp = spec_path(project, "1-1-a") + write_spec(sp, "awaiting-operator", baseline, operator_actions=ACTIONS) + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.PENDING, attempt=1) + task.spec_file = str(sp) + task.baseline_commit = baseline + task.baseline_untracked = untracked + # what the dead phase recorded at ITS dispatch, when the spec was unparked + task.park_eligible = True + engine.state.tasks[task.story_key] = task + result_json = { + "workflow": "auto-dev", + "story_key": "1-1-a", + "spec_file": str(sp), + "baseline_commit": baseline, + "escalations": [], + "followup_review_recommended": False, + } + # the persisted record the resume arm replays FROM — `_accept_current_dev_session` + # latches it as the accepted tree owner, so its task_id has to be the one the + # replayed attempt derives + task.record_session( + SessionRecord( + task_id=_session_task_id("1-1-a", "dev", task.attempt, task.generation), + role="dev", + status="completed", + result_json=dict(result_json), + ) + ) + recorded = SessionResult(status="completed", result_json=result_json) + + assert engine._dev_phase(task, resume_result=recorded) is True + + # the recorded result replaced the session: nothing was dispatched, so the + # only observation available was the persisted one + assert adapter.sessions == [] + assert task.park_eligible is True + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True)] + + +def test_no_waiver_record_when_a_later_check_inside_the_gate_rejects_the_park(project): + """The record's NEAR end, at the seam that writes it: the waiver fires and the + observation runs, but a check still inside `verify_dev` — the sprint pair, the + reachable one — then refuses the attempt. The flag rides only the passing + return, so nothing is journaled. + + That bound is what the record means. DW-6 asks which parks got IN without + proving work; an attempt refused by the same gate did not get in, and filing + it would make the inventory count refusals as admissions. + + Driven at `_verify_dev_artifacts` rather than through `engine.run()` because + the mismatch cannot survive the run loop: `_post_dev_state_sync` mirrors the + spec's status onto the board a dozen lines before this gate, so the pair is + already reconciled by the time a live run reaches here. The seam is the + subject anyway — this method is where the append lives. + + Ablation, measured: delete the `if outcome.park_proof_skipped:` gate so the + append is unconditional, and this fails on a non-empty record list (three + sibling rows fail with it). Re-keying the append on + `outcome.park_zero_diff is not None` is not an ablation for this row: it leaves + the row green because the + failing constructors carry neither park field, so a refused attempt is silent + under both spellings. That spelling's defect is the opposite one, a WAIVED + gate whose probe could not answer going unrecorded, and its detector is + `test_accepted_park_still_records_when_the_zero_diff_probe_faults`.""" + # the board never reached the token — the pair `verify_dev` demands is broken + write_sprint(project, {"epic-1": "backlog", "1-1-a": "in-progress"}) + engine, _ = make_engine(project, [], policy=_park_policy()) + baseline = rev_parse_head(project.project) + sp = spec_path(project, "1-1-a") + write_spec(sp, "awaiting-operator", baseline, operator_actions=ACTIONS) + task = StoryTask(story_key="1-1-a", epic=1, attempt=1) + task.spec_file = str(sp) + task.baseline_commit = baseline + task.baseline_untracked = sorted(verify.untracked_files(project.project)) + task.park_eligible = True + + outcome = engine._verify_dev_artifacts(task, {"workflow": "auto-dev", "spec_file": str(sp)}) + + # refused for the sprint pair, NOT for proof-of-work: the waiver did fire + assert not outcome.ok and outcome.retryable + assert "sprint" in outcome.reason and "no changes in worktree" not in outcome.reason + assert outcome.park_proof_skipped is False + assert [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] == [] + + +def test_the_waiver_record_stands_when_a_later_stage_rejects_the_park(project): + """The record's FAR end, and the half its prose is likeliest to overclaim: the + artifact gate passes with the waiver in force and the entry is written, then a + configured `[verify]` command fails and the attempt is rejected. The entry + stays — it never claimed the park was accepted, only that THIS ATTEMPT cleared + the dev ARTIFACT gate without proving work, which is true and stays true. + + Everything downstream of `_verify_dev_artifacts` runs after the append: + `_dev_phase` replaces this outcome with the verify commands' result a few + lines later, then decision routing, the review loop, the pre-commit workflows + and the commit. A reader treating the record as "this park committed" would + count this story, which never parked at all. + + Both attempts waive and both are recorded, one per attempt — the phase's + eligibility is captured once and a fresh attempt inside it inherits it — which + is also why no attempt-level join to a terminal event is promised.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, adapter = make_engine( + project, + [ + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=False, + ) + ] + * 2, + # host-shell fail verb, not `false` (#302) + policy=_park_policy(verify=VerifyPolicy(commands=(_FAIL,))), + ) + + summary = engine.run() + + # the park never happened: the verify commands rejected every attempt + assert summary.awaiting_operator == 0 and summary.deferred == 1 + kinds = [e["kind"] for e in engine.journal.entries()] + assert "story-awaiting-operator" not in kinds + # ...and the waiver records stand anyway, one per attempt that cleared the + # artifact gate. They say what the gate saw, not what the run decided. + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True), (2, True)] + assert len(adapter.sessions) == 2 + + +def test_eligible_phase_waives_on_a_different_spec_than_it_was_authorized_over(project): + """CHARACTERIZATION of behavior this change deliberately LEAVES OPEN — not a + guarantee, and not a gate. It is the shipped answer to the intent's I/O row + "Eligible phase, attempt resolves a DIFFERENT spec", and it is folded into the + first `deferred` entry on this change's spec ("the same authorization is also + never re-validated against spec IDENTITY"). A later change that closes that + deferral is EXPECTED to rewrite this row rather than be blocked by it. + + What it pins: `park_eligible` is a PHASE-level authorization answering one + question about ONE observation — was `task.spec_file` already parked at the + instant the phase was dispatched? Here it was not (the binding is a + `ready-for-dev` spec), so the phase is eligible. The session then returns a + result naming a DIFFERENT spec that was already at `awaiting-operator` before + the phase began, and writes no code at all. The authorization is not + re-validated against that identity, so the waiver is spent on an inherited + park declaration the phase was never authorized over, the residue-free tree is + accepted, and the attempt is journaled as a waived gate. + + ENGINE layer, and the choice is forced rather than preferred: `verify_dev` + takes `park_eligible` as a bare argument and has no notion of the phase + binding at all, so at that layer "the authorization was computed about another + spec" is not expressible — a caller can only assert the value it just passed + in. Only `_dev_phase` holds both halves: it computes eligibility from + `task.spec_file` at fresh entry and later hands the session's own + `result_json["spec_file"]` to the gate. Nothing between the two compares them, + which is precisely the finding. (No binding or roots gate refuses the + construction: the foreign spec sits inside `implementation_artifacts`, so + `spec_within_roots` admits it, and no verify gate reads `result.json`'s + `story_key`.) + + Ablation, measured rather than predicted: bind eligibility to the returned + spec identity in `verify_dev` with + `skip_proof = parked and park_eligible and + (not task.spec_file or str(spec_path) == task.spec_file)` — and this row fails + with `ScriptExhausted: no scripted result for session 1-1-a-dev-2`. Note the + surface it fails on, because it is a consequence and not the assertion below: + the waiver no longer fires, the residue-free tree owes a diff it never + produced, `verify_dev` refuses it with "no changes in worktree since baseline + commit", and the engine asks for the retry the one-entry script cannot supply. + The refusal is the cause and the `dev-decision` journal entry records it; the + exhausted script is only how the retry becomes visible. That failure is the + expected shape of CLOSING the deferral, which is why this row is + characterization rather than warranty — close it and rewrite this row.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + baseline = rev_parse_head(project.project) + # the phase's binding: NOT parked, so the dispatch-time answer is "eligible" + bound = spec_path(project, "1-1-a") + write_spec(bound, "ready-for-dev", baseline) + # somebody else's park, on disk before this phase ever starts + inherited = spec_path(project, "1-2-b") + write_spec(inherited, "awaiting-operator", baseline, operator_actions=ACTIONS) + + def resolves_the_other_spec(_spec): + # writes nothing — no code, and not even its own spec: the park + # declaration it reports was already there + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": "1-1-a", + "spec_file": str(inherited), + "baseline_commit": baseline, + "escalations": [], + "followup_review_recommended": False, + }, + ) + + # exactly ONE scripted session: the shipped behavior accepts on the first + # attempt, and under the ablation below the engine's request for a second is + # the whole signal + engine, adapter = make_engine(project, [resolves_the_other_spec], policy=_park_policy()) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(bound)) + engine.state.tasks[task.story_key] = task + + assert engine._dev_phase(task) is True + + assert len(adapter.sessions) == 1 # accepted on the first attempt, never retried + # authorized over the bound spec... + assert task.park_eligible is True + assert read_frontmatter(bound)["status"] == "ready-for-dev" + # ...and spent on the other one, which the gate then rebound the task to + assert task.spec_file == str(inherited) + records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] + assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True)] + + @pytest.mark.parametrize( "write_src, zero_diff", [(False, True), (True, False)], @@ -2989,15 +3234,17 @@ def test_accepted_park_records_whether_the_skipped_gate_would_have_passed( project, write_src, zero_diff ): """DW-6: the skip stops being silent. Proof-of-work is waived for every - ELECTED park, so afterwards a park that wrote real code and one that wrote - nothing at all were indistinguishable — the same green outcome, no trace of - which gate was waived or what it would have said. + ELECTED park, so afterwards a park the waived gate would have passed and one + it would have refused were indistinguishable — the same green outcome, no + trace of which gate was waived or what it would have said. The record carries the discriminator ON the entry rather than in its kind, because its readers are out-of-process: `zero_diff` is `true` when the whole residue was the spec and the board (the #676 shape the relaxation exists for) - and `false` when the session also committed real work and simply happened not - to need the waiver. One kind, one attempt, one answer. + and `false` when the gate would have found more than that and the waiver was + therefore not what carried the attempt. It is a fact about the TREE — the gate + it stands in for cannot attribute residue to a session either. One kind, one + attempt, one answer. The probe runs inside the shared gate on purpose — it measures from the baseline that gate derived, so a commit the newer-claim branch re-anchored @@ -3031,6 +3278,70 @@ def test_accepted_park_records_whether_the_skipped_gate_would_have_passed( assert records[0]["zero_diff"] is zero_diff +def test_a_committed_waived_park_correlates_by_story_key_and_journal_order(project): + """The JOIN the docs now hand to out-of-process readers, which nothing else + pins: `park-proof-of-work-skipped` answers "which attempts cleared the dev + artifact gate without proving work", `story-awaiting-operator` answers "which + parks committed", and a reader wanting BOTH correlates them on `story_key` + plus journal ORDER — the committed park's waiver being the last such record + preceding that event. + + The pair must be exercised together: testing each record separately would not + catch documentation that points readers at + `review-skipped-awaiting-operator`, which is appended when a park *enters* the + commit path and also exists for parks the later stages reject. This row pins + the supported post-commit correlation. + + It also pins the attempt asymmetry the docs rest their "no attempt-keyed join" + on, because that too was stated wrongly once: the WAIVER carries `attempt`, the + TERMINAL event does not. Both halves are asserted, so a future change that + added `attempt` to the terminal event would redden here and force the docs to + be re-read rather than silently drifting. + + Deliberately a residue-free park (`write_src=False`): the waiver has to be the + thing that carried it through the gate, or the row would be a correlation + between two records that would both exist anyway.""" + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [ + generic_dev_effect( + project, + "1-1-a", + final_status="awaiting-operator", + operator_actions=ACTIONS, + write_src=False, + ) + ], + policy=_park_policy(), + ) + + summary = engine.run() + + assert summary.awaiting_operator == 1 + entries = engine.journal.entries() + waivers = [ + i + for i, e in enumerate(entries) + if e["kind"] == "park-proof-of-work-skipped" and e["story_key"] == "1-1-a" + ] + committed = [ + i + for i, e in enumerate(entries) + if e["kind"] == "story-awaiting-operator" and e["story_key"] == "1-1-a" + ] + assert len(waivers) == 1 and len(committed) == 1 + + # the join: same story key, and the waiver PRECEDES the terminal event + assert waivers[0] < committed[0] + # the terminal event is genuinely post-commit — it carries the sha + assert entries[committed[0]]["commit"] == engine.state.tasks["1-1-a"].commit_sha + assert entries[committed[0]]["commit"] + # ...and the asymmetry that makes an attempt-keyed join unpromisable + assert entries[waivers[0]]["attempt"] == 1 + assert "attempt" not in entries[committed[0]] + + def test_accepted_park_still_records_when_the_zero_diff_probe_faults(project): """The record marks the WAIVED GATE, not the probe's success. A git fault leaves the observation unanswerable, but the gate was waived all the same — @@ -3055,22 +3366,25 @@ def test_accepted_park_still_records_when_the_zero_diff_probe_faults(project): ], policy=_park_policy(), ) - real = verify.has_changes_since + real = verify._changes_since def fault_the_observation(*args, **kwargs): raise verify.GitError("git diff exploded") # NOTE the patch is module-GLOBAL, not narrowed to the observation arm — this - # row works because the park path reaches no other `has_changes_since` caller, - # not because the fault was targeted. `zero_diff is None` is what proves the + # row works because the park path reaches no other proof-of-work probe, not + # because the fault was targeted. `zero_diff is None` is what proves the # observation arm is the one that swallowed it: only its `except GitError` - # produces that value. + # produces that value. `_changes_since` is the target rather than + # `has_changes_since` because the shared probe calls the tri-state body + # directly; patching the fail-open wrapper would leave the probe intact and + # this row would pass having faulted nothing. with pytest.MonkeyPatch.context() as mp: - mp.setattr(verify, "has_changes_since", fault_the_observation) + mp.setattr(verify, "_changes_since", fault_the_observation) summary = engine.run() # the context manager UNDID the patch — this says nothing about its breadth - assert verify.has_changes_since is real + assert verify._changes_since is real assert summary.awaiting_operator == 1 assert engine.state.tasks["1-1-a"].phase == Phase.AWAITING_OPERATOR records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] diff --git a/tests/test_events.py b/tests/test_events.py index 9656ac2c..83fee6d1 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -509,6 +509,9 @@ def test_relay_tolerates_an_unreadable_stdin(tmp_path, monkeypatch, capsys, exc) empty.""" monkeypatch.setenv("BMAD_LOOP_RUN_DIR", str(tmp_path)) monkeypatch.setenv("BMAD_LOOP_TASK_ID", "t1") + # An outer bmad-loop session exports this variable. This row exercises the + # legacy RUN_DIR/events fallback, so isolate it just as `_relay` does. + monkeypatch.delenv("BMAD_LOOP_EVENTS_DIR", raising=False) monkeypatch.setattr(sys, "stdin", _UnreadableStream(exc)) assert cli.main(["relay", "Stop"]) == 0 diff --git a/tests/test_model.py b/tests/test_model.py index 1f896e9d..1b264bd6 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -207,6 +207,40 @@ def test_park_eligible_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).park_eligible is False +@pytest.mark.parametrize( + "stored", + ["false", "true", "", 0, 1, None, [], ["x"], {}], + ids=["str-false", "str-true", "str-empty", "int-0", "int-1", "null", "list", "list-x", "dict"], +) +def test_park_eligible_only_a_real_boolean_true_authorizes_the_waiver(stored): + """`from_dict` reads this one field STRICTLY, and the asymmetry is the reason. + Every sibling bool on the task restores bookkeeping; this one authorizes the + dev gate's proof-of-work check to be WAIVED, so a wrong `False` costs one + retryable refusal while a wrong `True` re-opens the inheritance hole the field + exists to close. + + Under the ordinary `bool(...)` spelling every truthy non-boolean grants that + waiver, and the likeliest one is the string `"false"` — a hand-edited + state.json, or any bridge that stringifies JSON scalars — for which + `bool("false")` is True. The `"true"`/`1` rows are here for the same reason + from the other side: reading them as authorization would be GUESSING that a + non-boolean meant yes, and fail-closed does not guess. + + Ablation: restore `bool(d.get("park_eligible", False))` and the `str-false`, + `str-true`, `int-1` and `list-x` rows all fail.""" + doc = StoryTask(story_key="1-1-a", epic=1).to_dict() + doc["park_eligible"] = stored + assert StoryTask.from_dict(doc).park_eligible is False + + +def test_park_eligible_round_trips_the_authorized_value(): + """The other direction, so strictness is not mistaken for "always False": a + real JSON `true` — the only value `to_dict` ever writes — survives.""" + doc = StoryTask(story_key="1-1-a", epic=1, park_eligible=True).to_dict() + assert doc["park_eligible"] is True + assert StoryTask.from_dict(doc).park_eligible is True + + def test_verify_outcome_park_fields_are_absent_by_default(): """Both park fields are opt-in on the one leg that waives proof-of-work, and every other outcome must leave them at the inert pair — `park_proof_skipped` diff --git a/tests/test_verify.py b/tests/test_verify.py index 6d791f5a..9c38e210 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -15,6 +15,7 @@ OMIT, UNRESOLVABLE, _file_exists_cmd, + _Omit, fault_read_text, git, make_git_noisy, @@ -784,7 +785,7 @@ def test_verify_dev_park_unknown_when_the_policy_is_off(project): assert "'awaiting-operator'" in out.reason and "expected 'done'" in out.reason -def _residue_free(project, *, status, sprint, baseline=None): +def _residue_free(project, *, status, sprint, baseline: str | _Omit | None = None): """A dev attempt whose ONLY residue is the spec and the sprint board — the two paths proof-of-work already excludes. @@ -807,8 +808,19 @@ def _residue_free(project, *, status, sprint, baseline=None): `verify.AWAITING_OPERATOR` rather than the bare literal so they move with the branch above on a rename: were the two to drift, this helper would quietly stop writing the field and every park row would fail on "declares no usable - operator_actions" instead of on the thing it tests. `baseline` overrides what - the spec claims, for the row that probes the baseline-match gate.""" + operator_actions" instead of on the thing it tests. + + `baseline` has three meanings, and the third is not a special case of the + second. ``None`` (the default) claims the task's own recorded baseline — the + matching pair every ordinary row wants. A STRING overrides what the spec + claims, for the row that probes the baseline-match gate. ``OMIT`` writes no + `baseline_revision` key at all, which is the only way to reach the + proof-of-work probe with a baseline git cannot resolve: with a claim present + the baseline-match gate refuses first and the probe is never asked, so the + git-refusal rows would pass for the wrong reason. That third meaning rides on + ``OMIT`` being truthy in the `baseline or task.baseline_commit` expression + below — deliberate, but load-bearing, so do not "simplify" that expression to + an ``is None`` test without giving ``OMIT`` its own branch.""" write_sprint(project, {"1-1-a": sprint}) task = make_task(project) sp = spec_path(project, "1-1-a") @@ -1013,13 +1025,13 @@ def test_verify_dev_elected_park_with_code_residue_records_a_non_zero_diff(proje def test_verify_dev_park_zero_diff_observation_degrades_to_unknown(project, monkeypatch): - """The observation must never change an outcome. `has_changes_since` can raise - `GitError`, and on the gated legs that escalates the attempt — here the same - fault has to leave the park accepted and the answer honestly unknown. + """The observation must never change an outcome. The proof-of-work probe can + raise `GitError` (timeout, spawn or decode fault), and on the gated legs that + escalates the attempt — here the same fault has to leave the park accepted and + the answer honestly unknown. - Load-bearing because the probe fails OPEN (`rc != 0` -> "there are changes"), - so a fault swallowed at the wrong level would be recorded as a confident - `False` — a zero-diff park filed as one that wrote code, which is worse than no + Load-bearing because a fault swallowed at the wrong level would be recorded as + a confident answer about a question git never answered, which is worse than no record at all. This is the row that separates the two reasons `park_zero_diff` can be `None`: @@ -1028,6 +1040,11 @@ def test_verify_dev_park_zero_diff_observation_degrades_to_unknown(project, monk Collapsing them would make this park look like an ordinary leg and drop its journal record, which is the exact silence DW-6 exists to end. + The patch target is `_changes_since`, the tri-state body BOTH proof arms share + (`has_changes_since` is its fail-open collapse and no longer what the gate + calls) — patching the wrapper would leave the probe untouched and this row + would pass for no reason at all. + Ablation: drop the `except GitError` in the observation arm and this fails with the GitError propagating out of `verify_dev`, turning a bookkeeping probe into a failed attempt.""" @@ -1038,7 +1055,7 @@ def test_verify_dev_park_zero_diff_observation_degrades_to_unknown(project, monk def boom(*_a, **_kw): raise verify.GitError("git diff exploded") - monkeypatch.setattr(verify, "has_changes_since", boom) + monkeypatch.setattr(verify, "_changes_since", boom) out = verify.verify_dev( task, @@ -1056,6 +1073,78 @@ def boom(*_a, **_kw): assert out.park_proof_skipped is True +def test_verify_dev_park_zero_diff_is_unknown_when_git_refuses_the_probe(project): + """The THIRD cause of `zero_diff: null`, and the one that used to be recorded + as a confident answer: git refusing the diff outright. + + `git diff --quiet` reports rc 0 for "no differences" and rc 1 for + "differences"; anything else — rc 128 for a baseline it cannot resolve — is the + command failing rather than answering. The gate that this observation stands in + for reads every non-zero rc as "there are changes", which is right for a gate + (uncertainty must keep the stricter path) and wrong for a record: it filed "the + waived gate would have found changes" about a question git never answered, and + nothing downstream ever re-asks. + + No monkeypatch: the refusal is REAL, produced the way production produces one — + a recorded baseline that does not resolve in this repository, with the spec + carrying no `baseline_revision` claim so the baseline-match gate has nothing to + compare and the attempt reaches the probe. rc 128 is asserted directly first, so + a future git that answered differently would fail here rather than silently + turning this row into a duplicate of its `GitError` sibling. + + Two ablations, both measured, and they fail this row to DIFFERENT values — + which is the point, because only one of them is the defect that shipped. Point + `proof_of_work_probe` back at `has_changes_since` (the pre-fix spelling, where + the wrapper folds the refusal into its fail-open) and this fails with + `park_zero_diff is False`: the record asserting the gate would have found + changes. Collapse the observation arm's mapping to `not observed` instead and + it fails with `True`, because `not None` is True — a different wrong answer + from a different place, and the reason the arm maps the unknown explicitly + rather than negating it.""" + task, sp = _residue_free( + project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR, baseline=OMIT + ) + task.baseline_commit = "0" * 40 + rc, _ = verify._git(project.repo_root, "diff", "--quiet", task.baseline_commit, "--", ".") + assert rc == 128, "the premise: git REFUSES this baseline rather than answering" + + out = verify.verify_dev( + task, + project, + dev_result(sp), + review_enabled=False, + operator_park=True, + park_eligible=True, + ) + + assert out.ok + # the waiver is recorded; only the observation is honestly unknown + assert out.park_proof_skipped is True + assert out.park_zero_diff is None + + +def test_verify_dev_proof_of_work_gate_still_fails_open_on_a_refused_probe(project): + """The control for the row above, and the reason it can be trusted to have + changed only the record: the GATE's reading of the identical refusal is + unchanged. An ordinary terminal on a residue-free tree whose baseline git will + not resolve still PASSES proof-of-work, because uncertainty at a gate keeps the + stricter path — the same answer the arm gave when it called `has_changes_since` + and let that function collapse the refusal. + + Without this row the tri-state could have been introduced by narrowing the gate + too (refusing on `None`), which would turn every unresolvable baseline into a + burned attempt, and only this residue-free tree — where the refusal is the ONLY + thing standing between the attempt and a "no changes" retry — can tell the two + spellings apart.""" + task, sp = _residue_free(project, status="done", sprint="done", baseline=OMIT) + task.baseline_commit = "0" * 40 + + out = verify.verify_dev(task, project, dev_result(sp), review_enabled=False) + + assert out.ok + assert out.park_proof_skipped is False and out.park_zero_diff is None + + def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(project): """The SECOND documented cause of `zero_diff: null`, and the one a reader is likeliest to mistake for the first: not a git fault, but an attempt carrying @@ -1063,14 +1152,21 @@ def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(projec one, so there is nothing to measure from and the observation never happens — yet the waiver did, and the record still has to say so. - Both causes are named in `verify_dev`'s docstring, in `VerifyOutcome`'s field - comment and in `docs/FEATURES.md`; its sibling row above covers the git fault, - and this one covers the missing baseline, so neither claim rests on prose. - - Ablation: drop `and task.baseline_commit` from the observation arm's guard and - this fails with `park_zero_diff is False` — the probe runs against an empty - baseline, `has_changes_since` fails OPEN on the resulting git error, and an - attempt with nothing to measure gets filed as one that wrote real code.""" + All three causes are named in `verify_dev`'s docstring, in `VerifyOutcome`'s + field comment and in `docs/FEATURES.md`; the sibling rows cover the `GitError` + and the git refusal, and this one covers the missing baseline, so no claim + rests on prose. + + Ablation, measured rather than assumed, and the measurement changed when the + refusal fix landed: dropping `and task.baseline_commit` from the observation + arm's guard ALONE now leaves this row green, because the probe then runs + against an empty baseline, git REFUSES it, and `_changes_since` reports that + refusal as the same `None` the guard was suppressing. That convergence is the + point of the refusal fix, not a hole — the guard is now a spared git spawn + rather than the only thing standing between this attempt and a confident + answer. What does redden the row is the pre-fix PAIR: drop the guard and + collapse the arm's unknown mapping to `not observed`, and an attempt with + nothing to measure is filed `zero_diff: true`.""" task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) @@ -1098,8 +1194,8 @@ def test_verify_dev_park_zero_diff_excludes_the_orchestrators_own_writes(project this is the misattribution most likely to be audited: the orchestrator appends a harvested deferral to the ledger DURING the attempt, so a park whose session wrote nothing still leaves that file changed. Counted, the record would read - `zero_diff: false` — "this park committed real code" — about a diff the - orchestrator itself produced, and an audit of which parks got in without + `zero_diff: false` — "the waived gate would have found changes" — about a diff + the orchestrator itself produced, and an audit of which parks got in without proving work would quietly exonerate exactly the wrong ones. `engine_written` is what `Engine._harvest_gate_exclude` supplies for this, and @@ -5355,10 +5451,12 @@ def test_verify_dev_measures_proof_of_work_in_the_code_tree(project, tmp_path): cannot be resolved there. The proof-of-work probe deliberately is NOT graded by this row, and cannot be: - `has_changes_since` fails OPEN (`rc != 0 -> return True`), so pointing it at a - non-repo returns "there are changes" and a passing row stays green for the - wrong reason. The refusal row below is what grades it, which is why that one - asserts the exact reason rather than `not out.ok`. + the gate arm refuses only on a positive "nothing changed" (`is False` over + `_changes_since`'s tri-state), so pointing the probe at a non-repo yields the + unanswerable `None`, the gate PASSES, and a green row proves nothing. Measured: + re-anchor `proof_of_work_probe` on `paths.project` and this row stays green + while its sibling below reddens. That sibling is what grades the probe, which + is why it asserts the exact reason rather than `not out.ok`. """ paths = _repo_root_override(project, tmp_path) write_sprint(paths, {"1-1-a": "review"}) @@ -5379,16 +5477,20 @@ def test_verify_dev_refuses_proof_of_work_only_the_project_tree_holds(project, t that anything was implemented, because no session writes code there. The assertion is on the exact refusal REASON, not merely on `not out.ok` — but - NOT for the reason a reader might assume. Re-anchoring `has_changes_since` on - `paths.project` does not make the gate fault: `has_changes_since` fails OPEN - (`rc != 0 -> return True`, verify.py), so pointing it at a directory that is not - a git repository reports "there are changes" and the gate PASSES. The exact-reason - assertion is still the right call, for the neighbouring row's sake — that one - cannot grade this probe at all, precisely because the fail-open answer is also - the answer a correct run gives. - - Ablation: re-anchor `has_changes_since` on `paths.project` and this row reddens - on `not out.ok` with `ok=True`. + NOT for the reason a reader might assume. Re-anchoring the probe on + `paths.project` does not make the gate fault: pointing `_changes_since` at a + directory that is not a git repository yields its unanswerable `None`, which the + gate arm folds toward "there are changes" (`is False` is the only refusal), so + the gate PASSES. The exact-reason assertion is still the right call, for the + neighbouring row's sake — that one cannot grade this probe at all, precisely + because the fail-open answer is also the answer a correct run gives. + + Ablation names the surface actually reached, not `has_changes_since`: that + wrapper has no production caller left, so substituting it would prove nothing. + Change `proof_of_work_probe`'s first argument from `paths.repo_root` to + `paths.project` (verify.py, in `_verify_shared_gates`) and this row reddens, + measured, on `assert not out.ok` with `ok=True` — the neighbouring row staying + green in the same run. """ paths = _repo_root_override(project, tmp_path) write_sprint(paths, {"1-1-a": "review"}) @@ -5623,6 +5725,38 @@ def test_has_changes_since_excludes_artifact_only_edit(project): ) +def test_changes_since_reports_a_git_refusal_and_has_changes_since_collapses_it(project): + """The two-function split, at its own layer: `_changes_since` answers the + tri-state and `has_changes_since` is its fail-open collapse. + + `git diff --quiet` uses rc 0 / rc 1 for its two real answers, so any other rc + is git failing rather than answering. A GATE must read that as "there are + changes" — uncertainty keeps the stricter path, which is the long-standing + behavior this asserts unchanged — while an OBSERVER (the parked leg's skipped + proof-of-work record) must be able to say "unknown" rather than file a + confident answer git never gave. + + Both are asserted against ONE refusal so the collapse is pinned as a collapse: + the same call that answers `None` here answers `True` there. Ablation: make + `has_changes_since` return the tri-state unchanged and its assertion fails on + `None is True`; make `_changes_since` fold rc 128 back into `True` and its own + assertion fails. + + The refusal is real rather than injected — an all-zero oid no repository + resolves — so this row also carries the premise the park observation rows rest + on.""" + baseline = "0" * 40 + + assert verify._changes_since(project.project, baseline) is None + assert verify.has_changes_since(project.project, baseline) is True + + # and a resolvable baseline is untouched by the split: both answer the same + # real question, `False` on a tree that has not moved + head = verify.rev_parse_head(project.project) + assert verify._changes_since(project.project, head) is False + assert verify.has_changes_since(project.project, head) is False + + def test_has_changes_since_subtracts_baseline_untracked(project): """Untracked files already on disk when the baseline snapshot was taken are not this session's work. `None` deliberately keeps counting all of them — From ff1f3e8543f3b6185fc94e18aa42b36a4565e1f0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 01:55:38 -0700 Subject: [PATCH 05/45] sweep dw-verify-command-seam-faults-and-records: DW-2, DW-4, DW-5 via bmad-loop --- CHANGELOG.md | 36 ++ docs/FEATURES.md | 4 +- docs/plugin-authoring-guide.md | 72 ++-- docs/testing.md | 28 +- src/bmad_loop/diagnostics.py | 5 +- src/bmad_loop/engine.py | 37 +- src/bmad_loop/plugins/context.py | 10 + src/bmad_loop/stories_engine.py | 13 +- src/bmad_loop/sweep.py | 7 +- src/bmad_loop/verify.py | 213 +++++++++-- tests/test_cli.py | 70 ++++ tests/test_diagnostics.py | 38 +- tests/test_engine.py | 294 ++++++++++++++- tests/test_hook_bus.py | 12 +- tests/test_portability_guard.py | 589 +++++++++++++++++++++++++++++++ tests/test_stories_engine.py | 38 +- tests/test_sweep.py | 33 ++ tests/test_verify.py | 369 +++++++++++++++++++ 18 files changed, 1766 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 268b3e57..37312e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ breaking changes may land in a minor release. ### Added +- **Review-gate verify commands are journalled** (#656, partial). The three review gates + (`verify_review`, `verify_review_stories`, `verify_review_bundle`) now emit one + `verify-command-result` per command, `verification_stage: "review"`, sharing the story's + `verification_sequence` with its dev and fix passes and writing the same `verify/` stream + files. `post_dev_verify` stays dev/fix only — #656 narrows to the hook stage. A gate that + refuses before reaching its commands records nothing. + +- **Portability guard: `verify_commands_outcome` is callable only from + `verify._verify_review_commands`.** A fourth review gate composing run+classify itself would + reintroduce #695's project-vs-repo root split; the guard now fails and names the file and + line. Deliberately not widened to `run_verify_commands`, which has three legitimate callers + on two roots. + - **`repo_root` in run `state.json`** (#716). A run records the git root its code work happens in, so an out-of-process reader — `bmad-loop resolve`'s re-arm — uses the tree the run measured instead of re-deriving one. A `state.json` written before the field existed degrades to the @@ -45,6 +58,20 @@ breaking changes may land in a minor release. ### Changed +- **A story's `verification_sequence` now numbers its review passes too**, so the ordinals a + `post_dev_verify` handler receives shift: for an unchanged run whose review gate sits between + the dev and repair legs, the `fix` pass moves from 2 to 3. The ordinal was always documented + as a per-story counter across a run's verify passes rather than a per-leg one, and the review + passes are now among them — but a plugin that hardcoded the numbers, rather than joining on + the `verification_sequence` its context hands it, will read the wrong records. + +- **The `verify-command-result` census inverts its meaning.** It was "the dev-phase passes only, + never a complete count"; it is now every in-run pass — dev, fix and review — with only + `bmad-loop confirm --reverify` outside it (and a pass with no `[verify] commands` configured, + which records nothing because nothing ran). A run therefore retains one record set plus a + `verify/` stream file pair per command per review pass where the review leg previously + retained none, so `verify/` grows with the review budget on a story that loops. + - **psmux sessions now live in a per-project registry** (#537). bmad-loop points `PSMUX_DATA_DIR` at `//_mux`, so a prune in one project cannot address another's servers at all. A bare `psmux ls` no longer shows them — `bmad-loop mux` prints the @@ -180,6 +207,15 @@ breaking changes may land in a minor release. ### Fixed +- A verify command whose child cannot be started pauses the run instead of crashing it. Any + spawn-time `OSError` — most often a working directory that is missing, is a regular file, or + cannot be searched, but a missing shell or EMFILE too — raised out of `subprocess.run` past + every guard and ended the run with `crash.txt` + `state.crashed`. It is now translated into + one `CommandResult` per command carrying a `spawn_error` (and a synthetic return code outside + the range a signal-killed child reports, distinct from the timeout leg's `-1`), classified as + an environment fault, and escalated — budget untouched, re-armable. + `bmad-loop confirm --reverify` reports it as a refusal too. A command that merely times out + is unchanged: still an ordinary fixable retry. - Require a dispatch-time expectation before an `awaiting-operator` park skips proof-of-work, so a re-drive cannot verify green by inheriting an earlier in-run park; inherited parks with real changes still pass (#335, #676). Journal each waived artifact-gate pass as diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8847e148..2acf82ba 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -70,7 +70,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. - Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. - A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most `max_dev_attempts` per story) and has no suppression knob of its own; it follows `[notify]` like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is reduced to its first line and capped, with a `[…]` marker when it was trimmed, because a `Decision.reason` routinely carries a verify-output tail that would otherwise spill into `ATTENTION` and a desktop bubble; the untruncated reason stays in the `dev-decision` journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices. -- Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (`sh` reports rc `126`/`127`; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507); `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. +- Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (`sh` reports rc `126`/`127`; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time `OSError` counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507); `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: `has_session` maps every nonzero backend result to False, so a negative lookup is "the backend did not confirm it" rather than proof the session is gone — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. - CRITICAL resolution: `bmad-loop resolve ` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself. The re-arm advances the story's baseline in the **code tree** and is honest when it cannot: a failed advance is narrowed to typed git @@ -172,7 +172,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev and repair legs alike — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). +- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev, repair and review legs alike, carrying `verification_stage` and a per-story `verification_sequence` that orders the passes across all three; the two passes that leave no record are `bmad-loop confirm --reverify`, which runs after the run is over, and any pass with no `[verify] commands` configured, which records nothing because nothing ran — each entry also carrying `spawn_error`, set when the verify command's child could not be started at all — typically because its working directory is missing, is not a directory, or cannot be searched, though any spawn-time `OSError` (a missing shell, EMFILE, ENOMEM) reaches the same field and the wrapped exception is what names the cause — which is an environment fault that pauses the run rather than a command that failed — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index 1b2ca809..78aab61f 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -403,8 +403,16 @@ escalation emits before the run stops, on either leg. `post_dev_verify` exposes `ctx.command_results`: an immutable tuple of the per-command `CommandResult` records core just executed. Each has `command`, -`returncode`, the existing merged bounded `output_tail`, and separate `stdout` -and `stderr` strings. Those two are intended to be the streams essentially whole +`returncode`, the existing merged bounded `output_tail`, separate `stdout` +and `stderr` strings, and `spawn_error` — normally `None`, and set when the child +could not be started at all. The typical cause is the directory it was to run in +(missing, not a directory, or unsearchable), which the message names, but any +spawn-time `OSError` lands here — a missing shell, EMFILE, ENOMEM — so read the +wrapped exception rather than assuming the directory. Such a result carries no +real exit status (`verify.SPAWN_FAULT_RC`, deliberately outside the range a +signal-killed child reports and distinct from the timeout leg's `-1`) and +classifies as an environment fault, which pauses the run. The two stream strings +are intended to be the streams essentially whole — they are not cut to `[verify] stream_capture_kb`, which bounds only what is written to disk — but they are not unbounded either: a hard 32 MiB per-stream ceiling applies, so a pathologically chatty command cannot grow the orchestrator's @@ -415,9 +423,13 @@ is always detectable rather than silent. Ordinary suites never reach it. This is observation data only: a plugin cannot change the verifier's outcome or the commit decision. The run's `journal.jsonl` also records one `verify-command-result` entry per command with run/story/attempt/stage and -verification-sequence correlation, `output_tail`, byte counts, and run-relative `stdout_path` / +verification-sequence correlation — note that `attempt` is the dev/repair counter, so every review +cycle of one attempt shares a single value and only `verification_sequence` tells successive review +passes apart — `output_tail`, `spawn_error`, byte counts, and run-relative `stdout_path` / `stderr_path` pointers under the run's `verify/` directory; full streams are not -embedded in the journal. That store is deliberately separate from `logs/`, which +embedded in the journal. `spawn_error` rides the record because the record's +readers are out-of-process and `returncode` alone cannot separate a child that +never started from one that ran. That store is deliberately separate from `logs/`, which holds coding-CLI pane captures named after session task ids and is read as such by the TUI. @@ -470,34 +482,44 @@ carries the reason. A plugin reading these pointers must therefore treat both file holds a command's whole output. Treat verifier output as potentially sensitive and store, upload, sign, or act on it only from an explicitly configured plugin. -**The dev phase is the whole of this surface.** `[verify] commands` also run at -the _review_ gate — `verify_review` / `verify_review_stories` / -`verify_review_bundle` end on the same core classifier — and **none of those runs -are journalled or published to any hook.** They run in `repo_root`, the same root +**The dev phase is the whole of this HOOK, not of the journal.** `[verify] +commands` also run at the _review_ gate — `verify_review` / +`verify_review_stories` / `verify_review_bundle` end on the same core classifier +— and those runs **are journalled** (`verification_stage: "review"`, sharing the +story's one `verification_sequence` counter with the dev and fix passes) but are +**not published to any hook.** They run in `repo_root`, the same root the dev phase uses (#695); only the gates' own artifact reads — the spec, the sprint board, the deferred-work ledger — stay project-rooted. Five engine gates reach them: the converged review pass, the review-budget-exhaustion rescue, the review-timeout salvage, and both passes inside the skip-review commit path (which runs the gate -again after a repair). `bmad-loop confirm --reverify` runs the commands too, out -of band by construction — the run that parked the story is finished, so there is -no journal to write to and no hook bus to emit on. +again after a repair). The records do not name which of the five ran — the +neighbouring `review-result` / `review-skipped*` / `review-timeout-salvage*` +entries and the sequence ordering say that. `bmad-loop confirm --reverify` runs +the commands too, out of band by construction — the run that parked the story is +finished, so there is no journal to write to and no hook bus to emit on. Two consequences a handler has to be written for: -- **`verify-command-result` entries are not a complete census of a run's verifier - invocations.** Every story that reaches a commit ran the commands at least once - more than the records show. Never derive "the verifier ran N times" or "the last - thing the verifier saw" from the journal — derive only "these are the dev-phase - passes", which is what the records claim. -- **A green commit is not evidence that the last journalled pass was green**, and a - red journalled pass is not evidence the commit was blocked: a `fix` pass can fail - and the story still commit after a later review-gate run that left no record. - Correlate a decision with the `dev-decision` / `fix-decision` / `review-result` - entries beside the results, not with the results alone. - -The boundary is deliberate, not an oversight — the review leg would need its own -hook stage rather than a second meaning for one named `post_dev_verify` — and is -tracked as a follow-up in [#656](https://github.com/bmad-code-org/bmad-loop/issues/656). +- **`verify-command-result` entries are a complete census of a RUN's verifier + command invocations, but not of every gate visit or of a project's.** Every + command executed by an in-run dev, fix or review pass lands a record. A pass + with no `[verify] commands` configured executes nothing and therefore records + nothing; `bmad-loop confirm --reverify` stays outside because it runs after the + run that parked the story is over. Count distinct `verification_sequence` + values to derive recorded passes, while preserving that zero-command caveat. +- **One command record is not a pass verdict**, and an earlier red pass is not + evidence the commit was blocked: a failed pass can be followed by a green + review-gate pass and a commit. Group records by `verification_sequence`, then + correlate that group with its surrounding decision event instead of inferring + the decision from one command or from an older pass. The dev and fix legs use + `dev-decision` / `fix-decision`; review passes use the applicable neighbouring + `review-result`, `review-skipped*`, `review-timeout-salvage*`, + `review-budget-committed`, or `review-followup-damped` event described above. + +The hook boundary is deliberate, not an oversight — the review leg would need its +own stage rather than a second meaning for one named `post_dev_verify` — and is +tracked as a follow-up in [#656](https://github.com/bmad-code-org/bmad-loop/issues/656), +which now narrows to that stage: the journalling half of it has landed. ### Review diff --git a/docs/testing.md b/docs/testing.md index 759e85a5..e116298b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,18 +129,18 @@ test. A slice of the suite tests the **repo** rather than the product. The inventory: -| Guard | Where | Enforces | -| --------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Portability guard | `tests/test_portability_guard.py` | One shared AST scan over every `src/bmad_loop/**/*.py` (data scripts included), carrying ten guards: literal `["tmux", ...]` argvs only in the two backend files (the backends' own `[self._BINARY, ...]` spelling is deliberately unmatched, so this tripwire currently flags nothing — #549); sequence-form git argvs (list or tuple, literal or named constant) only as `_run_git`'s argv argument in `verify.py`, with string-form git spawns refused everywhere, `verify.py` included; no bare `/tmp`-class POSIX paths; no `signal.SIGKILL` attribute; `os.kill(pid, 0)` probes only in `process_host.py`; any `os.kill` at all only there too (a second, distinct guard); `start_new_session` only in the detach helpers; `shell=True` only in its two sanctioned files; `BMAD_LOOP_*` env reads only through the `envvars.py` registry, a plugin's own variable family, or the session-protocol vars the two stand-alone hook relays read back; plus a scanned-file-count floor so a broken scan root cannot pass vacuously | -| Settings-schema sync | `tests/test_settings_schema.py` | `src/bmad_loop/data/settings/core.toml` stays in lockstep with `policy.py` by reflection, in both directions: every spec maps to a live dataclass field with a matching default wherever one is baked in, every policy field is reachable from exactly one spec (or listed in the explicit `HIDDEN` set), and every `*Policy` dataclass is consciously classified | -| Exit-code allocation | `tests/test_entry_point.py` | `ExitCode` is pinned literally (OK=0, FAILURE=1, USAGE=2, INTERRUPTED=130) **and closed**: the enum's value set equals exactly those four, so codes 3–129/131+ cannot be allocated quietly | -| Extra-less core CLI | `tests/test_entry_point.py` | A fresh interpreter with `pyte`/`rich`/`textual`/`tomlkit` blocked at `find_spec` — the blocker **raises** rather than returning None, so the dev venv's installed copies cannot make it pass vacuously, and an `import pyte` floor proves it bites — imports `bmad_loop.cli` and `bmad_loop.settings_schema`, runs `list` to rc 0, and asserts `tui` degrades to the `bmad-loop[tui]` hint instead of a traceback. Every test job installs `--all-extras`, which is why #650 shipped broken for 23 releases; CI's isolated wheel `list` run is the same floor at install level | -| State-machine table | `tests/test_statemachine.py` | Every `Phase` has a transition row; `TERMINAL_PHASES` (model.py) equals the table's dead ends — a cross-module parity nothing else links; an N×N `parametrize` grid drives every pair (legal pairs land, illegal pairs raise and leave the phase untouched); the awaiting-operator reachability rule is additionally stated independently, because the N² grid reads its expectation out of the table under test | -| Check-id registry | `checks.py` + `tests/test_cli.py` | `ValidationReport.add` asserts its id is in `VALIDATE_CHECKS` at every **executed** call site, and an end-to-end test unions the ids a real passing **and** failing `validate --json` emit and asserts them registered. Both mechanisms are exercised-path enforcement — there is no static call-site scan, so an id on a branch neither reaches can still ship unregistered and raises `AssertionError` only when that branch first executes; a new check site therefore lands together with a test that reaches it | -| Skill-drift guard | `tests/test_module_skills_sync.py` | The seeded forks in `.claude/skills/` and `.agents/skills/` are byte-identical to canonical `src/bmad_loop/data/skills/`. **Documented limitation: CI-inert** — both trees are gitignored and absent in CI, so every parametrization skips there; the guard bites on dev boxes only. (The canonical-existence assertion runs before the skip and is CI-live.) | -| Schema-version parity | `tests/test_tui_app.py` | The TUI renderer's pinned validate schema version equals `documents.VALIDATE_SCHEMA_VERSION` — deliberate duplication, because an import would auto-follow a CLI bump and silently render a v2 document as v1 | -| Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | The hook relays' copies match their source: `test_hook_script.py` re-runs `install_into` and text-compares the project copy against the source; `test_probe_hook.py` compares the packaged resource — which only bites in a wheel-installed run, since an editable install resolves both sides to the same file | -| Version sync | `tests/test_release.py` + CI | `scripts/release.py check` runs as the `version-sync` job — `sync_version.check()` in-process, plus the CHANGELOG release contract (the canonical version's section exists; `## [Unreleased]` was reopened; its `compare/v...HEAD` link tracks the bump). `tests/test_release.py` covers the release helpers' pure logic **and** drives `cmd_check`/`cmd_prepare` over fixture changelogs; the version-field comparison itself is still CI-only | +| Guard | Where | Enforces | +| --------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Portability guard | `tests/test_portability_guard.py` | One shared AST scan over every `src/bmad_loop/**/*.py` (data scripts included), carrying thirteen guards: literal `["tmux", ...]` argvs only in the two backend files (the backends' own `[self._BINARY, ...]` spelling is deliberately unmatched, so this tripwire currently flags nothing — #549); sequence-form git argvs (list or tuple, literal or named constant) only as `_run_git`'s argv argument in `verify.py`, with string-form git spawns refused everywhere, `verify.py` included; no bare `/tmp`-class POSIX paths; no `signal.SIGKILL` attribute; `os.kill(pid, 0)` probes only in `process_host.py`; any `os.kill` at all only there too (a second, distinct guard); `start_new_session` only in the detach helpers; `shell=True` only in its two sanctioned files; `BMAD_LOOP_*` env reads only through the `envvars.py` registry, a plugin's own variable family, or the session-protocol vars the two stand-alone hook relays read back; a persisted `spec_file` / `dispatched_spec_file` resolved with a bare `Path(...)` only in the four files that run inside the tree the value was recorded against; `verify_commands_outcome` called only from `verify._verify_review_commands`; its classifier half `verify_command_results_outcome` called only from `verify.verify_commands_outcome` or `Engine._verify_commands_with_results` (a separate guard, because fencing the wrapper alone still lets a gate compose run+classify by hand and pick its own root — #695); plus a scanned-file-count floor so a broken scan root cannot pass vacuously | +| Settings-schema sync | `tests/test_settings_schema.py` | `src/bmad_loop/data/settings/core.toml` stays in lockstep with `policy.py` by reflection, in both directions: every spec maps to a live dataclass field with a matching default wherever one is baked in, every policy field is reachable from exactly one spec (or listed in the explicit `HIDDEN` set), and every `*Policy` dataclass is consciously classified | +| Exit-code allocation | `tests/test_entry_point.py` | `ExitCode` is pinned literally (OK=0, FAILURE=1, USAGE=2, INTERRUPTED=130) **and closed**: the enum's value set equals exactly those four, so codes 3–129/131+ cannot be allocated quietly | +| Extra-less core CLI | `tests/test_entry_point.py` | A fresh interpreter with `pyte`/`rich`/`textual`/`tomlkit` blocked at `find_spec` — the blocker **raises** rather than returning None, so the dev venv's installed copies cannot make it pass vacuously, and an `import pyte` floor proves it bites — imports `bmad_loop.cli` and `bmad_loop.settings_schema`, runs `list` to rc 0, and asserts `tui` degrades to the `bmad-loop[tui]` hint instead of a traceback. Every test job installs `--all-extras`, which is why #650 shipped broken for 23 releases; CI's isolated wheel `list` run is the same floor at install level | +| State-machine table | `tests/test_statemachine.py` | Every `Phase` has a transition row; `TERMINAL_PHASES` (model.py) equals the table's dead ends — a cross-module parity nothing else links; an N×N `parametrize` grid drives every pair (legal pairs land, illegal pairs raise and leave the phase untouched); the awaiting-operator reachability rule is additionally stated independently, because the N² grid reads its expectation out of the table under test | +| Check-id registry | `checks.py` + `tests/test_cli.py` | `ValidationReport.add` asserts its id is in `VALIDATE_CHECKS` at every **executed** call site, and an end-to-end test unions the ids a real passing **and** failing `validate --json` emit and asserts them registered. Both mechanisms are exercised-path enforcement — there is no static call-site scan, so an id on a branch neither reaches can still ship unregistered and raises `AssertionError` only when that branch first executes; a new check site therefore lands together with a test that reaches it | +| Skill-drift guard | `tests/test_module_skills_sync.py` | The seeded forks in `.claude/skills/` and `.agents/skills/` are byte-identical to canonical `src/bmad_loop/data/skills/`. **Documented limitation: CI-inert** — both trees are gitignored and absent in CI, so every parametrization skips there; the guard bites on dev boxes only. (The canonical-existence assertion runs before the skip and is CI-live.) | +| Schema-version parity | `tests/test_tui_app.py` | The TUI renderer's pinned validate schema version equals `documents.VALIDATE_SCHEMA_VERSION` — deliberate duplication, because an import would auto-follow a CLI bump and silently render a v2 document as v1 | +| Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | The hook relays' copies match their source: `test_hook_script.py` re-runs `install_into` and text-compares the project copy against the source; `test_probe_hook.py` compares the packaged resource — which only bites in a wheel-installed run, since an editable install resolves both sides to the same file | +| Version sync | `tests/test_release.py` + CI | `scripts/release.py check` runs as the `version-sync` job — `sync_version.check()` in-process, plus the CHANGELOG release contract (the canonical version's section exists; `## [Unreleased]` was reopened; its `compare/v...HEAD` link tracks the bump). `tests/test_release.py` covers the release helpers' pure logic **and** drives `cmd_check`/`cmd_prepare` over fixture changelogs; the version-field comparison itself is still CI-only | Rules for adding or touching a guard: @@ -152,8 +152,8 @@ Rules for adding or touching a guard: `os.kill`), and `verify.py`'s git exemption is narrowed further, to the `_run_git` argv position. Env-read exemptions are scoped **by variable name or family, never by file** — a file-wide pass would let a hook read a core knob unnoticed. -- **The detector itself gets executable coverage.** The two newest detectors — env-read and - git-argv — carry probe matrices: the scan is split (`_scan_source`) so probe fixtures run +- **The detector itself gets executable coverage.** Four detectors — env-read, git-argv, and the + two verify-composition ones — carry probe matrices: the scan is split (`_scan_source`) so probe fixtures run the same code path as the real scan, with a must-flag row per claimed access form and a must-stay-silent row per lookalike. When a new form turns up, add the failing probe row first, then fix the detector. The green "no findings today" assertion cannot grade a diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 73be7f2d..0e105ced 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -212,7 +212,9 @@ # The `verify-command-result` group at the end is the same convention applied to # the verifier records: `command` is operator-authored shell (`[verify] commands`), # `output_tail` is a build's own output, `capture_error` is an OSError string -# carrying a path, and the two pointers embed the story key. Routing them here +# carrying a path, `spawn_error` names the run's code root explicitly as its cwd +# (and a cwd-related wrapped exception may name it again), and the two pointers +# embed the story key. Routing them here # rather than leaving them to `scrub_json` is deliberate — that fallback fails # closed only by accident of shape, since `_IDENTIFIER_RE` forbids `/` and spaces # and so collapses paths, argv-ish commands and multi-line tails, while a @@ -235,6 +237,7 @@ "command", "output_tail", "capture_error", + "spawn_error", "stdout_path", "stderr_path", # An absolute host path naming the run's code tree diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 2f4b9c75..64d6b3a6 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -4805,7 +4805,9 @@ def _journal_verify_command_results( nothing to record. ``attempt`` and ``verification_stage`` make the public journal records - correlate to a concrete dev or repair verification pass. The filenames + 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 @@ -4880,6 +4882,13 @@ def _journal_verify_command_results( 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, ) @@ -5349,6 +5358,31 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): ) 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. + + Deliberately NOT a ``VerifyCommandRecords`` producer: that payload exists + for ``post_dev_verify``, which stays dev/fix only (#656 tracks the review + hook stage). Journalled, not 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: + self._journal_verify_command_results(task, "review", results) + + return sink + 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 @@ -5360,6 +5394,7 @@ def _verify_review(self, task: StoryTask): self.policy, sprint_reached_done=not self._dev_review_enabled(), operator_park=self._operator_park_enabled(), + on_results=self._review_command_sink(task), ) def _review_prompt(self, task: StoryTask) -> str: diff --git a/src/bmad_loop/plugins/context.py b/src/bmad_loop/plugins/context.py index 590ec9ab..6fb1a47b 100644 --- a/src/bmad_loop/plugins/context.py +++ b/src/bmad_loop/plugins/context.py @@ -222,6 +222,16 @@ def command_results(self) -> tuple[CommandResult, ...]: in the order the commands ran. Read-only observability for ``post_dev_verify``; nothing here feeds an engine decision. + Each record carries ``command``, ``returncode``, the merged bounded + ``output_tail``, the separate ``stdout`` / ``stderr`` streams with their + optional ``*_full_bytes`` emission counts (``None`` means the matching + stream was retained whole), and ``spawn_error``. That last one is normally + ``None`` and is set when the child could not be STARTED — its ``returncode`` + is then ``verify.SPAWN_FAULT_RC``, a sentinel outside the range any real + child reports, so a handler must not read the rc of such a record as an + exit status. The pass that produced it always ends the attempt as an + environment fault. + Empty is ambiguous ON ITS OWN and must not be read as "the commands did not run" — read it together with :attr:`verification_stage`, which is what separates the cases: diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 99f45b3d..7d38ffde 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -537,8 +537,17 @@ def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | No def _verify_review(self, task: StoryTask): # Drop the sprint-status gate (stories mode has no board); the id-keyed - # story spec's own `done` frontmatter is authoritative. - return verify.verify_review_stories(task, self.workspace.paths, self.policy) + # story spec's own `done` frontmatter is authoritative. The sink is the + # base engine's: stories mode runs the same verifier commands and its + # results belong in the same journal record kind (see + # `Engine._review_command_sink`), so a mode-specific one would only be a + # way for the three gates to drift apart on what they record. + return verify.verify_review_stories( + task, + self.workspace.paths, + self.policy, + on_results=self._review_command_sink(task), + ) def _sprint_board_instruction(self) -> str: # Stories mode has no sprint-status.yaml: `_post_dev_state_sync` is a no-op diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 302f828d..d0f1de45 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1876,7 +1876,12 @@ def _verify_review(self, task: StoryTask): self._close_bundle_ledger_when_spec_status( task, task.spec_file, "done", kind="sweep-bundle-reclosed" ) - return verify.verify_review_bundle(task, self.workspace.paths, self.policy) + return verify.verify_review_bundle( + task, + self.workspace.paths, + self.policy, + on_results=self._review_command_sink(task), + ) def _operator_park_enabled(self) -> bool: # A bundle carries no sprint-status entry, so the pair a park is verified diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index a3cfe538..74554ef3 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -4090,6 +4090,17 @@ class CommandResult: cut one. ``None`` means nothing was cut and the stream is the whole of it, so the many callers that build a result from three fields stay correct without knowing this exists. + + ``spawn_error`` is the discriminator for the one shape that has no return + code at all: the child was never started. The typical cause is the ``cwd`` + it was to run in — missing, not a directory, or unsearchable — and the + message names that directory as context, but the fault is caught as any + spawn-time ``OSError`` and the set is not closed: a missing shell, EMFILE + or ENOMEM reach the same field, and the wrapped exception is what says + which. ``None`` on every result that came from a process that actually ran — + including a timeout, which ran and hung. It is LAST and defaulted because the + construction sites pass three to seven POSITIONAL arguments; a field inserted + anywhere else would silently re-bind them. """ command: str @@ -4099,6 +4110,34 @@ class CommandResult: stderr: str = "" stdout_full_bytes: int | None = None stderr_full_bytes: int | None = None + spawn_error: str | None = None + + +# The synthetic return code on a result whose child never started. +# +# The magnitude is the load-bearing part. On POSIX ``subprocess`` reports ``-N`` +# for a child KILLED BY signal N, so every small negative integer is a real +# return code some child can produce: ``-2`` is SIGINT, ``-9`` SIGKILL, ``-15`` +# SIGTERM. A sentinel inside that range would be indistinguishable from a +# verify command the operator (or an OOM killer) had just killed. 1000 is far +# above the largest real-time signal any platform defines, so this value cannot +# be minted by a child that ran. +# +# Negative because two live arms depend on the sign: the win32 probe's +# ``returncode < 0`` early-out, and the ordinary ``returncode != 0`` failure arm +# that must still read it as a failure if anything ever reaches that far. And +# distinct from the timeout leg's ``-1``, because both are "no exit status +# exists" sentinels and a reader that conflated them would read a child that +# never started as one that ran and hung. +# +# ``spawn_error`` — not this code — is what the classifiers key on; the code +# exists so the journal record and the plugin payload carry an rc that no real +# child could have produced. +SPAWN_FAULT_RC = -1000 + +# The sink a caller hands :func:`verify_commands_outcome` to observe the results +# it is about to classify — the engine journals review-gate results through it. +CommandSink = Callable[[tuple[CommandResult, ...]], None] # sh launcher convention (verify commands run shell=True): 126 = command found @@ -4181,8 +4220,12 @@ def _win32_env_fault_reason(result: CommandResult, cwd: Path) -> str | None: """Windows env-fault evidence, cheapest signal first, or None. Each signal is independently sufficient; see the _CMD_* constants for why the rc alone isn't.""" if result.returncode < 0: - # the timeout sentinel: the command ran and hung, so it was found and it - # was runnable — none of the signals below can apply to it. + # One of the two "no exit status" sentinels, or a signal-killed child. + # None of the signals below can apply to any of them, though for opposite + # reasons: a timeout (`-1`) and a signal death mean the command WAS found + # and WAS runnable, while a spawn fault (`SPAWN_FAULT_RC`) means no child + # existed to probe — and that one is already answered by `spawn_error`, + # ahead of this function being called at all (see `env_fault_reason`). return None if result.returncode == _CMD_ENV_FAULT_RC: return f"rc={_CMD_ENV_FAULT_RC} — cmd reported the command as not found" @@ -4232,7 +4275,17 @@ def _win32_env_fault_reason(result: CommandResult, cwd: Path) -> str | None: def env_fault_reason(result: CommandResult, cwd: Path) -> str | None: """Why this verify command is an environment fault rather than a story failure, or None if it is not one. Per-shell: verify commands run through - the host shell, and sh and cmd signal a broken environment differently.""" + the host shell, and sh and cmd signal a broken environment differently. + + ``spawn_error`` is answered FIRST and unconditionally, before any rc reading + and before the win32 probe. Not merely an ordering preference: the probe + resolves a command's leading token as ``cwd / token`` to decide whether the + tool exists, and on this leg no child was started, so that lookup is about a + directory nothing ever entered and cannot speak to why. The result also + carries no exit status to read (see :data:`SPAWN_FAULT_RC`), which is why + the rc arms cannot classify it either.""" + if result.spawn_error is not None: + return result.spawn_error if result.returncode in ENV_FAULT_RCS: return f"rc={result.returncode}" if sys.platform != "win32": @@ -4285,7 +4338,13 @@ def run_verify_commands(policy: Policy, cwd: Path) -> list[CommandResult]: ``[-2000:]``), and one undecodable byte must not raise mid-loop and lose *every* command's result. Decoding stays on the locale codec (``text=True``) precisely because these are host tools — contrast tui/launch.py, which pins - ``encoding="utf-8"`` because its child is our own UTF-8 CLI.""" + ``encoding="utf-8"`` because its child is our own UTF-8 CLI. + + "One apiece" holds across all three legs: a completed child, a timeout, and a + child that could never be spawned each append exactly one result and the loop + goes on to the next command. The three are told apart on the result itself — + an rc for the first, ``rc=-1``/``"timed out"`` for the second, + ``spawn_error`` plus :data:`SPAWN_FAULT_RC` for the third.""" results = [] for command in policy.verify.commands: try: @@ -4319,6 +4378,42 @@ def run_verify_commands(policy: Policy, cwd: Path) -> list[CommandResult]: results.append( CommandResult(command, -1, "timed out", t_out, t_err, t_out_full, t_err_full) ) + except OSError as exc: + # The child was never started, so no exit status exists to classify: + # `subprocess.run` raises out of the fork/exec (or CreateProcess) + # itself when `cwd` is unusable — FileNotFoundError (missing), + # NotADirectoryError (a regular file, or a path beneath one), + # PermissionError (a directory without +x). `except OSError` rather + # than the three names because they are the reachable shapes TODAY, + # not a closed set: the base class is what the platform actually + # guarantees, and one uncaught sibling here crashes the whole run. + # + # Translated instead of raised, the same doctrine `_run_git` follows + # for the faults that land before a return code exists (#343): left + # uncaught this escapes every `except` in the engine's verification + # path and ends the run as a crash, when the fact it reports — a cwd + # no command can run in — is a textbook environment fault, identical + # for every story and unfixable by a repair session. + # + # A result is APPENDED and the loop CONTINUES, honouring this + # function's documented "one CommandResult apiece": a caller zipping + # results against `policy.verify.commands` must not silently lose the + # tail of the list to the first broken spawn. + results.append( + CommandResult( + command, + SPAWN_FAULT_RC, + f"{type(exc).__name__}: {exc}", + # What was OBSERVED, not a diagnosis. `except OSError` is + # wider than the cwd shapes that motivated it — a missing + # `/bin/sh`, EMFILE, ENOMEM all land here — so the cwd is + # named as context ("cwd was X") rather than blamed, and the + # exception carries whatever the real cause was. No "could + # not run" phrasing: `cli._reverify` prefixes its own + # ("' could not run: ..."), and the two stuttered. + spawn_error=(f"child not started; cwd was {cwd}; {type(exc).__name__}: {exc}"), + ) + ) return results @@ -4337,12 +4432,23 @@ def verify_command_results_outcome(results: list[CommandResult], cwd: Path) -> V for result in results: reason = env_fault_reason(result, cwd) if reason is not None: + # The explanatory clause branches on WHICH fault this is, because the + # rc-based one is a claim about the command and the spawn one is not: + # a child that never started was never looked for, so "command not + # found / not executable" would send the reader hunting for a binary + # when the directory is what is broken. Everything after the dash is + # shared — the remedy (fix the environment, re-arm) is the same. + clause = ( + "the command could not be started at all" + if result.spawn_error is not None + else "command not found / not executable" + ) + output = "" if result.spawn_error is not None else f"\n{result.output_tail}" return VerifyOutcome.escalate( f"verify environment fault ({reason}): {result.command}\n" - "command not found / not executable — this is the run environment, " + f"{clause} — this is the run environment, " "not the story; fix the environment, then re-arm the escalation " - "(the attempt budget resets on re-arm)\n" - f"{result.output_tail}", + f"(the attempt budget resets on re-arm){output}", env_fault=True, ) for result in results: @@ -4355,12 +4461,37 @@ def verify_command_results_outcome(results: list[CommandResult], cwd: Path) -> V return VerifyOutcome.passed() -def verify_commands_outcome(policy: Policy, cwd: Path) -> VerifyOutcome: - """Run the policy's deterministic verify commands and classify the results.""" - return verify_command_results_outcome(run_verify_commands(policy, cwd), cwd) - - -def _verify_review_commands(policy: Policy, paths: ProjectPaths) -> VerifyOutcome: +def verify_commands_outcome( + policy: Policy, cwd: Path, *, on_results: CommandSink | None = None +) -> VerifyOutcome: + """Run the policy's deterministic verify commands and classify the results. + + ``on_results`` observes the results BEFORE they are classified, which is the + same order ``Engine._verify_commands_with_results`` uses on the dev side: + journal first, decide second, so the record exists whatever the classifier + then does with it — including an escalation that ends the run. It is called + exactly once per invocation, with an empty tuple when no commands are + configured, because "the pass ran and executed nothing" and "no pass ran" are + different facts and only the second one is signalled by never getting here. + + The contract on the sink is that IT must not raise; this function adds no + guard of its own, deliberately. The engine's sink + (``_journal_verify_command_results``) degrades on stream-capture faults — an + ``OSError`` from a ``verify/`` write becomes a ``capture_error`` field — but + the ``Journal.append`` beneath it has no handler, so ENOSPC or a read-only run + dir still propagates. That is the same fail-loud boundary the dev leg already + stands on, and wrapping the call here would trade it for silence: a lost + journal write is a lost audit record, which is exactly the class of failure + that must not pass quietly.""" + results = run_verify_commands(policy, cwd) + if on_results is not None: + on_results(tuple(results)) + return verify_command_results_outcome(results, cwd) + + +def _verify_review_commands( + policy: Policy, paths: ProjectPaths, *, on_results: CommandSink | None = None +) -> VerifyOutcome: """Run a review gate's ``[verify] commands`` in ``paths.repo_root``. The two roots split by what is being addressed, and the split is deliberate: @@ -4392,8 +4523,18 @@ def _verify_review_commands(policy: Policy, paths: ProjectPaths) -> VerifyOutcom ``paths.repo_root`` is the ONLY member of ``paths`` this reads — it takes the whole dataclass to keep the three call sites uniform, not because it consults anything else. A future caller must not infer that artifact paths reach here. + + ``on_results`` is forwarded, not consumed: an engine-supplied sink is how + review-gate results reach the journal, which the dev side has always had and + these gates had not. Optional, so the gates stay callable from core (and from + tests) with no engine at all — no sink simply means nothing is recorded, + which is what every direct caller got before. + + This is also the ONLY sanctioned caller of ``verify_commands_outcome``; a + fourth gate reaching past it would re-open #695. Enforced, not merely stated + — see ``tests/test_portability_guard.py``. """ - return verify_commands_outcome(policy, paths.repo_root) + return verify_commands_outcome(policy, paths.repo_root, on_results=on_results) def verify_review( @@ -4403,6 +4544,7 @@ def verify_review( *, sprint_reached_done: bool = False, operator_park: bool = False, + on_results: CommandSink | None = None, ) -> VerifyOutcome: """Gate a completed review pass: spec at ``done``, sprint-status at ``done``, deterministic verify commands green. @@ -4434,7 +4576,12 @@ def verify_review( disagree about whether this run parks. They would: the engine's ``_operator_park_enabled`` is an override seam, and a mode that opts out of parking while still reaching this gate would otherwise find it accepting a - park the engine itself refuses to take.""" + park the engine itself refuses to take. + + ``on_results`` is handed straight to ``_verify_review_commands`` and is the + engine's hook for journalling this gate's verifier results; see there. It is + invoked only if the gate reaches its commands — an earlier refusal ran + nothing, so there is nothing to record.""" if not task.spec_file: return VerifyOutcome.retry("no spec file recorded for task") fm = _gate_frontmatter(Path(task.spec_file)) @@ -4471,7 +4618,7 @@ def verify_review( f"sprint-status for {task.story_key} is {sprint!r}, expected {expected!r}" ) - return _verify_review_commands(policy, paths) + return _verify_review_commands(policy, paths, on_results=on_results) def _is_signoff_regression(sprint: str | None, sprint_reached_done: bool, policy: Policy) -> bool: @@ -4490,11 +4637,22 @@ def _is_signoff_regression(sprint: str | None, sprint_reached_done: bool, policy return STATUS_ORDER.index(sprint) < STATUS_ORDER.index("done") -def verify_review_stories(task: StoryTask, paths: ProjectPaths, policy: Policy) -> VerifyOutcome: +def verify_review_stories( + task: StoryTask, + paths: ProjectPaths, + policy: Policy, + *, + on_results: CommandSink | None = None, +) -> VerifyOutcome: """verify_review for stories mode: same spec-done + verify-commands gates, minus the sprint-status gate (stories mode has no sprint board — the story spec's own frontmatter status is authoritative). ``task.spec_file`` is the - id-keyed story spec ``verify_dev_stories`` recorded on the dev pass.""" + id-keyed story spec ``verify_dev_stories`` recorded on the dev pass. + + ``on_results`` is handed straight to ``_verify_review_commands`` and is the + engine's hook for journalling this gate's verifier results; see there. It is + invoked only if the gate reaches its commands — an earlier refusal ran + nothing, so there is nothing to record.""" if not task.spec_file: return VerifyOutcome.retry("no spec file recorded for task") fm = _gate_frontmatter(Path(task.spec_file)) @@ -4503,16 +4661,27 @@ def verify_review_stories(task: StoryTask, paths: ProjectPaths, policy: Policy) status = status_of(fm) if status != "done": return VerifyOutcome.retry(f"spec status is {status!r}, expected 'done'") - return _verify_review_commands(policy, paths) + return _verify_review_commands(policy, paths, on_results=on_results) -def verify_review_bundle(task: StoryTask, paths: ProjectPaths, policy: Policy) -> VerifyOutcome: +def verify_review_bundle( + task: StoryTask, + paths: ProjectPaths, + policy: Policy, + *, + on_results: CommandSink | None = None, +) -> VerifyOutcome: """verify_review for a deferred-work bundle: no sprint-status check, but every dw id the bundle owns must be marked done in the ledger on disk. The legacy --dw-bundle skill flips them; on the generic bmad-build-auto path the orchestrator flips them after dev and, if review rewrites the ledger diff, again immediately before this review gate. Either way this gate is why we - can trust it happened.""" + can trust it happened. + + ``on_results`` is handed straight to ``_verify_review_commands`` and is the + engine's hook for journalling this gate's verifier results; see there. It is + invoked only if the gate reaches its commands — an earlier refusal ran + nothing, so there is nothing to record.""" if not task.spec_file: return VerifyOutcome.retry("no spec file recorded for task") fm = _gate_frontmatter(Path(task.spec_file)) @@ -4543,7 +4712,7 @@ def verify_review_bundle(task: StoryTask, paths: ProjectPaths, policy: Policy) - fixable=True, ) - return _verify_review_commands(policy, paths) + return _verify_review_commands(policy, paths, on_results=on_results) def commit_story(repo: Path, message: str) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index 6f53fbfc..10156d18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8009,6 +8009,46 @@ def test_confirm_reverify_success_lets_the_flip_through(project, capsys, monkeyp assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "done" +def test_confirm_reverify_reports_an_unusable_cwd_instead_of_crashing( + project, tmp_path, capsys, monkeypatch +): + """A `repo_root` no command can run in refuses the confirmation; it does not + raise out of the CLI. + + `_reverify` deliberately does not classify into the engine's vocabulary, but + it does reuse `env_fault_reason` — so it inherits the spawn-fault translation + with no edit of its own, and this row is what pins that inheritance. Before + it, the OSError from `subprocess.run` escaped `cmd_confirm` entirely and the + operator saw a traceback in place of "NOT confirmed". + + The park record and the board must be untouched, for the same reason the + red-command row beside this one asserts it: a refused `--reverify` has to + leave every record exactly where it found it.""" + from bmad_loop import operatoractions, sprintstatus + + install_bmad_config(project) + missing = tmp_path / "no-such-code-root" + (project.project / "_bmad" / "bmm" / "config.yaml").write_text( + "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" + "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n" + f"repo_root: '{missing.as_posix()}'\n", + encoding="utf-8", + ) + sp = _park_story(project) + before = sp.read_text() + _write_policy(project.project, '[verify]\ncommands = ["python -c \\"pass\\""]\n') + monkeypatch.setattr(cli, "_confirm", lambda _q: True) + + assert cli.main(_confirm_argv(project, "1-1-a", "--reverify")) == 1 + + err = capsys.readouterr().err + assert "--reverify failed" in err and "NOT confirmed" in err + assert "could not run" in err and str(missing) in err + assert sp.read_text() == before + assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "awaiting-operator" + assert "1-1-a" in operatoractions.load(project.project) + + def test_confirm_reverify_says_so_when_nothing_is_configured(project, capsys, monkeypatch): """An empty command list is not a green gate, and must not be reported as one.""" install_bmad_config(project) @@ -8429,6 +8469,36 @@ def test_a_resume_still_honors_reverify_and_says_what_was_not_done(project, caps assert "1-1-a" in operatoractions.load(project.project) +def test_a_resume_reverify_reports_an_unusable_cwd_without_losing_partial_state( + project, tmp_path, capsys, monkeypatch +): + """The resumable confirmation caller gets the same spawn-fault translation + as the initial confirmation and preserves the already-written sign-off.""" + from bmad_loop import operatoractions, sprintstatus + + install_bmad_config(project) + missing = tmp_path / "no-such-resume-code-root" + (project.project / "_bmad" / "bmm" / "config.yaml").write_text( + "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" + "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n" + f"repo_root: '{missing.as_posix()}'\n", + encoding="utf-8", + ) + spec = _interrupted_story(project) + before = spec.read_text(encoding="utf-8") + _write_policy(project.project, '[verify]\ncommands = ["python -c \\"pass\\""]\n') + monkeypatch.setattr(cli, "_confirm", lambda _q: True) + + assert cli.main(_confirm_argv(project, "1-1-a", "--reverify")) == 1 + + err = capsys.readouterr().err + assert "NOT advanced" in err and "NOT confirmed" not in err + assert "could not run" in err and str(missing) in err + assert spec.read_text(encoding="utf-8") == before + assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "awaiting-operator" + assert "1-1-a" in operatoractions.load(project.project) + + def test_list_marks_an_interrupted_confirmation_as_signed_off_not_refused(project, capsys): """An interrupted confirmation drifts, so a listing that reads `drift()` alone labels it NOT CONFIRMABLE — telling a human confirm will refuse a story confirm diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 291213a7..71c2bb0c 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -454,19 +454,21 @@ def test_a_windows_spec_path_normalizes_to_the_same_alias(): def test_verify_command_free_text_drops_to_presence_booleans(): """A `verify-command-result` record ships its correlation half, never its text. - `_scrub_entry` routes by field NAME, and five of this record's fields are free + `_scrub_entry` routes by field NAME, and six of this record's fields are free text: `command` is operator-authored shell, `output_tail` is a build's own - output, `capture_error` is an OSError string carrying a path, and the two - stream pointers embed the story key. Left to the `scrub_json` fallback they - fail closed only by ACCIDENT of shape — `_IDENTIFIER_RE` forbids `/` and - spaces, so paths, argv-ish commands and multi-line tails collapse — but a - one-word command like `make` satisfies it and ships verbatim. - - Ablation: remove the five names from `_JOURNAL_DROP_FIELDS`. `command` comes + output, `capture_error` is an OSError string carrying a path, `spawn_error` is + an OSError string carrying the run's code root twice, and the two stream + pointers embed the story key. Left to the `scrub_json` fallback they fail + closed only by ACCIDENT of shape — `_IDENTIFIER_RE` forbids `/` and spaces, so + paths, argv-ish commands and multi-line tails collapse — but a one-word + command like `make` satisfies it and ships verbatim. + + Ablation: remove the six names from `_JOURNAL_DROP_FIELDS`. `command` comes back as the literal `make` (reddening the presence assertion AND the canary - sweep), while `output_tail` / `capture_error` / `stdout_path` merely turn into - `` — which is why `make` is the value under test and not a - path-shaped one: only it separates the drop list from the fallback. + sweep), while `output_tail` / `capture_error` / `spawn_error` / `stdout_path` + merely turn into `` — which is why `make` is the value under + test and not a path-shaped one: only it separates the drop list from the + fallback. """ pseudo = sanitize.Pseudonymizer(salt=b"fixed") out = diagnostics._scrub_entry( @@ -482,6 +484,10 @@ def test_verify_command_free_text_drops_to_presence_booleans(): "returncode": 1, "output_tail": CODE, "capture_error": f"stdout: [Errno 28] No space left on device: '{HOME_PATH}/x'", + "spawn_error": ( + f"child not started; cwd was {HOME_PATH}/code; " + f"NotADirectoryError: [Errno 20] Not a directory: '{HOME_PATH}/code'" + ), "stdout_path": f"verify/verify-{STORY_KEY}-dev-2-3-0.stdout.log", "stderr_path": None, "stdout_bytes": 12, @@ -492,11 +498,19 @@ def test_verify_command_free_text_drops_to_presence_booleans(): 1.0, ) - for field in ("command", "output_tail", "capture_error", "stdout_path", "stderr_path"): + for field in ( + "command", + "output_tail", + "capture_error", + "spawn_error", + "stdout_path", + "stderr_path", + ): assert field not in out, f"{field} must never be emitted" assert out["command_present"] is True assert out["output_tail_present"] is True assert out["capture_error_present"] is True + assert out["spawn_error_present"] is True # the pointers keep the one fact they are worth: whether a stream was retained # at all — `stream_capture_kb = 0` and a failed write both leave it null. assert out["stdout_path_present"] is True diff --git a/tests/test_engine.py b/tests/test_engine.py index be76e553..cd41127c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7,6 +7,7 @@ import os import re import signal +import subprocess import sys import time from pathlib import Path @@ -184,8 +185,14 @@ def test_post_dev_verify_exposes_journaled_command_results(project, monkeypatch) assert summary.done == 1 (ctx,) = capture.contexts assert ctx.command_results == (result,) - (entry,) = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] - assert entry["verification_stage"] == "dev" + # scoped to the dev stage: the skip-review commit path runs the review gate + # too, and that pass now journals a record of its own (the hook stays dev/fix, + # which is why `capture.contexts` above is still a single context). + (entry,) = [ + e + for e in engine.journal.entries() + if e["kind"] == "verify-command-result" and e["verification_stage"] == "dev" + ] assert entry["verification_sequence"] == 1 assert entry["command_index"] == 0 and entry["returncode"] == 0 assert (engine.run_dir / entry["stdout_path"]).read_text(encoding="utf-8") == "out\n" @@ -197,6 +204,125 @@ def test_post_dev_verify_exposes_journaled_command_results(project, monkeypatch) assert not list((engine.run_dir / LOGS_DIR).glob("verify-*")) +def test_review_gate_verify_commands_are_journalled_under_the_review_stage(project, monkeypatch): + """A review gate's verifier pass leaves the same records a dev pass does. + + The three review gates used to discard their `CommandResult`s inside core, so + a review-leg pass wrote no `verify-command-result` entry and no `verify/` + stream files — a whole class of verifier invocation invisible to anything + reading the journal. The engine now hands them a sink + (`Engine._review_command_sink`) built on the very method the dev side uses. + + The dev record is asserted alongside, because the point is that the two share + ONE per-story `verification_sequence`: reading the records in ordinal order + replays the story's verifications in the order they ran, which a separate + review counter would break. + + Ablation: drop `on_results=` from `Engine._verify_review` and the review + record is gone while the dev one stays — reddening this and nothing else. + """ + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + policy=Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + verify=VerifyPolicy(commands=("pytest -q",)), + ), + ) + result = verify.CommandResult("pytest -q", 0, "out\nerr\n", "out\n", "err\n") + monkeypatch.setattr(verify, "run_verify_commands", lambda policy, cwd: [result]) + + summary = engine.run() + + assert summary.done == 1 + records = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] + stages = [e["verification_stage"] for e in records] + assert "review" in stages, stages + assert stages[0] == "dev" # the dev leg still records, and still records first + review_records = [e for e in records if e["verification_stage"] == "review"] + (entry,) = review_records + assert entry["story_key"] == "1-1-a" + assert entry["command"] == "pytest -q" and entry["command_index"] == 0 + assert entry["returncode"] == 0 and entry["spawn_error"] is None + # one shared per-story counter, so the review pass follows the dev pass + assert entry["verification_sequence"] > records[0]["verification_sequence"] + # the pointers name readable files in the verifier's own store, as on the dev leg + assert entry["stdout_path"].startswith(f"{VERIFY_DIR}/") + assert entry["stderr_path"].startswith(f"{VERIFY_DIR}/") + assert (engine.run_dir / entry["stdout_path"]).read_text(encoding="utf-8") == "out\n" + assert (engine.run_dir / entry["stderr_path"]).read_text(encoding="utf-8") == "err\n" + + +def test_review_gate_writes_no_verify_records_when_it_short_circuits(project): + """A review gate refused before its commands records nothing — nothing ran. + + A `verify-command-result` entry is a claim that the verifier was invoked, so a + gate that stopped at the sprint-status check must not mint one. This is the + whole reason the sink is threaded through the composition instead of being + fired at the top of the gate. + + The pair is local rather than borrowed: the same task and engine are driven + twice, once with the board short of `done` and once with it advanced, so the + "no records" half cannot be green for the trivial reason that nothing about + this setup records anything. + """ + engine, _ = make_engine( + project, + [], + policy=Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + verify=VerifyPolicy(commands=(_OK,)), + ), + ) + task = StoryTask(story_key="1-1-a", epic=1) + sp = spec_path(project, "1-1-a") + write_spec(sp, "done", verify.rev_parse_head(project.project)) + task.spec_file = str(sp) + + write_sprint(project, {"1-1-a": "in-progress"}) + refused = engine._verify_review(task) + + # the sprint-status arm — the check that sits immediately in front of the + # commands. Under the generic dev skill this run is `sprint_reached_done`, so + # the board short of `done` reads as a revoked sign-off (#334) rather than a + # plain retry; either way the gate returned WITHOUT running a command. + assert not refused.ok and "revoked the sprint sign-off" in refused.reason + assert not [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] + + # positive control: the same gate, the same sink, past the check it stopped at + write_sprint(project, {"1-1-a": "done"}) + assert engine._verify_review(task).ok + + (entry,) = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] + assert entry["verification_stage"] == "review" and entry["command"] == _OK + + +def test_review_gate_with_no_commands_configured_records_nothing(project): + """The empty-tuple call is not a record: the sink runs, `[verify] commands` is + empty, so `_journal_verify_command_results` returns without allocating a + sequence. + + Asserted at the engine because that is where the two halves meet — the seam + signals "the pass ran and executed nothing" (test_verify.py pins that), and + the recorder is what decides that fact costs no ordinal. An allocation here + would run the story's `verification_sequence` ahead of the journal it indexes. + """ + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1-1-a", epic=1) + sp = spec_path(project, "1-1-a") + write_spec(sp, "done", verify.rev_parse_head(project.project)) + task.spec_file = str(sp) + write_sprint(project, {"1-1-a": "done"}) + + assert engine._verify_review(task).ok # default Policy: no verify commands + + assert not [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] + assert engine._next_verification_sequence("1-1-a") == 1 # nothing was consumed + + def test_verify_stream_filenames_sanitize_the_whole_composition(project): """A long story key cannot push a composed filename past the segment cap. @@ -408,7 +534,16 @@ def _enospc(*_args, **_kwargs): summary = engine.run() assert summary.done == 1 # the run survives its own logging - entry = _sole_verify_record(engine) + # the dev leg's record: the skip-review commit path's own gate now journals + # one too, and both degrade identically — this row's subject is the dev pass. + # Unpacked rather than taken with `next(...)`: `_sole_verify_record`, which + # this replaced, reddened on a DUPLICATED record, and narrowing the filter + # must not quietly hand that property away. + (entry,) = [ + e + for e in engine.journal.entries() + if e["kind"] == "verify-command-result" and e["verification_stage"] == "dev" + ] assert entry["capture_error"] is not None assert "stdout" in entry["capture_error"] and "No space left" in entry["capture_error"] assert entry["stdout_path"] is None and entry["stderr_path"] is None @@ -430,8 +565,13 @@ def test_fix_verification_emits_post_dev_verify_with_command_results(project, mo assert [ (e["verification_stage"], e["verification_sequence"], e["command_index"]) for e in entries ] == [ + # the two review gates of the skip-review commit path interleave with the + # dev and repair passes, sharing one per-story sequence — reading the + # records in ordinal order replays the verifications in the order they ran ("dev", 1, 0), - ("fix", 2, 0), + ("review", 2, 0), + ("fix", 3, 0), + ("review", 4, 0), ] @@ -466,20 +606,22 @@ def test_verification_sequence_survives_a_resume(project): task = StoryTask(story_key="1-1-a", epic=1) assert first._journal_verify_command_results(task, "dev", _one_result()) == 1 assert first._journal_verify_command_results(task, "fix", _one_result()) == 2 + assert first._journal_verify_command_results(task, "review", _one_result()) == 3 # what a resume is: a fresh Engine (so a fresh counter) and a fresh Journal # over the run dir the paused process left behind. resumed, _ = make_engine(project, []) assert resumed.journal.path == first.journal.path, "the fixture must reuse the run dir" - assert resumed._journal_verify_command_results(task, "fix", _one_result()) == 3 - # and from there it increments in memory — the seed is not re-read per pass assert resumed._journal_verify_command_results(task, "fix", _one_result()) == 4 + # and from there it increments in memory — the seed is not re-read per pass + assert resumed._journal_verify_command_results(task, "fix", _one_result()) == 5 assert _journalled_sequences(resumed) == [ ("1-1-a", "dev", 1), ("1-1-a", "fix", 2), - ("1-1-a", "fix", 3), + ("1-1-a", "review", 3), ("1-1-a", "fix", 4), + ("1-1-a", "fix", 5), ] @@ -572,16 +714,17 @@ def _dev_then_fix_run(project, monkeypatch, capture): the repair session's verify passes and the story commits. Both legs emit `post_dev_verify`, which is what the callers need. - FOUR scripted returns, TWO journalled sequences — deliberately, and the - inequality is the documented scope boundary, not a miscount to "fix". Returns - 1 and 3 are the dev and repair verifications, which this PR journals. Returns - 2 and 4 are the two `_skip_review_and_commit` review gates (the second runs - after the repair), and the review leg is neither journalled nor published to - any hook — see the boundary section in `docs/plugin-authoring-guide.md` and - issue #656. The count is load-bearing, not padding: dropping the fourth value - leaves the post-repair gate with nothing to consume and the run ends - `crashed=True, crash_error='StopIteration: '` (measured), so a reader who - trims the list finds out immediately. + FOUR scripted returns, FOUR journalled sequences, TWO hook emits — and the + inequality that remains is the documented scope boundary, not a miscount to + "fix". Returns 1 and 3 are the dev and repair verifications; returns 2 and 4 + are the two `_skip_review_and_commit` review gates (the second runs after the + repair). All four are journalled now that the review gates carry a sink, but + only the dev and repair legs publish `post_dev_verify` — the review leg still + reaches no hook, which is the half of #656 that stays open (see the boundary + section in `docs/plugin-authoring-guide.md`). The count is load-bearing, not + padding: dropping the fourth value leaves the post-repair gate with nothing to + consume and the run ends `crashed=True, crash_error='StopIteration: '` + (measured), so a reader who trims the list finds out immediately. """ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, _ = make_engine( @@ -634,7 +777,10 @@ def test_post_dev_verify_discriminates_a_dev_emit_from_a_fix_emit(project, monke assert (dev_ctx.attempt, fix_ctx.attempt) == (1, 2) # ... and what now separates them assert (dev_ctx.verification_stage, dev_ctx.verification_sequence) == ("dev", 1) - assert (fix_ctx.verification_stage, fix_ctx.verification_sequence) == ("fix", 2) + # 3, not 2: the review gate between the two legs journals a pass of its own + # and takes ordinal 2 — which is exactly why a plugin joins on the sequence + # the context hands it rather than counting its own emits. + assert (fix_ctx.verification_stage, fix_ctx.verification_sequence) == ("fix", 3) # the join a correlating plugin performs: story + stage + sequence names # exactly this context's records, one per command, in command_index order. @@ -10426,6 +10572,66 @@ def test_verify_env_fault_pauses_dev_without_burning_budget(project): assert decision["env_fault"] is True +def test_unusable_verify_cwd_pauses_the_run_instead_of_crashing_it(project, monkeypatch): + """The DW-2 headline, end to end: a `cwd` the verify child cannot be started + in PAUSES the run; it does not end it as a crash. + + `run_verify_commands`' only handler was `except subprocess.TimeoutExpired`, so + the OSError raised out of the spawn escaped every guard on the engine's + verification path, landed in `Engine.run`'s catch-all, and wrote `crash.txt` + with `state.crashed` — a resumable environment problem presented to the + operator as an orchestrator bug. Translated, it takes the env-fault channel + the rc-based faults already take: escalate, pause, budget untouched. + + The refusal is injected at the spawn boundary rather than by handing the + engine a broken root, and that is a deliberate limit of this row: the engine + does most of its git work in the SAME directory the verify child runs in, so a + genuinely unusable `workspace.root` would fail somewhere earlier and this + would stop being a test about verify commands at all. Scoped to `shell=True` + so only the verify child is refused — the engine's git goes through `_run_git`, + which passes no shell. + + Ablation: remove the `except OSError` arm and this fails on `summary.crashed` + with the traceback in `crash.txt`, which is the bug verbatim.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + missing = project.project / "no-such-root" + real_run = subprocess.run + + def refusing_run(*args, **kwargs): + if kwargs.get("shell"): + raise NotADirectoryError(20, "Not a directory", str(missing)) + return real_run(*args, **kwargs) + + monkeypatch.setattr(subprocess, "run", refusing_run) + policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=False), + verify=VerifyPolicy(commands=("pytest -q",)), + ) + # one dev session scripted: a repair session against a broken environment + # must never be requested + engine, adapter = make_engine(project, [dev_effect(project, "1-1-a")], policy=policy) + + summary = engine.run() + + assert not summary.crashed + assert not (engine.run_dir / "crash.txt").exists() + assert summary.paused and summary.escalated == 1 and summary.deferred == 0 + assert [s.role for s in adapter.sessions] == ["dev"] + task = engine.state.tasks["1-1-a"] + assert task.phase == Phase.ESCALATED and task.attempt == 1 # budget untouched + assert engine.state.paused_stage == PAUSE_ESCALATION + assert "verify environment fault" in engine.state.paused_reason + assert "NotADirectoryError" in engine.state.paused_reason + decision = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"][-1] + assert decision["env_fault"] is True + # the discriminator reached the record, for the out-of-process reader + record = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"][-1] + assert record["spawn_error"] and str(missing) in record["spawn_error"] + assert record["returncode"] == verify.SPAWN_FAULT_RC + + def test_review_verify_env_fault_escalates_instead_of_fix_session(project): """An env fault at the review gate pauses the run — no fix session is dispatched and no review cycles are burned re-verifying a broken environment.""" @@ -10451,6 +10657,58 @@ def test_review_verify_env_fault_escalates_instead_of_fix_session(project): assert failed["env_fault"] is True +def test_review_spawn_fault_is_journalled_and_pauses_without_a_fix(project, monkeypatch): + """The spawn-fault discriminator survives the review sink before the same + environment escalation stops the loop; neither repair nor another review is + spent trying to fix the host.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + real_run = subprocess.run + shell_calls = 0 + failed_cwd = project.project / "review-cwd-became-unusable" + + def refuse_the_review_spawn(*args, **kwargs): + nonlocal shell_calls + if kwargs.get("shell"): + shell_calls += 1 + if shell_calls == 2: + raise NotADirectoryError(20, "Not a directory", str(failed_cwd)) + return real_run(*args, **kwargs) + + monkeypatch.setattr(subprocess, "run", refuse_the_review_spawn) + policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + verify=VerifyPolicy(commands=(_OK,)), + ) + engine, adapter = make_engine( + project, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + policy=policy, + ) + + summary = engine.run() + + assert not summary.crashed and not (engine.run_dir / "crash.txt").exists() + assert summary.paused and summary.escalated == 1 and summary.deferred == 0 + assert [s.role for s in adapter.sessions] == ["dev", "review"] + task = engine.state.tasks["1-1-a"] + assert task.phase == Phase.ESCALATED + assert task.attempt == 1 and task.review_cycle == 1 + assert "verify environment fault" in engine.state.paused_reason + records = [ + entry + for entry in engine.journal.entries() + if entry["kind"] == "verify-command-result" and entry["verification_stage"] == "review" + ] + (record,) = records + assert record["returncode"] == verify.SPAWN_FAULT_RC + assert record["spawn_error"] and str(failed_cwd) in record["spawn_error"] + failed = [ + entry for entry in engine.journal.entries() if entry["kind"] == "review-verify-failed" + ][-1] + assert failed["env_fault"] is True + + def test_skip_review_env_fault_escalates_not_defers(project): """review.enabled = false: an env fault at the commit gates pauses the run instead of deferring the story as if its code were broken.""" diff --git a/tests/test_hook_bus.py b/tests/test_hook_bus.py index 705906c5..2440940d 100644 --- a/tests/test_hook_bus.py +++ b/tests/test_hook_bus.py @@ -454,9 +454,15 @@ def on_post_dev_verify(self, c): assert summary.done == 1 assert seen == [("dev", 1, (result,))] # and the keys the plugin was handed are the ones its journal record carries, - # which is the correlation the whole surface exists for - (entry,) = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] - assert (entry["verification_stage"], entry["verification_sequence"]) == ("dev", 1) + # which is the correlation the whole surface exists for. Scoped to the dev + # stage: the review gate journals its own pass now, and that one deliberately + # reaches no plugin — the single `seen` entry above is the other half of that. + (entry,) = [ + e + for e in engine.journal.entries() + if e["kind"] == "verify-command-result" and e["verification_stage"] == "dev" + ] + assert entry["verification_sequence"] == 1 assert entry["story_key"] == "1-1-a" and entry["command"] == "pytest -q" diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index be482291..6102045a 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -55,6 +55,48 @@ # TUI checkpoint modal, and a probe ignoring `limits.git_timeout_s`. GIT_CHOKEPOINT = {"verify.py"} +# The one file allowed to CALL ``verify_commands_outcome`` — and within it, only +# from inside ``_verify_review_commands``, the helper that resolves the review +# gates' command cwd to ``paths.repo_root``. Three gates used to call the +# composition directly with ``paths.project``, which is #695; the helper exists so +# they cannot drift apart on that root again, and a fourth gate calling past it +# would silently reintroduce the bug in exactly the same shape. Like the git +# exemption the sanction is a call POSITION, not the whole file: verify.py could +# perfectly well grow another helper that calls the composition with some other +# cwd, and that is the thing being refused. +# +# Deliberately NOT widened to ``run_verify_commands``: that has three legitimate +# callers on two roots (the dev side in ``Workspace.root``, this helper in +# ``repo_root``, and ``cli._reverify``, handed ``repo_root`` by its callers), so it +# is not a chokepoint of this shape and a guard over it would be an allowlist that +# grows with every caller until it means nothing. Said here rather than left +# implied, because "why is only one of the two functions guarded" is the first +# question the next reader will have. +VERIFY_COMMANDS_CHOKEPOINT = {"verify.py"} +VERIFY_COMMANDS_SANCTIONED_CALLER = "_verify_review_commands" + +# The other half of the same invariant. Fencing the WRAPPER alone leaves the bug +# fully reachable: a fourth gate can spell the composition by hand — +# `verify_command_results_outcome(run_verify_commands(policy, paths.project), +# paths.project)` — and reintroduce #695 with the wrapper guard silent. That is +# also the likely way one gets written, because `Engine._verify_commands_with_results` +# already spells exactly that composition inline, so it is the shape a new gate +# would be copied from. +# +# Two sanctioned positions, keyed file -> the ONE enclosing function, because the +# two are different functions in different modules: `verify_commands_outcome` is +# the review/CLI composition point, `_verify_commands_with_results` the dev side's +# (which must keep its own spelling — it retains the results for the hook payload +# between the two calls, which is the whole reason it does not use the wrapper). +# +# Deliberately NOT extended to `run_verify_commands`: the spec forbids it, and its +# three callers legitimately run on two different roots, so a guard there would be +# an allowlist that grows with every caller until it means nothing. +VERIFY_CLASSIFY_CHOKEPOINT = { + "verify.py": "verify_commands_outcome", + "engine.py": "_verify_commands_with_results", +} + # Files where resolving a raw `task.spec_file` / `task.dispatched_spec_file` with a # bare `Path(...)` is CORRECT, because the reader runs inside the tree the value was # recorded against. `runs.py` is the chokepoint itself; `engine.py`, `verify.py` and @@ -405,6 +447,93 @@ def _env_read_key(node: ast.expr | None, aliases: dict[str, str]) -> str | None: return None +def _called_name(func: ast.expr) -> str | None: + """The trailing name of a call's callee, or None when the callee is neither a + plain name nor an attribute access. + + Both spellings resolve to the same name, because both reach the same + function: the bare name (inside the defining module, and after a + ``from .verify import`` anywhere else) and the attribute form + (``verify.verify_commands_outcome``, which is how every module outside core + reaches it). The module qualifier is deliberately ignored — a bypass written + as ``v.verify_commands_outcome`` under an aliased import is the same bypass, + and the cost of the looser match is a false positive, which is a review + prompt rather than a miss.""" + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + +def _verify_call_aliases(tree: ast.AST, target: str) -> frozenset[str]: + """Bare names statically bound to one guarded verify-call target. + + The call-site spelling alone misses the ordinary Python aliases a future + caller may use: rename-on-import and a local assignment from either the + module attribute or an already-known alias. Resolve those cheap, explicit + bindings while keeping this a single-file AST scan; computed names remain a + review-time concern because proving their value requires executing code. + """ + aliases = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + if alias.name == target + } + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + value = node.value + if value is None: + continue + value_name = _called_name(value) + if value_name != target and value_name not in aliases: + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for assignment_target in targets: + if isinstance(assignment_target, ast.Name) and assignment_target.id not in aliases: + aliases.add(assignment_target.id) + changed = True + return frozenset(aliases) + + +def _names_guarded_verify_call( + func: ast.expr, target: str, aliases: frozenset[str] = frozenset() +) -> bool: + name = _called_name(func) + if name == target or name in aliases: + return True + return ( + isinstance(func, ast.Call) + and isinstance(func.func, ast.Name) + and func.func.id == "getattr" + and len(func.args) >= 2 + and isinstance(func.args[1], ast.Constant) + and func.args[1].value == target + ) + + +def _names_verify_commands_outcome(func: ast.expr, aliases: frozenset[str] = frozenset()) -> bool: + """Whether a call's callee names ``verify_commands_outcome``. + + Direct names, attributes, rename-on-import, assignment aliases, and literal + ``getattr`` calls are covered. A computed target name is deliberately beyond + this static tripwire and remains a review-time concern.""" + return _names_guarded_verify_call(func, "verify_commands_outcome", aliases) + + +def _names_verify_classifier(func: ast.expr, aliases: frozenset[str] = frozenset()) -> bool: + """Whether a call's callee names ``verify_command_results_outcome`` — the + classifier half of the composition. Same reach and computed-name bound as + :func:`_names_verify_commands_outcome`.""" + return _names_guarded_verify_call(func, "verify_command_results_outcome", aliases) + + def _scan(): """Single pass over the tree → list of (kind, rel, lineno, line_text).""" findings = [] @@ -428,6 +557,8 @@ def _scan_source(src: str, rel: str): tree = ast.parse(src, filename=rel) docs = _docstring_node_ids(tree) env_aliases = _env_name_aliases(tree) + verify_command_aliases = _verify_call_aliases(tree, "verify_commands_outcome") + verify_classifier_aliases = _verify_call_aliases(tree, "verify_command_results_outcome") # First positional args of `_run_git(...)` calls — the one position where a # git argv literal feeds the chokepoint instead of bypassing it. Collected up @@ -444,6 +575,40 @@ def _scan_source(src: str, rel: str): } git_heads, git_commands = _git_name_bindings(tree) + # `verify_commands_outcome(...)` calls that sit inside a + # `_verify_review_commands` definition — the review gates' single sanctioned + # composition point. Collected up front, exactly like `run_git_argvs` above, + # so the walk can tag each finding with the position bit instead of trying to + # rediscover its enclosing function from a bare node. + # + # Nested defs are covered because `ast.walk` descends into the function body, + # and the enclosing-name check is paired with a FILE check in the offender + # filter — a `_verify_review_commands` grown in some other module must not + # sanction itself by name alone. + sanctioned_verify_command_calls = { + id(call) + for fn in ast.walk(tree) + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + and fn.name == VERIFY_COMMANDS_SANCTIONED_CALLER + for call in ast.walk(fn) + if isinstance(call, ast.Call) + and _names_verify_commands_outcome(call.func, verify_command_aliases) + } + + # The same collection for the classifier half. `.get(rel)` is None in every + # file that has no sanctioned position, and no function is named None, so the + # set comes out empty there — which is what makes the file half of the filter + # bite without a second membership test here. + sanctioned_classify_calls = { + id(call) + for fn in ast.walk(tree) + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + and fn.name == VERIFY_CLASSIFY_CHOKEPOINT.get(rel) + for call in ast.walk(fn) + if isinstance(call, ast.Call) + and _names_verify_classifier(call.func, verify_classifier_aliases) + } + def line_at(lineno: int) -> str: return lines[lineno - 1] if 1 <= lineno <= len(lines) else "" @@ -503,6 +668,42 @@ def line_at(lineno: int) -> str: ): findings.append(("git", rel, node.lineno, line_at(node.lineno), False)) + # A call to `verify_commands_outcome` — the run+classify composition the + # three review gates reach through `_verify_review_commands`. Each finding + # carries one extra field: whether it sits inside that helper, the only + # position the exemption covers. Prose naming the function (its own + # docstrings, `cli._reverify`'s "Deliberately NOT ...") is a Constant, not + # a Call, so it never reaches here. + if isinstance(node, ast.Call) and _names_verify_commands_outcome( + node.func, verify_command_aliases + ): + findings.append( + ( + "verifycmd", + rel, + node.lineno, + line_at(node.lineno), + id(node) in sanctioned_verify_command_calls, + ) + ) + + # ... and the classifier half, so a gate that skips the wrapper and + # composes run+classify by hand is caught by the same pass. Same shape: + # the finding carries whether it sits in this file's one sanctioned + # enclosing function. + if isinstance(node, ast.Call) and _names_verify_classifier( + node.func, verify_classifier_aliases + ): + findings.append( + ( + "verifyclassify", + rel, + node.lineno, + line_at(node.lineno), + id(node) in sanctioned_classify_calls, + ) + ) + # bare POSIX path string literal (skip docstrings) if ( isinstance(node, ast.Constant) @@ -686,6 +887,82 @@ def test_no_git_invocation_outside_verify(): ) +def _verify_command_offenders(findings) -> list[tuple[str, int, str]]: + """The review-gate chokepoint as a filter: a ``verify_commands_outcome`` call + is sanctioned only in a ``VERIFY_COMMANDS_CHOKEPOINT`` file AND only from + inside ``_verify_review_commands`` — the file alone is not enough, for the + same reason the git exemption is not file-wide.""" + return [ + (rel, ln, txt) + for _, rel, ln, txt, inside_helper in findings + if not (rel in VERIFY_COMMANDS_CHOKEPOINT and inside_helper) + ] + + +def test_verify_commands_outcome_called_only_from_the_review_chokepoint(): + """Only ``verify.py``'s ``_verify_review_commands`` may call + ``verify_commands_outcome`` — every review gate goes through that helper. + + The helper is what pins the review legs' command cwd to ``paths.repo_root`` + (#695). Three gates previously each spelled the composition themselves against + ``paths.project``; folding them onto one helper fixed all three at once, but + nothing stopped a fourth gate from spelling it out again and reintroducing the + bug in exactly the same shape — which is what this refuses. + + The bound is narrow on purpose and stated rather than implied: it does NOT + extend to ``run_verify_commands``, whose three callers legitimately run on two + different roots. See ``VERIFY_COMMANDS_CHOKEPOINT``.""" + offenders = _verify_command_offenders(_of("verifycmd")) + assert not offenders, ( + "verify_commands_outcome called outside verify.py's _verify_review_commands " + "— route the review gate through that helper so its command cwd stays " + "repo_root (#695):\n" + + "\n".join(f" {rel}:{ln}: {txt.strip()}" for rel, ln, txt in offenders) + ) + + +def _verify_classify_offenders(findings) -> list[tuple[str, int, str]]: + """The classifier half's invariant as a filter: a + ``verify_command_results_outcome`` call is sanctioned only in a + ``VERIFY_CLASSIFY_CHOKEPOINT`` file AND only inside that file's one listed + enclosing function.""" + return [ + (rel, ln, txt) + for _, rel, ln, txt, inside_helper in findings + if not (rel in VERIFY_CLASSIFY_CHOKEPOINT and inside_helper) + ] + + +def test_verify_command_results_outcome_called_only_from_its_two_compositions(): + """``verify_command_results_outcome`` is callable only from + ``verify.verify_commands_outcome`` and ``Engine._verify_commands_with_results``. + + The sibling guard above fences the WRAPPER, which on its own leaves #695 fully + reachable: a fourth review gate that skips `verify_commands_outcome` and writes + ``verify_command_results_outcome(run_verify_commands(policy, paths.project), + paths.project)`` picks its own root, twice, with that guard silent. And it is + the shape such a gate would most likely take, since the dev side already spells + that composition inline for its own (good) reason — it keeps the results + between the two calls to build the hook payload. + + Two sanctioned positions rather than one because the two compositions are + genuinely different functions in different modules; the pair is listed in + ``VERIFY_CLASSIFY_CHOKEPOINT`` and both halves — file and enclosing function — + are required. + + Still NOT extended to ``run_verify_commands``: the spec forbids it, and its + three callers legitimately run on two roots.""" + offenders = _verify_classify_offenders(_of("verifyclassify")) + assert not offenders, ( + "verify_command_results_outcome called outside its two sanctioned " + "compositions (verify.verify_commands_outcome, " + "Engine._verify_commands_with_results) — a review gate must reach the " + "commands through verify._verify_review_commands so its cwd stays " + "repo_root (#695):\n" + + "\n".join(f" {rel}:{ln}: {txt.strip()}" for rel, ln, txt in offenders) + ) + + def test_spec_path_resolved_only_through_the_anchor(): """A persisted `spec_file` is re-anchored through ``runs.task_spec_path``, never resolved with a bare ``Path(...)``, outside the tree-local consumers. @@ -1177,6 +1454,318 @@ def test_git_argv_exemption_is_scoped_to_the_chokepoint_call(label, rel, source, ) +# The review-gate chokepoint's scoping, as rows: `(rel, source, is_offender)`. +# The repo-wide assertion above cannot distinguish a working detector from a +# broken one — today's tree has exactly one call, inside the sanctioned helper, so +# "nothing is flagged" is green both when the invariant holds and when the scan +# stopped seeing calls at all. Only synthetic sources separate the two, and only +# they can carry the bypass that does not exist yet. +VERIFY_COMMANDS_SCOPE_CASES = [ + # The bug this refuses, in the shape it would actually take: a fourth review + # gate composing run+classify itself, against whichever root it picked (#695). + ( + "fourth-gate-direct-call", + "verify.py", + "def verify_review_epic(task, paths, policy):\n" + " return verify_commands_outcome(policy, paths.project)\n", + True, + ), + # Same bypass reached through the module attribute, from outside core — the + # spelling any non-verify caller would use. + ( + "engine-attribute-call", + "engine.py", + "from . import verify\n" + "def _verify_review(self, task):\n" + " return verify.verify_commands_outcome(self.policy, self.workspace.root)\n", + True, + ), + # Being verify.py is not enough on its own: a second helper in the same file + # calling the composition with some other cwd is exactly what the position + # bit exists to catch, and a file-wide exemption would wave it through. + ( + "verify-other-helper", + "verify.py", + "def _verify_something_else(policy, paths):\n" + " return verify_commands_outcome(policy, paths.project)\n", + True, + ), + # The name does not travel: a `_verify_review_commands` grown in another + # module cannot sanction itself, which is why the filter pairs the enclosing + # function with the FILE. + ( + "helper-name-in-another-file", + "sweep.py", + "def _verify_review_commands(policy, paths):\n" + " return verify_commands_outcome(policy, paths.repo_root)\n", + True, + ), + # …while the real sanctioned site stays silent. + ( + "sanctioned-helper", + "verify.py", + "def _verify_review_commands(policy, paths, *, on_results=None):\n" + " return verify_commands_outcome(policy, paths.repo_root, on_results=on_results)\n", + False, + ), + ( + "rename-on-import", + "engine.py", + "from .verify import verify_commands_outcome as classify\n" + "def _verify_review(self, task):\n" + " return classify(self.policy, self.workspace.root)\n", + True, + ), + ( + "assignment-alias", + "engine.py", + "from . import verify\n" + "classify = verify.verify_commands_outcome\n" + "def _verify_review(self, task):\n" + " return classify(self.policy, self.workspace.root)\n", + True, + ), + ( + "annotated-assignment-alias", + "engine.py", + "from . import verify\n" + "classify: object = verify.verify_commands_outcome\n" + "def _verify_review(self, task):\n" + " return classify(self.policy, self.workspace.root)\n", + True, + ), + ( + "literal-getattr", + "engine.py", + "from . import verify\n" + "def _verify_review(self, task):\n" + " return getattr(verify, 'verify_commands_outcome')(self.policy, self.workspace.root)\n", + True, + ), + ( + "sanctioned-assignment-alias", + "verify.py", + "classify = verify_commands_outcome\n" + "def _verify_review_commands(policy, paths, *, on_results=None):\n" + " return classify(policy, paths.repo_root, on_results=on_results)\n", + False, + ), + # A nested def inside the helper is still inside it — `ast.walk` descends, and + # a closure that forwards the composition is not a second call site. + ( + "nested-inside-helper", + "verify.py", + "def _verify_review_commands(policy, paths, *, on_results=None):\n" + " def run():\n" + " return verify_commands_outcome(policy, paths.repo_root, on_results=on_results)\n" + " return run()\n", + False, + ), + # The bound this guard deliberately does NOT claim: `run_verify_commands` has + # three legitimate callers on two roots, so calling it directly is not an + # offence here. Widening to it would turn the allowlist into a caller list. + ( + "run_verify_commands-untouched", + "cli.py", + "for result in verify.run_verify_commands(pol, cwd):\n pass\n", + False, + ), + # Prose naming the function is a Constant, not a Call — `cli._reverify`'s + # "Deliberately NOT `verify_commands_outcome`" docstring must stay silent, or + # the first fix would be to delete the sentence that explains the design. + ( + "prose-in-docstring", + "cli.py", + 'def _reverify(project, cwd):\n """Deliberately NOT verify_commands_outcome."""\n', + False, + ), +] + + +# The classifier half's scoping, as rows: `(rel, source, is_offender)`. Same +# reason the wrapper's matrix is executable — today's tree has exactly two calls, +# both sanctioned, so the repo-wide assertion is green whether the invariant holds +# or the scan stopped seeing calls. +VERIFY_CLASSIFY_SCOPE_CASES = [ + # THE hole the wrapper guard leaves open, in the shape it would actually be + # written: a fourth gate composing run+classify by hand and picking its own + # root, twice. Note `run_verify_commands` inside it is deliberately NOT an + # offence — only the classifier call is flagged. + ( + "hand-composed-fourth-gate", + "verify.py", + "def verify_review_epic(task, paths, policy):\n" + " return verify_command_results_outcome(\n" + " run_verify_commands(policy, paths.project), paths.project\n" + " )\n", + True, + ), + # The same bypass from outside core, through the module attribute. + ( + "sweep-attribute-call", + "sweep.py", + "from . import verify\n" + "def _verify_review(self, task):\n" + " results = verify.run_verify_commands(self.policy, self.workspace.paths.project)\n" + " return verify.verify_command_results_outcome(results, self.workspace.paths.project)\n", + True, + ), + # Being verify.py is not enough: a second helper there calling the classifier + # is exactly what the position bit exists to catch. + ( + "verify-other-helper", + "verify.py", + "def _classify_somewhere_else(results, cwd):\n" + " return verify_command_results_outcome(results, cwd)\n", + True, + ), + # The two sanctioned positions stay silent — and they are FILE-SPECIFIC ... + ( + "sanctioned-wrapper-in-verify", + "verify.py", + "def verify_commands_outcome(policy, cwd, *, on_results=None):\n" + " results = run_verify_commands(policy, cwd)\n" + " return verify_command_results_outcome(results, cwd)\n", + False, + ), + ( + "rename-on-import", + "sweep.py", + "from .verify import verify_command_results_outcome as classify\n" + "def _verify_review(self, task):\n" + " return classify(results, self.workspace.root)\n", + True, + ), + ( + "assignment-alias", + "sweep.py", + "from . import verify\n" + "classify = verify.verify_command_results_outcome\n" + "def _verify_review(self, task):\n" + " return classify(results, self.workspace.root)\n", + True, + ), + ( + "annotated-assignment-alias", + "sweep.py", + "from . import verify\n" + "classify: object = verify.verify_command_results_outcome\n" + "def _verify_review(self, task):\n" + " return classify(results, self.workspace.root)\n", + True, + ), + ( + "sanctioned-dev-side-in-engine", + "engine.py", + "def _verify_commands_with_results(self, task, verification_stage):\n" + " results = tuple(verify.run_verify_commands(self.policy, self.workspace.root))\n" + " return verify.verify_command_results_outcome(list(results), self.workspace.root)\n", + False, + ), + # ... which is the half a NAME-ONLY collection would lose: each sanctioned + # function name, in the OTHER file, is an offender. Note where that half is + # actually enforced — `sanctioned_classify_calls` keys the enclosing name off + # `VERIFY_CLASSIFY_CHOKEPOINT.get(rel)`, so a call in the wrong file never + # enters the set at all. The `rel in VERIFY_CLASSIFY_CHOKEPOINT` test in + # `_verify_classify_offenders` is therefore belt-and-braces, kept for symmetry + # with the wrapper filter (where it IS load-bearing, since that sanctioned + # caller is a bare name). ABLATION for these two rows: relax the collection to + # `fn.name in set(VERIFY_CLASSIFY_CHOKEPOINT.values())` — dropping the filter's + # redundant file test does NOT redden them, and mistaking one for the other + # would leave the real keying untested. + ( + "dev-side-name-in-verify", + "verify.py", + "def _verify_commands_with_results(self, task, verification_stage):\n" + " return verify_command_results_outcome(results, self.workspace.root)\n", + True, + ), + ( + "wrapper-name-in-engine", + "engine.py", + "def verify_commands_outcome(policy, cwd):\n" + " return verify_command_results_outcome(run_verify_commands(policy, cwd), cwd)\n", + True, + ), + # A nested def inside a sanctioned function is still inside it. + ( + "nested-inside-sanctioned", + "verify.py", + "def verify_commands_outcome(policy, cwd, *, on_results=None):\n" + " def classify(results):\n" + " return verify_command_results_outcome(results, cwd)\n" + " return classify(run_verify_commands(policy, cwd))\n", + False, + ), + # Prose is a Constant, not a Call: the docstrings that explain this very + # split must not be the thing that trips it. + ( + "prose-in-docstring", + "verify.py", + "def _verify_review_commands(policy, paths):\n" + ' """Kept separate from verify_command_results_outcome."""\n', + False, + ), +] + + +@pytest.mark.parametrize( + ("label", "rel", "source", "is_offender"), + VERIFY_CLASSIFY_SCOPE_CASES, + ids=[c[0] for c in VERIFY_CLASSIFY_SCOPE_CASES], +) +def test_verify_classify_detector_is_scoped_to_its_two_compositions( + label, rel, source, is_offender +): + """Both halves of the classifier detector, driven through `_scan_source` — the + same code path the real scan uses — so "flags the hand-composed gate" and + "stays silent on the two real compositions" are asserted rather than inferred + from an empty repo-wide result.""" + findings = [f for f in _scan_source(source, rel) if f[0] == "verifyclassify"] + offenders = _verify_classify_offenders(findings) + assert bool(offenders) is is_offender, ( + f"a verify_command_results_outcome call in {rel} here should " + f"{'be refused' if is_offender else 'be allowed'}:\n{source}" + ) + + +def test_verify_classify_detector_leaves_run_verify_commands_alone(): + """The bound this guard does NOT claim, asserted so it cannot drift shut. + + `run_verify_commands` has three legitimate callers on two different roots (the + dev side in `Workspace.root`, `_verify_review_commands` in `repo_root`, and + `cli._reverify`), so it is not a chokepoint of this shape and the spec forbids + widening to it. The hand-composed probe above contains such a call precisely so + a future widening reddens here instead of silently turning the allowlist into a + caller list.""" + source = ( + "def verify_review_epic(task, paths, policy):\n" + " return verify_command_results_outcome(\n" + " run_verify_commands(policy, paths.project), paths.project\n" + " )\n" + ) + findings = _scan_source(source, "verify.py") + # exactly ONE finding from that snippet, and it is the classifier call + assert [f[0] for f in findings if f[0].startswith("verify")] == ["verifyclassify"] + + +@pytest.mark.parametrize( + ("label", "rel", "source", "is_offender"), + VERIFY_COMMANDS_SCOPE_CASES, + ids=[c[0] for c in VERIFY_COMMANDS_SCOPE_CASES], +) +def test_verify_commands_detector_is_scoped_to_the_review_helper(label, rel, source, is_offender): + """Both halves of the detector, driven through `_scan_source` — the same code + path the real scan uses — so "flags the bad shape" and "stays silent on the + good one" are asserted rather than inferred from an empty repo-wide result.""" + findings = [f for f in _scan_source(source, rel) if f[0] == "verifycmd"] + offenders = _verify_command_offenders(findings) + assert bool(offenders) is is_offender, ( + f"a verify_commands_outcome call in {rel} here should " + f"{'be refused' if is_offender else 'be allowed'}:\n{source}" + ) + + # The allowlist's scoping, as rows: `(rel, key, is_offender)`. Same reason the # access-form matrix is executable — a file-scoped exemption and a family-scoped one # are indistinguishable on today's tree, where every read already sits inside its diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 94720d20..664a329d 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -8,7 +8,14 @@ import pytest import yaml -from conftest import attach_profile, git, install_build_auto_skill, write_gated_ledger, write_spec +from conftest import ( + _OK, + attach_profile, + git, + install_build_auto_skill, + write_gated_ledger, + write_spec, +) from bmad_loop import stories from bmad_loop.adapters.base import SessionResult @@ -40,6 +47,7 @@ Policy, ReviewPolicy, ScmPolicy, + VerifyPolicy, ) from bmad_loop.runs import STOP_REQUEST_FILE, graceful_stop_requested from bmad_loop.stories_engine import StoriesEngine @@ -246,6 +254,34 @@ def test_two_story_happy_path(project): assert engine.state.tasks["2"].phase == Phase.DONE +def test_story_review_gate_journals_its_verify_commands(project): + """`StoriesEngine._verify_review` threads the base engine's review sink, so a + stories-mode review-leg verifier pass lands the same `verify-command-result` + records the base engine's does. + + Its own row rather than a claim carried by `test_engine.py`: the sink is + passed at each override, so dropping it here would leave every stories run + silently unrecorded while the base engine's tests stayed green — the shape the + #695 root bug already took across these same three gates. + + Ablation: remove `on_results=` from `StoriesEngine._verify_review` and the + record assertion fails at zero entries.""" + setup_stories(project, [entry("1")]) + engine, _ = make_engine( + project, [], policy=_stories_policy(verify=VerifyPolicy(commands=(_OK,))) + ) + sp = story_spec(project, "1") + write_spec(sp, "done", rev_parse_head(project.project)) + task = StoryTask(story_key="1", epic=1) + task.spec_file = str(sp) + + assert engine._verify_review(task).ok + + (record,) = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] + assert record["verification_stage"] == "review" + assert record["command"] == _OK and record["story_key"] == "1" + + def test_run_state_pins_stories_mode(project): setup_stories(project, [entry("1")]) engine, _ = make_engine(project, [stories_dev_effect()]) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index eadb6ee8..3085a0be 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -8,6 +8,7 @@ import pytest from conftest import ( + _OK, _file_exists_cmd, attach_profile, bundle_dev_effect, @@ -2121,6 +2122,38 @@ def test_bundle_pre_gate_state_sync_is_a_noop(project): assert task.board_advance_intended is None +def test_bundle_review_gate_journals_its_verify_commands(project): + """`SweepEngine._verify_review` threads the base engine's review sink, so a + bundle's review-leg verifier pass lands the same `verify-command-result` + records a story's does. + + Its own row rather than a claim carried by `test_engine.py`: the sink is + passed at each override, so dropping it here would leave every sweep run + silently unrecorded while the base engine's tests stayed green — which is the + shape the #695 root bug already took across these same three gates. + + Ablation: remove `on_results=` from `SweepEngine._verify_review` and the + record assertion fails at zero entries.""" + write_ledger(project, {"DW-1": "done 2026-06-11"}) + pol = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + verify=VerifyPolicy(commands=(_OK,)), + ) + engine, _ = make_sweep(project, [], policy=pol) + spec = project.implementation_artifacts / "spec-dw-fix.md" + spec.parent.mkdir(parents=True, exist_ok=True) + write_spec(spec, "done", git(project.project, "rev-parse", "HEAD")) + task = StoryTask(story_key="dw-fix", epic=0, dw_ids=["DW-1"]) + task.spec_file = str(spec) + + assert engine._verify_review(task).ok + + (entry,) = [e for e in engine.journal.entries() if e["kind"] == "verify-command-result"] + assert entry["verification_stage"] == "review" + assert entry["command"] == _OK and entry["story_key"] == "dw-fix" + + def test_bundle_ledger_close_skips_on_unreadable_spec(project, monkeypatch): """The bundle counterpart of the sprint-board sync: an unreadable bundle spec must not close any dw id (the ledger write is a repair — it must never fire off diff --git a/tests/test_verify.py b/tests/test_verify.py index 9c38e210..cb5ccd15 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,5 +1,6 @@ import dataclasses import hashlib +import inspect import io import json import os @@ -2079,6 +2080,257 @@ def test_verify_commands_rc1_stays_fixable_retry(tmp_path): assert not out.ok and out.fixable and out.retryable and not out.env_fault +# ---- unusable `cwd`: the spawn fault has no return code (DW-2) ---------------- + +_unsearchable_dir_skips = ( + pytest.mark.skipif(os.name == "nt", reason="Windows chmod only toggles the read-only flag"), + pytest.mark.skipif( + os.geteuid() == 0 if hasattr(os, "geteuid") else False, + reason="root searches a 000 directory", + ), +) + + +def _unusable_cwd(request, tmp_path, shape: str) -> Path: + """One of the ``cwd`` shapes ``subprocess.run`` refuses before the target + program starts — the reachable OSError subclasses of the spawn leg. + + Each is a real filesystem state, never a monkeypatched raise: what is being + pinned is that the OS's own refusal is caught, and a synthetic + ``FileNotFoundError`` would pass just as well against a handler that only + named that one class (the narrowing this change exists to avoid). + + The 000 directory's mode is restored by a finalizer — ``tmp_path``'s own + cleanup cannot remove a directory it may not search, and the leftover turns + into an rm_rf warning on every later session sharing the tmp root.""" + if shape == "missing": + return tmp_path / "nowhere" # FileNotFoundError + if shape == "file": + target = tmp_path / "a-file" + target.write_text("x\n", encoding="utf-8") + return target # NotADirectoryError + if shape == "under-file": + target = tmp_path / "a-file-2" + target.write_text("x\n", encoding="utf-8") + return target / "beneath" # NotADirectoryError, one level down + unsearchable = tmp_path / "locked" + unsearchable.mkdir() + request.addfinalizer(lambda: unsearchable.chmod(0o700)) + unsearchable.chmod(0o000) + return unsearchable # PermissionError + + +@pytest.mark.parametrize( + "shape", + [ + "missing", + "file", + "under-file", + pytest.param("unsearchable", marks=_unsearchable_dir_skips), + ], +) +def test_unusable_cwd_becomes_a_result_instead_of_an_exception(request, tmp_path, shape): + """A `cwd` no command can run in yields a RESULT, not a raised OSError. + + Before this, `run_verify_commands`' only handler was `except + subprocess.TimeoutExpired`, so all three shapes escaped every guard in the + engine's verification path and ended the run as a crash (`crash.txt` + + `state.crashed`) — over a fact that is a textbook environment problem. + + All three shapes are driven, not just the first: `except FileNotFoundError` + would be a perfectly plausible fix and would leave two of them uncaught, so a + single-shape row could not tell the narrow handler from the right one. + + Ablation: remove the `except OSError` arm and every parametrization fails with + the raw OSError, not with a wrong-message assertion.""" + cwd = _unusable_cwd(request, tmp_path, shape) + policy = Policy(verify=VerifyPolicy(commands=(_OK,))) + + (result,) = verify.run_verify_commands(policy, cwd) + + assert result.command == _OK + assert result.spawn_error is not None + assert str(cwd) in result.spawn_error # the failing cwd, which is the finding + assert result.returncode == verify.SPAWN_FAULT_RC + assert result.returncode != -1 # NOT the timeout sentinel: no child ran at all + assert result.output_tail # names the exception, for the human reading it + + +def test_unusable_cwd_yields_one_result_per_command(tmp_path): + """The documented "one CommandResult apiece" holds on the spawn leg too: the + loop appends and CONTINUES rather than aborting on the first refusal. + + A caller zipping results against `policy.verify.commands` — or merely counting + them — must not silently lose the tail of the list, and the engine journals one + record per result, so a short list is a short audit trail. + + Ablation: `break` (or `raise`) instead of `continue` in the new arm and the + length assertion fails at 1.""" + commands = ("first-check", "second-check", "third-check") + policy = Policy(verify=VerifyPolicy(commands=commands)) + + results = verify.run_verify_commands(policy, tmp_path / "nowhere") + + assert [r.command for r in results] == list(commands) + assert all(r.spawn_error is not None for r in results) + outcome = verify.verify_command_results_outcome(results, tmp_path / "nowhere") + assert "first-check" in outcome.reason + assert "second-check" not in outcome.reason and "third-check" not in outcome.reason + + +def test_a_spawn_fault_unrelated_to_the_cwd_translates_too(tmp_path, monkeypatch): + """The handler is `except OSError`, not three named cwd classes — and the + record must not describe every one of them as a directory problem. + + A missing `/bin/sh`, EMFILE from a descriptor-exhausted host, ENOMEM from a + fork that could not allocate: all reach the same arm, none is a fact about + the working directory. The message therefore states what was OBSERVED (the + child was not started) and names the cwd as context only, leaving the wrapped + exception to say why. + + Injected, because a real ENOMEM cannot be provoked from a test without + breaking the host running it. What that costs is honest: this row grades the + message and the classification, while the sibling rows above drive the OS's + own refusals for real. + + Ablation: restore a message hardcoding the cwd as the cause (`could not run + in {cwd}: ...`) and the "does not blame the directory" assertion fails.""" + real_run = subprocess.run + + def out_of_memory(*args, **kwargs): + if kwargs.get("shell"): + raise OSError(12, "Cannot allocate memory") + return real_run(*args, **kwargs) + + monkeypatch.setattr(subprocess, "run", out_of_memory) + policy = Policy(verify=VerifyPolicy(commands=(_OK,))) + + (result,) = verify.run_verify_commands(policy, tmp_path) + + assert result.spawn_error is not None + assert "Cannot allocate memory" in result.spawn_error # the real cause survives + # the cwd is context, not a verdict: it appears, but not as the diagnosis + assert str(tmp_path) in result.spawn_error + assert "could not run in" not in result.spawn_error + + out = verify.verify_command_results_outcome([result], tmp_path) + assert not out.ok and out.env_fault and not out.retryable + + +def test_unusable_cwd_escalates_as_an_environment_fault(tmp_path): + """Classified, the spawn fault escalates and PAUSES rather than retrying. + + Same channel as rc 126/127 and for the same reason: an unusable `cwd` is + deterministic for a given tree, identical for every story, and unfixable by a + repair session — which is what `env_fault=True` means. A `retryable` outcome + would burn the attempt budget re-running the same refusal. + + The explanatory clause is asserted from BOTH directions. The rc-based leg's + fixed "command not found / not executable" is a claim about the command, and + on this leg no command was ever looked for — so it must be gone, not merely + joined by better text.""" + cwd = tmp_path / "nowhere" + policy = Policy(verify=VerifyPolicy(commands=(_OK,))) + + out = verify.verify_commands_outcome(policy, cwd) + + assert not out.ok and out.env_fault + assert not out.retryable and not out.fixable + assert "verify environment fault" in out.reason + assert str(cwd) in out.reason + assert "could not be started" in out.reason + assert "command not found / not executable" not in out.reason + # The exception already rides `spawn_error`, which is interpolated as the + # environment-fault reason. Repeating `output_tail` would print it twice. + assert out.reason.count("FileNotFoundError") == 1 + + +def test_rc_env_fault_keeps_its_own_explanatory_clause(tmp_path): + """The complement, so the branch is pinned from both sides: rc 127 still says + "command not found / not executable" — that leg IS a claim about the command, + and branching must not have quietly rewritten it for everyone.""" + policy = Policy(verify=VerifyPolicy(commands=("exit 127",))) + + out = verify.verify_commands_outcome(policy, tmp_path) + + assert not out.ok and out.env_fault + assert "command not found / not executable" in out.reason + assert "could not be started" not in out.reason + + +def test_spawn_fault_rc_cannot_collide_with_a_real_return_code(): + """The sentinel sits outside every value a child that RAN can report. + + On POSIX `subprocess` reports `-N` for a child killed by signal N, so the + small negatives are all real return codes: `-2` is SIGINT, `-9` SIGKILL. A + sentinel in that range would make "the verify command was killed" and "the + verify command never started" the same observation to anything keying on the + rc — and the journal record invites exactly that, since it ships the rc to + out-of-process readers. + + Asserted against `signal.Signals` rather than a hardcoded ceiling, so a + platform with higher real-time signals grades this honestly instead of + against this test's idea of the range. + + Ablation: set `SPAWN_FAULT_RC = -2` — the value this shipped with first — and + the collision assertion fails naming SIGINT.""" + import signal as signal_mod + + assert verify.SPAWN_FAULT_RC < 0 # the win32 early-out and the failure arm + assert verify.SPAWN_FAULT_RC != -1 # not the timeout leg's sentinel + collisions = [s for s in signal_mod.Signals if -s.value == verify.SPAWN_FAULT_RC] + assert not collisions, f"SPAWN_FAULT_RC is a signal death: {collisions}" + # nor an ordinary exit status, which is what the positive range holds + assert verify.SPAWN_FAULT_RC not in verify.ENV_FAULT_RCS + + +def test_spawn_fault_is_answered_before_any_rc_or_win32_probe(tmp_path): + """`env_fault_reason` reads `spawn_error` FIRST, ahead of the rc arms and the + win32 token probe. + + Not a style preference. The probe resolves a command's leading token as `cwd / + token` to tell "tool missing" from "command failed" — and on this leg `cwd` is + exactly what could not be used, so it has nothing true to say about a directory + the child never entered. Driven through `env_fault_reason` directly so the row + holds on POSIX, where the probe is not reached at all. + + The result carries `SPAWN_FAULT_RC`, which is in neither `ENV_FAULT_RCS` nor + `{0}` — so if the ordering ever regressed, the rc arms could not answer for it + and the reason would come back None on POSIX.""" + result = verify.CommandResult( + "pytest -q", verify.SPAWN_FAULT_RC, "NotADirectoryError: ...", spawn_error="cwd is a file" + ) + + assert verify.env_fault_reason(result, tmp_path) == "cwd is a file" + # and a result from a child that really ran is untouched by the new arm + assert verify.env_fault_reason(verify.CommandResult("pytest -q", 1, "F"), tmp_path) is None + + +def test_timeout_stays_an_ordinary_fixable_retry_with_no_spawn_error(tmp_path, monkeypatch): + """The two "no exit status" shapes must not collapse into one. + + A timed-out command RAN — it was found, it was executable, it hung — so it + stays a fixable retry a repair session can act on. Only a child that never + started is an environment fault. Sharing a sentinel between them (or letting + the new arm swallow the timeout) would pause runs over slow test suites. + + Ablation: set `SPAWN_FAULT_RC = -1` and the sentinel assertion below stops + discriminating; set `spawn_error` on the timeout leg and the classification + flips to `env_fault`.""" + monkeypatch.setattr(verify, "COMMAND_TIMEOUT_S", 0.5) + sleeper = tmp_path / "sleeper.py" + sleeper.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") + policy = Policy(verify=VerifyPolicy(commands=(f'"{sys.executable}" "{sleeper}"',))) + + (result,) = verify.run_verify_commands(policy, tmp_path) + + assert result.spawn_error is None + assert result.returncode == -1 and result.output_tail == "timed out" + + out = verify.verify_command_results_outcome([result], tmp_path) + assert not out.ok and out.retryable and out.fixable and not out.env_fault + + def test_verify_commands_bound_a_stream_instead_of_holding_it_whole(tmp_path, monkeypatch): """A chatty command's stream is cut to `MAX_STREAM_MEMORY_BYTES` as it is collected, and what it emitted is recorded rather than lost. @@ -3143,6 +3395,123 @@ def spy_classify(results, cwd): assert seen["classify"] == repo_root +def _break_the_check_before_the_commands(project, task, mode) -> None: + """Fail the LAST gate check that precedes the verify commands, per mode. + + Deliberately the last one rather than the first: every gate opens on the spec + status, so breaking that would prove only that the earliest check + short-circuits and would leave the sprint and ledger checks — the ones that + sit immediately in front of the commands — unexercised in all three modes. + ``review_stories`` has no later check to break, so its spec status is the + honest subject there.""" + if mode == "review": + write_sprint(project, {"1-1-a": "in-progress"}) + elif mode == "review_stories": + write_spec(Path(task.spec_file), "in-progress", task.baseline_commit) + else: + bundle_ledger(project, {"DW-1": "open", "DW-2": "open"}) + + +@pytest.mark.parametrize("mode", ["review", "review_stories", "review_bundle"]) +def test_verify_review_gates_hand_their_results_to_the_sink(project, mode): + """Every review gate offers its verifier results to `on_results` — the seam + the engine journals review-leg `verify-command-result` records through. + + Before this the three gates discarded their `CommandResult`s inside core, so a + review pass left no per-command record and no `verify/` stream files, unlike + the dev side. The results are handed over BEFORE classification (the order + `Engine._verify_commands_with_results` already used), so the record exists + whatever the classifier then decides — including an escalation that ends the + run. + + Both commands are asserted, not just the count: the sink receives the whole + tuple in configured order, which is what makes a record-per-command possible.""" + task, gate = _review_gate_at_done(project, mode) + seen: list[tuple[verify.CommandResult, ...]] = [] + policy = Policy(verify=VerifyPolicy(commands=(_OK, _FAIL))) + + out = gate(task, project, policy, on_results=seen.append) + + assert not out.ok and out.fixable # the classification is unchanged by the sink + (results,) = seen # called exactly once per gate invocation + assert [r.command for r in results] == [_OK, _FAIL] + assert [r.returncode for r in results] == [0, 1] + + +@pytest.mark.parametrize( + "subject", + [ + verify.verify_commands_outcome, + verify._verify_review_commands, + verify.verify_review, + verify.verify_review_stories, + verify.verify_review_bundle, + ], +) +def test_review_result_sinks_are_keyword_only_with_a_default(subject): + """The additive observation seam cannot silently bind a new positional + argument at any layer; every existing call shape remains valid.""" + parameter = inspect.signature(subject).parameters["on_results"] + + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY + assert parameter.default is None + + +@pytest.mark.parametrize("mode", ["review", "review_stories", "review_bundle"]) +def test_verify_review_gates_skip_the_sink_when_they_short_circuit(project, mode): + """A gate that refuses before reaching its commands offers nothing: nothing + ran, so there is nothing to record. + + The distinction is load-bearing for the journal — a record claims a verifier + pass happened — and it is why the sink is threaded through the composition + rather than fired at the top of each gate. + + Ablation: fire the sink at the top of each gate — necessarily with an empty + tuple, since no results exist there yet — and every mode fails here on + `seen == []`.""" + task, gate = _review_gate_at_done(project, mode) + _break_the_check_before_the_commands(project, task, mode) + seen: list[tuple[verify.CommandResult, ...]] = [] + + out = gate(task, project, Policy(verify=VerifyPolicy(commands=(_OK,))), on_results=seen.append) + + assert not out.ok + assert seen == [] + + +@pytest.mark.parametrize("mode", ["review", "review_stories", "review_bundle"]) +def test_verify_review_gates_call_the_sink_with_no_commands_configured(project, mode): + """With `[verify] commands` empty the sink is still called, with `()`. + + "The pass ran and executed nothing" and "no pass ran" are different facts, and + only the second is signalled by never calling the sink — which is precisely + what the short-circuit row above asserts. The engine's sink then records + nothing and allocates no sequence for an empty tuple, so this costs no journal + entry; what it buys is that the two cases stay distinguishable at the seam.""" + task, gate = _review_gate_at_done(project, mode) + seen: list[tuple[verify.CommandResult, ...]] = [] + + assert gate(task, project, Policy(), on_results=seen.append).ok + + assert seen == [()] + + +@pytest.mark.parametrize("mode", ["review", "review_stories", "review_bundle"]) +def test_verify_review_gates_are_unchanged_with_no_sink(project, mode): + """Called from core with no sink — as every pre-existing caller does — the + gates behave exactly as before: `on_results` is keyword-with-default, and its + absence is not a second code path. + + Both verdicts, so the row cannot be satisfied by a gate that started refusing + (or accepting) everything.""" + task, gate = _review_gate_at_done(project, mode) + + assert gate(task, project, Policy(verify=VerifyPolicy(commands=(_OK,)))).ok + + refused = gate(task, project, Policy(verify=VerifyPolicy(commands=(_FAIL,)))) + assert not refused.ok and refused.fixable and "verify command failed" in refused.reason + + @pytest.mark.parametrize("mode", ["review", "review_bundle"]) def test_verify_review_gates_read_artifacts_from_the_project_root(project, tmp_path, mode): """The other half of the split `_verify_review_commands` states, and the half From da3b917d75edf8a76b4e323ccc83e4c025ee9b4a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 04:01:17 -0700 Subject: [PATCH 06/45] sweep dw-root-divergence-test-fixtures: DW-3, DW-9 via bmad-loop --- tests/conftest.py | 184 ++++++++++++++++++++++++- tests/test_cli.py | 193 ++++++++++++++++++++++++-- tests/test_conftest.py | 106 ++++++++++++++- tests/test_engine.py | 220 ++++++++++++++++++++++++++++++ tests/test_verify.py | 300 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 955 insertions(+), 48 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d2a95626..159097b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations +import dataclasses import io import json import shutil @@ -536,6 +537,180 @@ def project(tmp_path: Path, _project_template: Path) -> ProjectPaths: ) +# --------------------------------------- divergent roots (`repo_root` override) +# +# `isolation = "none"` plus a `repo_root:` key in _bmad/bmm/config.yaml is the ONE +# supported shape where `paths.project` and `paths.repo_root` name different +# directories (`bmadconfig.worktree_isolation_conflict` refuses the other, and +# `ProjectPaths.rebased` sets both roots, so worktree isolation never diverges). +# The `project` fixture above sets no override, so `repo_root == project` there and +# nothing built on it can tell the two apart. These helpers centralize the shared +# marker probes, config writer, and nested builder so new coverage does not have to +# re-derive those load-bearing pieces. + +# Two markers, one per root. A row that plants both and probes each pins the cwd +# from BOTH directions — the marker only `repo_root` holds must pass AND the one +# only `project` holds must fail. The positive probe identifies `repo_root`; the +# negative probe rules out the tempting `project` regression explicitly. +MARKER_IN_REPO_ROOT = "only-in-repo-root.txt" +MARKER_IN_PROJECT = "only-in-project.txt" + +# RELATIVE probes on purpose: an absolute path answers the same from any cwd, so +# only a relative one is cwd-sensitive — and `_file_exists_cmd` keeps it honest on +# both host shells rather than a POSIX-only `test` cmd rejects. +REPO_ROOT_MARKER_CMD = _file_exists_cmd(MARKER_IN_REPO_ROOT) +PROJECT_MARKER_CMD = _file_exists_cmd(MARKER_IN_PROJECT) + + +def plant_root_markers(*, repo_root: Path, project: Path) -> None: + """Plant one marker in each root, for a two-direction cwd probe. + + Deliberately plain untracked files: an engine row's baseline snapshot + (`Engine._dev_phase` stamps `baseline_untracked` from `workspace.root`) + absorbs anything planted before the run, so these cannot themselves satisfy + proof-of-work and the row still needs real session work to pass its gate. + + KEYWORD-ONLY, and the two roots must differ. Both parameters are `Path`, so + positionally a swapped call type-checks, runs, and grades the OPPOSITE + direction to the one its row claims; and handed the collapsed `project` + fixture (`repo_root == project`, the default) both markers land in one tree, + where every probe passes from either cwd and the two-direction claim grades + nothing at all. Neither mistake can raise on its own, so the precondition is + asserted here rather than left to each caller to remember. Existing opposite- + root markers are refused too: otherwise a stale file could make a wrong cwd + satisfy both probes. + """ + assert repo_root.resolve() != project.resolve(), ( + "plant_root_markers needs two DIFFERENT roots: with the collapsed `project` " + "fixture both markers land in one tree and the two-direction probe grades " + "nothing. Build the divergent fixture first (nested_repo_root_paths, or a " + "write_repo_root_override code root)." + ) + assert not (project / MARKER_IN_REPO_ROOT).exists(), ( + f"stale {MARKER_IN_REPO_ROOT} in project would make the repo-root probe " + "pass from the wrong cwd" + ) + assert not (repo_root / MARKER_IN_PROJECT).exists(), ( + f"stale {MARKER_IN_PROJECT} in repo_root would make the project-root probe " + "pass from the wrong cwd" + ) + (repo_root / MARKER_IN_REPO_ROOT).write_text("x\n", encoding="utf-8") + (project / MARKER_IN_PROJECT).write_text("x\n", encoding="utf-8") + + +# The config file every divergent-roots row overrides, and the artifact-path body +# `install_bmad_config` and `write_repo_root_override` both write. One text, so a +# change to the artifact keys cannot reach the plain config and skip the override +# one (or the reverse) — the two would then differ in a way no row asserts. +BMAD_CONFIG_REL = Path("_bmad") / "bmm" / "config.yaml" +_ARTIFACT_PATH_KEYS = ( + "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" + "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n" +) + + +def write_repo_root_override(paths: ProjectPaths, code_root: Path) -> None: + """Rewrite `_bmad/bmm/config.yaml` with a `repo_root:` pointing at `code_root`. + + The one supported divergent-roots config: `isolation = "none"` plus a + `repo_root:` key (`bmadconfig.worktree_isolation_conflict` refuses the other + combination, and `ProjectPaths.rebased` sets both roots, so worktree isolation + never diverges). Overwrites rather than appends, so it is exact whether or not + `install_bmad_config` ran first. + + `code_root` need not be a git checkout, and several rows deliberately pass a + plain directory or a missing one. + """ + assert code_root.is_absolute(), ( + "write_repo_root_override requires an absolute code_root: bmadconfig " + "resolves relative configured paths against the process cwd, not the project" + ) + config = paths.project / BMAD_CONFIG_REL + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text( + _ARTIFACT_PATH_KEYS + + f"repo_root: {json.dumps(code_root.as_posix(), ensure_ascii=False)}\n", + encoding="utf-8", + ) + + +NESTED_SUBDIR = "app" + + +def nested_repo_root_paths(paths: ProjectPaths) -> ProjectPaths: + """The MONOREPO shape of the override: `repo_root` an ANCESTOR of `project`. + + The BMAD project lives at ``/app`` inside a checkout whose root is the + git root — `repo_root` stays `paths.project` (the sandbox repo) while + `project` and all three artifact dirs move under ``app/``. + + Why a second shape at all. `tests/test_verify.py::_repo_root_override` builds + the SIBLING shape, where the artifact tree is disjoint from the code tree — so + a code-root spelling collapses to ``()`` while a project-root spelling is a + non-empty tail that matches nothing in the code tree. Both spellings therefore + agree on the gate outcome and a wrong-but-plausible pathspec passes unnoticed. + Nested, the wrong pathspec is not empty: resolved + against the code root, ``_bmad-output/implementation-artifacts/spec-1-1-a.md`` + names the OUTER project's real artifact dir. That is the "not merely wrong, it + is SILENTLY wrong" failure the production docstrings describe, and it is + separable by VALUE. + + Seeds and COMMITS ``app/src.txt`` so `dev_effect` works unchanged — it + reads `paths.project / "src.txt"` and `rev_parse_head(paths.project)`, and git + resolves `.git` upward from the subdir. Committed rather than left untracked + for the reason `plant_root_markers` gives: a session's edit to a TRACKED file + is proof of work the attempt's baseline snapshot cannot absorb. + + Also writes ``app/.gitignore`` with the `bmad-loop init` run-state entry. + Init writes that file next to the project it initializes, and the sandbox + template's own root-anchored ``.bmad-loop/runs/`` does not match a nested one — + so without it a nested engine run's journal would show up as untracked work. + + The subdirectory is FIXED at `NESTED_SUBDIR` rather than a parameter because + every consumer's assertions spell the ``app/`` prefix literally. A parameter + would make a non-default argument redden those rows on a prefix mismatch instead + of on the contract they grade. + + Refuses input it cannot honor. It commits unconditionally, so a second call on + the same `paths` (or one whose subdir a caller pre-created) dies inside `git` + with a raw ``CalledProcessError`` from ``git commit`` — "nothing to commit" — + naming neither the helper nor the precondition. Both guards below fail with + the precondition instead. + """ + assert paths.project == paths.repo_root, ( + "nested_repo_root_paths builds the divergence; it cannot be applied to paths " + "that already have one. Pass the plain `project` fixture." + ) + staged = git(paths.project, "diff", "--cached", "--name-only") + assert not staged, ( + "nested_repo_root_paths commits its seed files and requires an empty index; " + f"already staged: {staged}" + ) + project = paths.project / NESTED_SUBDIR + assert not project.exists(), ( + f"{NESTED_SUBDIR}/ already exists under {paths.project}: this helper seeds and " + "COMMITS it, so a second call (or a caller that pre-created it) would reach " + "`git commit` with nothing staged." + ) + output_folder = project / "_bmad-output" + impl = output_folder / "implementation-artifacts" + plan = output_folder / "planning-artifacts" + impl.mkdir(parents=True, exist_ok=True) + plan.mkdir(parents=True, exist_ok=True) + (project / "src.txt").write_text("original\n", encoding="utf-8") + (project / ".gitignore").write_text(".bmad-loop/runs/\n", encoding="utf-8") + git(paths.project, "add", f"{NESTED_SUBDIR}/src.txt", f"{NESTED_SUBDIR}/.gitignore") + git(paths.project, "commit", "-q", "-m", f"seed the {NESTED_SUBDIR}/ project") + return dataclasses.replace( + paths, + project=project, + implementation_artifacts=impl, + planning_artifacts=plan, + output_folder=output_folder, + repo_root=paths.project, + ) + + UNRESOLVABLE = "stubbed: the provider is registered but not serving" @@ -564,12 +739,9 @@ def stub(self, strict: bool = False): def install_bmad_config(paths: ProjectPaths) -> None: """Write the _bmad/bmm/config.yaml that bmadconfig.load_paths resolves.""" - cfg = paths.project / "_bmad" / "bmm" - cfg.mkdir(parents=True) - (cfg / "config.yaml").write_text( - "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" - "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n" - ) + cfg = paths.project / BMAD_CONFIG_REL + cfg.parent.mkdir(parents=True) + cfg.write_text(_ARTIFACT_PATH_KEYS) def _write_skill_stubs(skills: Path, catalog: dict) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 10156d18..e5130cd2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,8 @@ import pytest import yaml from conftest import ( + PROJECT_MARKER_CMD, + REPO_ROOT_MARKER_CMD, UNRESOLVABLE, escalated_run, fault_read_text, @@ -25,10 +27,12 @@ install_dev_shim, machine_json, mark_ledger_done, + plant_root_markers, refuse_to_resolve, spec_path, write_gated_ledger, write_ledger, + write_repo_root_override, write_script_launcher, write_spec, write_sprint, @@ -8028,12 +8032,7 @@ def test_confirm_reverify_reports_an_unusable_cwd_instead_of_crashing( install_bmad_config(project) missing = tmp_path / "no-such-code-root" - (project.project / "_bmad" / "bmm" / "config.yaml").write_text( - "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" - "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n" - f"repo_root: '{missing.as_posix()}'\n", - encoding="utf-8", - ) + write_repo_root_override(project, missing) sp = _park_story(project) before = sp.read_text() _write_policy(project.project, '[verify]\ncommands = ["python -c \\"pass\\""]\n') @@ -8049,6 +8048,116 @@ def test_confirm_reverify_reports_an_unusable_cwd_instead_of_crashing( assert "1-1-a" in operatoractions.load(project.project) +def _diverge_repo_root(paths, code_root: Path) -> None: + """Compose the two shared conftest halves of the divergent fixture: the + `repo_root:` config override, and one cwd marker per root. + + Both load-bearing halves are conftest's, not this module's. + `write_repo_root_override` writes the same three keys the two unusable-cwd + rows above need, and + `plant_root_markers` is the same planter the review-gate row in + `test_verify.py` uses — so the four `[verify] commands` callers graded by a + two-direction probe cannot end up asking subtly different questions. + + Why this fixture exists at all: `cli._reverify` is handed `paths.repo_root` at + both of its call sites, and every pre-existing row here runs a cwd-INSENSITIVE + command (`python -c "pass"`) under the collapsed `project` fixture, so nothing + observed which root the commands ran in. + + `code_root` is deliberately NOT a git repo, because that is what an operator's + `repo_root:` pointing at a non-checkout looks like and `_reverify` runs no git + there. Note the limit of that choice: `_land_confirmation` SWALLOWS the + `GitError` its `path_ignored`/`commit_paths` calls raise in such a root, so a + regression that started shelling out to git here would pass these rows + unnoticed. (`test_verify.py::test_verify_review_gates_run_commands_in_repo_root` + makes the stronger claim, about a gate that does not swallow it.) + """ + write_repo_root_override(paths, code_root) + plant_root_markers(repo_root=code_root, project=paths.project) + + +def _marker_policy(paths, marker_root: str) -> None: + """A `[verify] commands` policy holding the RELATIVE probe for one root. + + A dict rather than `X if marker_root == "repo_root" else Y`: under the + conditional any value but that exact literal silently selected the project + probe, so a typo in the `parametrize` list would have graded BOTH legs in the + project direction and left both green — a false-green mechanism inside the + rows built to prevent false greens. An unknown key raises `KeyError` here. + """ + command = {"repo_root": REPO_ROOT_MARKER_CMD, "project": PROJECT_MARKER_CMD}[marker_root] + _write_policy(paths.project, f"[verify]\ncommands = [{json.dumps(command)}]\n") + + +@pytest.mark.parametrize("marker_root", ["repo_root", "project"]) +def test_confirm_reverify_runs_the_commands_in_the_code_tree( + project, tmp_path, capsys, monkeypatch, marker_root +): + """`cmd_confirm`'s `_reverify` call site, pinned from BOTH directions. + + The marker only `repo_root` holds must let the confirmation through AND the + marker only `project` holds must refuse it. The positive leg identifies + `repo_root`; the negative leg rules out the tempting `paths.project` regression + explicitly. No pre-existing row could see either: they all run cwd-insensitive + commands under the `project` fixture, where the two roots are the same object. + + The refusal leg asserts the SPECIFIC failure (`NOT confirmed`) and that all + three records are byte-identical to before — a refused `--reverify` has to + leave the spec, the board and the park entry exactly where it found them, and + `rc == 1` alone is reachable from several other refusals in this command. + + All three are compared as BYTES, which is what "untouched" means and what the + claim above says. A parsed read-back (`sprintstatus.story_status`, key + membership in `operatoractions.load`) answers a weaker question: a refusal + that rewrote the board's other rows, reordered its keys, or rewrote the record + in place would satisfy it. The semantic assertions are kept beside the byte + ones because they say WHAT the unchanged bytes are. + + Ablation: pass `paths.project` at the `_reverify` call site in `cmd_confirm` + and both legs redden. + """ + from bmad_loop import operatoractions, sprintstatus + + install_bmad_config(project) + code_root = tmp_path / "code-root" + code_root.mkdir() + _diverge_repo_root(project, code_root) + sp = _park_story(project) + record = operatoractions.record_path(project.project, "1-1-a") + before_spec = sp.read_bytes() + before_board = project.sprint_status.read_bytes() + before_record = record.read_bytes() + _marker_policy(project, marker_root) + monkeypatch.setattr(cli, "_confirm", lambda _q: True) + + classify_cwds: list[Path] = [] + real_env_fault = verify.env_fault_reason + + def classify_in(result, cwd): + classify_cwds.append(cwd) + return real_env_fault(result, cwd) + + monkeypatch.setattr(verify, "env_fault_reason", classify_in) + + rc = cli.main(_confirm_argv(project, "1-1-a", "--reverify")) + captured = capsys.readouterr() + + assert classify_cwds == [code_root.resolve()] + if marker_root == "repo_root": + assert rc == 0 + assert "verify commands passed" in captured.out + assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "done" + assert operatoractions.load(project.project) == {} + else: + assert rc == 1 + assert "--reverify failed" in captured.err and "NOT confirmed" in captured.err + assert sp.read_bytes() == before_spec + assert project.sprint_status.read_bytes() == before_board + assert record.read_bytes() == before_record + assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "awaiting-operator" + assert "1-1-a" in operatoractions.load(project.project) + + def test_confirm_reverify_says_so_when_nothing_is_configured(project, capsys, monkeypatch): """An empty command list is not a green gate, and must not be reported as one.""" install_bmad_config(project) @@ -8478,12 +8587,7 @@ def test_a_resume_reverify_reports_an_unusable_cwd_without_losing_partial_state( install_bmad_config(project) missing = tmp_path / "no-such-resume-code-root" - (project.project / "_bmad" / "bmm" / "config.yaml").write_text( - "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" - "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n" - f"repo_root: '{missing.as_posix()}'\n", - encoding="utf-8", - ) + write_repo_root_override(project, missing) spec = _interrupted_story(project) before = spec.read_text(encoding="utf-8") _write_policy(project.project, '[verify]\ncommands = ["python -c \\"pass\\""]\n') @@ -8499,6 +8603,71 @@ def test_a_resume_reverify_reports_an_unusable_cwd_without_losing_partial_state( assert "1-1-a" in operatoractions.load(project.project) +@pytest.mark.parametrize("marker_root", ["repo_root", "project"]) +def test_a_resume_reverify_runs_the_commands_in_the_code_tree( + project, tmp_path, capsys, monkeypatch, marker_root +): + """The resumable confirmation's own `_reverify` call site — the second of the + two, and a separate line of code from `cmd_confirm`'s. + + Same two-direction probe, and the refusal leg asserts the wording that + distinguishes this caller: "NOT advanced" and NOT "NOT confirmed". The + confirmation itself already happened here, so telling a human it was not + confirmed sends them looking for a sign-off to redo. Asserting the absence + matters as much as the presence — one shared message would satisfy a bare + "it refused". + + The already-written sign-off must survive either way, which is what makes + this leg's "nothing was lost" claim testable at all — and it is compared as + BYTES, together with the board and the park record, for the reason the + `cmd_confirm` twin gives: a parsed read-back cannot see a record rewritten in + place. + + Ablation: pass `paths.project` at the `_reverify` call site in + `_resume_confirmation` and both legs redden. + """ + from bmad_loop import operatoractions, sprintstatus + + install_bmad_config(project) + code_root = tmp_path / "code-root" + code_root.mkdir() + _diverge_repo_root(project, code_root) + spec = _interrupted_story(project) + record = operatoractions.record_path(project.project, "1-1-a") + before_spec = spec.read_bytes() + before_board = project.sprint_status.read_bytes() + before_record = record.read_bytes() + _marker_policy(project, marker_root) + monkeypatch.setattr(cli, "_confirm", lambda _q: True) + + classify_cwds: list[Path] = [] + real_env_fault = verify.env_fault_reason + + def classify_in(result, cwd): + classify_cwds.append(cwd) + return real_env_fault(result, cwd) + + monkeypatch.setattr(verify, "env_fault_reason", classify_in) + + rc = cli.main(_confirm_argv(project, "1-1-a", "--reverify")) + captured = capsys.readouterr() + + assert classify_cwds == [code_root.resolve()] + if marker_root == "repo_root": + assert rc == 0 + assert "verify commands passed" in captured.out + assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "done" + assert operatoractions.load(project.project) == {} + else: + assert rc == 1 + assert "NOT advanced" in captured.err and "NOT confirmed" not in captured.err + assert spec.read_bytes() == before_spec + assert project.sprint_status.read_bytes() == before_board + assert record.read_bytes() == before_record + assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "awaiting-operator" + assert "1-1-a" in operatoractions.load(project.project) + + def test_list_marks_an_interrupted_confirmation_as_signed_off_not_refused(project, capsys): """An interrupted confirmation drifts, so a listing that reads `drift()` alone labels it NOT CONFIRMABLE — telling a human confirm will refuse a story confirm diff --git a/tests/test_conftest.py b/tests/test_conftest.py index bfc5f725..447c9a10 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -17,12 +17,13 @@ import json import os import subprocess +from dataclasses import replace import conftest import pytest from conftest import make_git_noisy -from bmad_loop import verify +from bmad_loop import bmadconfig, verify def test_template_drops_sample_hooks_but_keeps_hooks_dir_and_exclude(project): @@ -50,6 +51,109 @@ def test_template_drops_sample_hooks_but_keeps_hooks_dir_and_exclude(project): assert (git_dir / "info" / "exclude").is_file() +def test_plant_root_markers_refuses_physical_aliases(tmp_path): + """Different spellings of one directory do not make a divergent-roots probe.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + project_alias = tmp_path / "project-alias" + try: + project_alias.symlink_to(repo_root, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + + with pytest.raises(AssertionError, match="DIFFERENT roots"): + conftest.plant_root_markers(repo_root=repo_root, project=project_alias) + + +@pytest.mark.parametrize( + ("stale_root", "marker"), + [ + ("project", conftest.MARKER_IN_REPO_ROOT), + ("repo_root", conftest.MARKER_IN_PROJECT), + ], +) +def test_plant_root_markers_refuses_opposite_root_residue(tmp_path, stale_root, marker): + """A stale opposite-root marker cannot turn the cwd probe into a false green.""" + repo_root = tmp_path / "repo" + project = tmp_path / "project" + repo_root.mkdir() + project.mkdir() + {"repo_root": repo_root, "project": project}[stale_root].joinpath(marker).write_text("stale\n") + + with pytest.raises(AssertionError, match="stale"): + conftest.plant_root_markers(repo_root=repo_root, project=project) + + +def test_write_repo_root_override_creates_the_config_tree(project, tmp_path): + """The standalone writer does not depend on another fixture running first.""" + config = project.project / conftest.BMAD_CONFIG_REL + assert not config.parent.exists() + code_root = tmp_path / "code-root" + code_root.mkdir() + + conftest.write_repo_root_override(project, code_root) + + assert config.is_file() + assert bmadconfig.load_paths(project.project).repo_root == code_root.resolve() + + +def test_write_repo_root_override_quotes_yaml_punctuation(project, tmp_path): + """YAML punctuation and non-BMP Unicode survive the config round trip.""" + config = project.project / conftest.BMAD_CONFIG_REL + config.parent.mkdir(parents=True) + code_root = tmp_path / "code'root-😀" + code_root.mkdir() + + conftest.write_repo_root_override(project, code_root) + + assert bmadconfig.load_paths(project.project).repo_root == code_root.resolve() + + +def test_write_repo_root_override_refuses_a_relative_code_root(project): + """A relative override cannot acquire process-cwd semantics by accident.""" + with pytest.raises(AssertionError, match="absolute code_root"): + conftest.write_repo_root_override(project, conftest.Path("relative-code-root")) + + assert not (project.project / conftest.BMAD_CONFIG_REL).exists() + + +def test_nested_repo_root_paths_refuses_a_nonempty_index(project): + """Its seed commit must never absorb setup another fixture already staged.""" + staged = project.project / "staged.txt" + staged.write_text("belongs to the caller\n", encoding="utf-8") + conftest.git(project.project, "add", staged.name) + + with pytest.raises(AssertionError, match="empty index"): + conftest.nested_repo_root_paths(project) + + assert not (project.project / conftest.NESTED_SUBDIR).exists() + + +def test_nested_repo_root_paths_refuses_already_divergent_input(project, tmp_path): + """The builder owns divergence and leaves pre-diverged input untouched.""" + paths = replace(project, repo_root=tmp_path / "other-root") + + with pytest.raises(AssertionError, match="already have one"): + conftest.nested_repo_root_paths(paths) + + assert not (project.project / conftest.NESTED_SUBDIR).exists() + + +def test_nested_repo_root_paths_refuses_an_existing_nested_project(project): + """The builder never overwrites a caller-owned `app/` directory.""" + nested = project.project / conftest.NESTED_SUBDIR + nested.mkdir() + sentinel = nested / "caller-owned.txt" + sentinel.write_text("keep\n", encoding="utf-8") + + with pytest.raises(AssertionError, match="already exists"): + conftest.nested_repo_root_paths(project) + + assert sentinel.read_text(encoding="utf-8") == "keep\n" + assert not (nested / "src.txt").exists() + assert not (nested / ".gitignore").exists() + + def test_template_leaves_no_detached_git_maintenance_writing_into_the_copies(project, tmp_path): """No background git process may outlive a commit into the sandbox. diff --git a/tests/test_engine.py b/tests/test_engine.py index cd41127c..4b95666e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -16,6 +16,10 @@ from conftest import ( _FAIL, _OK, + MARKER_IN_PROJECT, + MARKER_IN_REPO_ROOT, + PROJECT_MARKER_CMD, + REPO_ROOT_MARKER_CMD, _disarm_check_script, _file_exists_cmd, _self_disarming_cmd, @@ -26,6 +30,8 @@ fault_read_text, generic_dev_effect, git, + nested_repo_root_paths, + plant_root_markers, refuse_to_resolve, review_effect, set_sprint, @@ -2529,6 +2535,220 @@ def resolve_fault(self, *args, **kwargs): assert engine._harvest_gate_exclude(task) == () +# ------------------- `[verify] commands` run where the SESSION ran (#695, DW-3) +# +# `Engine._verify_commands_with_results` runs the commands in `self.workspace.root` +# — which `Workspace.default` sets to `paths.repo_root`, the CODE tree. Both of its +# stages (`dev` and `fix`) were unpinned: every other engine row mocks +# `verify.run_verify_commands` with a `lambda policy, cwd:` that DISCARDS the cwd, +# so moving the root back to `paths.project` left the whole suite green. These two +# rows therefore run the real commands, and use `conftest.nested_repo_root_paths` +# so the two roots are genuinely different directories. + + +@pytest.mark.parametrize("marker_root", ["repo_root", "project"]) +def test_dev_stage_verify_commands_run_in_the_code_tree(project, monkeypatch, marker_root): + """The `dev` stage, pinned from BOTH directions by a full engine run. + + A marker only `repo_root` holds must let the story through AND a marker only + `project` holds must fail it. The positive leg identifies `repo_root`; the + negative leg rules out the tempting `project` regression explicitly. + + Deliberately does NOT mock `verify.run_verify_commands`: that mock is what + made this caller blind in the first place, and a spy over it would pin only + the argument rather than the behavior an operator sees. + + The markers are planted BEFORE the run, so `Engine._dev_phase`'s + `baseline_untracked` snapshot absorbs them and neither can be mistaken for + proof of work; the passing leg still owes a real diff, which `dev_effect`'s + edit to the tracked `app/src.txt` supplies. + + `max_dev_attempts=1` keeps the failing leg to one scripted session: a fixable + verify failure otherwise routes a repair session the fixed-length script + cannot serve. + + Ablation: hand `paths.project` to `run_verify_commands` / + `verify_command_results_outcome` in `_verify_commands_with_results` and BOTH + legs redden — the repo-root leg on `done`, the project leg on the deferral it + no longer gets. The gate here is a cwd choice rather than a check, so only + putting the other root back reproduces the bug; deleting code cannot. + """ + paths = nested_repo_root_paths(project) + plant_root_markers(repo_root=paths.repo_root, project=paths.project) + write_sprint(paths, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + paths, + [dev_effect(paths, "1-1-a", followup_review=False)], + policy=Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=False), + limits=LimitsPolicy(max_dev_attempts=1), + scm=ScmPolicy(rollback_on_failure=True), + verify=VerifyPolicy( + commands=( + # a dict, not `X if marker_root == "repo_root" else Y`: under + # the conditional any value but that exact literal silently + # selected the project probe, so a typo in the `parametrize` + # list graded BOTH legs in the project direction and both + # still passed — a false green inside a row built to prevent + # false greens. An unknown key raises `KeyError`. + { + "repo_root": REPO_ROOT_MARKER_CMD, + "project": PROJECT_MARKER_CMD, + }[marker_root], + ) + ), + ), + ) + # the premise the whole row rests on: two genuinely different directories, + # and genuinely NESTED. `!=` alone is satisfied by a builder regression that + # flattened the nest (say, back onto the sibling shape), under which the + # `project` leg would fail for the unrelated reason that its cwd is not a + # checkout at all. + assert paths.project != paths.repo_root + assert paths.project.parent == paths.repo_root + + classify_cwds: list[Path] = [] + real_classify = verify.verify_command_results_outcome + + def classify_in(results, cwd): + classify_cwds.append(cwd) + return real_classify(results, cwd) + + monkeypatch.setattr(verify, "verify_command_results_outcome", classify_in) + + summary = engine.run() + + # The first classification is this dev pass; a passing run reaches later review + # gates too, and every one must classify against the same root it executed in. + assert classify_cwds and classify_cwds[0] == paths.repo_root + assert all(cwd == paths.repo_root for cwd in classify_cwds) + dev_records = [ + e + for e in engine.journal.entries() + if e["kind"] == "verify-command-result" and e["verification_stage"] == "dev" + ] + if marker_root == "repo_root": + assert summary.done == 1 and summary.deferred == 0 + assert [r["returncode"] for r in dev_records] == [0] + else: + assert summary.deferred == 1 and summary.done == 0 + assert [r["returncode"] for r in dev_records] == [1] + # the SPECIFIC failure, not a bare "it did not finish": a deferral is + # reachable from every other gate this run gets near + reason = engine.state.tasks["1-1-a"].defer_reason + assert "verify command failed" in reason and MARKER_IN_PROJECT in reason + + +@pytest.mark.parametrize("marker_root", ["repo_root", "project"]) +def test_fix_stage_verify_commands_run_in_the_code_tree(project, monkeypatch, marker_root): + """The `fix` stage, driven directly — the second unpinned caller. + + The intent pins this phase-specific caller directly from the REVIEW_VERIFY + phase it is entered at. That keeps the cwd choice isolated from the separate + production transition into repair, whose sequencing can also depend on + stateful operator-authored commands. + + One marker NAME, two plant locations: the repair session writes + `only-in-repo-root.txt` into `repo_root` on one leg and into `project` on the + other, and the command probes it RELATIVELY. The only variable is which + directory holds the file, so rc separates the legs if and only if the commands + run in `workspace.root`. Planted BY the session rather than before it, because + a repair that repaired nothing is not the thing under test. + + `max_dev_attempts=2` with the production-reachable `attempt=1` makes the + `while task.attempt < max_dev_attempts` loop run exactly once, so one scripted + session covers the whole phase and the failing leg falls out into its DEFER. + + The refusal leg is graded specifically rather than by the bare action. What + `_fix_phase` can be asked for is bounded: the `fix-decision` record pins + `session_status="completed"`, `ok=False`, `env_fault=False`, and the marker + assertion below pins that the repair actually WROTE something. Together those + distinguish the verify refusal from the other path to the same DEFER action, + without freezing today's empty `Decision.reason` as a contract. + + Ablation: hand `paths.project` to `run_verify_commands` in + `_verify_commands_with_results` and both legs redden (PROCEED becomes DEFER + and back). + """ + from bmad_loop.escalation import Action + + paths = nested_repo_root_paths(project) + sp = spec_path(paths, "1-1-a") + write_spec(sp, "done", rev_parse_head(paths.repo_root)) + # a dict for the reason the dev-stage row above gives: under an `if/else` on + # the literal, a typo'd parametrize value silently graded the project + # direction on both legs + target = {"repo_root": paths.repo_root, "project": paths.project}[marker_root] + + def repair(_spec): + (target / MARKER_IN_REPO_ROOT).write_text("x\n", encoding="utf-8") + return SessionResult(status="completed") + + engine, adapter = make_engine( + paths, + [repair], + policy=Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + limits=LimitsPolicy(max_dev_attempts=2), + verify=VerifyPolicy(commands=(REPO_ROOT_MARKER_CMD,)), + ), + ) + # the premise both legs rest on: genuinely different, and genuinely NESTED — + # `!=` alone is satisfied by a flattened builder under which the `project` leg + # would fail because its cwd is not a checkout, not because of the cwd choice + assert paths.project != paths.repo_root + assert paths.project.parent == paths.repo_root + task = StoryTask( + story_key="1-1-a", + epic=1, + phase=Phase.REVIEW_VERIFY, + attempt=1, + spec_file=str(sp), + ) + engine.state.tasks[task.story_key] = task + + classify_cwds: list[Path] = [] + real_classify = verify.verify_command_results_outcome + + def classify_in(results, cwd): + classify_cwds.append(cwd) + return real_classify(results, cwd) + + monkeypatch.setattr(verify, "verify_command_results_outcome", classify_in) + + decision = engine._fix_phase(task, "verify commands failed after a clean review") + + assert len(adapter.sessions) == 1 # exactly one repair, so rc is that repair's + assert classify_cwds == [paths.repo_root] + # the repair actually repaired: without this the refusal leg passes unchanged + # when `repair` writes nothing at all, which is a reason for rc 1 that has + # nothing to do with which root the command ran in + assert (target / MARKER_IN_REPO_ROOT).is_file() + fix_records = [ + e + for e in engine.journal.entries() + if e["kind"] == "verify-command-result" and e["verification_stage"] == "fix" + ] + fix_decisions = [e for e in engine.journal.entries() if e["kind"] == "fix-decision"] + if marker_root == "repo_root": + assert decision.action == Action.PROCEED + assert [r["returncode"] for r in fix_records] == [0] + assert [d["ok"] for d in fix_decisions] == [True] + else: + # SPECIFIC, not a bare "it deferred": the records say the repair ran to + # completion and the commands returned 1 rather than failing to spawn — + # which would be an env fault and escalate instead. + assert decision.action == Action.DEFER + assert [r["returncode"] for r in fix_records] == [1] + assert [r["spawn_error"] for r in fix_records] == [None] + assert [(d["ok"], d["env_fault"], d["session_status"]) for d in fix_decisions] == [ + (False, False, "completed") + ] + + def test_dev_retry_notifies_the_operator_with_the_reason(project): """#640(d): RETRY was the only dev outcome that notified nothing, and it is the outcome that DISCARDS a completed implementation — the non-fixable leg rolls the diff --git a/tests/test_verify.py b/tests/test_verify.py index cb5ccd15..7af370bc 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -14,12 +14,15 @@ _OK, MISSING_TOOL_CMD, OMIT, + PROJECT_MARKER_CMD, + REPO_ROOT_MARKER_CMD, UNRESOLVABLE, - _file_exists_cmd, _Omit, fault_read_text, git, make_git_noisy, + nested_repo_root_paths, + plant_root_markers, refuse_to_resolve, spec_path, write_spec, @@ -3302,8 +3305,8 @@ def test_verify_review_gates_run_commands_in_repo_root(project, tmp_path, mode): `cli._reverify` both already used `repo_root`. Pinned from BOTH directions on purpose: a marker only the repo root holds - must pass AND a marker only the project holds must fail. Either assertion - alone is satisfied by a cwd that is neither of them. + must pass AND a marker only the project holds must fail. The positive probe + identifies `repo_root`; the negative probe rules out `project` explicitly. It does NOT pin the other half of the split. The artifact reads resolve through `paths.sprint_status` / `paths.deferred_work` (derived from @@ -3317,6 +3320,13 @@ def test_verify_review_gates_run_commands_in_repo_root(project, tmp_path, mode): plain dir is the honest fixture. Turning it into a real repo would let a regression that started shelling out to git there pass unnoticed. + The two markers and their RELATIVE probes come from `conftest` + (`plant_root_markers`, `REPO_ROOT_MARKER_CMD`, `PROJECT_MARKER_CMD`) rather + than being built here: the four other unpinned `[verify] commands` callers + (both `Engine._verify_commands_with_results` stages, both `cli._reverify` + call sites) are graded by the same two-direction probe, and a re-derived + fixture would let one of those rows quietly ask a different question. + INVERSE ablation: restore the pre-#695 root — `verify_commands_outcome(policy, paths.project)` in `_verify_review_commands` — and all three modes fail on the FIRST assertion, the repo-root marker going missing, before the refusal leg is @@ -3324,19 +3334,13 @@ def test_verify_review_gates_run_commands_in_repo_root(project, tmp_path, mode): cannot reproduce the bug; only putting the old root back does.""" repo_root = tmp_path / "code-root" repo_root.mkdir() - (repo_root / "only-in-repo-root.txt").write_text("x\n", encoding="utf-8") - (project.project / "only-in-project.txt").write_text("x\n", encoding="utf-8") + plant_root_markers(repo_root=repo_root, project=project.project) paths = dataclasses.replace(project, repo_root=repo_root) task, gate = _review_gate_at_done(project, mode) - # relative paths, so the probe is cwd-sensitive on both OSes - in_repo_root = Policy( - verify=VerifyPolicy(commands=(_file_exists_cmd("only-in-repo-root.txt"),)) - ) - assert gate(task, paths, in_repo_root).ok + assert gate(task, paths, Policy(verify=VerifyPolicy(commands=(REPO_ROOT_MARKER_CMD,)))).ok - in_project = Policy(verify=VerifyPolicy(commands=(_file_exists_cmd("only-in-project.txt"),))) - out = gate(task, paths, in_project) + out = gate(task, paths, Policy(verify=VerifyPolicy(commands=(PROJECT_MARKER_CMD,)))) assert not out.ok and "verify command failed" in out.reason @@ -6002,12 +6006,22 @@ def test_verify_dev_stories_roots_its_exclude_on_the_code_tree(project, tmp_path the gate's git root, and it is pinned at the SEAM rather than by outcome — on purpose. - Under the supported override the story record and manifest sit outside the code - tree whichever root is used, so both spellings end in "nothing was excluded" and - no outcome assertion can separate them. What the wrong root actually costs is - invisible in a passing gate: a `project`-relative pathspec is resolved by git - against the CODE tree, silently excluding whatever happens to live at that - relative path there. So the contract is the root itself. + Under the SIBLING fixture this row uses (`_repo_root_override`: an artifact + tree disjoint from the code tree) the story record and manifest sit outside the + code tree whichever root is used, so both spellings end in "nothing was + excluded" and no outcome assertion over THIS fixture can separate them. What + the wrong root actually costs is invisible in a passing gate: a + `project`-relative pathspec is resolved by git against the CODE tree, silently + excluding whatever happens to live at that relative path there. So the + contract here is the root itself. + + The claim is scoped to the fixture, not to the function. Under the NESTED + (monorepo) shape the artifacts are inside the code tree and the two spellings + differ by value and by outcome — see + `test_stories_relpaths_separates_the_two_roots_in_a_monorepo` for the value + and, for THIS gate's outcome, + `test_verify_dev_stories_refuses_a_bare_spec_flip_under_the_monorepo_shape` + (whose sprint-mode twin drops the `_stories` infix). Ablation: pass `paths.project` at the call site and the recorded root reddens. """ @@ -6052,6 +6066,226 @@ def test_stories_relpaths_follows_the_root_it_is_given(project, tmp_path): assert verify._stories_relpaths(paths.repo_root, spec_folder) == () +# ------------------------------------- the MONOREPO shape of the same override +# +# `_repo_root_override` above builds the SIBLING shape: `artifacts-root` beside +# `sandbox`, so every artifact sits outside the code tree. The code-root spelling +# collapses to `()` while the project-root spelling is a non-empty tail that still +# matches nothing in the code tree. Values can differ, but both spellings have the +# same gate outcome, so the seam rows assert on the recorded root instead. +# +# `conftest.nested_repo_root_paths` is the shape that CAN separate them: the BMAD +# project at `/app`, `repo_root` its ancestor. There the wrong pathspec is +# not empty, it is *plausible* — `_bmad-output/implementation-artifacts/...` +# resolved against the code root names the OUTER project's real artifact dir. +# That is the "not merely wrong, it is SILENTLY wrong" failure the production +# docstrings describe, and the sibling fixture could never exhibit it. An +# ADDITIONAL variant, not a replacement: the sibling rows grade the disjoint +# layout, which is a supported configuration in its own right. + + +def test_verify_dev_exclude_relpaths_separates_the_two_roots_in_a_monorepo(project): + """Both spellings are non-empty and unequal, and the code-tree one is prefixed. + + The silently-wrong value assertion the sibling shape cannot make. There the + wrong non-empty tail matches nothing in the code tree; here it names a real + outer artifact. The two spellings differ by exactly the `app/` prefix, so the + equality pins WHICH root the relpaths were measured against rather than merely + that something was measured. + + The last assertion is the point of the whole shape: the `project`-rooted + spelling, handed to git in the CODE tree, resolves onto a real file that is + NOT the one it meant to exclude. A silently-wrong exclusion, not an absent + one. + + Ablation: pin `base = paths.project` inside `verify_dev_exclude_relpaths` and + the prefix assertions redden — both spellings come back identical. + """ + paths = nested_repo_root_paths(project) + # the premise every assertion below rests on: genuinely divergent, and + # genuinely NESTED (the sibling shape satisfies the first and not the second) + assert paths.project != paths.repo_root + assert paths.project.parent == paths.repo_root + sp = spec_path(paths, "1-1-a") + sp.write_text("---\nstatus: in-review\n---\n", encoding="utf-8") + # the OUTER project's board — the file a `project`-rooted pathspec silently + # names when git resolves it in the code tree + write_sprint(project, {"1-1-a": "review"}) + + from_code_root = verify.verify_dev_exclude_relpaths(paths, sp, root=paths.repo_root) + from_project = verify.verify_dev_exclude_relpaths(paths, sp, root=paths.project) + + assert from_code_root and from_project and from_code_root != from_project + assert from_code_root == tuple(f"app/{rel}" for rel in from_project) + assert from_code_root == ( + "app/_bmad-output/implementation-artifacts/sprint-status.yaml", + "app/_bmad-output/implementation-artifacts/spec-1-1-a.md", + ) + # silently wrong, not empty: the wrong spelling names the outer board + assert (paths.repo_root / from_project[0]).is_file() + assert (paths.repo_root / from_project[0]) != paths.sprint_status + + +def test_stories_relpaths_separates_the_two_roots_in_a_monorepo(project): + """Same rule, same shape, for the stories-mode exclude. + + Its sibling row above asserts `() `for the code-tree spelling because under the + disjoint layout there is genuinely nothing to exclude. Nested, both spellings + produce two pathspecs and only the prefix tells them apart. + + Ablation: drop the `relative_to(root)` rebase in `_stories_relpaths` (return + the project-relative tail whatever the root) and the prefix assertion reddens. + """ + paths = nested_repo_root_paths(project) + assert paths.project != paths.repo_root + assert paths.project.parent == paths.repo_root + spec_folder = paths.implementation_artifacts / "spec-x" + spec_folder.mkdir(parents=True) + + from_code_root = verify._stories_relpaths(paths.repo_root, spec_folder) + from_project = verify._stories_relpaths(paths.project, spec_folder) + + assert from_code_root and from_project and from_code_root != from_project + assert from_code_root == tuple(f"app/{rel}" for rel in from_project) + assert from_code_root == ( + "app/_bmad-output/implementation-artifacts/spec-x/stories", + "app/_bmad-output/implementation-artifacts/spec-x/stories.yaml", + ) + + +def test_verify_dev_refuses_a_bare_spec_flip_under_the_monorepo_shape(project): + """The OUTCOME assertion the sibling shape cannot make (DW-9). + + An attempt whose only residue is its own spec's status flip and the board has + produced nothing, and the proof-of-work gate must say so. That refusal is only + reachable when the exclusions actually MATCH: git has to recognise + `app/_bmad-output/.../sprint-status.yaml` and the spec as excluded before its + tracked-diff probe can come back empty. + + Under the sibling shape this is unaskable — the artifacts sit outside the code + tree, so a `project`-rooted exclude names nothing there, but so does a correct + one, and `_changes_since` answers False either way. The two seam rows above + say exactly that about their own fixture; this row is the counterexample their + prose now points at. + + Ablation: force `root=paths.project` at the `verify_dev_exclude_relpaths` call + site inside `_verify_shared_gates.proof_of_work_probe` and this reddens with + `ok=True` — the exclusions stop matching the two tracked bookkeeping changes, + which then count as the work the attempt never did. + + Deliberately asserts the SPECIFIC reason: `not out.ok` alone passes for every + other gate this function runs (workflow tag, status, baseline match, sprint + pair), none of which is what this row is about. + """ + paths = nested_repo_root_paths(project) + # the premise the refusal rests on: genuinely divergent, and genuinely NESTED. + # `!=` alone is satisfied by the sibling shape, under which the exclusions + # match nothing and this row would grade a different question entirely. + assert paths.project != paths.repo_root + assert paths.project.parent == paths.repo_root + # Seed the bookkeeping as tracked content first. The attempt below then + # exercises git's exclude pathspec branch, not only the separate untracked + # filtering branch in `_changes_since`. + initial_baseline = verify.rev_parse_head(paths.repo_root) + write_sprint(paths, {"1-1-a": "ready-for-dev"}) + sp = spec_path(paths, "1-1-a") + write_spec(sp, "ready-for-dev", initial_baseline) + git( + paths.repo_root, + "add", + paths.sprint_status.relative_to(paths.repo_root).as_posix(), + sp.relative_to(paths.repo_root).as_posix(), + ) + git(paths.repo_root, "commit", "-q", "-m", "seed tracked BMAD bookkeeping") + + task = StoryTask(story_key="1-1-a", epic=1) + # the baseline is stamped where the session's cwd is: the CODE tree + task.baseline_commit = verify.rev_parse_head(paths.repo_root) + write_sprint(paths, {"1-1-a": "review"}) + write_spec(sp, "in-review", task.baseline_commit) + # ...and no source edit at all: the spec flip and the board ARE the residue + + out = verify.verify_dev(task, paths, dev_result(sp)) + + assert not out.ok + assert out.reason == "no changes in worktree since baseline commit" + # the same attempt with one real source edit passes, so the refusal above is + # about the missing work and not about the fixture being unusable + (paths.project / "src.txt").write_text("real work\n", encoding="utf-8") + assert verify.verify_dev(task, paths, dev_result(sp)).ok + + +def test_verify_dev_stories_refuses_bookkeeping_only_changes_under_the_monorepo_shape(project): + """The stories-mode twin of the outcome row above (DW-9). + + `_stories_relpaths` had a VALUE row and a SEAM row but no outcome row, so its + production caller — the stories-mode proof-of-work gate — was still graded + solely by `test_verify_dev_stories_roots_its_exclude_on_the_code_tree`, whose + own amended docstring now says that fixture cannot separate the two roots. A + value row proves the helper computes the right string; only this proves the + gate ACTS on it. + + Same construction as `test_verify_dev_refuses_a_bare_spec_flip_under_the_monorepo_shape`, + driving `verify_dev_stories` instead: the attempt's residue is only bookkeeping + git must recognise as excluded before the tracked-diff probe can come back empty. + Nested, the exclude is `app/_bmad-output/planning-artifacts/epic-a/...`; rooted + on `project` it loses the `app/` prefix, matches nothing in the code tree, and + the bookkeeping then counts as the work the attempt never did. + + The residue is deliberately NOT the driven story's own spec. That file is + already excluded by `verify_dev_exclude_relpaths` (the gate's own + `spec_path` exclusion), so a row whose only residue was the spec refuses + identically whichever root `_stories_relpaths` was given — it grades a + different exclude and passes the relevant ablation. The residue is therefore + the two paths ONLY `_stories_relpaths` covers, one per element of its returned + tuple: the `stories.yaml` manifest, and a sibling record under `stories/`. + + Ablation: pass `paths.project` to `_stories_relpaths` at its call site in + `verify_dev_stories` and this reddens with `ok=True`. + + Asserts the SPECIFIC reason for the reason the dev twin gives: `not out.ok` + alone is reachable from every other gate this function runs (spec resolution, + id prefix, workflow tag, status, baseline match). + """ + paths = nested_repo_root_paths(project) + assert paths.project != paths.repo_root + assert paths.project.parent == paths.repo_root + spec_folder = paths.planning_artifacts / "epic-a" + initial_baseline = verify.rev_parse_head(paths.repo_root) + sp = write_story(spec_folder, "1", "x", "ready-for-dev", initial_baseline) + manifest = spec_folder / "stories.yaml" + manifest.write_text("stories: [1]\n", encoding="utf-8") + sibling = write_story(spec_folder, "2", "y", "draft", initial_baseline) + git( + paths.repo_root, + "add", + spec_folder.relative_to(paths.repo_root).as_posix(), + ) + git(paths.repo_root, "commit", "-q", "-m", "seed tracked stories bookkeeping") + + task = StoryTask(story_key="1", epic=1) + # stamped where the session's cwd is: the CODE tree + task.baseline_commit = verify.rev_parse_head(paths.repo_root) + write_story(spec_folder, "1", "x", "done", task.baseline_commit) + # The manifest and sibling record are tracked modifications, so git itself + # must honor both pathspecs returned by `_stories_relpaths`. + manifest.write_text("stories: [1, 2]\n", encoding="utf-8") + write_spec(sibling, "done", task.baseline_commit) + + out = verify.verify_dev_stories( + task, paths, dev_result(sp), spec_folder=spec_folder, review_enabled=False + ) + + assert not out.ok + assert out.reason == "no changes in worktree since baseline commit" + # the same attempt with one real source edit passes, so the refusal above is + # about the missing work and not about the fixture being unusable + (paths.project / "src.txt").write_text("real work\n", encoding="utf-8") + assert verify.verify_dev_stories( + task, paths, dev_result(sp), spec_folder=spec_folder, review_enabled=False + ).ok + + def test_artifact_relpaths_returns_in_repo_folders(project): """The orchestrator-owned artifact folders, repo-relative posix.""" rels = verify.artifact_relpaths(project) @@ -7268,17 +7502,25 @@ def test_verify_dev_roots_its_exclude_on_the_code_tree(project, tmp_path, monkey """The gate's OWN exclude composition, pinned at the seam. `_stories_relpaths` has carried a seam pin since this wave landed; the sprint - gate's call did not, and no outcome row can supply one. Under the supported - override the artifact tree is disjoint from the code tree, so a `project`-rooted - exclude yields pathspecs git matches nothing against — and `has_changes_since` - fails OPEN (`rc != 0 -> return True`), so the passing row stays green and the - refusal row reddens for its own unrelated reason. Reverting `root=paths.repo_root` - to `root=paths.project` left the entire suite green before this row existed, which - is how the anchor this wave exists to establish could be silently undone. - - The contract is therefore the ROOT itself, exactly as the stories-mode row states - it: a pathspec relative to the wrong root is not merely wrong, it is SILENTLY - wrong. + gate's call did not, and no outcome row OVER THE SIBLING FIXTURE can supply one. + Under `_repo_root_override` the artifact tree is disjoint from the code tree, so + a `project`-rooted exclude yields pathspecs git matches nothing against — and + `has_changes_since` fails OPEN (`rc != 0 -> return True`), so the passing row + stays green and the refusal row reddens for its own unrelated reason. Reverting + `root=paths.repo_root` to `root=paths.project` left the entire suite green before + this row existed, which is how the anchor this wave exists to establish could be + silently undone. + + The contract here is therefore the ROOT itself, exactly as the stories-mode row + states it: a pathspec relative to the wrong root is not merely wrong, it is + SILENTLY wrong. + + "No outcome row can supply one" was true of this fixture and is no longer true + of the function: `test_verify_dev_refuses_a_bare_spec_flip_under_the_monorepo_shape` + is that outcome row, built on the NESTED shape where the artifacts live inside + the code tree and the wrong pathspec is plausible rather than empty. Both rows + are kept — the seam pin grades the disjoint layout, which is a supported + configuration the outcome row does not cover. Ablation: pass `root=paths.project` at the call site and the recorded root reddens. """ From ac99718a446352997eedff47c8dccae16f37f983 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 05:24:11 -0700 Subject: [PATCH 07/45] sweep dw-session-task-id-namespace: DW-7, DW-8 via bmad-loop --- CHANGELOG.md | 4 + docs/FEATURES.md | 4 +- src/bmad_loop/adapters/generic.py | 7 +- src/bmad_loop/adapters/opencode_http.py | 7 +- src/bmad_loop/cli.py | 11 +- src/bmad_loop/engine.py | 13 +-- src/bmad_loop/model.py | 16 +-- src/bmad_loop/resolve.py | 32 +++++- src/bmad_loop/sweep.py | 29 ++++++ tests/test_cli.py | 48 ++++++++- tests/test_generic_tmux.py | 27 +++++ tests/test_opencode_http.py | 26 +++++ tests/test_resolve.py | 127 +++++++++++++++++++++++- tests/test_sweep.py | 107 +++++++++++++++++++- 14 files changed, 427 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37312e08..620a480b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,10 @@ breaking changes may land in a minor release. ### Fixed +- Prevent escalated sweep restarts from reusing abandoned session ids, and clear stale + `escalation.json` artifacts when either adapter reuses a task directory. +- Route interactive resolve task ids through the shared whole-composition sanitizer while + preserving generation-zero ids for clean story keys. - A verify command whose child cannot be started pauses the run instead of crashing it. Any spawn-time `OSError` — most often a working directory that is missing, is a regular file, or cannot be searched, but a missing shell or EMFILE too — raised out of `subprocess.run` past diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 2acf82ba..50bc2cb3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -132,7 +132,9 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w resuming" advice, since it otherwise resumes in the same gesture. Each re-arm also bumps a per-task **generation**, so the re-minted session id cannot collide with the abandoned attempt's record — ids already on disk keep their exact spelling, since the suffix appears only above generation zero - (#705). + (#705). Sweep migration and triage tasks make the same rollover automatically when an + `ESCALATED` task restarts with a fresh attempt budget; mid-flight, non-escalated restarts keep + their current generation because their continuing attempt counter already provides a fresh id. - Attempt-owned sprint-spec recovery (#123, #630): a bound plain attempt whose only residue is its own lifecycle flip is normalized back to its pre-attempt lifecycle status, proven Git-clean, and retried. Every bound retry chain snapshots its first spec input byte-for-byte and retains it across both dev-verification and review-verification repair sessions; a resolved re-drive therefore retains the operator-corrected `ready-for-dev` input rather than a failed child's later body. Repair entry points validate retained authority before constructing a prompt that can reset the spec. A non-fixable retry parks the failed child first, restores that snapshot, and re-establishes the promised route after resetting sibling residue. The same snapshot restores pre-launch operator edits when a plain child puts a tracked spec back at Git baseline. Git-ignored and pre-existing-untracked bound specs use the byte snapshot as their dirtiness oracle and are force-included only in the private recovery ref before restoration; index-only force-adds and cached removals also trigger cleanup and restore baseline index ownership. That real repair reports `rollback-owned-spec-restored`, never `rollback-skipped-clean`. Missing, unreadable, deleted, retargeted, changed external, or unsafe legacy authority pauses once with spec-specific adoption instructions and clears the unusable pair so manual recovery can converge; recovery also refuses a reset whose baseline would replace the canonical path or a parent directory with a symlink, tree, file, or other unsafe shape. An initial Sprint binding fault may safely degrade to an unbound bare-key launch; an existing Stories folder+id target instead aborts unless it can be snapshotted. Once an explicit binding is durable, a later snapshot fault aborts before child launch while retaining that authority for recovery. Fresh sprint tasks with no recorded path remain bare-key dispatches; other substantive changes or sibling residue follow rollback policy; Stories remains folder+id; Sweep remains intent-bundle routing; snapshots are retired after commit; and recovery never auto-commits the human correction. - Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, the dev primitive saves the attempted change as a patch file (referenced from the halt output) before reverting the tree. If that reading turns out to be correct, the resolve agent adds `"restore_patch": ""` to its `resolution.json`; the orchestrator re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing. `bmad-loop resolve --no-interactive --restore-patch ` does the same by hand. A patch that fails to apply escalates rather than dispatching onto a half-restored tree. Sweep bundles get the same recovery. diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 1c798a63..643445b2 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -541,9 +541,12 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: task_dir = self.tasks_dir / spec.task_id task_dir.mkdir(parents=True, exist_ok=True) (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8") - # A re-armed/resumed run reuses task_ids; drop any prior cycle's result - # so a session that writes nothing can't be read as a stale completion. + # Task ids are supplied by the caller, so defensively reset cycle-scoped + # outputs if one is reused. A silent session must not inherit a stale result. (task_dir / "result.json").unlink(missing_ok=True) + # The sweep skill also writes escalation.json here, and + # `resolve._gather_escalations` reads it alongside result.json. + (task_dir / "escalation.json").unlink(missing_ok=True) self._ensure_session(spec.cwd) # Stamped before launch: hook events carry wall-clock ns, and diff --git a/src/bmad_loop/adapters/opencode_http.py b/src/bmad_loop/adapters/opencode_http.py index 6f1b7ac3..df13fe3b 100644 --- a/src/bmad_loop/adapters/opencode_http.py +++ b/src/bmad_loop/adapters/opencode_http.py @@ -622,9 +622,12 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: task_dir = self.tasks_dir / spec.task_id task_dir.mkdir(parents=True, exist_ok=True) (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8") - # A re-armed/resumed run reuses task_ids; drop any prior cycle's result - # so a session that writes nothing can't be read as a stale completion. + # Task ids are supplied by the caller, so defensively reset cycle-scoped + # outputs if one is reused. A silent session must not inherit a stale result. (task_dir / "result.json").unlink(missing_ok=True) + # The sweep skill also writes escalation.json here, and + # `resolve._gather_escalations` reads it alongside result.json. + (task_dir / "escalation.json").unlink(missing_ok=True) # Same hazard, same reason, for the file the #194 tail scan reads (mirrors # GenericAdapter.start_session, which unlinks its pane tee here). This one # bites hardest on the path the classifier exists to serve: an env fault diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index fc20550d..ef81a5a1 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3105,7 +3105,16 @@ def cmd_resolve(args: argparse.Namespace) -> int: print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…") try: produced = resolve.run_session( - adapters["dev"], project, run_dir, story_key, model=model + adapters["dev"], + project, + run_dir, + story_key, + # This CALL precedes the re-arm below, so the generation it passes is + # the one still on disk — the pre-bump value. Not an ordering of the + # read: `rearm_escalation` reloads state and bumps its own copy, so + # this `task` object reads the same either way. + generation=task.generation, + model=model, ) except NotImplementedError: print( diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 64d6b3a6..c43ded5f 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -369,12 +369,13 @@ def _session_task_id(story_key: str, part: str, seq: int, generation: int) -> st 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 once per human re-arm - (``runs.rearm_escalation``). Re-arm resets ``attempt`` to 0 and the next - dispatch bumps it back to 1, so without this the re-minted id was BYTE-EQUAL - to a record the abandoned attempt already appended to ``task.sessions`` — and - ``_resumable_session``, which scans that append-only list, replayed the - abandoned attempt's verdict for the fresh one (#705). + ``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 diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index b16e7b9e..54923d36 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -202,16 +202,16 @@ class StoryTask: # rather than burning another cycle. Reset to 0 by runs.rearm_escalation so a # human-resolved re-drive gets a fresh damping budget. Survives the round-trip. followup_reviews_spent: int = 0 - # How many times a human re-arm (`runs.rearm_escalation`) has re-opened this - # task. Re-arm resets `attempt` to 0 and the next dispatch bumps it back to 1, - # so without a discriminator the re-minted session task_id is byte-equal to a - # record the ABANDONED attempt already appended to the append-only `sessions` - # list — and `Engine._resumable_session`, which matches on that id, replays the - # abandoned attempt's verdict for the fresh one (#705). Feeds + # Session-id namespace rollovers performed when an escalated task is reopened + # while its attempt budget resets to 0. This happens in `runs.rearm_escalation` + # and in the sweep engine's ESCALATED restart arms. The next dispatch bumps the + # attempt back to 1, so without a discriminator the re-minted session task_id is + # byte-equal to a record the ABANDONED attempt already appended to the + # append-only `sessions` list. Feeds # `engine._session_task_id`, which emits the suffix only above zero, so every # id already on disk stays byte-identical across the upgrade. `task.sessions` - # is deliberately NOT cleared at re-arm: the run-dir audit trail it indexes is - # read by a second resolve cycle. + # is deliberately NOT cleared when a task is reopened: the run-dir audit trail + # it indexes is read by a later resolve cycle. generation: int = 0 # set from the bmad-build-auto session's `followup_review_recommended` # frontmatter (PR #2505): when True and review.trigger = "recommended", the diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 34a71c78..a5275da9 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -21,6 +21,7 @@ from typing import Any from .adapters.base import SessionSpec +from .engine import _session_task_id from .model import RunState from .platform_util import safe_segment from .runs import ( @@ -252,14 +253,41 @@ def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, A return ctx -def run_session(adapter, project: Path, run_dir: Path, story_key: str, *, model: str = "") -> bool: +def run_session( + adapter, + project: Path, + run_dir: Path, + story_key: str, + *, + generation: int, + model: str = "", +) -> bool: """Launch the interactive resolve agent attached to the caller's terminal. Blocks until the agent session exits. Returns whether the agent produced a resolution marker. The context file must already be written (build_context). + + Nothing consumes this session's ``task_id``: no task dir is created, + ``interactive_argv`` ignores it, ``interactive_env`` does not export it, and no + ``SessionRecord`` is appended. It is minted through ``engine._session_task_id`` + anyway because this was the FOURTH hand-mint site outside that chokepoint and + there are now none. The property being restored is whole-composition + sanitization: sanitizing ``story_key`` alone and concatenating the suffix AFTER + can push a key already at ``MAX_SEGMENT`` back past it, and ``safe_segment``'s + digest differs between the two orders. Since nothing reads the id, no collision + follows from one — ``seq`` is a hardcoded 1 and several ``cmd_resolve`` paths + return before the re-arm, so a later resolve of the same story may legitimately + re-mint a byte-identical id. + + ``generation`` is required with no default for the reason that docstring gives — + an implicit 0 is right in every run that never re-armed and wrong only on the one + that did, so a default here would reproduce the defect at this seam. ``cmd_resolve`` + CALLS this before ``runs.rearm_escalation``, so the value it passes is the one still + on disk, i.e. pre-bump. Not because the read is ordered against the bump: the re-arm + reloads state and mutates its own copy, leaving the caller's ``task`` untouched. """ spec = SessionSpec( - task_id=f"{safe_segment(story_key)}-resolve-1", + task_id=_session_task_id(story_key, "resolve", 1, generation), role="dev", prompt=f"/bmad-loop-resolve {story_key}", cwd=project, diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index d0f1de45..b3947afe 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -536,6 +536,33 @@ def ask(self, decision: Decision) -> DecisionOption: # ------------------------------------------------------------ sweep engine +def _rearm_generation(task: StoryTask) -> None: + """Open a new session-id generation for a sweep task restarting from ESCALATED. + + The restart resets ``attempt`` to 0 for a fresh budget, and that reset is exactly + what makes the next dispatch re-mint ``attempt == 1`` — an id byte-equal to the + abandoned attempt's, since ``engine._session_task_id`` emits its discriminator only + above zero. The artifact a shared id corrupts is ``tasks//escalation.json``: the + sweep skill writes it and ``resolve._gather_escalations`` reads it once per RECORDED + session, so two records carrying one id return the abandoned cycle's escalation for + the fresh session too. ``result.json`` is NOT at risk: both adapters unlink it in + ``start_session``. + + Same pattern as ``runs.rearm_escalation``, DIFFERENT reason: #705's harm is + ``_resumable_session`` verdict replay, which runs only on the dev/review phases and + never reaches ``TRIAGE_RUNNING``/``TRIAGE_VERIFY``. ``cmd_resolve`` *can* reach a + sweep task (``_escalate`` raises with ``PAUSE_ESCALATION`` and a story key, which + the engine persists), and its own bump there is harmless: the re-arm leaves the task + PENDING, so this restart arm does not fire on top of it. + + Call ONLY from the ``Phase.ESCALATED`` arm. A non-escalated restart keeps its + attempt counter, so ``attempt += 1`` already yields a fresh id; bumping there would + move the namespace for nothing and break the "every id already on disk stays + byte-identical" property the suffix rule exists to hold. + """ + task.generation += 1 + + class SweepEngine(Engine): """Engine variant whose loop processes the deferred-work ledger instead of sprint-status. Bundles reuse the inherited story pipeline through the @@ -891,6 +918,7 @@ def _ensure_migration(self, text: str) -> None: self.journal.append("resume-restart", story_key=MIGRATE_KEY, phase=str(task.phase)) if task.phase == Phase.ESCALATED: task.attempt = 0 # the human resumed deliberately; fresh budget + _rearm_generation(task) # ...and into a fresh session-id namespace if task.baseline_commit and not verify.worktree_clean(self.workspace.root): self._safe_reset(task) # a session died mid-rewrite; restore our ledger text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" @@ -1172,6 +1200,7 @@ def _ensure_triage(self, open_now: set[str], cycle: int = 1) -> TriagePlan: self.journal.append("resume-restart", story_key=triage_key, phase=str(task.phase)) if task.phase == Phase.ESCALATED: task.attempt = 0 # the human resumed deliberately; fresh budget + _rearm_generation(task) # ...and into a fresh session-id namespace task.phase = Phase.PENDING # deliberate reset, not a normal transition feedback: Path | None = None diff --git a/tests/test_cli.py b/tests/test_cli.py index e5130cd2..ed624027 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3147,6 +3147,44 @@ def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): assert load_state(run_dir).tasks["s1"].phase == Phase.PENDING +def test_resolve_passes_the_tasks_own_generation_to_the_session(tmp_path, monkeypatch): + """`cmd_resolve` hands the resolve session the generation it read off the task — a + real value, not a constant. The row seeds a NON-zero generation deliberately: + `escalated_run` builds tasks at the default 0, so an `assert seen == [0]` cannot + tell "read from the task" from "hardcoded 0" — that earlier form passed with + `generation=task.generation` ablated to a literal `generation=0`. + + The call precedes `rearm_escalation`, so the value passed is the pre-bump one still + on disk. That follows from call ORDER, not from where the read sits: the re-arm + reloads state and bumps its own copy, leaving this `task` object untouched either + way. Nothing consumes the resulting id, so no collision claim rides on it.""" + from bmad_loop import resolve + from bmad_loop.journal import load_state, save_state + + _escalated_run(tmp_path, "r1") + run_dir = tmp_path / ".bmad-loop" / "runs" / "r1" + state = load_state(run_dir) + state.tasks["s1"].generation = 2 # a story already re-armed twice + save_state(run_dir, state) + seen: list[int] = [] + + def fake_session(adapter, project, rd, story_key, *, generation, model=""): + seen.append(generation) + marker = resolve.resolution_path(rd, story_key) + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + return True + + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "run_session", fake_session) + # --no-resume: re-arm only, so the bump this row contrasts against still runs + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + assert seen == [2] # the task's own generation, not a constant + assert load_state(run_dir).tasks["s1"].generation == 3 # the re-arm bumped past it + + def test_resolve_interactive_unsupported_adapter(tmp_path, monkeypatch, capsys): from bmad_loop import resolve @@ -3384,7 +3422,7 @@ def test_resolve_restore_patch_unresolvable_from_resolution_json_rejected( run_dir = _escalated_run(tmp_path, "r1", spec_file=str(spec)) ran: list = [] - def fake_session(adapter, project, rd, story_key, *, model=""): + def fake_session(adapter, project, rd, story_key, *, generation, model=""): # the resolve agent records a restore_patch in its output marker ran.append(story_key) marker = resolve.resolution_path(rd, story_key) @@ -3569,7 +3607,7 @@ def test_resolve_interactive_restore_patch_from_resolution_json(tmp_path, monkey patch.write_text("diff", encoding="utf-8") run_dir = _escalated_run(tmp_path, "r1", spec_file=str(spec)) - def fake_session(adapter, project, rd, story_key, *, model=""): + def fake_session(adapter, project, rd, story_key, *, generation, model=""): # the resolve agent records a restore_patch in its output marker marker = resolve.resolution_path(rd, story_key) marker.parent.mkdir(parents=True, exist_ok=True) @@ -3625,7 +3663,7 @@ def test_resolve_rereads_isolation_after_the_agent_session( _write_policy(tmp_path, '[scm]\nisolation = "none"\n') _escalated_run(tmp_path, "r1", spec_file=str(spec)) - def fake_session(adapter, project, rd, story_key, *, model=""): + def fake_session(adapter, project, rd, story_key, *, generation, model=""): # the human and the agent conclude the story needs isolation, and the operator # edits policy.toml from another terminal while the session is still open if flipped_mid_session: @@ -3668,7 +3706,7 @@ def test_resolve_corrupt_resolution_json_aborts_loudly(tmp_path, monkeypatch, ca spec.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") run_dir = _escalated_run(tmp_path, "r1", spec_file=str(spec)) - def fake_session(adapter, project, rd, story_key, *, model=""): + def fake_session(adapter, project, rd, story_key, *, generation, model=""): marker = resolve.resolution_path(rd, story_key) marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text('{"restore_patch": "artifacts/attempt.patch",}', encoding="utf-8") @@ -3699,7 +3737,7 @@ def test_resolve_empty_restore_patch_field_aborts_loudly(tmp_path, monkeypatch, spec.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") run_dir = _escalated_run(tmp_path, "r1", spec_file=str(spec)) - def fake_session(adapter, project, rd, story_key, *, model=""): + def fake_session(adapter, project, rd, story_key, *, generation, model=""): marker = resolve.resolution_path(rd, story_key) marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text(json.dumps({"restore_patch": ""}), encoding="utf-8") diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 19f7f099..ea8e4800 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -3137,6 +3137,33 @@ def test_start_session_resets_reused_task_log(tmp_path): assert _classify(adapter, "timeout", task_id=task_id).env_fault is False +def test_start_session_drops_a_reused_task_dirs_escalation(tmp_path): + """The sweep skill writes `escalation.json` into tasks// and + `resolve._gather_escalations` reads it beside result.json. A re-armed run reuses + task_ids, so a prior cycle's escalation left there is handed to whatever session + lands on the id next — the same reuse hazard result.json's unlink already covers, + against a third reader. An ABSENT file must still start cleanly (missing_ok).""" + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + adapter._ensure_session = lambda cwd: None # skip the tmux server plumbing + task_id = _ENV_FAULT_TASK + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir(parents=True, exist_ok=True) + stale = task_dir / "escalation.json" + stale.write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "last cycle"}]}), + encoding="utf-8", + ) + + adapter.start_session(make_spec(tmp_path, task_id=task_id)) + assert not stale.exists() + + # ...and with the file already gone the unlink is a no-op, not an error. What this + # second call asserts is that it RETURNS (the missing_ok path); re-asserting the + # file's absence would only restate the line above, since nothing re-created it. + assert adapter.start_session(make_spec(tmp_path, task_id=task_id)) is not None + + def test_classify_env_fault_bounds_pathological_pattern(tmp_path, monkeypatch): """A pathological operator regex can't hang run() teardown: each match is bounded by ENV_FAULT_MATCH_TIMEOUT_S, and exceeding it aborts the WHOLE scan and declines diff --git a/tests/test_opencode_http.py b/tests/test_opencode_http.py index a0492cf5..d2d61d9c 100644 --- a/tests/test_opencode_http.py +++ b/tests/test_opencode_http.py @@ -1290,6 +1290,32 @@ def test_missing_binary_is_a_clean_error(tmp_path): adapter.start_session(spec) +def test_start_session_drops_a_reused_task_dirs_escalation(tmp_path): + """Parity with GenericAdapter: both adapters own a tasks// dir, so both must + drop a prior cycle's `escalation.json` — the file the sweep skill writes and + `resolve._gather_escalations` reads beside result.json — before a re-armed run + reusing the id lands there. No fake server needed: the unlink runs BEFORE + _spawn_server's PATH check raises, so a missing binary still exercises it.""" + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spec = SessionSpec(task_id="t-1", role="triage", prompt="p", cwd=tmp_path) + task_dir = adapter.tasks_dir / "t-1" + task_dir.mkdir(parents=True, exist_ok=True) + stale = task_dir / "escalation.json" + stale.write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "last cycle"}]}), + encoding="utf-8", + ) + + with pytest.raises(OpencodeServerError, match="not found on PATH"): + adapter.start_session(spec) + assert not stale.exists() + + # ...and the ordinary case — no prior escalation — reaches the same spawn error, + # i.e. the unlink is missing_ok and did not become the failure itself + with pytest.raises(OpencodeServerError, match="not found on PATH"): + adapter.start_session(spec) + + def test_kill_unknown_handle_is_a_noop(tmp_path): adapter = make_adapter(tmp_path) adapter.kill(SessionHandle(task_id="never-started", native_id="ses_x")) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 064e70c6..1a55a99f 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -9,11 +9,13 @@ from conftest import escalated_run, git from bmad_loop import devcontract, platform_util, resolve, runs, verify +from bmad_loop.engine import _session_task_id from bmad_loop.journal import load_state, save_state from bmad_loop.model import ( PAUSE_ESCALATION, Phase, RunState, + SessionRecord, ) from bmad_loop.platform_util import safe_segment @@ -2000,6 +2002,43 @@ def test_rearm_rejects_unescalated_story(tmp_path): runs.rearm_escalation(run_dir, isolated_redrive=False) +# ------------------------------------------------- _gather_escalations + + +def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): + """The outermost surface DW-7 names. `_gather_escalations` walks the append-only + `task.sessions` — which a re-arm deliberately does NOT clear — and reads + `tasks//escalation.json` once per record. While the ESCALATED + restart re-minted an id byte-equal to the abandoned attempt's, BOTH records + addressed the one file, so the abandoned cycle's escalation was returned a second + time as the fresh session's. Bumping `generation` gives the fresh record its own + id, and the file is read exactly once.""" + run_dir, state, task = _escalated_run(tmp_path) + key = "6-4-cli-list-command" + abandoned = _session_task_id(key, "triage", 1, 0) + fresh = _session_task_id(key, "triage", 1, 1) # post-bump: the -g1 namespace + assert abandoned != fresh + + task.sessions.clear() + task.sessions.append(SessionRecord(task_id=abandoned, role="dev", status="completed")) + task.sessions.append(SessionRecord(task_id=fresh, role="dev", status="completed")) + esc_dir = run_dir / "tasks" / abandoned + esc_dir.mkdir(parents=True, exist_ok=True) + (esc_dir / "escalation.json").write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "abandoned cycle"}]}), + encoding="utf-8", + ) + + found = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in found] == ["abandoned cycle"] # once, not twice + + # the pre-fix shape for contrast: one shared id makes the SAME file answer both + # records, and the abandoned escalation is attributed to the fresh session too + task.sessions[1] = SessionRecord(task_id=abandoned, role="dev", status="completed") + collided = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in collided] == ["abandoned cycle", "abandoned cycle"] + + # ----------------------------------------------------------- run_session @@ -2024,7 +2063,10 @@ def fake_subprocess_run(argv, cwd, env): monkeypatch.setattr(resolve.subprocess, "run", fake_subprocess_run) adapter = _FakeAdapter(None) - assert resolve.run_session(adapter, tmp_path, run_dir, "6-4-cli-list-command") is True + assert ( + resolve.run_session(adapter, tmp_path, run_dir, "6-4-cli-list-command", generation=0) + is True + ) def test_run_session_no_resolution(tmp_path, monkeypatch): @@ -2032,7 +2074,10 @@ def test_run_session_no_resolution(tmp_path, monkeypatch): resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") monkeypatch.setattr(resolve.subprocess, "run", lambda *a, **k: None) assert ( - resolve.run_session(_FakeAdapter(None), tmp_path, run_dir, "6-4-cli-list-command") is False + resolve.run_session( + _FakeAdapter(None), tmp_path, run_dir, "6-4-cli-list-command", generation=0 + ) + is False ) @@ -2046,11 +2091,87 @@ def test_run_session_clears_stale_marker(tmp_path, monkeypatch): stale.write_text('{"from": "last time"}', encoding="utf-8") monkeypatch.setattr(resolve.subprocess, "run", lambda *a, **k: None) # agent records nothing assert ( - resolve.run_session(_FakeAdapter(None), tmp_path, run_dir, "6-4-cli-list-command") is False + resolve.run_session( + _FakeAdapter(None), tmp_path, run_dir, "6-4-cli-list-command", generation=0 + ) + is False ) assert not stale.exists() # stale marker was removed, not reused +class _SpecCapture(_FakeAdapter): + """Records the SessionSpec `run_session` built; its task_id is the whole subject.""" + + def __init__(self): + super().__init__(None) + self.specs: list = [] + + def interactive_argv(self, spec): + self.specs.append(spec) + return super().interactive_argv(spec) + + +def _minted_id(tmp_path, monkeypatch, story_key, generation) -> str: + monkeypatch.setattr(resolve.subprocess, "run", lambda *a, **k: None) + adapter = _SpecCapture() + resolve.run_session(adapter, tmp_path, tmp_path / "run", story_key, generation=generation) + # without this, a run_session that stopped building a spec at all would fail every + # id row below with a bare IndexError rather than naming what broke + assert adapter.specs, "run_session built no SessionSpec" + return adapter.specs[0].task_id + + +def test_run_session_id_is_byte_identical_to_the_hand_mint_at_generation_zero( + tmp_path, monkeypatch +): + """Routing the id through `engine._session_task_id` must not move any run-dir + path already on disk: for an ordinary clean key at generation 0 the composition + point returns exactly what the hand-mint did.""" + assert _minted_id(tmp_path, monkeypatch, "6-4-cli-list-command", 0) == ( + "6-4-cli-list-command-resolve-1" + ) + + +def test_run_session_id_carries_the_generation_discriminator(tmp_path, monkeypatch): + """A resolve spec in generation 1 carries that namespace discriminator. + + Repeated sessions before a successful re-arm may legitimately stay in the same + generation; this row tests generation composition, not per-invocation uniqueness. + """ + assert _minted_id(tmp_path, monkeypatch, "6-4-cli-list-command", 1) == ( + "6-4-cli-list-command-resolve-1-g1" + ) + + +def test_run_session_id_sanitizes_the_whole_composition(tmp_path, monkeypatch): + """The property the hand-mint lost. `safe_segment` caps at MAX_SEGMENT and is + identity for a clean name, so sanitizing the KEY alone and concatenating the + suffix AFTER returns a segment past the cap for a key already at it — while + sanitizing the whole composition returns one legal segment.""" + key = "a" * platform_util.MAX_SEGMENT + minted = _minted_id(tmp_path, monkeypatch, key, 0) + + assert minted == safe_segment(f"{key}-resolve-1") + assert len(minted) <= platform_util.MAX_SEGMENT + # ...whereas the part-wise mint this replaced overflowed (130 chars) + assert len(safe_segment(key) + "-resolve-1") > platform_util.MAX_SEGMENT + + +def test_run_session_id_digest_differs_from_the_part_wise_order(tmp_path, monkeypatch): + """The OTHER half of `_session_task_id`'s stated contract. `safe_segment` digests + the string it was handed, so for a dirty key the two orders differ in content, not + just in length: sanitize-then-append embeds a digest of the bare key, while the + chokepoint embeds a digest of the whole composition. The cap row above covers + length; nothing covered this.""" + dirty = "6-4:cli?list" + assert safe_segment(dirty) != dirty # the key really is dirty + + minted = _minted_id(tmp_path, monkeypatch, dirty, 0) + + assert minted == safe_segment(f"{dirty}-resolve-1") + assert minted != safe_segment(dirty) + "-resolve-1" + + # ---------------- item 9: build_context stories-mode enrichment -------------- diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 3085a0be..a75b1a2c 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -2392,6 +2392,9 @@ def test_triage_session_env_fault_escalates_then_resume_restores_budget(project) dec = [e for e in engine.journal.entries() if e["kind"] == "triage-decision"][-1] assert dec["env_fault"] is True + abandoned = [s.task_id for s in adapter.sessions] + assert abandoned == ["sweep-triage-triage-1"] # generation 0 emits no suffix + # resume once the outage clears: the ESCALATED-resume resets attempt to 0 # (fresh budget) and re-drives triage to completion good = triage_result(["DW-1"], skip=[{"id": "DW-1", "reason": "moot"}]) @@ -2399,6 +2402,47 @@ def test_triage_session_env_fault_escalates_then_resume_restores_budget(project) assert not resumed.run().paused assert resumed.state.tasks["sweep-triage"].phase == Phase.DONE assert len(radapter.sessions) == 1 + # ...and it does so in a NEW generation. The attempt reset above is exactly what + # would otherwise re-mint `attempt == 1` — an id byte-equal to the abandoned + # attempt's, pointing the fresh record at the abandoned cycle's + # tasks//escalation.json, which `resolve._gather_escalations` reads per record. + # (result.json is not the hazard here: both start_sessions unlink it on launch.) + assert resumed.state.tasks["sweep-triage"].generation == 1 + assert [s.task_id for s in radapter.sessions] == ["sweep-triage-triage-1-g1"] + assert radapter.sessions[0].task_id not in abandoned + + +def test_repeated_triage_escalation_restarts_keep_advancing_generation(project): + """Every ESCALATED restart opens a new namespace, not only the first one. + + Starting from generation zero alone would let ``generation += 1`` regress to + ``generation = 1`` while every first-restart assertion stayed green. A second + escalation proves the next reset advances to generation two and cannot re-mint + either earlier session id. + """ + write_ledger(project, {"DW-1": "open"}) + outage = SessionResult( + status="timeout", + env_fault=True, + env_fault_evidence="API Error: Unable to connect (ECONNREFUSED)", + ) + engine, first = make_sweep(project, [outage]) + assert engine.run().paused + first_id = first.sessions[0].task_id + + resumed_once, second = resume_sweep(project, engine, [outage]) + assert resumed_once.run().paused + assert resumed_once.state.tasks["sweep-triage"].generation == 1 + second_id = second.sessions[0].task_id + assert second_id == "sweep-triage-triage-1-g1" + + good = triage_result(["DW-1"], skip=[{"id": "DW-1", "reason": "moot"}]) + resumed_twice, third = resume_sweep(project, resumed_once, [triage_effect(good)]) + assert not resumed_twice.run().paused + assert resumed_twice.state.tasks["sweep-triage"].generation == 2 + third_id = third.sessions[0].task_id + assert third_id == "sweep-triage-triage-1-g2" + assert len({first_id, second_id, third_id}) == 3 def test_triage_plain_timeout_still_retries_to_cap(project): @@ -2497,6 +2541,59 @@ def test_triage_escalation_resume_retries_triage(project): assert len(adapter.sessions) == 1 +def test_non_escalated_triage_restart_keeps_its_generation(project): + """Control for the ESCALATED-arm bump: a task restarted from a NON-escalated + phase (the host died mid-triage) keeps its attempt counter, so `attempt += 1` + already yields a fresh number and the namespace must not move. Bumping outside + that arm would break the property `_session_task_id`'s suffix rule exists to + hold — every id an existing run already wrote to disk stays byte-identical.""" + write_ledger(project, {"DW-1": "open"}) + good = triage_result(["DW-1"], skip=[{"id": "DW-1", "reason": "moot"}]) + engine, adapter = make_sweep(project, [triage_effect(good)]) + # a session that never reported: TRIAGE_RUNNING with one attempt already spent + task = StoryTask(story_key="sweep-triage", epic=0) + task.phase = Phase.TRIAGE_RUNNING + task.attempt = 1 + engine.state.tasks["sweep-triage"] = task + + assert not engine.run().paused + + assert engine.state.tasks["sweep-triage"].generation == 0 # NOT bumped + assert engine.state.tasks["sweep-triage"].attempt == 2 # the counter continued + # attempt 2 is already a fresh id; no -g suffix rewrites the namespace + assert [s.task_id for s in adapter.sessions] == ["sweep-triage-triage-2"] + + +def test_non_escalated_migrate_restart_keeps_its_generation(project): + """The migrate twin of the row above. Both restart arms scope the bump to + `Phase.ESCALATED` independently, so pinning only the triage one leaves + `_ensure_migration`'s scoping free: dedenting its `_rearm_generation(task)` call + a level passes the whole triage-side suite.""" + write_legacy_ledger(project, LEGACY_LEDGER) + manifest = legacy_manifest() + mapping = [ + {"key": manifest[0]["key"], "dw_id": "DW-1"}, + {"key": manifest[1]["key"], "dw_id": "DW-2"}, + ] + plan = triage_result(["DW-2"], skip=[{"id": "DW-2", "reason": "moot"}]) + engine, adapter = make_sweep( + project, + [migrate_effect(project, migrated_ledger(), mapping), triage_effect(plan)], + ) + # a migration session that never reported: TRIAGE_RUNNING, one attempt spent + task = StoryTask(story_key="sweep-migrate", epic=0) + task.phase = Phase.TRIAGE_RUNNING + task.attempt = 1 + engine.state.tasks["sweep-migrate"] = task + + assert not engine.run().paused + + assert engine.state.tasks["sweep-migrate"].generation == 0 # NOT bumped + assert engine.state.tasks["sweep-migrate"].attempt == 2 # the counter continued + # attempt 2 is already a fresh id; no -g suffix rewrites the namespace + assert adapter.sessions[0].task_id == "sweep-migrate-triage-2" + + def test_interactive_decisions_build_and_close(project): write_ledger(project, {"DW-1": "open", "DW-2": "open"}) plan = triage_result( @@ -4152,8 +4249,10 @@ def test_migration_escalation_resume_retries(project): write_legacy_ledger(project, LEGACY_LEDGER) manifest = legacy_manifest() bad = migrate_effect(project, LEGACY_LEDGER, []) # no conversion at all - engine, _ = make_sweep(project, [bad, bad]) + engine, first = make_sweep(project, [bad, bad]) assert engine.run().paused + abandoned = [s.task_id for s in first.sessions] + assert abandoned == ["sweep-migrate-triage-1", "sweep-migrate-triage-2"] mapping = [ {"key": manifest[0]["key"], "dw_id": "DW-1"}, @@ -4170,6 +4269,12 @@ def test_migration_escalation_resume_retries(project): assert resumed.state.tasks["sweep-migrate"].phase == Phase.DONE assert resumed.state.tasks["sweep-triage"].phase == Phase.DONE assert len(adapter.sessions) == 2 + # the ESCALATED-resume opened a new generation of the migrate task, so its + # restarted attempt 1 does not re-mint the abandoned attempt 1's id + assert resumed.state.tasks["sweep-migrate"].generation == 1 + migrate_id = adapter.sessions[0].task_id + assert migrate_id == "sweep-migrate-triage-1-g1" + assert migrate_id not in abandoned def test_no_legacy_skips_migration(project): From f31c2bbe9a6b8447d0aed719327d45d5d5a35a76 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 07:38:40 -0700 Subject: [PATCH 08/45] sweep dw-stale-restore-fault-and-redaction: DW-10, DW-12 via bmad-loop --- CHANGELOG.md | 3 + src/bmad_loop/diagnostics.py | 19 ++++-- src/bmad_loop/runs.py | 8 ++- tests/test_cli.py | 4 +- tests/test_diagnostics.py | 118 ++++++++++++++++++++++++++++++++++- tests/test_runs.py | 75 ++++++++++++++++++++++ 6 files changed, 217 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 620a480b..aa2f66f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,9 @@ breaking changes may land in a minor release. ### Fixed +- Emit `diagnose --json` v2, replacing journal `patch` / `stashed_to` paths with + `patch_present` / `stashed_to_present`, and silently degrade Git stale-commit probe + failures while propagating non-Git faults. - Prevent escalated sweep restarts from reusing abandoned session ids, and clear stale `escalation.json` artifacts when either adapter reuses a task directory. - Route interactive resolve task ids through the shared whole-composition sanitizer while diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 0e105ced..cb7ac2fd 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -69,7 +69,9 @@ # would falsely tell a consumer pinned to v1 that the fields it reads are gone, # while a consumer actually broken by the repackaging finds out immediately — # the fence is gone and json.loads fails. Bump only on a payload break. -SCHEMA_VERSION = 1 +# v2 replaces journal-entry `patch` / `stashed_to` values with the presence keys +# `patch_present` / `stashed_to_present`. +SCHEMA_VERSION = 2 DEFAULT_JOURNAL_CAP = 200 # Subdirectories whose mere existence/size is diagnostic but whose CONTENTS are @@ -263,6 +265,15 @@ # `/`, `\` and `:`), so — exactly as for `repo` — only an assertion on the # field's ABSENCE can grade this, and the canary sweep cannot. "stories_root", + # A relative or absolute operator-selected or retained forensic patch path. + # `story_key` already correlates these records, so aliasing adds no value; + # drop it because the fallback redacts separator-bearing paths but lets a + # bare feature- or spec-named patch through verbatim. + "patch", + # The absolute deferred-stash target embeds the run directory, story key, + # and spec filename. Drop rather than create a second spec correlation; + # the fallback redacts it only by virtue of its current separators. + "stashed_to", } ) # Journal fields whose value is a LIST of story keys (sprint unknown-keys). @@ -494,9 +505,9 @@ def _category_roots(category: str, run_dir: Path, events_dir: Path | None) -> li maintainer is reading the dump to understand. Both are summed into ONE ``FileGroup`` named ``events``: the payload shape is - the schema, and splitting the category (or adding a field) would be a break - for a v1 consumer. Which root the events came from is not what the count is - for — "did the hooks fire at all" is. + the schema, and splitting the category (or adding a field) would be a payload + break requiring another schema bump. Which root the events came from is not + what the count is for — "did the hooks fire at all" is. """ if category != _EVENTS_CATEGORY: return [run_dir / category] diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 0404ba41..21b251ae 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -4629,8 +4629,8 @@ def _stale_restore_residue( human is the classifier. `bmad-loop resolve` echoes these to stderr. Best-effort throughout: a deleted or unreadable patch, a non-repo project, a - bad old baseline — none may wedge a resolve. Every failure degrades to the - pre-#90 behavior and says so in the journal. + bad old baseline — none may wedge a resolve. A patch parse failure journals + its degrade; a commits-probe Git failure deliberately degrades silently. """ if not old_latch: return set() @@ -4661,7 +4661,9 @@ def _stale_restore_residue( if old_baseline: try: shas = verify.commits_above(repo, old_baseline) - except Exception: # nosec B110 - warn-only, must not fail re-arm + except verify.GitError: + # Follow rearm_escalation's baseline-advance taxonomy boundary; + # this warn-only probe remains silent. shas = [] if shas: journal.append( diff --git a/tests/test_cli.py b/tests/test_cli.py index ed624027..a4e4779c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5056,7 +5056,7 @@ def test_diagnose_json_emits_pure_document(project, capsys): _seed_run(project.project) doc = machine_json(["diagnose", "--project", str(project.project), "--json"], capsys) - assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 1 + assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 2 assert doc["runs"], "the document carries the run it resolved" for canary in CANARIES: assert canary not in json.dumps(doc), f"LEAK via CLI: {canary!r}" @@ -5076,7 +5076,7 @@ def test_diagnose_json_out_writes_document_and_keeps_stdout_empty(project, tmp_p assert "written to" in err # the confirmation moved to stderr written = out_file.read_text() doc = json.loads(written) - assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 1 + assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 2 assert "```" not in written # no fences in a file written in JSON mode for canary in CANARIES: assert canary not in written, f"LEAK via CLI: {canary!r}" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 71c2bb0c..07aab2ce 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -694,6 +694,122 @@ def test_sentinel_upstream_record_drops_the_stories_root_it_names(): assert canary not in rendered, f"LEAK: {canary!r}" +_PATCH_PATH_ROUTING_ROWS = ( + ( + "stale-restore-excluded", + "patch", + f"{HOME_PATH}/artifacts/{SPEC_NAME}.patch", + ), + ( + "stale-restore-unparseable", + "patch", + f"{HOME_PATH}/artifacts/{SPEC_NAME}.patch", + ), + ("attempt-restored", "patch", "attempt.patch"), + ("attempt-restore-failed", "patch", f"{HOME_PATH}/artifacts/attempt.patch"), + ( + "unit-closed", + "patch", + f"{HOME_PATH}/.bmad-loop/runs/r1/failed/{STORY_KEY}/changes.patch", + ), + ( + "deferred-artifacts-stashed", + "stashed_to", + f"{HOME_PATH}/.bmad-loop/runs/r1/deferred/{STORY_KEY}/{SPEC_NAME}", + ), +) + + +@pytest.mark.parametrize( + ("kind", "field", "value"), + _PATCH_PATH_ROUTING_ROWS, + ids=[row[0] for row in _PATCH_PATH_ROUTING_ROWS], +) +def test_patch_and_stash_path_fields_are_dropped_at_the_routing_seam(kind, field, value): + """Every current producer is routed by field name, including a retained + unit's full forensic path and a bare operator latch. + + Ablation: remove either field from ``_JOURNAL_DROP_FIELDS`` and its rows fail + the structural absence/presence assertions even when path-shaped canaries stay green. + """ + control_story = "1.2-ControlStory" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + {"ts": 2.0, "kind": kind, "story_key": control_story, field: value}, + pseudo, + {}, + 1.0, + ) + + assert field not in scrubbed + assert scrubbed[f"{field}_present"] is True + story_alias = next( + a for ns, orig, a in pseudo.entries() if ns == "story" and orig == control_story + ) + assert scrubbed["story_key"] == story_alias + assert scrubbed["story_key"] != control_story + entries = pseudo.entries() + assert not [orig for ns, orig, _alias in entries if ns == "spec"] + legend_values = {orig for _ns, orig, _alias in entries} + assert value not in legend_values + assert Path(value).name not in legend_values + rendered = json.dumps(scrubbed) + for canary in (value, HOME_PATH, SPEC_NAME, PROPRIETARY, *CANARIES): + assert canary not in rendered, f"LEAK: {canary!r}" + legend = json.dumps(pseudo.legend()) + for canary in (value, HOME_PATH, SPEC_NAME, PROPRIETARY, *CANARIES): + assert canary not in legend, f"LEAK via legend: {canary!r}" + + +def test_patch_and_stash_paths_are_absent_from_public_diagnostic_renders(project): + """Journal records flow through collect and both public renderers. + + Ablation: remove either DROP route and the decoded JSON entry retains the + source field, so this fails even though the absolute-path leak sweep stays green. + """ + run_dir = _seed_run(project.project) + patch_path = f"{HOME_PATH}/artifacts/patch-{SPEC_NAME}.patch" + stash_path = f"{HOME_PATH}/.bmad-loop/runs/r1/deferred/{STORY_KEY}/stash-{SPEC_NAME}" + journal = Journal(run_dir) + journal.append( + "stale-restore-excluded", + story_key=STORY_KEY, + patch=patch_path, + files=["newfile.txt"], + ) + journal.append( + "deferred-artifacts-stashed", + story_key=STORY_KEY, + stashed_to=stash_path, + ) + + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + diag = diagnostics.collect([run_dir], pseudo=pseudo, project=project.project) + markdown = diagnostics.render_markdown(diag, pseudo=pseudo) + json_text = diagnostics.render_json(diag, pseudo=pseudo) + document = json.loads(json_text) + entries = document["runs"][0]["journal"]["entries"] + excluded = next(entry for entry in entries if entry["kind"] == "stale-restore-excluded") + stashed = next(entry for entry in entries if entry["kind"] == "deferred-artifacts-stashed") + + assert "patch" not in excluded + assert excluded["patch_present"] is True + assert "stashed_to" not in stashed + assert stashed["stashed_to_present"] is True + story_alias = next(a for ns, orig, a in pseudo.entries() if ns == "story" and orig == STORY_KEY) + assert excluded["story_key"] == story_alias + assert stashed["story_key"] == story_alias + assert story_alias in markdown + rendered = markdown + json_text + for canary in (patch_path, stash_path, HOME_PATH, SPEC_NAME, PROPRIETARY, *CANARIES): + assert canary not in rendered, f"LEAK: {canary!r}" + spec_legend_values = {orig for ns, orig, _alias in pseudo.entries() if ns == "spec"} + assert spec_legend_values == {SPEC_NAME} + legend_values = set(pseudo.legend().values()) + for dropped in (patch_path, Path(patch_path).name, stash_path, Path(stash_path).name): + assert dropped not in legend_values + + def test_target_field_routes_by_kind_because_it_carries_two_kinds_of_value(): """`target` is a BRANCH on the merge kinds and a sprint STATUS on `board-advance-*`. @@ -1132,7 +1248,7 @@ def test_legacy_in_tree_events_still_counted_and_summed_with_the_primary( group = _events_group(run_dir, project.project) assert group is not None - # ONE group, summed — the payload shape is the v1 schema and does not split. + # ONE group, summed — the schema-versioned payload shape does not split. assert group.count == 5 diff --git a/tests/test_runs.py b/tests/test_runs.py index fb0af4ce..6eaf397d 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2982,6 +2982,81 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): assert warned[0]["commits"] == [git(tmp_path, "rev-parse", "HEAD")] +def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_path): + """A bad old baseline is warn-only, and the persisted reset proves re-arm + reached its save rather than returning early. + + Ablation: catch a type outside ``verify.GitError`` and the real rev-list + failure escapes before any of these completion assertions can run. + """ + from bmad_loop.model import Phase + + run_dir, _spec, _patch = _stale_restore_tree(tmp_path) + state = load_state(run_dir) + task = state.tasks["1-1-a"] + initial_generation = task.generation + task.baseline_commit = "0" * 39 + "1" # sha-shaped, but names no object + save_state(run_dir, state) + + runs.rearm_escalation(run_dir, isolated_redrive=False) + + task = load_state(run_dir).tasks["1-1-a"] + assert task.phase == Phase.PENDING + assert task.attempt == 0 + assert task.generation == initial_generation + 1 + assert task.restore_patch is None + assert task.baseline_commit == git(tmp_path, "rev-parse", "HEAD") + assert not _kinds(run_dir, "stale-restore-commits") + excluded = _kinds(run_dir, "stale-restore-excluded") + assert len(excluded) == 1 + assert excluded[0]["files"] == ["newfile.txt"] + + +def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): + """A non-repository code tree reaches the same typed, silent degrade. + + Ablation: catch a type outside ``verify.GitError`` and the pinned probe fault + escapes, so the persisted generation and latch reset never appear. + """ + from bmad_loop.model import Phase + + run_dir, _spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, restore_patch_stale="old.patch") + state = load_state(run_dir) + task = state.tasks["1-1-a"] + initial_generation = task.generation + task.baseline_commit = "0" * 39 + "1" + save_state(run_dir, state) + with pytest.raises(verify.GitError): + verify.commits_above(tmp_path, task.baseline_commit) + + runs.rearm_escalation(run_dir, isolated_redrive=False) + + task = load_state(run_dir).tasks["1-1-a"] + assert task.phase == Phase.PENDING + assert task.attempt == 0 + assert task.generation == initial_generation + 1 + assert task.restore_patch is None + assert task.baseline_commit == "0" * 39 + "1" + assert not _kinds(run_dir, "stale-restore-commits") + assert len(_kinds(run_dir, "stale-restore-unparseable")) == 1 + + +def test_rearm_does_not_swallow_a_non_git_fault_from_the_commits_probe(monkeypatch, tmp_path): + """Only Git faults are warn-only; programming faults must escape. + + Ablation: widen the catch back to ``Exception`` and this fails with + ``DID NOT RAISE``, directly grading the narrowing rather than its old behavior. + """ + run_dir, _spec, _patch = _stale_restore_tree(tmp_path) + + def boom(repo, baseline): + raise MemoryError("not a git answer") + + monkeypatch.setattr(runs.verify, "commits_above", boom) + with pytest.raises(MemoryError, match="not a git answer"): + runs.rearm_escalation(run_dir, isolated_redrive=False) + + def test_archive_run(tmp_path): run_dir = _make_state_run(tmp_path, "20260611-100000-aaaa") (run_dir / "journal.jsonl").write_text('{"kind":"x"}\n') From dd69b32394cd7085a9ff53806f9b085653a2f7b4 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 11:40:08 -0700 Subject: [PATCH 09/45] sweep dw2-gather-escalations-hardening: DW-68, DW-70, DW-71, DW-72, DW-73 via bmad-loop --- CHANGELOG.md | 2 + src/bmad_loop/resolve.py | 64 +++++- src/bmad_loop/sweep.py | 13 +- tests/test_resolve.py | 423 ++++++++++++++++++++++++++++++++++++++- tests/test_sweep.py | 6 +- 5 files changed, 484 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2f66f0..22cb01cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,8 @@ breaking changes may land in a minor release. - Emit `diagnose --json` v2, replacing journal `patch` / `stashed_to` paths with `patch_present` / `stashed_to_present`, and silently degrade Git stale-commit probe failures while propagating non-Git faults. +- De-duplicate interactive-resolution escalations across repeated task IDs and mirrored + artifacts, and skip malformed artifacts without aborting resolution. - Prevent escalated sweep restarts from reusing abandoned session ids, and clear stale `escalation.json` artifacts when either adapter reuses a task directory. - Route interactive resolve task ids through the shared whole-composition sanitizer while diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index a5275da9..8a986b08 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -22,6 +22,7 @@ from .adapters.base import SessionSpec from .engine import _session_task_id +from .escalation import critical_escalations from .model import RunState from .platform_util import safe_segment from .runs import ( @@ -77,15 +78,56 @@ def read_resolution(run_dir: Path, story_key: str) -> dict[str, Any] | None: def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[dict[str, Any]]: - """The CRITICAL escalations recorded by this story's sessions, newest first. + """The CRITICAL escalations recorded by this story's sessions, newest first, + each DISTINCT escalation exactly once. Reads each session's tasks//result.json (and escalation.json) — the - same files the engine inspected when it decided to pause.""" + same files the engine inspected when it decided to pause. Ordering is + `reversed(task.sessions)` and, within a directory, result.json before + escalation.json; a duplicate keeps its FIRST occurrence's position, which is + what preserves "newest first". Three guards, each for a defect this reader + hit on the way to the operator: + + * ``seen_ids`` — ``task.sessions`` is append-only and a re-arm deliberately + does NOT clear it, so state can carry two records under one ``task_id``. + ``sweep._rearm_generation`` bumps the id namespace for new restarts but + does not migrate records already persisted, and both records address the + SAME mutable ``tasks//escalation.json`` — reading it per record + attributes the abandoned cycle's escalation to the fresh session too. + Open each directory once. + * the content-keyed map — the sweep skill's own contract + (``data/skills/bmad-loop-sweep/automation-mode.md``) tells a producer to + write ``escalation.json`` and then mirror the same entries into + ``result.json`` ``escalations``. That mirroring is deliberate and stays; + the READER absorbs it, so a compliant producer is not shown to the human + twice. The key is canonical JSON because the two copies are parsed + separately — identity cannot see the mirroring and ``dict`` is unhashable + — and ``setdefault`` makes the first occurrence win. De-duplication is + global across the pass, not per directory; it removes only exact repeats, + so a directory holding CRITICAL A in one file and A + B in the other still + yields both. + * the ``except`` tuple and the ``list`` check — ``build_context`` is an + OBSERVATION path: a malformed artifact must cost its own contents and + nothing more, never raise out to the interactive resolve command. + ``UnicodeDecodeError`` is a ``ValueError``, not an ``OSError`` (the same + rationale recorded on ``read_resolution`` above), and ``json.loads`` can + also raise a plain ``ValueError`` when an integer exceeds Python's configured + digit limit. Deeply nested input can raise ``RecursionError`` while either + parsing the document or canonicalizing an entry, so both operations live + under the same artifact-level guard. Meanwhile, + ``critical_escalations`` iterates ``escalations`` with no list guard of its + own, so a ``{"escalations": null}`` artifact would raise ``TypeError`` + here. The guard belongs in this caller; the shared predicate stays the + single definition of CRITICAL.""" task = state.tasks.get(story_key) - found: list[dict[str, Any]] = [] if task is None: - return found + return [] + seen_ids: set[str] = set() + found: dict[str, dict[str, Any]] = {} for session in reversed(task.sessions): + if session.task_id in seen_ids: + continue + seen_ids.add(session.task_id) task_dir = run_dir / "tasks" / session.task_id for fname in ("result.json", "escalation.json"): fpath = task_dir / fname @@ -93,12 +135,16 @@ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[ continue try: doc = json.loads(fpath.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + if not isinstance(doc, dict) or not isinstance(doc.get("escalations"), list): + continue + artifact_entries: dict[str, dict[str, Any]] = {} + for esc in critical_escalations(doc): + artifact_entries.setdefault(json.dumps(esc, sort_keys=True), esc) + except (OSError, ValueError, RecursionError): continue - for esc in doc.get("escalations", []) if isinstance(doc, dict) else []: - if isinstance(esc, dict) and str(esc.get("severity", "")).upper() == "CRITICAL": - found.append(esc) - return found + for key, esc in artifact_entries.items(): + found.setdefault(key, esc) + return list(found.values()) def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: str) -> Path: diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index b3947afe..bc66cb07 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -543,10 +543,15 @@ def _rearm_generation(task: StoryTask) -> None: what makes the next dispatch re-mint ``attempt == 1`` — an id byte-equal to the abandoned attempt's, since ``engine._session_task_id`` emits its discriminator only above zero. The artifact a shared id corrupts is ``tasks//escalation.json``: the - sweep skill writes it and ``resolve._gather_escalations`` reads it once per RECORDED - session, so two records carrying one id return the abandoned cycle's escalation for - the fresh session too. ``result.json`` is NOT at risk: both adapters unlink it in - ``start_session``. + sweep skill writes it, and two records carrying one id both name that one mutable + file, so the abandoned cycle's escalation is the fresh session's too. + ``resolve._gather_escalations`` now opens each distinct ``task_id`` once and + de-duplicates entries by content, so it no longer reports the same aliased file + twice. Both adapters also unlink cycle outputs in ``start_session``, which stops a + healthy restart from inheriting stale contents — but cleanup still leaves the two + historical records naming one mutable directory: a healthy restart erases the + abandoned cycle's artifact, while a re-escalation replaces it for both records. + Minting a fresh id is what preserves one artifact namespace per recorded cycle. Same pattern as ``runs.rearm_escalation``, DIFFERENT reason: #705's harm is ``_resumable_session`` verdict replay, which runs only on the dev/review phases and diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 1a55a99f..795600bc 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -624,6 +624,9 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): ) ) assert ctx["spec_file"] is None # no task at all + # ... and the escalation gather degrades on the same absence rather than + # dereferencing the missing task (`_gather_escalations` returns [] up front). + assert ctx["escalations"] == [] def test_build_context_no_session_files(tmp_path): @@ -2007,12 +2010,12 @@ def test_rearm_rejects_unescalated_story(tmp_path): def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): """The outermost surface DW-7 names. `_gather_escalations` walks the append-only - `task.sessions` — which a re-arm deliberately does NOT clear — and reads - `tasks//escalation.json` once per record. While the ESCALATED - restart re-minted an id byte-equal to the abandoned attempt's, BOTH records - addressed the one file, so the abandoned cycle's escalation was returned a second - time as the fresh session's. Bumping `generation` gives the fresh record its own - id, and the file is read exactly once.""" + `task.sessions` — which a re-arm deliberately does NOT clear — and now opens each + distinct `tasks/` directory once. Before that reader guard, an + ESCALATED restart that re-minted the abandoned attempt's id made BOTH records + address one file and returned its escalation twice. Bumping `generation` gives the + fresh record its own artifact namespace; the reader also degrades safely on older + persisted state where the collision already exists.""" run_dir, state, task = _escalated_run(tmp_path) key = "6-4-cli-list-command" abandoned = _session_task_id(key, "triage", 1, 0) @@ -2032,11 +2035,413 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): found = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["abandoned cycle"] # once, not twice - # the pre-fix shape for contrast: one shared id makes the SAME file answer both - # records, and the abandoned escalation is attributed to the fresh session too + # DW-71: the id bump only protects records minted AFTER it. State persisted + # before the bump still carries two records under ONE id, both addressing that + # directory's single mutable escalation.json — the reader itself has to return + # the escalation once rather than attribute it to the fresh session too. task.sessions[1] = SessionRecord(task_id=abandoned, role="dev", status="completed") collided = resolve._gather_escalations(run_dir, state, key) - assert [e["detail"] for e in collided] == ["abandoned cycle", "abandoned cycle"] + assert [e["detail"] for e in collided] == ["abandoned cycle"] + + +def test_gather_escalations_opens_a_repeated_task_id_once(tmp_path, monkeypatch): + """DW-71's own leg, watched at the I/O rather than the return value. + + Content de-duplication would hide a re-read behind the identical entry it + yields, so "returned once" alone cannot tell the `seen_ids` guard from the + content map. Two records under one `task_id` must OPEN that directory's + artifacts exactly once — which is also what stops a directory rewritten + mid-pass from answering two records differently.""" + run_dir, state, task = _escalated_run(tmp_path) + key = "6-4-cli-list-command" + shared = _session_task_id(key, "triage", 1, 0) + task.sessions.clear() + for _ in range(2): + task.sessions.append(SessionRecord(task_id=shared, role="dev", status="completed")) + esc_dir = run_dir / "tasks" / shared + esc_dir.mkdir(parents=True, exist_ok=True) + result_file = esc_dir / "result.json" + result_file.write_text(json.dumps({"escalations": []}), encoding="utf-8") + esc_file = esc_dir / "escalation.json" + esc_file.write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "shared id"}]}), + encoding="utf-8", + ) + + reads: list[str] = [] + real_read_text = Path.read_text + + def counting_read_text(self, *args, **kwargs): + reads.append(str(self)) + return real_read_text(self, *args, **kwargs) + + # `monkeypatch.context()`, NOT a bare `setattr` + `undo()`: the autouse + # `_isolate_state_root` / `_isolate_mux_registry` fixtures record onto the SAME + # function-scoped monkeypatch instance this test receives (conftest says so in + # `_isolate_state_root`'s own docstring), so an explicit `undo()` here would roll + # back the suite's `BMAD_LOOP_STATE_DIR` isolation too, mid-test. + with monkeypatch.context() as mp: + mp.setattr(Path, "read_text", counting_read_text) + found = resolve._gather_escalations(run_dir, state, key) + + assert reads.count(str(result_file)) == 1 # each artifact once, not once per record + assert reads.count(str(esc_file)) == 1 + assert [e["detail"] for e in found] == ["shared id"] + + +def _two_session_dirs(tmp_path): + """A task carrying TWO records with DISTINCT `task_id`s, plus both task + directories. `task.sessions` is append-only and chronological, so `sessions[1]` + is the NEWER attempt and `reversed(...)` must reach its directory first. + + This shape exists because no single-directory row can see either of this + reader's cross-session contracts: rescope the content map per directory, or + drop `reversed`, and every one-directory row below stays green.""" + run_dir, state, task = _escalated_run(tmp_path) + key = "6-4-cli-list-command" + older = _session_task_id(key, "triage", 1, 0) + newer = _session_task_id(key, "triage", 1, 1) # post-bump: the -g1 namespace + assert older != newer + task.sessions.clear() + dirs: list[Path] = [] + for task_id in (older, newer): + task.sessions.append(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + dirs.append(d) + return run_dir, state, key, dirs[0], dirs[1] + + +def test_gather_escalations_dedupes_one_entry_across_two_sessions(tmp_path): + """De-duplication is GLOBAL across the pass, not scoped to one directory. + + An escalation a retry does not resolve is re-raised by the next attempt, so two + DIFFERENT `tasks//` directories carry the byte-identical entry and the + operator learns nothing from the repeat. This is the only row that can tell a + global content map from a per-directory one.""" + run_dir, state, key, older_dir, newer_dir = _two_session_dirs(tmp_path) + entry = {"type": "spec-gap", "severity": "CRITICAL", "detail": "unresolved across attempts"} + for d in (older_dir, newer_dir): + (d / "escalation.json").write_text(json.dumps({"escalations": [entry]}), encoding="utf-8") + + found = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in found] == ["unresolved across attempts"] + + +def test_gather_escalations_orders_distinct_sessions_newest_first(tmp_path): + """The documented "newest first" order is a CROSS-SESSION property: nothing + inside one directory can pin it, because `reversed(task.sessions)` is what + reaches the newer record's directory before the older one's. Drop `reversed` + and only this row notices.""" + run_dir, state, key, older_dir, newer_dir = _two_session_dirs(tmp_path) + for d, detail in ((older_dir, "older"), (newer_dir, "newer")): + (d / "escalation.json").write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), + encoding="utf-8", + ) + + found = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in found] == ["newer", "older"] + + +def _task_dir(run_dir, task): + """Where `_gather_escalations` looks for result.json / escalation.json, DERIVED + from the session record the fixture actually appended — never a literal. + + A hardcoded directory name is a false green waiting on a fixture change: it can + drift off the record the reader walks, and a row asserting an EMPTY result would + then pass because nothing was read rather than because the filter worked.""" + d = run_dir / "tasks" / task.sessions[-1].task_id + d.mkdir(parents=True, exist_ok=True) + return d + + +def test_gather_escalations_returns_a_mirrored_entry_once(tmp_path): + """DW-68/72. The sweep skill's contract (bmad-loop-sweep/automation-mode.md) + tells a producer to write escalation.json and then mirror the same entries into + result.json `escalations` — so every COMPLIANT escalation reached the operator + twice. The mirroring stays; the reader absorbs it. Asserted through + `build_context` because `context.json` is the surface the human reads.""" + run_dir, state, task = _escalated_run(tmp_path) + entry = {"type": "spec-gap", "severity": "CRITICAL", "detail": "mirrored once"} + # Same JSON object, deliberately authored in a different member order. Raw + # `json.dumps(esc)` keys would treat these as distinct; `sort_keys=True` must + # make the de-duplication key semantic rather than source-order-sensitive. + reordered = {"detail": "mirrored once", "severity": "CRITICAL", "type": "spec-gap"} + task_dir = _task_dir(run_dir, task) + for fname, value in (("result.json", entry), ("escalation.json", reordered)): + (task_dir / fname).write_text(json.dumps({"escalations": [value]}), encoding="utf-8") + + ctx = json.loads( + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) + ) + assert ctx["escalations"] == [entry] + + +def test_gather_escalations_keeps_distinct_entries_from_both_files(tmp_path): + """De-duplication removes only the exact repeat. A directory whose result.json + carries A and whose escalation.json carries A + B still yields both, in + newest-first order (result.json before escalation.json) — the guard must not + collapse a partially-mirrored pair into one.""" + run_dir, state, task = _escalated_run(tmp_path) + a = {"type": "spec-gap", "severity": "CRITICAL", "detail": "A"} + b = {"type": "spec-gap", "severity": "CRITICAL", "detail": "B"} + task_dir = _task_dir(run_dir, task) + (task_dir / "result.json").write_text(json.dumps({"escalations": [a]}), encoding="utf-8") + (task_dir / "escalation.json").write_text(json.dumps({"escalations": [a, b]}), encoding="utf-8") + + found = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") + assert [e["detail"] for e in found] == ["A", "B"] + + +def test_gather_escalations_keeps_full_objects_that_share_a_detail(tmp_path): + """Exact content, not one convenient field, defines a duplicate. Two + escalations may explain the same symptom while identifying different gaps; + both complete dictionaries must reach the resolver.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + first = { + "type": "spec-gap", + "severity": "CRITICAL", + "detail": "same operator-facing explanation", + "location": "SPEC.md", + } + second = { + "type": "environment-gap", + "severity": "CRITICAL", + "detail": "same operator-facing explanation", + "location": "policy.toml", + } + (task_dir / "result.json").write_text( + json.dumps({"escalations": [first, second]}), encoding="utf-8" + ) + + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + + +def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_path): + """Within one session directory, result.json precedes escalation.json.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + first = {"severity": "CRITICAL", "detail": "from result"} + second = {"severity": "CRITICAL", "detail": "from escalation"} + (task_dir / "result.json").write_text(json.dumps({"escalations": [first]}), encoding="utf-8") + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [second]}), encoding="utf-8" + ) + + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + + +def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): + """A later copy must not move an entry behind intervening distinct content.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + first = {"severity": "CRITICAL", "detail": "first"} + second = {"severity": "CRITICAL", "detail": "second"} + (task_dir / "result.json").write_text(json.dumps({"escalations": [first]}), encoding="utf-8") + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [second, first]}), encoding="utf-8" + ) + + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + + +def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): + """The content map spans the whole pass, including one producer's list.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + entry = {"severity": "CRITICAL", "detail": "listed twice"} + (task_dir / "result.json").write_text( + json.dumps({"escalations": [entry, entry]}), encoding="utf-8" + ) + + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [entry] + + +def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_path): + """Delegating the filter preserves its case-insensitive and shape semantics.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + critical = {"severity": "critical", "detail": "case folded"} + preference = {"severity": "PREFERENCE", "detail": "not critical"} + (task_dir / "result.json").write_text( + json.dumps({"escalations": [None, "junk", preference, critical]}), encoding="utf-8" + ) + + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [critical] + + +def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): + """DW-70/73. `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so the + old `except (OSError, json.JSONDecodeError)` let a non-UTF-8 artifact crash + `build_context` — the interactive resolve path, an OBSERVATION surface that must + degrade. The bad file costs its own contents and nothing more.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + (task_dir / "result.json").write_bytes(_BAD_UTF8) + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "still readable"}]}), + encoding="utf-8", + ) + + ctx = json.loads( + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) + ) + assert [e["detail"] for e in ctx["escalations"]] == ["still readable"] + + +def test_gather_escalations_skips_a_plain_json_value_error(tmp_path, monkeypatch): + """`json.loads` raises plain ValueError, not JSONDecodeError, when an integer + exceeds Python's configured digit limit. That malformed file costs only its + contents; its valid sibling still reaches context.json.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + marker = '"detail":' + ("9" * 5000) + (task_dir / "result.json").write_text( + '{"escalations":[{"severity":"CRITICAL",' + marker + "}]}", encoding="utf-8" + ) + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "sibling survives"}]}), + encoding="utf-8", + ) + + real_loads = json.loads + + def loads_with_digit_limit(data, *args, **kwargs): + if marker in data: + raise ValueError("integer exceeds configured digit limit") + return real_loads(data, *args, **kwargs) + + with monkeypatch.context() as mp: + mp.setattr(resolve.json, "loads", loads_with_digit_limit) + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + + ctx = json.loads(path.read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] + + +def test_gather_escalations_skips_a_json_recursion_error(tmp_path): + """A deeply nested artifact can exceed the decoder's recursion guard. + + Confirm the real decoder failure first so this stays a regression test for + ``RecursionError`` rather than another synthetic exception row. The bad file + still costs only its own contents; its valid sibling reaches context.json. + """ + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + depth = sys.getrecursionlimit() * 20 + nested = "[" * depth + "0" + "]" * depth + malformed = '{"escalations":[{"severity":"CRITICAL","detail":' + nested + "}]}" + with pytest.raises(RecursionError): + json.loads(malformed) + (task_dir / "result.json").write_text(malformed, encoding="utf-8") + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "sibling survives"}]}), + encoding="utf-8", + ) + + ctx = json.loads( + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) + ) + assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] + + +def test_gather_escalations_skips_a_canonicalization_recursion_error(tmp_path, monkeypatch): + """Canonical-key construction is part of the guarded artifact read too.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + bad = {"severity": "CRITICAL", "detail": "canonicalization recurses"} + sibling = {"severity": "CRITICAL", "detail": "sibling survives"} + (task_dir / "result.json").write_text(json.dumps({"escalations": [bad]}), encoding="utf-8") + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [sibling]}), encoding="utf-8" + ) + real_dumps = json.dumps + + def dumps_with_recursion_error(value, *args, **kwargs): + if value == bad: + raise RecursionError("canonicalization depth exceeded") + return real_dumps(value, *args, **kwargs) + + with monkeypatch.context() as mp: + mp.setattr(resolve.json, "dumps", dumps_with_recursion_error) + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["escalations"] == [sibling] + + +@pytest.mark.parametrize("bad", [None, 1, "x", {}]) +def test_gather_escalations_skips_a_non_list_escalations_field(tmp_path, monkeypatch, bad): + """DW-70/73's other half. `escalation.critical_escalations` iterates + `escalations` with no list guard of its own, so `{"escalations": null}` raised + `TypeError` straight out of `build_context`. The guard sits in this caller; the + shared predicate stays the single definition of CRITICAL. + + Every parameter must fail when the list guard is ablated. ``None`` and ``1`` + raise without it; the call trace below distinguishes the iterable ``"x"`` and + ``{}`` shapes, which the shared filter would otherwise accept as empty.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + (task_dir / "result.json").write_text(json.dumps({"escalations": bad}), encoding="utf-8") + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "sibling survives"}]}), + encoding="utf-8", + ) + + filtered: list[dict] = [] + real_critical_escalations = resolve.critical_escalations + + def recording_critical_escalations(doc): + filtered.append(doc) + return real_critical_escalations(doc) + + with monkeypatch.context() as mp: + mp.setattr(resolve, "critical_escalations", recording_critical_escalations) + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + + ctx = json.loads(path.read_text(encoding="utf-8")) + assert filtered == [ + { + "escalations": [ + {"severity": "CRITICAL", "detail": "sibling survives"}, + ] + } + ] + assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] + + +def test_gather_escalations_preference_only_yields_nothing(tmp_path): + """The CRITICAL-only filter is unchanged by the de-duplication rewrite: a + directory carrying only non-CRITICAL entries contributes nothing, and mirroring + a PREFERENCE across both files still contributes nothing. + + The second half is the POSITIVE CONTROL, and it is what makes the first half + mean anything. `== []` passes just as well when the directory was never read, so + the same files are re-written with a CRITICAL alongside the PREFERENCE and that + entry must come back. Absence then evidences the severity filter rather than an + unread path.""" + run_dir, state, task = _escalated_run(tmp_path) + key = "6-4-cli-list-command" + pref = {"type": "nit", "severity": "PREFERENCE", "detail": "ignore me"} + task_dir = _task_dir(run_dir, task) + for fname in ("result.json", "escalation.json"): + (task_dir / fname).write_text(json.dumps({"escalations": [pref]}), encoding="utf-8") + + assert resolve._gather_escalations(run_dir, state, key) == [] + + crit = {"type": "spec-gap", "severity": "CRITICAL", "detail": "kept"} + for fname in ("result.json", "escalation.json"): + (task_dir / fname).write_text(json.dumps({"escalations": [pref, crit]}), encoding="utf-8") + found = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in found] == ["kept"] # this directory IS read # ----------------------------------------------------------- run_session diff --git a/tests/test_sweep.py b/tests/test_sweep.py index a75b1a2c..e1597e85 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -2405,8 +2405,10 @@ def test_triage_session_env_fault_escalates_then_resume_restores_budget(project) # ...and it does so in a NEW generation. The attempt reset above is exactly what # would otherwise re-mint `attempt == 1` — an id byte-equal to the abandoned # attempt's, pointing the fresh record at the abandoned cycle's - # tasks//escalation.json, which `resolve._gather_escalations` reads per record. - # (result.json is not the hazard here: both start_sessions unlink it on launch.) + # tasks//escalation.json. Both adapters now clear cycle outputs at launch, and + # `resolve._gather_escalations` opens each distinct task_id once, but neither makes + # two historical records stop aliasing one mutable directory. The fresh id preserves + # a separate artifact namespace for each cycle, independent of cleanup. assert resumed.state.tasks["sweep-triage"].generation == 1 assert [s.task_id for s in radapter.sessions] == ["sweep-triage-triage-1-g1"] assert radapter.sessions[0].task_id not in abandoned From dc0c36f7adcae46f622308032ec8a484156b58be Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 14:15:53 -0700 Subject: [PATCH 10/45] sweep escalation-watermark: DW-11 via bmad-loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop a second resolve cycle re-presenting CRITICAL escalations the human already answered. `runs.rearm_escalation` stamps `task.escalations_resolved_upto = len(task.sessions)` beside the existing unconditional generation bump, gated on a new required keyword-only `resolution_recorded` supplied by the caller rather than read from `resolution.json` — that marker survives the re-arm that consumed it, so its presence says nothing about the current gesture. `resolve._gather_escalations` takes a keyword-only `start` and returns `(shown, suppressed)` from one walk; `build_context` threads the watermark in and the count out; `cmd_resolve` prints the count to the operator only when non-zero and only after the adapter has proved it supports an interactive session. `context.json`'s key set is unchanged — the agent-facing contract is untouched. The watermark is projected into `diagnose --json` and the markdown task table so a short `context.json` can be explained from a bug report. A re-arm that accepted no resolution — no session, or a session that wrote none — never moves the watermark, so those paths keep showing the whole trail. --- CHANGELOG.md | 5 + docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 33 +- src/bmad_loop/diagnostics.py | 19 +- src/bmad_loop/model.py | 15 + src/bmad_loop/resolve.py | 64 +++- src/bmad_loop/runs.py | 36 +- src/bmad_loop/tui/app.py | 14 +- tests/test_cli.py | 292 +++++++++++++++-- tests/test_diagnostics.py | 39 ++- tests/test_engine.py | 44 ++- tests/test_engine_worktree.py | 14 +- tests/test_model.py | 16 + tests/test_resolve.py | 600 ++++++++++++++++++++++++++-------- tests/test_runs.py | 48 ++- tests/test_stories_engine.py | 6 +- tests/test_sweep.py | 32 +- tests/test_tui_app.py | 76 ++++- 18 files changed, 1131 insertions(+), 224 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cb01cd..1c9201b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,11 @@ breaking changes may land in a minor release. ### Fixed +- Stop a second resolve cycle re-presenting escalations the human already answered + (DW-11). A re-arm that accepted a `resolution.json` watermarks the story's + append-only session trail; later cycles hand the agent only what was recorded since + and print the withheld count. A re-arm that accepted no resolution — no session, or + a session that wrote none — never moves it. - Emit `diagnose --json` v2, replacing journal `patch` / `stashed_to` paths with `patch_present` / `stashed_to_present`, and silently degrade Git stale-commit probe failures while propagating non-Git faults. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 50bc2cb3..eada1024 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -68,7 +68,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. -- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. +- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second `bmad-loop resolve` used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a `resolution.json` now **watermarks** the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's `context.json` (the agent-facing contract is unchanged). The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, `resolve --no-interactive`, and the TUI's Re-arm button all leave it where it stands, so those paths keep showing the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the `esc-upto` column of `bmad-loop diagnose`'s markdown task table, and as `escalations_resolved_upto` under `--json` (that is the key to grep in a support bundle), which is what explains a short `context.json` on a bug report. - A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most `max_dev_attempts` per story) and has no suppression knob of its own; it follows `[notify]` like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is reduced to its first line and capped, with a `[…]` marker when it was trimmed, because a `Decision.reason` routinely carries a verify-output tail that would otherwise spill into `ATTENTION` and a desktop bubble; the untruncated reason stays in the `dev-decision` journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices. - Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (`sh` reports rc `126`/`127`; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time `OSError` counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507); `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: `has_session` maps every nonzero backend result to False, so a negative lookup is "the backend did not confirm it" rather than proof the session is gone — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ef81a5a1..6a8a285a 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3098,10 +3098,21 @@ def cmd_resolve(args: argparse.Namespace) -> int: print(err, file=sys.stderr) return 1 + # DW-11: whether THIS gesture accepted a resolution, which is what gates the + # `escalations_resolved_upto` watermark in `runs.rearm_escalation`. False here + # covers `--no-interactive` deliberately: that path accepted nothing IN THIS + # GESTURE (the human may have fixed the spec by hand, but nothing recorded which + # escalations that answered), so the next cycle shows everything — today's + # behavior, and the safe direction. Not derived from `resolution.json`: the marker + # survives the re-arm that consumed it, so its presence says nothing about this + # gesture. + resolution_recorded = False if args.interactive: adapters = _make_adapters(project, run_dir, pol) model = pol.adapter.resolved("dev").model - resolve.build_context(state, run_dir, story_key, isolation=pol.scm.isolation) + _ctx_path, withheld = resolve.build_context( + state, run_dir, story_key, isolation=pol.scm.isolation + ) print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…") try: produced = resolve.run_session( @@ -3123,6 +3134,25 @@ def cmd_resolve(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 + resolution_recorded = bool(produced) + # DW-11. Reported to the operator, never into `context.json`: filtering the + # agent's list silently would trade one misleading surface for another — the + # human would have no way to tell "nothing else was ever raised" from "the rest + # is hidden". Worded for what the code can prove: these entries were PRESENTED + # to an earlier resolve cycle that recorded a resolution — not that any + # particular one of them was individually answered. + # + # Printed here rather than beside the context build, because until + # `run_session` returns without `NotImplementedError` this adapter is not known + # to support an interactive session at all — and an operator whose command is + # about to fail must not be told escalations were withheld from an agent that + # never launched. + if withheld: + print( + f"{withheld} earlier escalation(s) for {story_key} were not shown to the " + "agent: they were presented to an earlier resolve cycle that recorded a " + "resolution" + ) if not produced: print( f"no resolution recorded for {story_key} (agent did not write resolution.json)", @@ -3227,6 +3257,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: story_key, restore_patch=restore_patch, isolated_redrive=pol.scm.isolation == "worktree", + resolution_recorded=resolution_recorded, ) except runs.RearmError as e: print(f"error: {e}", file=sys.stderr) diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index cb7ac2fd..9e56549f 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -349,6 +349,11 @@ class TaskDiag: # dumps as `rearmed=True, attempt=1, n_sessions=2` — byte-identical to a HEALTHY # post-re-arm task. A counter, so it carries no customer content. generation: int + # DW-11's watermark: how far into the append-only `sessions` list an accepted + # resolution reached. Without it a support bundle cannot explain a SHORT + # `context.json` — a story whose older escalations are filtered out dumps + # identically to one that only ever raised the entries shown. A counter too. + escalations_resolved_upto: int dw_count: int n_sessions: int sessions: SessionTally @@ -630,6 +635,7 @@ def _task_diag(task: StoryTask, pseudo: sanitize.Pseudonymizer, weight: float) - spec_present=bool(task.spec_file), worktree_isolated=bool(task.worktree_path), generation=task.generation, + escalations_resolved_upto=task.escalations_resolved_upto, dw_count=len(task.dw_ids), n_sessions=len(task.sessions), sessions=_session_tally([task]), @@ -1045,15 +1051,20 @@ def render_markdown( # `gen` rides beside `att` because the pair is the discriminator: a # #705-class replay and a healthy post-re-arm task agree on every other # column here, so dropping it from the human report leaves the one field - # that separates them visible only under `--json`. + # that separates them visible only under `--json`. `esc-upto` rides beside + # `gen` on that same rule: DW-11's watermark is the only field separating + # "this story raised one escalation" from "its earlier ones are filtered + # out as already answered", and a short `context.json` is read off exactly + # this report. out.append( - "| alias | epic | phase | att | gen | rev | committed | spec | dw | sessions " - "| weighted | raw |" + "| alias | epic | phase | att | gen | esc-upto | rev | committed | spec | dw " + "| sessions | weighted | raw |" ) - out.append("|---|---|---|---|---|---|---|---|---|---|---|---|") + out.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|") for t in r.tasks: out.append( f"| `{t.alias}` | {t.epic} | {t.phase} | {t.attempt} | {t.generation} " + f"| {t.escalations_resolved_upto} " f"| {t.review_cycle} | {t.committed} | {t.spec_present} | {t.dw_count} " f"| {t.n_sessions} | {t.tokens.get('weighted', 0)} " f"| {t.tokens.get('total', 0)} |" diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 54923d36..d6b17f56 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -213,6 +213,19 @@ class StoryTask: # is deliberately NOT cleared when a task is reopened: the run-dir audit trail # it indexes is read by a later resolve cycle. generation: int = 0 + # How much of the append-only `sessions` list an accepted escalation resolution + # already covered: a LENGTH, i.e. an index INTO `task.sessions`, not a count of + # escalations and not a generation number. `resolve._gather_escalations` shows only + # the escalations recorded by sessions at or after this position, so a second + # resolve cycle does not re-present entries the human already disambiguated + # (DW-11). Stamped in `runs.rearm_escalation`, and only when its caller passes + # `resolution_recorded=True` — a re-arm that accepted nothing must not advance it, + # or escalations nobody answered become invisible forever. `record_session` is the + # sole mutation of `sessions` in `src/`, and a re-arm deliberately does NOT clear + # the list, which is what makes a length stable across cycles. 0 = nothing answered + # yet, which is also what a pre-upgrade `state.json` deserializes to (unfiltered, + # the pre-DW-11 behavior). + escalations_resolved_upto: int = 0 # set from the bmad-build-auto session's `followup_review_recommended` # frontmatter (PR #2505): when True and review.trigger = "recommended", the # orchestrator runs a follow-up review pass (bmad-build-auto re-invoked on the @@ -430,6 +443,7 @@ def to_dict(self) -> dict[str, Any]: "review_cycle": self.review_cycle, "followup_reviews_spent": self.followup_reviews_spent, "generation": self.generation, + "escalations_resolved_upto": self.escalations_resolved_upto, "followup_review_recommended": self.followup_review_recommended, "baseline_commit": self.baseline_commit, "baseline_untracked": self.baseline_untracked, @@ -598,6 +612,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": review_cycle=int(d.get("review_cycle", 0)), followup_reviews_spent=int(d.get("followup_reviews_spent", 0)), generation=int(d.get("generation", 0)), + escalations_resolved_upto=int(d.get("escalations_resolved_upto", 0)), followup_review_recommended=bool(d.get("followup_review_recommended", False)), baseline_commit=d.get("baseline_commit"), baseline_untracked=( diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 8a986b08..a734431c 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -77,9 +77,21 @@ def read_resolution(run_dir: Path, story_key: str) -> dict[str, Any] | None: return doc -def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[dict[str, Any]]: +def _gather_escalations( + run_dir: Path, state: RunState, story_key: str, *, start: int = 0 +) -> tuple[list[dict[str, Any]], int]: """The CRITICAL escalations recorded by this story's sessions, newest first, - each DISTINCT escalation exactly once. + each DISTINCT escalation exactly once, paired with how many DISTINCT entries + were withheld as already answered. + + ``start`` is ``task.escalations_resolved_upto`` — a position in the append-only + ``task.sessions`` list, stamped by ``runs.rearm_escalation`` when a resolve cycle + recorded a resolution (DW-11). Records BELOW it were already put to the human and + answered, so their escalations are not shown again; the count of those the human + can no longer see is returned for the operator, never written into + ``context.json`` (the agent-facing contract is the unanswered set alone). The + default 0 reproduces the pre-DW-11 walk byte-for-byte, which is what a + pre-upgrade ``state.json`` deserializes to. Reads each session's tasks//result.json (and escalation.json) — the same files the engine inspected when it decided to pause. Ordering is @@ -118,16 +130,31 @@ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[ ``critical_escalations`` iterates ``escalations`` with no list guard of its own, so a ``{"escalations": null}`` artifact would raise ``TypeError`` here. The guard belongs in this caller; the shared predicate stays the - single definition of CRITICAL.""" + single definition of CRITICAL. + + The watermark is a FOURTH concern layered onto that same single walk, not a + second pass: ``reversed(task.sessions)`` reaches the unanswered tail first, so + entries are routed into two content-keyed maps by the record's own index and the + suppressed count is the answered keys that never appeared in the shown map. Two + consequences are deliberate. An entry raised on BOTH sides of the watermark is + shown and counted 0 — "not shown" is the claim the number makes, so it must never + count something the operator can see. And ``start`` only SELECTS a map; nothing is + indexed with it, so a watermark past the end of the list yields an empty shown + list rather than an IndexError. A ``task_id`` repeated across the watermark is + opened once by ``seen_ids``, at its newest occurrence — the shown side, the + conservative direction.""" task = state.tasks.get(story_key) if task is None: - return [] + return [], 0 seen_ids: set[str] = set() found: dict[str, dict[str, Any]] = {} - for session in reversed(task.sessions): + answered: dict[str, dict[str, Any]] = {} + last = len(task.sessions) - 1 + for offset, session in enumerate(reversed(task.sessions)): if session.task_id in seen_ids: continue seen_ids.add(session.task_id) + target = found if last - offset >= start else answered task_dir = run_dir / "tasks" / session.task_id for fname in ("result.json", "escalation.json"): fpath = task_dir / fname @@ -143,12 +170,21 @@ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[ except (OSError, ValueError, RecursionError): continue for key, esc in artifact_entries.items(): - found.setdefault(key, esc) - return list(found.values()) + target.setdefault(key, esc) + return list(found.values()), sum(1 for key in answered if key not in found) -def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: str) -> Path: - """Write resolve//context.json for the resolve skill to read. +def build_context( + state: RunState, run_dir: Path, story_key: str, *, isolation: str +) -> tuple[Path, int]: + """Write resolve//context.json for the resolve skill to read, and + return it beside the number of already-answered escalations withheld from it. + + The count is for the OPERATOR's terminal (`cli.cmd_resolve` prints it) and is + deliberately not a `context.json` field: the skill's contract is singular — resolve + the escalation you are shown — and a count of things the agent cannot see is not + something it can act on. It comes from the same single walk that produced the shown + list, never from a second `_gather_escalations` call subtracting lengths. `isolation` is the LIVE policy's `scm.isolation`, and it is required rather than defaulted for the reason this surface exists at all: three of the fields below — @@ -177,6 +213,12 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # the main checkout while `stories_engine._stories_folder` was still the mount, so # one `context.json` could name two trees. stories_root = task_stories_root(task, state) + # DW-11: hide what an earlier resolve cycle already answered. `start` is the task's + # own watermark — 0 for a task never resolved, and for every pre-upgrade + # `state.json`, which is the unfiltered pre-DW-11 walk. + escalations, withheld = _gather_escalations( + run_dir, state, story_key, start=task.escalations_resolved_upto if task else 0 + ) context = { "story_key": story_key, "run_id": state.run_id, @@ -197,7 +239,7 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: "spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None), "baseline_commit": task.baseline_commit if task else None, "paused_reason": state.paused_reason, - "escalations": _gather_escalations(run_dir, state, story_key), + "escalations": escalations, # as_posix so the context contract is the same string on every OS (the # path is consumed by the agent, and Python/tools accept '/' on Windows). "resolution_path": resolution_path(run_dir, story_key).as_posix(), @@ -250,7 +292,7 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(context, indent=2), encoding="utf-8") - return path + return path, withheld def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 21b251ae..fa6df27f 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3657,6 +3657,7 @@ def rearm_escalation( *, restore_patch: str | None = None, isolated_redrive: bool, + resolution_recorded: bool, ) -> str: """Re-arm an escalation-paused story so the next resume re-drives it. @@ -3680,7 +3681,10 @@ def rearm_escalation( otherwise let the re-drive re-mint a session id byte-equal to one the abandoned attempt already recorded (#705). `task.sessions` is deliberately NOT cleared — a second resolve cycle reads that run-dir audit trail — so - the id is what has to change. + the id is what has to change. That preserved trail is also what + `resolution_recorded` watermarks: keeping it whole is what lets a later + cycle tell the answered prefix from the unanswered tail, instead of + choosing between re-presenting everything and losing the audit (DW-11). - The spec's `baseline_revision` is re-stamped on BOTH legs, and only when the advance above actually RAN — `advanced` records that both git reads succeeded, not that HEAD changed, so a resolve session that committed nothing still @@ -3723,6 +3727,25 @@ def rearm_escalation( defect this parameter exists to close. Both callers (`cli.cmd_resolve`, `tui.TuiApp._do_rearm`) hold a loaded policy already. + `resolution_recorded` says whether THIS gesture accepted a resolution, and it + alone gates the `escalations_resolved_upto` watermark (DW-11): the next resolve + cycle hides every escalation recorded below it, so advancing it over entries no + human answered would bury them forever and report them as already answered — the + inverse of the defect the watermark exists to fix. Keyword-only and REQUIRED for + the same reason as `isolated_redrive`: a default would be wrong in silence on + exactly the path that matters. It is a PARAMETER rather than a disk read because + the fact is not on disk. `resolution.json` is unlinked at one site in `src/` + (`resolve.run_session`, before it launches), which only `cli.cmd_resolve`'s + interactive arm reaches, and nothing deletes the marker at or after a re-arm — so + the marker survives the re-arm that consumed it, and `resolve --no-interactive` or + the TUI's Re-arm button would read the PREVIOUS cycle's marker as its own. The + caller already holds the answer: `cmd_resolve` binds it from `resolve.run_session`, + and both non-interactive callers know by construction that no session ran. Do not + unlink the marker here either — the TUI's Re-arm button is gated on its presence. + + The generation bump stays UNCONDITIONAL beside the gated stamp: it answers session-id + reuse (#705), which an abandoned attempt needs exactly as much as a resolved one. + Returns the re-armed story key. Raises RearmError when the run is not paused at the escalation stage, the target story is not escalated, or a supplied `restore_patch` fails `validate_restore_latch` (the shared precondition set — @@ -3769,6 +3792,17 @@ def rearm_escalation( # replay the abandoned verdict for the fresh attempt (#705). Bumped BEFORE any # dispatch, so the id is unique from the re-drive's first session onward. task.generation += 1 + # DW-11. How much of the preserved audit trail this resolution covered, so the next + # `resolve` shows the human only what they have not already answered. Gated on the + # CALLER's answer, never on `resolution.json`: the marker survives the re-arm that + # consumed it (only `resolve.run_session` unlinks it, and two of the three callers + # never run one), so reading it here would let a later marker-less gesture stamp + # over escalations nobody saw. A length, taken BEFORE the re-drive appends anything + # — `record_session` is the sole mutation of this list — and left where it stands + # when nothing was accepted, which reproduces the pre-DW-11 behavior for that + # gesture: everything shown, nothing reported withheld. + if resolution_recorded: + task.escalations_resolved_upto = len(task.sessions) task.review_cycle = 0 task.followup_reviews_spent = 0 # human-resolved re-drive gets a fresh damping budget task.defer_reason = None diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index b64cc1ff..6c96f528 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -969,7 +969,19 @@ def _do_rearm( before_entries = runs.journal_entries_or_none(run_dir) hold_resume = False try: - runs.rearm_escalation(run_dir, story_key, isolated_redrive=isolation == "worktree") + runs.rearm_escalation( + run_dir, + story_key, + isolated_redrive=isolation == "worktree", + # DW-11. This gesture runs no resolve session, so it accepted nothing: + # the escalation watermark must not advance. A `resolution.json` on + # disk is NOT evidence to the contrary here — `_restore_recorded` + # already records the governing fact for this surface, that a stale + # marker is indistinguishable from a fresh one, which is why this path + # declines the restore latch too. Stamping on its presence would bury + # escalations raised since the marker was written. + resolution_recorded=False, + ) except RearmError as e: self.notify(f"re-arm failed: {e}", severity="error") return diff --git a/tests/test_cli.py b/tests/test_cli.py index a4e4779c..ba23f7f0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2624,7 +2624,9 @@ def test_resolve_restamps_the_code_root_before_it_rearms(project, monkeypatch, c run_dir, moved, _ = _resolve_run_with_a_moved_code_root(project, monkeypatch) seen: list = [] - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): seen.append(load_state(rd).code_root) return key @@ -2748,7 +2750,9 @@ def test_resolve_echoes_this_rearms_stale_restore_events(tmp_path, monkeypatch, run_dir = _escalated_run(tmp_path, "r1") Journal(run_dir).append("stale-restore-excluded", story_key="s1", files=["FROM-LAST-TIME.txt"]) - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): journal = Journal(rd) journal.append("stale-restore-excluded", story_key=key, patch="a.patch", files=["new.txt"]) journal.append("stale-restore-unparseable", story_key=key, patch="b.patch", error="OSErr") @@ -2788,7 +2792,9 @@ def test_resolve_echoes_the_rearm_baseline_records(tmp_path, monkeypatch, capsys _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): journal = Journal(rd) journal.append( "rearm-baseline-advance-failed", @@ -2839,7 +2845,9 @@ def test_resolve_restamp_echo_warns_on_both_legs(tmp_path, monkeypatch, capsys): from bmad_loop.journal import Journal def rearm_with(restore: bool): - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): Journal(rd).append( "rearm-baseline-restamped", story_key=key, @@ -2893,7 +2901,9 @@ def test_resolve_survives_a_corrupt_journal(tmp_path, monkeypatch, capsys, outco from bmad_loop import runs from bmad_loop.journal import JOURNAL_FILE - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): if outcome == "rearm-error": raise runs.RearmError("cannot re-open story spec /x/spec.md") return key @@ -2929,7 +2939,9 @@ def test_resolve_echoes_a_skipped_restamp(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): Journal(rd).append( "rearm-baseline-restamp-skipped", story_key=key, @@ -2977,7 +2989,9 @@ def test_resolve_echoes_the_residue_even_when_the_rearm_aborts(tmp_path, monkeyp _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): # journalled first, exactly as the real residue pass is ordered Journal(rd).append( "stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c1", "c2"] @@ -3032,7 +3046,9 @@ def test_resolve_holds_the_resume_when_the_correction_cannot_reach_the_redrive( from bmad_loop.journal import Journal def rearm_journalling(kind, **fields): - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): Journal(rd).append(kind, story_key=key, **fields) return key @@ -3099,7 +3115,9 @@ def test_resolve_appends_the_next_step_imperative(tmp_path, monkeypatch, capsys) _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): journal = Journal(rd) journal.append( # table row with a next_step "rearm-baseline-advance-failed", @@ -3135,7 +3153,9 @@ def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): _escalated_run(tmp_path, "r1") calls = {} monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: calls.setdefault("ctx", True)) + monkeypatch.setattr( + resolve, "build_context", lambda *a, **k: (calls.setdefault("ctx", True), 0) + ) monkeypatch.setattr( resolve, "run_session", lambda *a, **k: calls.setdefault("session", True) or True ) @@ -3176,7 +3196,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) # --no-resume: re-arm only, so the bump this row contrasts against still runs assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 @@ -3190,7 +3210,14 @@ def test_resolve_interactive_unsupported_adapter(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + # DW-11: a NON-ZERO withheld count, deliberately. This command is about to fail, + # and an operator must not be told escalations were withheld from an agent that + # never launched — which is why the count is printed AFTER the adapter has proved + # it supports an interactive session, not beside the context build. + # + # Ablation: move the withheld print above the `try:` and this row reddens on the + # stdout assertion below. + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 3)) def boom(*a, **k): raise NotImplementedError @@ -3198,7 +3225,232 @@ def boom(*a, **k): monkeypatch.setattr(resolve, "run_session", boom) rc = cli.main(["resolve", "--project", str(tmp_path), "r1"]) assert rc == 1 - assert "no interactive session mode" in capsys.readouterr().err + captured = capsys.readouterr() + assert "no interactive session mode" in captured.err + assert "were not shown" not in captured.out + + +def _withheld_line(out: str) -> str: + (line,) = [ln for ln in out.splitlines() if "were not shown" in ln] + return line + + +def test_resolve_reports_the_escalations_it_withheld(tmp_path, monkeypatch, capsys): + """The number an operator reads is `build_context`'s OWN second member, not a + constant and not a re-derivation. Seeded to 3 so a hardcoded 1 (or a length of + something else) cannot pass, and worded for what the code can prove: these entries + were PRESENTED to an earlier cycle that recorded a resolution. + + Ablation: delete the `if withheld:` print from `cmd_resolve` and this reddens.""" + from bmad_loop import resolve + + _escalated_run(tmp_path, "r1") + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 3)) + monkeypatch.setattr(resolve, "run_session", lambda *a, **k: True) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + line = _withheld_line(capsys.readouterr().out) + assert line.startswith("3 earlier escalation(s) for s1 were not shown") + assert "recorded a resolution" in line + + +def test_resolve_says_nothing_when_it_withheld_nothing(tmp_path, monkeypatch, capsys): + """A first cycle, and every pre-upgrade `state.json`, withholds nothing — and must + print nothing, or the line becomes noise on the surface it exists to inform. + + `launching resolve agent` is the positive control: an absence assertion passes for + every reason stdout could be empty, including a command that returned before it + ever reached the print. + + Ablation: make the print unconditional (drop `if withheld:`) and this reddens.""" + from bmad_loop import resolve + + _escalated_run(tmp_path, "r1") + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "run_session", lambda *a, **k: True) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + out = capsys.readouterr().out + assert "launching resolve agent for s1" in out # the path WAS taken + assert "were not shown" not in out + + +def test_resolve_no_interactive_builds_no_context_and_reports_nothing( + tmp_path, monkeypatch, capsys +): + """`--no-interactive` runs no agent, so there is no context to filter and no + audience for the count. It also accepted nothing IN THIS GESTURE, so the watermark + must stand — the human may have fixed the spec by hand, but nothing recorded which + escalations that answered. The generation bump is the positive control that the + re-arm really ran. + + The run carries a session record deliberately: on a task with an EMPTY `sessions` + list an unconditional stamp writes `len([]) == 0`, so `escalations_resolved_upto == + 0` would hold with the gate ablated and the assertion would grade nothing.""" + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, "r1", details=("never answered",)) + built: list[int] = [] + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (built.append(1), (None, 5))[1]) + + assert ( + cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--no-resume"]) + == 0 + ) + + assert built == [] + assert "were not shown" not in capsys.readouterr().out + task = load_state(run_dir).tasks["s1"] + assert len(task.sessions) == 1 # a stamp here would be a VISIBLE 1 + assert task.escalations_resolved_upto == 0 + assert task.generation == 1 # positive control: the re-arm ran + + +def _escalated_trail_run(tmp_path, run_id="r1", *, details=("first cycle",)): + """An escalated run whose task carries one completed session record per entry in + `details`, each with the `tasks//escalation.json` the engine wrote when it + paused. Nothing about the escalation walk is stubbed by the rows that use it.""" + import json as _json + + from bmad_loop.engine import _session_task_id + from bmad_loop.journal import load_state, save_state + from bmad_loop.model import SessionRecord + + run_dir = _escalated_run(tmp_path, run_id) + state = load_state(run_dir) + task = state.tasks["s1"] + task.sessions.clear() + for seq, detail in enumerate(details, start=1): + task_id = _session_task_id("s1", "review", seq, 0) + task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + _json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), + encoding="utf-8", + ) + save_state(run_dir, state) + return run_dir + + +def _redrive_escalates(run_dir, detail): + """What a re-driven session that escalated again leaves behind, re-escalated so a + second `bmad-loop resolve` is legal on it.""" + import json as _json + + from bmad_loop.engine import _session_task_id + from bmad_loop.journal import load_state, save_state + from bmad_loop.model import Phase, SessionRecord + + state = load_state(run_dir) + task = state.tasks["s1"] + task_id = _session_task_id("s1", "review", 1, task.generation) + assert task_id not in {r.task_id for r in task.sessions} + task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + _json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), + encoding="utf-8", + ) + task.phase = Phase.ESCALATED + save_state(run_dir, state) + + +def _marker_writing_session(run_dir_marker=True): + from bmad_loop import resolve + + def fake_session(adapter, project, rd, story_key, *, generation, model=""): + marker = resolve.resolution_path(rd, story_key) + marker.parent.mkdir(parents=True, exist_ok=True) + if run_dir_marker: + marker.write_text("{}", encoding="utf-8") + return run_dir_marker + + return fake_session + + +def test_resolve_prints_the_number_the_real_walk_produced(tmp_path, monkeypatch, capsys): + """Every other CLI row here stubs `build_context` to a literal, so the number an + operator actually sees is otherwise never produced by the real walk. This row runs + two whole cycles with only `_make_adapters` and `run_session` stubbed: the first + shows both escalations and withholds nothing, the re-arm stamps the watermark, the + re-drive escalates again, and the second cycle prints the count `_gather_escalations` + computed — against a `context.json` that carries only the new entry. + + Ablation: revert `_gather_escalations` to the unsliced walk and the second cycle + prints nothing while `context.json` carries all three.""" + import json as _json + + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, details=("older A", "older B")) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "run_session", _marker_writing_session()) + + argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] + assert cli.main(argv) == 0 + first = capsys.readouterr().out + assert "launching resolve agent for s1" in first + assert "were not shown" not in first # a first cycle withholds nothing + assert load_state(run_dir).tasks["s1"].escalations_resolved_upto == 2 + + _redrive_escalates(run_dir, "raised by the re-drive") + + assert cli.main(argv) == 0 + assert _withheld_line(capsys.readouterr().out).startswith( + "2 earlier escalation(s) for s1 were not shown" + ) + ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] + + +def test_resolve_leaves_the_watermark_when_the_agent_wrote_no_resolution( + tmp_path, monkeypatch, capsys +): + """`cmd_resolve` prints "no resolution recorded" and FALLS THROUGH — no `return` — + so an abandoned or crashed resolve session re-arms the story anyway. That gesture + accepted nothing, so it must not advance the watermark: the escalations the agent + walked away from would otherwise be invisible to every later cycle and reported to + the operator as already answered. + + Driven as a whole SECOND cycle through the real walk, because the consequence is + what the next `resolve` shows, not what one field reads. + + Ablation: remove the `if resolution_recorded:` gate in `rearm_escalation` and this + reddens on the watermark, then again on the second cycle's absent line.""" + import json as _json + + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, details=("nobody ever answered this",)) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "run_session", _marker_writing_session(run_dir_marker=False)) + + argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] + assert cli.main(argv) == 0 + assert "no resolution recorded for s1" in capsys.readouterr().err + + task = load_state(run_dir).tasks["s1"] + assert task.escalations_resolved_upto == 0 # UNCHANGED + assert task.generation == 1 # positive control: the re-arm still ran + + _redrive_escalates(run_dir, "raised by the re-drive") + + assert cli.main(argv) == 0 + assert "were not shown" not in capsys.readouterr().out + ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == [ + "raised by the re-drive", + "nobody ever answered this", + ] def test_resolve_in_ctl_session_detaches_before_resume(tmp_path, monkeypatch, capsys): @@ -3431,7 +3683,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) @@ -3615,7 +3867,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) rc = cli.main(["resolve", "--project", str(tmp_path), "r1", "--resume"]) @@ -3675,12 +3927,14 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): seen: list[bool] = [] - def recording_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def recording_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): seen.append(isolated_redrive) return key monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) monkeypatch.setattr(runs, "rearm_escalation", recording_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -3713,7 +3967,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) @@ -3744,7 +3998,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 07aab2ce..37136adc 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1749,14 +1749,22 @@ def test_diag_surfaces_the_split_code_root_and_the_task_generation(project): `paused_reason_present` / `worktree_isolated` style, and a small counter. The path itself must NOT appear — that is what `_JOURNAL_DROP_FIELDS` drops. - Ablation: delete `repo_root_diverges=` from `collect_run` (or `generation=` from - `_task_diag`) and this reddens on the corresponding assertion; deleting the field - from the dataclass reddens as a TypeError at construction. + `escalations_resolved_upto` (DW-11) is projected on the same warrant and asserted + here for the same reason: a task whose older escalations are filtered out of + `context.json` dumps identically to one that only ever raised the entries shown, + so a support bundle cannot explain a short resolve context without it. A counter + too — it indexes `task.sessions`, so it carries no customer content. + + Ablation: delete `repo_root_diverges=` from `collect_run` (or `generation=` / + `escalations_resolved_upto=` from `_task_diag`) and this reddens on the + corresponding assertion; deleting the field from the dataclass reddens as a + TypeError at construction. """ run_dir = _seed_run(project.project) state = load_state(run_dir) state.repo_root = str(project.project / "code-tree") state.tasks[STORY_KEY].generation = 2 + state.tasks[STORY_KEY].escalations_resolved_upto = 3 save_state(run_dir, state) diag, _pseudo, combined = _render_all([run_dir]) @@ -1764,6 +1772,7 @@ def test_diag_surfaces_the_split_code_root_and_the_task_generation(project): assert run.repo_root_diverges is True assert run.tasks[0].generation == 2 + assert run.tasks[0].escalations_resolved_upto == 3 # a presence flag, never the path — the same rule `repo` is dropped under assert "code-tree" not in combined @@ -1780,6 +1789,7 @@ def test_diag_repo_root_diverges_is_false_for_the_ordinary_layout(project): assert run.repo_root_diverges is False assert run.tasks[0].generation == 0 + assert run.tasks[0].escalations_resolved_upto == 0 def _md_task_row(md: str) -> list[str]: @@ -1800,19 +1810,28 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): `generation` rides beside `attempt` because that is the column pair a #705-class replay turns on: a collided re-drive and a healthy post-re-arm task agree on every - other cell in this row. + other cell in this row. DW-11's `escalations_resolved_upto` rides beside it on the + same warrant, stated verbatim in its own field comment: it is the only field that + separates "this story raised one escalation" from "its earlier ones are filtered + out of `context.json` as already answered", and that question is asked of a bug + report. Seeded to a value that is neither the attempt, the generation nor the + review cycle, so a cell reading a NEIGHBOUR cannot pass. Ablation: drop the `code root differs from project` line from `render_markdown` and both this test and the sibling below redden on their first assertion. Drop `{t.generation}` from the row f-string together with its header and separator cells - and this test reddens at `names[4]` (`"rev" != "gen"`) while the sibling reddens at - the row cell — as `"1" != "0"`, the review cycle shifted left rather than a missing - key, which is why the cell is read positionally and the three widths are compared. + and this test reddens at `names[4]` (`"esc-upto" != "gen"`) while the sibling + reddens at the row cell — the review cycle shifted left rather than a missing key, + which is why the cell is read positionally and the three widths are compared. Drop + `{t.escalations_resolved_upto}` the same way and this test reddens at `names[5]` + (`"rev" != "esc-upto"`); drop ONLY the row cell and it reddens on the width + comparison, which is what a skewed table actually looks like. """ run_dir = _seed_run(project.project) state = load_state(run_dir) state.repo_root = str(project.project / "code-tree") state.tasks[STORY_KEY].generation = 2 + state.tasks[STORY_KEY].escalations_resolved_upto = 3 save_state(run_dir, state) pseudo = sanitize.Pseudonymizer() @@ -1825,11 +1844,13 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): (rule,) = [ln for ln in md.splitlines() if ln.startswith("|---|")] names = [c.strip() for c in header.strip("|").split("|")] assert names[4] == "gen" + assert names[5] == "esc-upto" # header, separator and row must agree on width or the table renders skewed - assert len(cells) == len(names) == len(rule.strip("|").split("|")) == 12 + assert len(cells) == len(names) == len(rule.strip("|").split("|")) == 13 assert cells[3] == "2" # attempt, seeded by `_seed_run` assert cells[4] == "2" # generation — NOT the review cycle, which is 1 - assert cells[5] == "1" # review cycle, still in its own column + assert cells[5] == "3" # the DW-11 watermark, in its own column + assert cells[6] == "1" # review cycle, still in its own column # still a flag and a counter: the path itself never renders assert "code-tree" not in md diff --git a/tests/test_engine.py b/tests/test_engine.py index 4b95666e..e12b0bf8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5481,7 +5481,7 @@ def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): # the resolve workflow's re-arm: a resolved re-drive, which is precisely the # recovery that PRESERVES the artifact folders' tracked content through # `safe_reset` — so a close left standing here would never be reverted. - rearm_escalation(engine.run_dir, isolated_redrive=False) + rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) resumed, _ = resume_engine( project, @@ -9138,7 +9138,9 @@ def test_resolved_escalation_resume_skips_clean_rollback(project): assert summary.paused and summary.escalated == 1 assert load_state(engine.run_dir).tasks["1-1-a"].phase == Phase.ESCALATED - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, @@ -9185,7 +9187,9 @@ def escalate_dirty(spec): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, @@ -9273,7 +9277,7 @@ def escalate_bound_repair(session): corrected = sp.read_text().replace("test spec", "human corrected frozen intent") sp.write_text(corrected) head_before_rearm = rev_parse_head(repo) - rearm_escalation(engine.run_dir, isolated_redrive=False) + rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) assert rev_parse_head(repo) == head_before_rearm # no correction commit at re-arm assert read_frontmatter(sp)["status"] == "ready-for-dev" @@ -9844,7 +9848,9 @@ def halt_blocked(spec): assert task.phase == Phase.ESCALATED assert task.spec_file and Path(task.spec_file).name == sp.name # recorded despite HALT - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step assert read_frontmatter(sp)["status"] == "ready-for-dev" # re-drive will not HALT @@ -10080,7 +10086,7 @@ def test_intent_gap_restore_redrive_applies_patch_and_lands_done(project): assert engine.run().escalated == 1 rearm_escalation( - engine.run_dir, restore_patch=str(patch), isolated_redrive=False + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True ) # human confirmed the reading sp = spec_path(project, "1-1-a") assert read_frontmatter(sp)["status"] == "in-review" # routes step-01 -> step-04 @@ -10109,7 +10115,9 @@ def test_restore_redrive_prompt_points_at_the_spec(project): engine, _ = make_engine(project, [_escalate_with_patch(project, "1-1-a", patch)]) assert engine.run().escalated == 1 - rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) + rearm_escalation( + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + ) seen: list[str] = [] resumed, adapter = resume_engine( project, engine, [_restoring_dev_effect(project, "1-1-a", seen)] @@ -10130,7 +10138,9 @@ def test_intent_gap_restore_reapplies_after_mid_redrive_rollback(project): patch = project.implementation_artifacts / "attempt.patch" engine, _ = make_engine(project, [_escalate_with_patch(project, "1-1-a", patch)]) assert engine.run().escalated == 1 - rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) + rearm_escalation( + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + ) seen: list[str] = [] resumed, _ = resume_engine( @@ -10165,7 +10175,9 @@ def test_intent_gap_restore_escalates_when_resolution_commits_overlap(project): (repo / "src.txt").write_text("corrected by resolution\n") git(repo, "add", "src.txt") git(repo, "commit", "-q", "-m", "resolution: overlapping fix") - rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) + rearm_escalation( + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + ) seen: list[str] = [] resumed, _ = resume_engine(project, engine, [_restoring_dev_effect(project, "1-1-a", seen)]) @@ -10552,7 +10564,9 @@ def test_resume_re_gates_a_human_armed_re_drive(project): ) engine, _ = make_engine(project, [escalating]) assert engine.run().escalated == 1 - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 # the confusable state # a gate lands on the story while the operator is resolving it write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) @@ -11068,7 +11082,7 @@ def test_session_env_fault_pauses_dev_without_burning_budget(project): assert end["env_fault_evidence"] == evidence # the resolve workflow's re-arm step restores the attempt budget - rearm_escalation(engine.run_dir, isolated_redrive=False) + rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 @@ -12256,7 +12270,9 @@ def test_resume_with_epic_filter_stays_in_scoped_epic(project): assert summary.paused and summary.escalated == 1 assert engine.state.current_epic == 9 - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, engine, @@ -12318,7 +12334,9 @@ def test_resolved_redrive_reescalates_instead_of_deferring(project): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation(engine.run_dir, isolated_redrive=False) # human resolved; re-drive re-armed + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # human resolved; re-drive re-armed # re-drive never reaches `done` (env still blocked): both attempts land at # in-progress with no escalation — the exact non-convergence that used to defer resumed, _ = resume_engine( diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 3a3bf153..ec0d9412 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2101,7 +2101,12 @@ def commit_fails(*_a, **_k): assert not project.deferred_work.exists() # the row is only in the doomed worktree monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert runs.rearm_escalation(engine.run_dir, "1-1-a", isolated_redrive=True) == "1-1-a" + assert ( + runs.rearm_escalation( + engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True + ) + == "1-1-a" + ) state = load_state(engine.run_dir) state.clear_pause() @@ -5762,7 +5767,12 @@ def commit_fails(*_a, **_k): assert _ledger_entry(project, "DW-1").open monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert runs.rearm_escalation(engine.run_dir, "1-1-a", isolated_redrive=True) == "1-1-a" + assert ( + runs.rearm_escalation( + engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True + ) + == "1-1-a" + ) state = load_state(engine.run_dir) state.clear_pause() diff --git a/tests/test_model.py b/tests/test_model.py index 1b264bd6..50fd5ac0 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -291,6 +291,22 @@ def test_generation_defaults_zero_for_legacy_state(): assert StoryTask.from_dict(doc).generation == 0 +def test_escalations_resolved_upto_round_trips(): + task = StoryTask(story_key="1-1-a", epic=1, escalations_resolved_upto=3) + assert StoryTask.from_dict(task.to_dict()).escalations_resolved_upto == 3 + + +def test_escalations_resolved_upto_defaults_zero_for_legacy_state(): + """A `state.json` written before DW-11 must resume UNFILTERED. 0 is the value + `resolve._gather_escalations` reads as "nothing answered yet", so every escalation + the run recorded is still shown and nothing is reported withheld — byte-for-byte + today's behavior. Any other default would hide entries the human never saw, on a + run that was mid-escalation across the upgrade.""" + doc = StoryTask(story_key="1-1-a", epic=1).to_dict() + del doc["escalations_resolved_upto"] # state.json from before the field existed + assert StoryTask.from_dict(doc).escalations_resolved_upto == 0 + + def test_resolved_redrive_round_trips(): task = StoryTask(story_key="1-1-a", epic=1, resolved_redrive=True) assert StoryTask.from_dict(task.to_dict()).resolved_redrive is True diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 795600bc..15913b43 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -92,6 +92,20 @@ def _escalated_run( return run.run_dir, run.state, run.task +def _context(state, run_dir, story_key, *, isolation): + """`build_context`'s Path alone, for the ~30 rows that assert on `context.json`. + + `build_context` returns `(path, withheld)` since DW-11, and the withheld count is + an OPERATOR-facing number the CLI prints — no row here is about it. Routing every + Path-only caller through one unpack pins the arity for all of them at once: grow + the tuple a third member and this helper fails, rather than every row silently + binding a longer tuple to `path` (which is what a bare `path, _ = ...` at each + site would do). The rows that ARE about the count call `resolve.build_context` + directly, so the number is never produced by this helper.""" + path, _withheld = resolve.build_context(state, run_dir, story_key, isolation=isolation) + return path + + # ------------------------------------------------------------ set_frontmatter_field # # `set_frontmatter_status`'s own tests live in tests/test_frontmatter.py, next to @@ -531,7 +545,7 @@ def test_build_context_gathers_critical_escalations(tmp_path): ), encoding="utf-8", ) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == "6-4-cli-list-command" assert ctx["spec_file"] == spec.as_posix() @@ -589,7 +603,7 @@ def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_ run_dir, state, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what the resolve session actually runs from - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="worktree") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="worktree") ctx = json.loads(path.read_text(encoding="utf-8")) assert Path(ctx["spec_file"]).is_absolute() # the worktree's copy, not the main checkout's twin — compared as posix, which is @@ -611,17 +625,15 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=None, worktree_path=str(wt)) ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_file"] is None # task present, spec-less escalation assert "no-such-story" not in state.tasks ctx = json.loads( - resolve.build_context(state, run_dir, "no-such-story", isolation="worktree").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "no-such-story", isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] is None # no task at all # ... and the escalation gather degrades on the same absence rather than @@ -631,7 +643,7 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): def test_build_context_no_session_files(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, with_session=False) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["escalations"] == [] assert ctx["paused_reason"].startswith("CRITICAL") @@ -646,25 +658,25 @@ def test_build_context_restore_supported_signal(tmp_path): run_dir, state, task = _escalated_run(tmp_path, spec_file="/abs/spec.md", with_session=False) key = "6-4-cli-list-command" - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is True - path = resolve.build_context(state, run_dir, key, isolation="worktree") + path = _context(state, run_dir, key, isolation="worktree") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.worktree_path = str(tmp_path / "wt") # recorded worktree execution - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.worktree_path = "" task.spec_file = None # spec-less escalation: a restored patch has no review to resume - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.spec_file = "/abs/spec.md" state.source = "stories" task.sentinel_kind = "missing-prd" # pre-planning wedge: nothing attempted to restore - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False @@ -675,7 +687,7 @@ def test_build_context_sanitizes_dirty_story_key(tmp_path): dirty = "6-4:cli?list" seg = safe_segment(dirty) assert seg != dirty - path = resolve.build_context(state, run_dir, dirty, isolation="") + path = _context(state, run_dir, dirty, isolation="") assert path.parent.name == seg ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == dirty @@ -689,7 +701,7 @@ def test_rearm_flips_phase_and_spec_status(tmp_path): spec = tmp_path / "spec.md" spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - key = runs.rearm_escalation(run_dir, isolated_redrive=False) + key = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert key == "6-4-cli-list-command" state = load_state(run_dir) task = state.tasks[key] @@ -713,7 +725,7 @@ def test_rearm_strips_stale_terminal_section(tmp_path): encoding="utf-8", ) run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) text = spec.read_text(encoding="utf-8") assert "Auto Run Result" not in text and "names not unique" not in text assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" @@ -756,7 +768,7 @@ def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive tmp_path, spec_file=str(spec), worktree_path=str(tmp_path / "wt" / "u1") ) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["story_key"] == "6-4-cli-list-command" @@ -778,7 +790,7 @@ def test_rearm_does_not_warn_about_unreachable_writes_without_a_worktree(tmp_pat spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -846,9 +858,9 @@ def test_rearm_journals_a_status_flip_that_silently_did_nothing(tmp_path, shape) if shape == "no-frontmatter": with pytest.raises(runs.RearmError, match="no frontmatter `status:`"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) else: - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) records = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-flip-skipped"] if shape == "already-at-target": @@ -879,7 +891,7 @@ def test_rearm_journals_a_status_flip_that_silently_did_nothing(tmp_path, shape) def test_rearm_journals_event(tmp_path): run_dir, _, _ = _escalated_run(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) journal = (run_dir / "journal.jsonl").read_text(encoding="utf-8") assert "story-escalation-resolved" in journal @@ -898,7 +910,7 @@ def test_rearm_advances_baseline_to_resolved_head(project): # a file the resolve session (or the user) left untracked must enter the # snapshot, so the redrive reset treats it as pre-existing, not run-created (root / "leftover.txt").write_text("keep me\n", encoding="utf-8") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == git(root, "rev-parse", "HEAD") assert task.baseline_commit != old_head @@ -917,7 +929,7 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" assert task.baseline_untracked is None @@ -927,7 +939,7 @@ def test_rearm_keeps_stale_baseline_outside_a_repo(tmp_path): # best-effort contract: a project dir that is not a git repo (or a broken # one) must not make re-arm fail — the old baseline simply stands run_dir, _, _ = _escalated_run(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" @@ -947,7 +959,7 @@ def test_rearm_journals_a_failed_baseline_advance(tmp_path): """ run_dir, _, _ = _escalated_run(tmp_path) # tmp_path is not a git repo - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-advance-failed"] assert entry["story_key"] == "6-4-cli-list-command" @@ -975,7 +987,7 @@ def boom(repo): monkeypatch.setattr(runs.verify, "untracked_files", boom) with pytest.raises(MemoryError): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) @pytest.mark.parametrize("restore", [None, "artifacts/attempt.patch"]) @@ -999,7 +1011,9 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir, restore_patch=restore, isolated_redrive=False) + runs.rearm_escalation( + run_dir, restore_patch=restore, isolated_redrive=False, resolution_recorded=True + ) fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == old_head # NOT re-stamped with the stale sha @@ -1024,7 +1038,7 @@ def test_rearm_bumps_the_task_generation(tmp_path): before = load_state(run_dir).tasks["6-4-cli-list-command"] assert before.generation == 0 and len(before.sessions) == 1 - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.generation == 1 @@ -1032,7 +1046,7 @@ def test_rearm_bumps_the_task_generation(tmp_path): assert len(task.sessions) == 1 # the audit trail survives the re-arm save_state(run_dir, _rearmable(run_dir)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert load_state(run_dir).tasks["6-4-cli-list-command"].generation == 2 @@ -1057,7 +1071,7 @@ def test_rearm_advances_the_baseline_in_the_code_tree(tmp_path): git(code, "commit", "-q", "-m", "resolution fixture") (code / "leftover.txt").write_text("keep me\n") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == git(code, "rev-parse", "HEAD") != head @@ -1115,7 +1129,9 @@ def test_rearm_reads_stale_restore_residue_from_the_code_tree(tmp_path): git(code, "commit", "-q", "-m", "resolution fixture") new_head = git(code, "rev-parse", "HEAD") - runs.rearm_escalation(run_dir, isolated_redrive=False) # from scratch: the latch is dropped + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # from scratch: the latch is dropped task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == new_head @@ -1156,7 +1172,7 @@ def test_rearm_falls_back_to_project_when_no_code_root_was_recorded(tmp_path): (run_dir / "state.json").write_text(json.dumps(raw), encoding="utf-8") assert load_state(run_dir).repo_root == "" - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert load_state(run_dir).tasks["6-4-cli-list-command"].baseline_commit == head @@ -1203,7 +1219,7 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what `bmad-loop resolve` actually runs from - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) fm = verify.read_frontmatter(wt / rel) assert fm["status"] == "ready-for-dev" # the flip landed in the WORKTREE @@ -1240,7 +1256,7 @@ def test_rearm_journals_a_skip_when_the_recorded_spec_is_not_readable(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file="wt/_bmad-output/specs/gone.md") runs.rearm_escalation( - run_dir, isolated_redrive=False + run_dir, isolated_redrive=False, resolution_recorded=True ) # must not raise: the flip's no-op is not a refusal kinds = _kinds(run_dir) @@ -1280,7 +1296,7 @@ def test_rearm_records_an_unreachable_spec_even_when_the_advance_failed(tmp_path _resolve_repo(tmp_path) run_dir, _, _ = _escalated_run(tmp_path, spec_file="wt/_bmad-output/specs/gone.md") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) kinds = _kinds(run_dir) (skipped,) = [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] @@ -1308,7 +1324,7 @@ def test_rearm_restamps_normally_when_the_spec_resolves(tmp_path): spec.write_text("---\nstatus: 'escalated'\nbaseline_revision: 'old'\n---\n\nbody\n") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) kinds = _kinds(run_dir) assert [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] == [] @@ -1337,7 +1353,7 @@ def test_rearm_clears_sentinel_preserving_a_copy(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - returned = runs.rearm_escalation(run_dir, isolated_redrive=False) + returned = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert returned == key # sentinel deleted from disk, a copy preserved under the run dir @@ -1377,7 +1393,7 @@ def test_rearm_non_sentinel_spec_still_flips_status(tmp_path): # detected as a sentinel) → status-flip, not delete. run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.is_file() # not deleted assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1397,7 +1413,7 @@ def test_rearm_sentinel_named_spec_never_detected_is_not_deleted(tmp_path): # stories mode, but sentinel_kind unset — the run never classified it as a sentinel run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.is_file() # NOT deleted despite the sentinel-shaped name assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1414,7 +1430,7 @@ def test_rearm_sprint_spec_named_like_a_sentinel_is_not_deleted(tmp_path): spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nreal work\n", encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) # sprint-status source - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.is_file() # NOT deleted despite the sentinel-shaped name assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" # flipped like any spec assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1441,7 +1457,10 @@ def test_rearm_rejects_restore_patch_on_a_sentinel(tmp_path): with pytest.raises(runs.RearmError, match="sentinel"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, ) assert sentinel.is_file() # nothing deleted, copy NOT preserved — no clear happened @@ -1463,7 +1482,10 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): with pytest.raises(runs.RearmError, match="no recorded spec file"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, ) task = load_state(run_dir).tasks["6-4-cli-list-command"] @@ -1472,7 +1494,7 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): assert not (run_dir / "journal.jsonl").exists() # nothing journaled runs.rearm_escalation( - run_dir, isolated_redrive=False + run_dir, isolated_redrive=False, resolution_recorded=True ) # a from-scratch re-arm remains available assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.PENDING @@ -1490,14 +1512,20 @@ def test_rearm_rejects_restore_patch_for_a_worktree_executed_task(tmp_path): with pytest.raises(runs.RearmError, match="worktree-isolation"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=True + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=True, + resolution_recorded=True, ) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.phase == Phase.ESCALATED # nothing mutated; still armed for a re-resolve assert task.restore_patch is None # a from-scratch re-arm of the same task is unaffected — the guard is latch-only - assert runs.rearm_escalation(run_dir, isolated_redrive=True) == "6-4-cli-list-command" + assert ( + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + == "6-4-cli-list-command" + ) def test_validate_restore_latch_passes_a_clean_in_place_escalation(tmp_path): @@ -1524,7 +1552,12 @@ def test_rearm_restore_patch_on_a_real_stories_spec_is_allowed(tmp_path): spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) task = load_state(run_dir).tasks[key] assert task.phase == Phase.PENDING assert task.restore_patch == "artifacts/attempt.patch" @@ -1564,7 +1597,12 @@ def test_rearm_restore_patch_restamps_spec_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "resolution fixture") new_head = git(tmp_path, "rev-parse", "HEAD") - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head # step-04 diffs from the ADVANCED baseline @@ -1614,7 +1652,7 @@ def test_rearm_restamps_spec_baseline_on_the_from_scratch_leg_too(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, spec, new_head = _escalated_spec_run(tmp_path, old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # no restore fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head @@ -1665,7 +1703,7 @@ def test_rearm_restores_the_spec_when_the_baseline_restamp_aborts(tmp_path): git(tmp_path, "commit", "-q", "-m", "resolution fixture") with pytest.raises(runs.RearmError, match="baseline_revision"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.read_bytes() == before # flip AND strip both undone # nothing was persisted either, so the escalation is still armed for a corrected spec @@ -1716,7 +1754,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.read_bytes() == before # the published flip is rolled back assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED @@ -1762,7 +1800,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert spec.read_bytes() == before # the undo reached a spec outside the mount assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED @@ -1781,7 +1819,7 @@ def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, _spec, new_head = _escalated_spec_run(tmp_path, old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == old_head @@ -1790,7 +1828,7 @@ def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): # a second re-arm has nothing left to overwrite: no duplicate record save_state(run_dir, _rearmable(run_dir)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert len([e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"]) == 1 @@ -1816,7 +1854,7 @@ def test_rearm_does_not_report_a_divergence_the_run_never_had(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, spec, new_head = _escalated_spec_run(tmp_path, old_head, recorded=old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # the re-stamp itself ran: this row is about what was REPORTED, not what was skipped assert verify.read_frontmatter(spec)["baseline_revision"] == new_head @@ -1852,7 +1890,7 @@ def test_rearm_reports_a_claim_the_advanced_head_would_have_masked(tmp_path): encoding="utf-8", ) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == new_head # the claim, carried verbatim @@ -1869,7 +1907,7 @@ def test_rearm_prefers_the_fresh_revision_when_the_spec_carries_both_keys(tmp_pa tmp_path, old_head, extra=f"baseline_commit: {'a' * 40}\n" ) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == old_head # NOT the stale baseline_commit @@ -1905,7 +1943,7 @@ def test_build_context_tolerates_non_utf8_present_spec(tmp_path): (stories_dir / f"{key}-slug.md").write_bytes(_BAD_UTF8) # a real spec, undecodable run_dir, state, _ = _escalated_run(tmp_path, source="stories") - path = resolve.build_context(state, run_dir, key, isolation="") # must not raise + path = _context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["stories"]["spec_folder"] == "" # best-effort context still produced assert "sentinel" not in ctx["stories"] # the undecodable spec yields no sentinel @@ -1920,7 +1958,7 @@ def test_build_context_tolerates_non_utf8_sentinel(tmp_path): (stories_dir / f"{key}-unresolved.md").write_bytes(_BAD_UTF8) # undecodable sentinel run_dir, state, _ = _escalated_run(tmp_path, source="stories", sentinel_kind="unresolved") - path = resolve.build_context(state, run_dir, key, isolation="") # must not raise + path = _context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["stories"]["sentinel"]["kind"] == "unresolved" assert ctx["stories"]["sentinel"]["blocking_condition"] == "" # unreadable → empty @@ -1941,7 +1979,7 @@ def test_rearm_non_utf8_present_spec_fails_clean_and_stays_armed(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") with pytest.raises(runs.RearmError) as exc: - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert "UTF-8" in str(exc.value) and "resolve" in str(exc.value) assert spec.read_bytes() == _BAD_UTF8 # spec untouched task = load_state(run_dir).tasks[key] @@ -1961,7 +1999,9 @@ def test_rearm_tolerates_non_utf8_sentinel(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - assert runs.rearm_escalation(run_dir, isolated_redrive=False) == key # must not raise + assert ( + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) == key + ) # must not raise assert not sentinel.exists() # cleared by deletion assert (run_dir / "sentinels" / f"{key}-unresolved.md").is_file() # copy preserved assert load_state(run_dir).tasks[key].spec_file is None # cleared → PENDING re-dispatch @@ -1994,7 +2034,7 @@ def test_rearm_rejects_non_escalation_stage(tmp_path): ), ) with pytest.raises(runs.RearmError, match="not paused at an escalation"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) def test_rearm_rejects_unescalated_story(tmp_path): @@ -2002,7 +2042,7 @@ def test_rearm_rejects_unescalated_story(tmp_path): task.phase = Phase.DONE # terminal but not escalated save_state(run_dir, state) with pytest.raises(runs.RearmError, match="not escalated"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # ------------------------------------------------- _gather_escalations @@ -2032,7 +2072,7 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): encoding="utf-8", ) - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["abandoned cycle"] # once, not twice # DW-71: the id bump only protects records minted AFTER it. State persisted @@ -2040,7 +2080,7 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): # directory's single mutable escalation.json — the reader itself has to return # the escalation once rather than attribute it to the fresh session too. task.sessions[1] = SessionRecord(task_id=abandoned, role="dev", status="completed") - collided = resolve._gather_escalations(run_dir, state, key) + collided, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in collided] == ["abandoned cycle"] @@ -2082,7 +2122,7 @@ def counting_read_text(self, *args, **kwargs): # back the suite's `BMAD_LOOP_STATE_DIR` isolation too, mid-test. with monkeypatch.context() as mp: mp.setattr(Path, "read_text", counting_read_text) - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert reads.count(str(result_file)) == 1 # each artifact once, not once per record assert reads.count(str(esc_file)) == 1 @@ -2124,7 +2164,7 @@ def test_gather_escalations_dedupes_one_entry_across_two_sessions(tmp_path): for d in (older_dir, newer_dir): (d / "escalation.json").write_text(json.dumps({"escalations": [entry]}), encoding="utf-8") - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["unresolved across attempts"] @@ -2140,7 +2180,7 @@ def test_gather_escalations_orders_distinct_sessions_newest_first(tmp_path): encoding="utf-8", ) - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["newer", "older"] @@ -2173,9 +2213,7 @@ def test_gather_escalations_returns_a_mirrored_entry_once(tmp_path): (task_dir / fname).write_text(json.dumps({"escalations": [value]}), encoding="utf-8") ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert ctx["escalations"] == [entry] @@ -2192,7 +2230,7 @@ def test_gather_escalations_keeps_distinct_entries_from_both_files(tmp_path): (task_dir / "result.json").write_text(json.dumps({"escalations": [a]}), encoding="utf-8") (task_dir / "escalation.json").write_text(json.dumps({"escalations": [a, b]}), encoding="utf-8") - found = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") + found, _ = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") assert [e["detail"] for e in found] == ["A", "B"] @@ -2218,7 +2256,10 @@ def test_gather_escalations_keeps_full_objects_that_share_a_detail(tmp_path): json.dumps({"escalations": [first, second]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( + [first, second], + 0, + ) def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_path): @@ -2232,7 +2273,10 @@ def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_pa json.dumps({"escalations": [second]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( + [first, second], + 0, + ) def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): @@ -2246,7 +2290,10 @@ def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): json.dumps({"escalations": [second, first]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( + [first, second], + 0, + ) def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): @@ -2258,7 +2305,7 @@ def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): json.dumps({"escalations": [entry, entry]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [entry] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ([entry], 0) def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_path): @@ -2271,7 +2318,7 @@ def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_pa json.dumps({"escalations": [None, "junk", preference, critical]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [critical] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ([critical], 0) def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): @@ -2288,9 +2335,7 @@ def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): ) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert [e["detail"] for e in ctx["escalations"]] == ["still readable"] @@ -2319,7 +2364,7 @@ def loads_with_digit_limit(data, *args, **kwargs): with monkeypatch.context() as mp: mp.setattr(resolve.json, "loads", loads_with_digit_limit) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] @@ -2346,9 +2391,7 @@ def test_gather_escalations_skips_a_json_recursion_error(tmp_path): ) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] @@ -2372,7 +2415,7 @@ def dumps_with_recursion_error(value, *args, **kwargs): with monkeypatch.context() as mp: mp.setattr(resolve.json, "dumps", dumps_with_recursion_error) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["escalations"] == [sibling] @@ -2405,7 +2448,7 @@ def recording_critical_escalations(doc): with monkeypatch.context() as mp: mp.setattr(resolve, "critical_escalations", recording_critical_escalations) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert filtered == [ @@ -2435,15 +2478,310 @@ def test_gather_escalations_preference_only_yields_nothing(tmp_path): for fname in ("result.json", "escalation.json"): (task_dir / fname).write_text(json.dumps({"escalations": [pref]}), encoding="utf-8") - assert resolve._gather_escalations(run_dir, state, key) == [] + assert resolve._gather_escalations(run_dir, state, key) == ([], 0) crit = {"type": "spec-gap", "severity": "CRITICAL", "detail": "kept"} for fname in ("result.json", "escalation.json"): (task_dir / fname).write_text(json.dumps({"escalations": [pref, crit]}), encoding="utf-8") - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["kept"] # this directory IS read +# -------------------------------------- DW-11: the escalation watermark + + +def _watermarked_trail(tmp_path, per_session): + """A task whose append-only `sessions` list carries ONE record per element of + `per_session`, each with its own `tasks//escalation.json` holding that + record's CRITICAL details. Returns `(run_dir, state, task, key)` with the state + already saved, so a row can re-arm it without re-saving by hand. + + The ids are minted through `engine._session_task_id`, varying the SEQ inside + generation 0 — the trail one pre-re-arm cycle leaves behind. Distinctness is + asserted rather than assumed: a shared id collapses into the reader's `seen_ids` + guard, leaving one directory and one side to route to, and every row below would + then pass with the filter ablated. Varying the seq (not the generation) also + keeps the whole namespace clear of the ids a LATER re-arm mints, so a re-drive + record cannot silently overwrite a trail artifact. + """ + run_dir, state, task = _escalated_run(tmp_path) + key = "6-4-cli-list-command" + task.sessions.clear() + for seq, details in enumerate(per_session, start=1): + task_id = _session_task_id(key, "review", seq, 0) + assert task_id not in {r.task_id for r in task.sessions} + task.sessions.append(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + json.dumps( + { + "escalations": [ + {"type": "spec-gap", "severity": "CRITICAL", "detail": detail} + for detail in details + ] + } + ), + encoding="utf-8", + ) + save_state(run_dir, state) + return run_dir, state, task, key + + +def _redrive_escalates(run_dir, key, detail, *, escalated=False): + """Append the record + artifact a re-driven session that escalated again leaves + behind — through `record_session`, the SOLE mutation of `task.sessions` in + `src/`, which is what makes a length watermark meaningful. The id carries the + re-arm's own generation, exactly as `engine._session_task_id` would mint it.""" + state = load_state(run_dir) + task = state.tasks[key] + assert task.generation > 0 # a re-arm ran, so this id is in a fresh namespace + task_id = _session_task_id(key, "review", 1, task.generation) + assert task_id not in {r.task_id for r in task.sessions} + task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + json.dumps( + {"escalations": [{"type": "spec-gap", "severity": "CRITICAL", "detail": detail}]} + ), + encoding="utf-8", + ) + if escalated: + task.phase = Phase.ESCALATED + save_state(run_dir, state) + + +def test_gather_escalations_shows_the_whole_trail_at_watermark_zero(tmp_path): + """The default is the PRE-DW-11 walk, byte-for-byte. 0 is what a task that was + never resolved carries and what a pre-upgrade `state.json` deserializes to, so + this row is also the legacy-state contract at the reader.""" + run_dir, state, task, key = _watermarked_trail(tmp_path, [["older"], ["newer"]]) + assert task.escalations_resolved_upto == 0 + + found, withheld = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in found] == ["newer", "older"] + assert withheld == 0 + + +def test_gather_escalations_hides_sessions_below_the_watermark(tmp_path): + """The defect DW-11 names. `task.sessions` is append-only and a re-arm + deliberately does not clear it, so a second resolve cycle re-presented every + escalation the story ever raised — interleaved with the new ones and with + nothing marking which was which, against a skill contract that is singular + ("present THE escalation"). + + Ablation: ignore `start` in `_gather_escalations` (route everything to `found`) + and this row fails by showing the answered entry again.""" + run_dir, state, _task, key = _watermarked_trail( + tmp_path, [["answered last cycle"], ["raised since"]] + ) + + found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) + assert [e["detail"] for e in found] == ["raised since"] + assert withheld == 1 + + +def test_gather_escalations_counts_the_entries_it_withheld(tmp_path): + """The number the operator is shown is the count of DISTINCT withheld entries, + not of sessions or of directories — and it comes from the same single walk that + produced the shown list, never a second call subtracting lengths.""" + run_dir, state, _task, key = _watermarked_trail(tmp_path, [["a", "b", "c"], ["new"]]) + + found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) + assert [e["detail"] for e in found] == ["new"] + assert withheld == 3 + + +def test_gather_escalations_does_not_count_an_entry_it_still_shows(tmp_path): + """ "Not shown" is the claim the number makes, so it must never count something + the operator can see. An escalation the re-drive re-raised appears on BOTH sides + of the watermark: it is shown once (the newest-first content map) and contributes + 0 to the count, while its answered-only sibling contributes 1. + + The sibling is the in-row positive control: an `assert withheld == 0` alone would + pass just as well if the answered directory were never read at all. + + Ablation: drop the `key not in found` clause from the count and this reddens at + 2 != 1.""" + run_dir, state, _task, key = _watermarked_trail( + tmp_path, + [["re-raised by the re-drive", "answered and gone"], ["re-raised by the re-drive"]], + ) + + found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) + assert [e["detail"] for e in found] == ["re-raised by the re-drive"] # once, not twice + assert withheld == 1 # "answered and gone" only + + +def test_gather_escalations_attributes_a_task_id_spanning_the_watermark_to_the_shown_side( + tmp_path, +): + """One `task_id` on an answered record AND an unanswered one — the shape the + pre-`generation` id namespace produced, which persisted state still carries. The + `seen_ids` guard opens that directory ONCE, at its newest occurrence, which is + the unanswered side: the entry is SHOWN. Over-showing is the conservative + direction; the alternative buries an escalation on an ambiguity. + + Ablation: walk the trail FORWARD — `for index, session in enumerate(task.sessions)` + with `target = found if index >= start else answered`, a rewrite that still reads + correct and leaves every other row in this block green except the ordering sibling + — and this reddens at `([], 1)`. The shared directory is then opened at its + ANSWERED occurrence, so the escalation is buried AND counted as already answered: + the second member is what catches that, which is why the assertion is a tuple and + not the shown list alone. MEASURED, and the recipe is specific for a reason: + deleting the `seen_ids` guard does NOT redden this row (the directory is read + twice, but the key lands in `found` first and the count's `key not in found` + clause absorbs the duplicate), so `seen_ids` is graded by its own siblings above, + not here.""" + run_dir, state, task, key = _watermarked_trail(tmp_path, [["spans the watermark"]]) + shared = task.sessions[0].task_id + task.sessions.append(SessionRecord(task_id=shared, role="dev", status="completed")) + save_state(run_dir, state) + + assert resolve._gather_escalations(run_dir, state, key, start=1) == ( + [{"type": "spec-gap", "severity": "CRITICAL", "detail": "spans the watermark"}], + 0, + ) + + +def test_gather_escalations_with_no_sessions_is_empty_and_reports_nothing(tmp_path): + run_dir, state, task, key = _watermarked_trail(tmp_path, []) + assert task.sessions == [] + assert resolve._gather_escalations(run_dir, state, key) == ([], 0) + + +def test_gather_escalations_past_the_end_of_the_trail_never_raises(tmp_path): + """A watermark beyond the list — hand-edited state, or a trail that shrank — + must yield an empty shown list, not an IndexError. `start` only SELECTS a map; + nothing is indexed with it, which is what makes that true structurally. + + The `2` is load-bearing: `== ([], 2)` proves both directories were READ and + filtered. An `== []` alone would pass equally if the walk had found nothing.""" + run_dir, state, _task, key = _watermarked_trail(tmp_path, [["first"], ["second"]]) + + assert resolve._gather_escalations(run_dir, state, key, start=9) == ([], 2) + + +def test_rearm_stamps_the_watermark_when_a_resolution_was_recorded(tmp_path): + """The stamp records how much of the audit trail the accepted resolution covered + — a LENGTH of `task.sessions`, taken before the re-drive appends anything. + + Ablation: drop the stamp from `rearm_escalation` and this reddens at 0 != 1, + taking the second-cycle rows below with it.""" + run_dir, _, _ = _escalated_run(tmp_path) + before = load_state(run_dir).tasks["6-4-cli-list-command"] + assert before.escalations_resolved_upto == 0 and len(before.sessions) == 1 + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.escalations_resolved_upto == 1 + assert len(task.sessions) == 1 # the trail the watermark indexes still stands + assert task.generation == 1 # positive control: the bump ran on this gesture too + + +def test_rearm_leaves_the_watermark_where_it_was_when_nothing_was_recorded(tmp_path): + """`cmd_resolve` prints "no resolution recorded" and FALLS THROUGH to re-arm, and + both non-interactive re-arm gestures run no session at all. None of them accepted + anything, so none may advance the watermark: escalations no human answered would + otherwise become invisible to every later cycle and be reported as already + answered — the inverse of the defect. + + The generation assertion is the positive control and the discriminator: the bump + is UNCONDITIONAL (it answers session-id reuse, #705, which an abandoned attempt + needs just as much), so this row cannot pass by the re-arm having done nothing. + + Ablation: remove the `if resolution_recorded:` gate and this reddens at 1 != 0.""" + run_dir, _, _ = _escalated_run(tmp_path) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=False) + + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.escalations_resolved_upto == 0 + assert task.generation == 1 + + +def test_a_second_resolve_cycle_shows_only_what_the_redrive_raised(tmp_path): + """The whole chain with no seam hand-set: escalate, re-arm on a recorded + resolution, let the re-drive append its own session record and artifact, then + build the context a second time. `build_context` reads the watermark off the task + it loaded — nothing in this row passes `start`.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["the first cycle answered this"]]) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + _redrive_escalates(run_dir, key, "raised by the re-drive") + + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + ctx = json.loads(path.read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] + assert withheld == 1 + + +def test_a_rearm_over_a_surviving_marker_does_not_move_the_watermark(tmp_path): + """`resolution.json` SURVIVES the re-arm that consumed it: the only unlink in + `src/` is in `resolve.run_session`, which two of the three re-arm callers never + reach, and nothing deletes it at or after a re-arm. So a marker-presence gate + reads the PREVIOUS cycle's marker as this gesture's own, and a second re-arm + running no session would stamp over an escalation nobody has seen — hiding it + forever and reporting it as already answered. + + The marker is deliberately left on disk here and never removed, which is the + state a real second gesture opens on. + + Ablation: replace the `resolution_recorded` parameter with a + `resolution_path(run_dir, key).is_file()` read inside `rearm_escalation` and this + row reddens twice — the watermark advances to 2, and the context comes back + empty with the new escalation counted as withheld.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered in cycle 1"]]) + + marker = resolve.resolution_path(run_dir, key) + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + assert load_state(run_dir).tasks[key].escalations_resolved_upto == 1 + assert marker.is_file() # MEASURED: nothing deletes it at re-arm + + _redrive_escalates(run_dir, key, "raised after cycle 1", escalated=True) + + # the `--no-interactive` / TUI gesture: no session ran, so nothing was accepted + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=False) + + task = load_state(run_dir).tasks[key] + assert task.escalations_resolved_upto == 1 # NOT len(sessions) == 2 + assert task.generation == 2 # positive control: this gesture DID re-arm + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + ctx = json.loads(path.read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised after cycle 1"] + assert withheld == 1 + + +def test_build_context_keeps_the_withheld_count_out_of_the_payload(tmp_path): + """The count is the OPERATOR's, not the agent's: `bmad-loop-resolve/SKILL.md` + documents `escalations` as the list to resolve, and a number for entries the + session cannot see is nothing it can act on. Any spelling of a leak reddens this, + because the key set is compared whole rather than probed for one name.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered"], ["new"]]) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + _redrive_escalates(run_dir, key, "new one") + + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + assert withheld == 2 # the count exists... + ctx = json.loads(path.read_text(encoding="utf-8")) + assert set(ctx) == { + "story_key", + "run_id", + "spec_file", + "baseline_commit", + "paused_reason", + "escalations", + "resolution_path", + "restore_supported", + "spec_reaches_the_redrive", + "redrive_base_ref", + } # ...and reaches no field of the agent contract + + # ----------------------------------------------------------- run_session @@ -2460,7 +2798,7 @@ def interactive_env(self, spec): def test_run_session_detects_resolution(tmp_path, monkeypatch): run_dir, state, _ = _escalated_run(tmp_path) - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + _context(state, run_dir, "6-4-cli-list-command", isolation="") def fake_subprocess_run(argv, cwd, env): # simulate the agent writing the resolution marker @@ -2476,7 +2814,7 @@ def fake_subprocess_run(argv, cwd, env): def test_run_session_no_resolution(tmp_path, monkeypatch): run_dir, state, _ = _escalated_run(tmp_path) - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + _context(state, run_dir, "6-4-cli-list-command", isolation="") monkeypatch.setattr(resolve.subprocess, "run", lambda *a, **k: None) assert ( resolve.run_session( @@ -2490,7 +2828,7 @@ def test_run_session_clears_stale_marker(tmp_path, monkeypatch): """A marker left by a previous resolve of this story must not be read as this session's output (the agent that says 'already resolved' writes none).""" run_dir, state, _ = _escalated_run(tmp_path) - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + _context(state, run_dir, "6-4-cli-list-command", isolation="") stale = resolve.resolution_path(run_dir, "6-4-cli-list-command") stale.parent.mkdir(parents=True, exist_ok=True) stale.write_text('{"from": "last time"}', encoding="utf-8") @@ -2604,9 +2942,7 @@ def test_build_context_stories_carries_manifest_entry(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md", source="stories") state.spec_folder = "epic-1" - ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="").read_text(encoding="utf-8") - ) + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) st = ctx["stories"] assert st["spec_folder"] == "epic-1" assert st["story"]["title"] == "List command" @@ -2630,9 +2966,7 @@ def test_build_context_stories_sentinel_indicator(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") state.spec_folder = "epic-1" - ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="").read_text(encoding="utf-8") - ) + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) sent = ctx["stories"]["sentinel"] assert sent["kind"] == "unresolved" assert "intent too vague" in sent["blocking_condition"] @@ -2642,9 +2976,7 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): """Sprint mode leaves the context contract unchanged — no stories block.""" run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") # sprint source ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert "stories" not in ctx @@ -2666,9 +2998,9 @@ def test_build_context_leaves_an_out_of_mount_spec_unchanged(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(spec), worktree_path=str(wt)) ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_file"] == spec.as_posix() @@ -2709,7 +3041,7 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): state.spec_folder = "epic-1" ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") + _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == (wt / rel).as_posix() sent = ctx["stories"]["sentinel"] @@ -2758,7 +3090,7 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec state.spec_folder = "epic-1" ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") + _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == outside.as_posix() # unchanged: absolute passes through sent = ctx["stories"]["sentinel"] @@ -2780,9 +3112,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): wt = tmp_path / ".bmad-loop" / "runs" / "20260613-111429-6a14" / "worktrees" / "1" run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_reaches_the_redrive"] is False @@ -2790,9 +3122,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): tmp_path, "20260613-111429-6a15", spec_file=str(tmp_path / "specs" / "6-4.md") ) plain = json.loads( - resolve.build_context( - plain_state, plain_dir, "6-4-cli-list-command", isolation="" - ).read_text(encoding="utf-8") + _context(plain_state, plain_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert plain["spec_reaches_the_redrive"] is True @@ -2815,9 +3147,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) state.target_branch = "feat/the-pinned-one" ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) # the paired claim: the edit has no future, and THIS is the tree that does assert ctx["spec_reaches_the_redrive"] is False @@ -2829,9 +3161,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat ) plain_state.target_branch = "feat/the-pinned-one" # set, but no mount to make it apply plain = json.loads( - resolve.build_context( - plain_state, plain_dir, "6-4-cli-list-command", isolation="" - ).read_text(encoding="utf-8") + _context(plain_state, plain_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert plain["redrive_base_ref"] == "HEAD" @@ -2878,7 +3210,7 @@ def test_rearm_warns_about_an_unreachable_spec_write_only_when_it_is_actionable( run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) unreachable = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(unreachable) is warns @@ -2950,7 +3282,7 @@ def _commit(status, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) unreachable = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(unreachable) is warns @@ -3009,7 +3341,7 @@ def _sentinel_run( sentinel = folder / f"{key}-unresolved.md" sentinel.write_text( - "---\nstatus: blocked\n---\n\n## Auto Run Result\n\n" "Status: blocked\nintent too vague\n", + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", encoding="utf-8", ) mount = tmp_path / "wt" @@ -3073,7 +3405,7 @@ def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redriv ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=isolated) + runs.rearm_escalation(run_dir, isolated_redrive=isolated, resolution_recorded=True) assert not sentinel.exists() # the sentinel really was cleared on every row records = _upstream_records(run_dir) @@ -3162,7 +3494,7 @@ def _commit(intent, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) records = _upstream_records(run_dir) assert bool(records) is warns @@ -3197,7 +3529,7 @@ def test_rearm_exempts_a_stories_folder_configured_outside_the_project( ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert bool(_upstream_records(run_dir)) is not external @@ -3239,7 +3571,9 @@ def test_rearm_of_a_sentinel_survives_a_project_that_is_not_a_repository(tmp_pat ) monkeypatch.chdir(tmp_path) - assert runs.rearm_escalation(run_dir, isolated_redrive=True) == key # no GitError + assert ( + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) == key + ) # no GitError assert not sentinel.exists() # the destructive half still completed (rec,) = _upstream_records(run_dir) @@ -3291,7 +3625,7 @@ def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_pat monkeypatch.chdir(tmp_path) # the flip: policy now says `none`, while the recorded mount still says otherwise - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["redrive"] == "in-place" @@ -3348,7 +3682,7 @@ def test_rearm_in_place_proof_reads_the_working_tree_not_the_commit(tmp_path, mo root, spec_file=rel, worktree_path=str(mount), target_branch="main" ) monkeypatch.chdir(root) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) fired = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(fired) is warns, f"corrected={corrected}" @@ -3409,7 +3743,7 @@ def test_rearm_base_ref_degrades_to_head_for_a_run_that_pinned_no_target(tmp_pat run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -3462,7 +3796,7 @@ def test_rearm_does_not_refuse_a_flip_the_redrive_never_reads( monkeypatch.chdir(tmp_path) runs.rearm_escalation( - run_dir, isolated_redrive=True + run_dir, isolated_redrive=True, resolution_recorded=True ) # must not raise: this flip cannot reach the re-drive kinds = _kinds(run_dir) @@ -3495,7 +3829,7 @@ def test_rearm_suppresses_the_unreachable_warning_only_on_proof(tmp_path, monkey run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) kinds = _kinds(run_dir) (unreachable,) = [e for e in kinds if e["kind"] == "rearm-spec-write-unreachable"] @@ -3537,7 +3871,7 @@ def test_rearm_does_not_warn_when_the_spec_dir_is_shared_with_the_redrive(tmp_pa ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] # and the flip really landed on the shared file the re-drive will read @@ -3581,7 +3915,7 @@ def test_rearm_still_warns_for_a_spec_spelled_out_of_but_resolving_into_the_work run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spelled), worktree_path=str(wt)) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (unreachable,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert unreachable["status"] == "ready-for-dev" @@ -3620,7 +3954,7 @@ def _refuse(self, *a, **kw): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (unreachable,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert unreachable["status"] == "ready-for-dev" @@ -3658,7 +3992,7 @@ def test_rearm_writes_the_project_rooted_spec_when_no_worktree_was_recorded(tmp_ run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel) # worktree_path="" -> the fallback monkeypatch.chdir(tmp_path / "elsewhere") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) fm = verify.read_frontmatter(spec) assert fm["status"] == "ready-for-dev" # the project-rooted copy was flipped diff --git a/tests/test_runs.py b/tests/test_runs.py index 6eaf397d..b041b403 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2791,7 +2791,12 @@ def test_rearm_restore_mode_sets_in_review_strips_arr_and_latches(tmp_path): from bmad_loop.model import Phase run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING and task.attempt == 0 @@ -2809,7 +2814,9 @@ def test_rearm_plain_mode_sets_ready_for_dev_and_clears_stale_latch(tmp_path): # a stale latch from a prior restore attempt the human then chose to redo fresh run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, restore_patch_stale="old.patch") - runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore_patch => from-scratch + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # no restore_patch => from-scratch task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -2840,7 +2847,10 @@ def test_rearm_aborts_when_the_spec_status_cannot_be_reopened(tmp_path): with pytest.raises(runs.RearmError, match="re-open story spec"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, ) assert spec.read_text(encoding="utf-8") == spec_text # byte-identical @@ -2860,7 +2870,7 @@ def test_rearm_resets_followup_reviews_spent(tmp_path): state.tasks["1-1-a"].review_cycle = 2 save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.followup_reviews_spent == 0 @@ -2905,7 +2915,9 @@ def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): story's commit. The resolve session's own untracked file still is.""" run_dir, _spec, patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False) # from-scratch re-arm replaces the latch + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # from-scratch re-arm replaces the latch task = load_state(run_dir).tasks["1-1-a"] assert "human.txt" in task.baseline_untracked @@ -2922,7 +2934,12 @@ def test_rearm_re_latching_the_same_patch_still_excludes_its_residue(tmp_path): still residue (and `git apply` would otherwise fail with 'already exists').""" run_dir, _spec, _patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) task = load_state(run_dir).tasks["1-1-a"] assert task.restore_patch == "artifacts/attempt.patch" @@ -2940,7 +2957,9 @@ def test_rearm_missing_stale_patch_degrades_loudly_without_raising(tmp_path): git(tmp_path, "add", "committed.txt") git(tmp_path, "commit", "-q", "-m", "attempt commit") - runs.rearm_escalation(run_dir, isolated_redrive=False) # must not raise RearmError + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # must not raise RearmError task = load_state(run_dir).tasks["1-1-a"] assert {"human.txt", "newfile.txt"} <= set(task.baseline_untracked) # full snapshot @@ -2956,7 +2975,12 @@ def test_rearm_without_a_stale_latch_journals_no_stale_restore_events(tmp_path): run_dir, _spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, git_project=True) (tmp_path / "human.txt").write_text("from the resolve session\n") - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) assert "human.txt" in load_state(run_dir).tasks["1-1-a"].baseline_untracked assert _kinds(run_dir) == [] @@ -2972,7 +2996,7 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "attempt commit") old_baseline = load_state(run_dir).tasks["1-1-a"].baseline_commit - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.baseline_commit != old_baseline # baseline advanced past the commit @@ -2998,7 +3022,7 @@ def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_p task.baseline_commit = "0" * 39 + "1" # sha-shaped, but names no object save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3029,7 +3053,7 @@ def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): with pytest.raises(verify.GitError): verify.commits_above(tmp_path, task.baseline_commit) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3054,7 +3078,7 @@ def boom(repo, baseline): monkeypatch.setattr(runs.verify, "commits_above", boom) with pytest.raises(MemoryError, match="not a git answer"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) def test_archive_run(tmp_path): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 664a329d..63eac7f0 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1365,7 +1365,7 @@ def test_blocked_resolve_rearm_then_redispatch_to_done(project): assert not any(s.role == "dev" for s in adapter.sessions) # story 2 not leapfrogged # human fixed the frozen spec → re-arm (must run while still escalation-paused) - runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False) + runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True) assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev" # resume re-drives the re-armed story, then continues the schedule to story 2 @@ -1406,7 +1406,7 @@ def test_resolved_wedge_is_still_gated_on_redispatch(project): assert wedged.phase == Phase.ESCALATED and wedged.attempt == 0 and not wedged.sessions runs.rearm_escalation( - engine.run_dir, "1", isolated_redrive=False + engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True ) # human fixed the frozen spec assert load_state(engine.run_dir).tasks["1"].rearmed # ...and the re-drive is armed # a gate on story 1 lands while the run is down @@ -1441,7 +1441,7 @@ def test_sentinel_rearm_deletes_by_recorded_verdict_e2e(project): assert engine.run().paused assert load_state(engine.run_dir).tasks["1"].sentinel_kind == "unresolved" # recorded - runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False) + runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True) assert not sentinel.exists() # cleared by the recorded verdict assert (engine.run_dir / "sentinels" / "1-unresolved.md").is_file() # copy preserved reloaded = load_state(engine.run_dir) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index e1597e85..93cf106c 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -3467,7 +3467,11 @@ def test_sweep_bundle_restore_redrive_reaches_done_and_clears_latch(project, mon patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + engine.run_dir, + "dw-fix", + restore_patch=str(patch), + isolated_redrive=False, + resolution_recorded=True, ) resumed, adapter = resume_sweep( @@ -3508,7 +3512,11 @@ def test_sweep_restore_redrive_exhaustion_pauses_not_defers(project, monkeypatch patch.parent.mkdir(parents=True, exist_ok=True) patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + engine.run_dir, + "dw-fix", + restore_patch=str(patch), + isolated_redrive=False, + resolution_recorded=True, ) resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) @@ -3530,7 +3538,7 @@ def test_sweep_from_scratch_redrive_exhaustion_pauses_not_defers(project): ) engine = _run_to_dev_escalation(project, policy=policy) runs.rearm_escalation( - engine.run_dir, "dw-fix", isolated_redrive=False + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True ) # from-scratch, no restore resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) @@ -4678,7 +4686,9 @@ def test_rearmed_bundle_redrives_when_triage_json_lost(project): # cached triage plan reloaded and re-emitted its name. Recovery now keys on # the persisted task, so losing the cache changes nothing. engine = _run_to_dev_escalation(project) - runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + ) _lose_triage(engine.run_dir) resumed, adapter = resume_sweep(project, engine, _redrive_script(project)) @@ -4700,7 +4710,9 @@ def test_fresh_triage_different_bundle_name_no_double_drive(project, corruption) # would orphan the re-armed one. It must re-drive by identity, and its ids # must have left the open set before the fresh triage sees them. engine = _run_two_bundle_dev_escalation(project) - runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + ) _lose_triage(engine.run_dir, corruption) fresh = triage_result( @@ -4738,7 +4750,11 @@ def test_restore_patch_latch_honored_when_triage_json_lost(project, monkeypatch) patch.parent.mkdir(parents=True, exist_ok=True) patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + engine.run_dir, + "dw-fix", + restore_patch=str(patch), + isolated_redrive=False, + resolution_recorded=True, ) _lose_triage(engine.run_dir) @@ -4863,7 +4879,9 @@ def test_regenerated_intent_when_bundle_file_missing(project): # The triage session's authored prose is the one unrecoverable piece; the # verbatim ledger entries are re-attached and become the contract. engine = _run_to_dev_escalation(project) - runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + ) _lose_triage(engine.run_dir) intent = Path(engine.state.tasks["dw-fix"].bundle_file) intent.unlink() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 7bfbbc44..3d0754bc 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4550,6 +4550,67 @@ async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypat await until(pilot, lambda: rearms == ["1"] and calls == ["20260611-100000-aaaa"]) +async def test_tui_rearm_does_not_move_the_escalation_watermark(project, monkeypatch): + """DW-11, on the one re-arm surface a stale `resolution.json` actively invites. + + Every other TUI row here monkeypatches `runs.rearm_escalation` away, so none can + observe what it stamps — this one lets the REAL function run. The marker on disk is + the shape that matters: `resolve.run_session` is the only thing in `src/` that + unlinks it and this gesture never calls it, so the marker survived the CLI cycle + that consumed it, and `resolution_ready` (the sole enabler of this button) still + reads True. `_do_rearm` therefore has to declare `resolution_recorded=False` from + what it KNOWS — it ran no session — rather than from what is on disk, which is + exactly the verdict `_restore_recorded` already records for this surface. + + The watermark is seeded to 1 over a two-record trail so "did not move" is + distinguishable from "was never set"; `generation` is the positive control that the + re-arm really ran. + + Ablation: pass `resolution_recorded=True` from `_do_rearm` (or gate the stamp on + `resolution_path(...).is_file()` inside `rearm_escalation`) and this reddens at + 2 != 1.""" + from bmad_loop import resolve + from bmad_loop.engine import _session_task_id + from bmad_loop.journal import load_state + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: None) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: needs a human decision on the auth scheme.", + ) + state = load_state(run_dir) + task = state.tasks["1"] + task.phase = Phase.ESCALATED + task.sessions.clear() + for seq in (1, 2): + task.record_session( + SessionRecord( + task_id=_session_task_id("1", "review", seq, 0), role="dev", status="completed" + ) + ) + task.escalations_resolved_upto = 1 # an earlier CLI cycle answered the first record + save_state(run_dir, state) + # the marker that cycle's agent wrote — nothing deleted it at its re-arm + marker = resolve.resolution_path(run_dir, "1") + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + app._do_rearm("20260611-100000-aaaa", run_dir, "1") + await pilot.pause() + + rearmed = load_state(run_dir).tasks["1"] + assert rearmed.escalations_resolved_upto == 1 # NOT len(sessions) == 2 + assert rearmed.generation == 1 # positive control: the re-arm ran + + async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch): """The mode `runs.rearm_escalation` needs comes from policy.toml, read HERE. @@ -4576,7 +4637,8 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch.setattr( runs, "rearm_escalation", - lambda rd, sk, *, isolated_redrive: seen.append(isolated_redrive) or "ready-for-dev", + lambda rd, sk, *, isolated_redrive, resolution_recorded: seen.append(isolated_redrive) + or "ready-for-dev", ) run_dir, _spec = _stories_paused_run( project.project, @@ -4746,7 +4808,7 @@ async def test_escalation_rearm_surfaces_a_failed_baseline_advance(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): Journal(rd).append( "rearm-baseline-advance-failed", story_key=sk, @@ -4806,7 +4868,7 @@ async def test_escalation_rearm_aims_the_code_root_before_it_rearms(project, mon monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") seen: list = [] - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): seen.append(load_state(rd).code_root) return "ready-for-dev" @@ -4947,7 +5009,7 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): journal = Journal(rd) journal.append( "stale-restore-commits", @@ -5042,7 +5104,7 @@ async def test_escalation_rearm_holds_the_resume_it_folds_in(project, monkeypatc monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): Journal(rd).append( "rearm-spec-write-unreachable", story_key=sk, @@ -5114,7 +5176,7 @@ async def test_escalation_rearm_echoes_residue_when_the_rearm_aborts(project, mo monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): # exactly the real ordering: residue journalled, THEN the abort Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] @@ -5175,7 +5237,7 @@ async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] ) From 5aff22212915b5c84b12d4ea8f6aac9c87213469 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 14:28:19 -0700 Subject: [PATCH 11/45] Revert "sweep escalation-watermark: DW-11 via bmad-loop" This reverts commit dc0c36f7adcae46f622308032ec8a484156b58be. --- CHANGELOG.md | 5 - docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 33 +- src/bmad_loop/diagnostics.py | 19 +- src/bmad_loop/model.py | 15 - src/bmad_loop/resolve.py | 64 +--- src/bmad_loop/runs.py | 36 +- src/bmad_loop/tui/app.py | 14 +- tests/test_cli.py | 292 ++--------------- tests/test_diagnostics.py | 39 +-- tests/test_engine.py | 44 +-- tests/test_engine_worktree.py | 14 +- tests/test_model.py | 16 - tests/test_resolve.py | 600 ++++++++-------------------------- tests/test_runs.py | 48 +-- tests/test_stories_engine.py | 6 +- tests/test_sweep.py | 32 +- tests/test_tui_app.py | 76 +---- 18 files changed, 224 insertions(+), 1131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9201b1..22cb01cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,11 +207,6 @@ breaking changes may land in a minor release. ### Fixed -- Stop a second resolve cycle re-presenting escalations the human already answered - (DW-11). A re-arm that accepted a `resolution.json` watermarks the story's - append-only session trail; later cycles hand the agent only what was recorded since - and print the withheld count. A re-arm that accepted no resolution — no session, or - a session that wrote none — never moves it. - Emit `diagnose --json` v2, replacing journal `patch` / `stashed_to` paths with `patch_present` / `stashed_to_present`, and silently degrade Git stale-commit probe failures while propagating non-Git faults. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index eada1024..50bc2cb3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -68,7 +68,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. -- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second `bmad-loop resolve` used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a `resolution.json` now **watermarks** the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's `context.json` (the agent-facing contract is unchanged). The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, `resolve --no-interactive`, and the TUI's Re-arm button all leave it where it stands, so those paths keep showing the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the `esc-upto` column of `bmad-loop diagnose`'s markdown task table, and as `escalations_resolved_upto` under `--json` (that is the key to grep in a support bundle), which is what explains a short `context.json` on a bug report. +- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. - A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most `max_dev_attempts` per story) and has no suppression knob of its own; it follows `[notify]` like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is reduced to its first line and capped, with a `[…]` marker when it was trimmed, because a `Decision.reason` routinely carries a verify-output tail that would otherwise spill into `ATTENTION` and a desktop bubble; the untruncated reason stays in the `dev-decision` journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices. - Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (`sh` reports rc `126`/`127`; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time `OSError` counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507); `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: `has_session` maps every nonzero backend result to False, so a negative lookup is "the backend did not confirm it" rather than proof the session is gone — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 6a8a285a..ef81a5a1 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3098,21 +3098,10 @@ def cmd_resolve(args: argparse.Namespace) -> int: print(err, file=sys.stderr) return 1 - # DW-11: whether THIS gesture accepted a resolution, which is what gates the - # `escalations_resolved_upto` watermark in `runs.rearm_escalation`. False here - # covers `--no-interactive` deliberately: that path accepted nothing IN THIS - # GESTURE (the human may have fixed the spec by hand, but nothing recorded which - # escalations that answered), so the next cycle shows everything — today's - # behavior, and the safe direction. Not derived from `resolution.json`: the marker - # survives the re-arm that consumed it, so its presence says nothing about this - # gesture. - resolution_recorded = False if args.interactive: adapters = _make_adapters(project, run_dir, pol) model = pol.adapter.resolved("dev").model - _ctx_path, withheld = resolve.build_context( - state, run_dir, story_key, isolation=pol.scm.isolation - ) + resolve.build_context(state, run_dir, story_key, isolation=pol.scm.isolation) print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…") try: produced = resolve.run_session( @@ -3134,25 +3123,6 @@ def cmd_resolve(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 - resolution_recorded = bool(produced) - # DW-11. Reported to the operator, never into `context.json`: filtering the - # agent's list silently would trade one misleading surface for another — the - # human would have no way to tell "nothing else was ever raised" from "the rest - # is hidden". Worded for what the code can prove: these entries were PRESENTED - # to an earlier resolve cycle that recorded a resolution — not that any - # particular one of them was individually answered. - # - # Printed here rather than beside the context build, because until - # `run_session` returns without `NotImplementedError` this adapter is not known - # to support an interactive session at all — and an operator whose command is - # about to fail must not be told escalations were withheld from an agent that - # never launched. - if withheld: - print( - f"{withheld} earlier escalation(s) for {story_key} were not shown to the " - "agent: they were presented to an earlier resolve cycle that recorded a " - "resolution" - ) if not produced: print( f"no resolution recorded for {story_key} (agent did not write resolution.json)", @@ -3257,7 +3227,6 @@ def cmd_resolve(args: argparse.Namespace) -> int: story_key, restore_patch=restore_patch, isolated_redrive=pol.scm.isolation == "worktree", - resolution_recorded=resolution_recorded, ) except runs.RearmError as e: print(f"error: {e}", file=sys.stderr) diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 9e56549f..cb7ac2fd 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -349,11 +349,6 @@ class TaskDiag: # dumps as `rearmed=True, attempt=1, n_sessions=2` — byte-identical to a HEALTHY # post-re-arm task. A counter, so it carries no customer content. generation: int - # DW-11's watermark: how far into the append-only `sessions` list an accepted - # resolution reached. Without it a support bundle cannot explain a SHORT - # `context.json` — a story whose older escalations are filtered out dumps - # identically to one that only ever raised the entries shown. A counter too. - escalations_resolved_upto: int dw_count: int n_sessions: int sessions: SessionTally @@ -635,7 +630,6 @@ def _task_diag(task: StoryTask, pseudo: sanitize.Pseudonymizer, weight: float) - spec_present=bool(task.spec_file), worktree_isolated=bool(task.worktree_path), generation=task.generation, - escalations_resolved_upto=task.escalations_resolved_upto, dw_count=len(task.dw_ids), n_sessions=len(task.sessions), sessions=_session_tally([task]), @@ -1051,20 +1045,15 @@ def render_markdown( # `gen` rides beside `att` because the pair is the discriminator: a # #705-class replay and a healthy post-re-arm task agree on every other # column here, so dropping it from the human report leaves the one field - # that separates them visible only under `--json`. `esc-upto` rides beside - # `gen` on that same rule: DW-11's watermark is the only field separating - # "this story raised one escalation" from "its earlier ones are filtered - # out as already answered", and a short `context.json` is read off exactly - # this report. + # that separates them visible only under `--json`. out.append( - "| alias | epic | phase | att | gen | esc-upto | rev | committed | spec | dw " - "| sessions | weighted | raw |" + "| alias | epic | phase | att | gen | rev | committed | spec | dw | sessions " + "| weighted | raw |" ) - out.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|") + out.append("|---|---|---|---|---|---|---|---|---|---|---|---|") for t in r.tasks: out.append( f"| `{t.alias}` | {t.epic} | {t.phase} | {t.attempt} | {t.generation} " - f"| {t.escalations_resolved_upto} " f"| {t.review_cycle} | {t.committed} | {t.spec_present} | {t.dw_count} " f"| {t.n_sessions} | {t.tokens.get('weighted', 0)} " f"| {t.tokens.get('total', 0)} |" diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index d6b17f56..54923d36 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -213,19 +213,6 @@ class StoryTask: # is deliberately NOT cleared when a task is reopened: the run-dir audit trail # it indexes is read by a later resolve cycle. generation: int = 0 - # How much of the append-only `sessions` list an accepted escalation resolution - # already covered: a LENGTH, i.e. an index INTO `task.sessions`, not a count of - # escalations and not a generation number. `resolve._gather_escalations` shows only - # the escalations recorded by sessions at or after this position, so a second - # resolve cycle does not re-present entries the human already disambiguated - # (DW-11). Stamped in `runs.rearm_escalation`, and only when its caller passes - # `resolution_recorded=True` — a re-arm that accepted nothing must not advance it, - # or escalations nobody answered become invisible forever. `record_session` is the - # sole mutation of `sessions` in `src/`, and a re-arm deliberately does NOT clear - # the list, which is what makes a length stable across cycles. 0 = nothing answered - # yet, which is also what a pre-upgrade `state.json` deserializes to (unfiltered, - # the pre-DW-11 behavior). - escalations_resolved_upto: int = 0 # set from the bmad-build-auto session's `followup_review_recommended` # frontmatter (PR #2505): when True and review.trigger = "recommended", the # orchestrator runs a follow-up review pass (bmad-build-auto re-invoked on the @@ -443,7 +430,6 @@ def to_dict(self) -> dict[str, Any]: "review_cycle": self.review_cycle, "followup_reviews_spent": self.followup_reviews_spent, "generation": self.generation, - "escalations_resolved_upto": self.escalations_resolved_upto, "followup_review_recommended": self.followup_review_recommended, "baseline_commit": self.baseline_commit, "baseline_untracked": self.baseline_untracked, @@ -612,7 +598,6 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": review_cycle=int(d.get("review_cycle", 0)), followup_reviews_spent=int(d.get("followup_reviews_spent", 0)), generation=int(d.get("generation", 0)), - escalations_resolved_upto=int(d.get("escalations_resolved_upto", 0)), followup_review_recommended=bool(d.get("followup_review_recommended", False)), baseline_commit=d.get("baseline_commit"), baseline_untracked=( diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index a734431c..8a986b08 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -77,21 +77,9 @@ def read_resolution(run_dir: Path, story_key: str) -> dict[str, Any] | None: return doc -def _gather_escalations( - run_dir: Path, state: RunState, story_key: str, *, start: int = 0 -) -> tuple[list[dict[str, Any]], int]: +def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[dict[str, Any]]: """The CRITICAL escalations recorded by this story's sessions, newest first, - each DISTINCT escalation exactly once, paired with how many DISTINCT entries - were withheld as already answered. - - ``start`` is ``task.escalations_resolved_upto`` — a position in the append-only - ``task.sessions`` list, stamped by ``runs.rearm_escalation`` when a resolve cycle - recorded a resolution (DW-11). Records BELOW it were already put to the human and - answered, so their escalations are not shown again; the count of those the human - can no longer see is returned for the operator, never written into - ``context.json`` (the agent-facing contract is the unanswered set alone). The - default 0 reproduces the pre-DW-11 walk byte-for-byte, which is what a - pre-upgrade ``state.json`` deserializes to. + each DISTINCT escalation exactly once. Reads each session's tasks//result.json (and escalation.json) — the same files the engine inspected when it decided to pause. Ordering is @@ -130,31 +118,16 @@ def _gather_escalations( ``critical_escalations`` iterates ``escalations`` with no list guard of its own, so a ``{"escalations": null}`` artifact would raise ``TypeError`` here. The guard belongs in this caller; the shared predicate stays the - single definition of CRITICAL. - - The watermark is a FOURTH concern layered onto that same single walk, not a - second pass: ``reversed(task.sessions)`` reaches the unanswered tail first, so - entries are routed into two content-keyed maps by the record's own index and the - suppressed count is the answered keys that never appeared in the shown map. Two - consequences are deliberate. An entry raised on BOTH sides of the watermark is - shown and counted 0 — "not shown" is the claim the number makes, so it must never - count something the operator can see. And ``start`` only SELECTS a map; nothing is - indexed with it, so a watermark past the end of the list yields an empty shown - list rather than an IndexError. A ``task_id`` repeated across the watermark is - opened once by ``seen_ids``, at its newest occurrence — the shown side, the - conservative direction.""" + single definition of CRITICAL.""" task = state.tasks.get(story_key) if task is None: - return [], 0 + return [] seen_ids: set[str] = set() found: dict[str, dict[str, Any]] = {} - answered: dict[str, dict[str, Any]] = {} - last = len(task.sessions) - 1 - for offset, session in enumerate(reversed(task.sessions)): + for session in reversed(task.sessions): if session.task_id in seen_ids: continue seen_ids.add(session.task_id) - target = found if last - offset >= start else answered task_dir = run_dir / "tasks" / session.task_id for fname in ("result.json", "escalation.json"): fpath = task_dir / fname @@ -170,21 +143,12 @@ def _gather_escalations( except (OSError, ValueError, RecursionError): continue for key, esc in artifact_entries.items(): - target.setdefault(key, esc) - return list(found.values()), sum(1 for key in answered if key not in found) + found.setdefault(key, esc) + return list(found.values()) -def build_context( - state: RunState, run_dir: Path, story_key: str, *, isolation: str -) -> tuple[Path, int]: - """Write resolve//context.json for the resolve skill to read, and - return it beside the number of already-answered escalations withheld from it. - - The count is for the OPERATOR's terminal (`cli.cmd_resolve` prints it) and is - deliberately not a `context.json` field: the skill's contract is singular — resolve - the escalation you are shown — and a count of things the agent cannot see is not - something it can act on. It comes from the same single walk that produced the shown - list, never from a second `_gather_escalations` call subtracting lengths. +def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: str) -> Path: + """Write resolve//context.json for the resolve skill to read. `isolation` is the LIVE policy's `scm.isolation`, and it is required rather than defaulted for the reason this surface exists at all: three of the fields below — @@ -213,12 +177,6 @@ def build_context( # the main checkout while `stories_engine._stories_folder` was still the mount, so # one `context.json` could name two trees. stories_root = task_stories_root(task, state) - # DW-11: hide what an earlier resolve cycle already answered. `start` is the task's - # own watermark — 0 for a task never resolved, and for every pre-upgrade - # `state.json`, which is the unfiltered pre-DW-11 walk. - escalations, withheld = _gather_escalations( - run_dir, state, story_key, start=task.escalations_resolved_upto if task else 0 - ) context = { "story_key": story_key, "run_id": state.run_id, @@ -239,7 +197,7 @@ def build_context( "spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None), "baseline_commit": task.baseline_commit if task else None, "paused_reason": state.paused_reason, - "escalations": escalations, + "escalations": _gather_escalations(run_dir, state, story_key), # as_posix so the context contract is the same string on every OS (the # path is consumed by the agent, and Python/tools accept '/' on Windows). "resolution_path": resolution_path(run_dir, story_key).as_posix(), @@ -292,7 +250,7 @@ def build_context( path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(context, indent=2), encoding="utf-8") - return path, withheld + return path def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index fa6df27f..21b251ae 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3657,7 +3657,6 @@ def rearm_escalation( *, restore_patch: str | None = None, isolated_redrive: bool, - resolution_recorded: bool, ) -> str: """Re-arm an escalation-paused story so the next resume re-drives it. @@ -3681,10 +3680,7 @@ def rearm_escalation( otherwise let the re-drive re-mint a session id byte-equal to one the abandoned attempt already recorded (#705). `task.sessions` is deliberately NOT cleared — a second resolve cycle reads that run-dir audit trail — so - the id is what has to change. That preserved trail is also what - `resolution_recorded` watermarks: keeping it whole is what lets a later - cycle tell the answered prefix from the unanswered tail, instead of - choosing between re-presenting everything and losing the audit (DW-11). + the id is what has to change. - The spec's `baseline_revision` is re-stamped on BOTH legs, and only when the advance above actually RAN — `advanced` records that both git reads succeeded, not that HEAD changed, so a resolve session that committed nothing still @@ -3727,25 +3723,6 @@ def rearm_escalation( defect this parameter exists to close. Both callers (`cli.cmd_resolve`, `tui.TuiApp._do_rearm`) hold a loaded policy already. - `resolution_recorded` says whether THIS gesture accepted a resolution, and it - alone gates the `escalations_resolved_upto` watermark (DW-11): the next resolve - cycle hides every escalation recorded below it, so advancing it over entries no - human answered would bury them forever and report them as already answered — the - inverse of the defect the watermark exists to fix. Keyword-only and REQUIRED for - the same reason as `isolated_redrive`: a default would be wrong in silence on - exactly the path that matters. It is a PARAMETER rather than a disk read because - the fact is not on disk. `resolution.json` is unlinked at one site in `src/` - (`resolve.run_session`, before it launches), which only `cli.cmd_resolve`'s - interactive arm reaches, and nothing deletes the marker at or after a re-arm — so - the marker survives the re-arm that consumed it, and `resolve --no-interactive` or - the TUI's Re-arm button would read the PREVIOUS cycle's marker as its own. The - caller already holds the answer: `cmd_resolve` binds it from `resolve.run_session`, - and both non-interactive callers know by construction that no session ran. Do not - unlink the marker here either — the TUI's Re-arm button is gated on its presence. - - The generation bump stays UNCONDITIONAL beside the gated stamp: it answers session-id - reuse (#705), which an abandoned attempt needs exactly as much as a resolved one. - Returns the re-armed story key. Raises RearmError when the run is not paused at the escalation stage, the target story is not escalated, or a supplied `restore_patch` fails `validate_restore_latch` (the shared precondition set — @@ -3792,17 +3769,6 @@ def rearm_escalation( # replay the abandoned verdict for the fresh attempt (#705). Bumped BEFORE any # dispatch, so the id is unique from the re-drive's first session onward. task.generation += 1 - # DW-11. How much of the preserved audit trail this resolution covered, so the next - # `resolve` shows the human only what they have not already answered. Gated on the - # CALLER's answer, never on `resolution.json`: the marker survives the re-arm that - # consumed it (only `resolve.run_session` unlinks it, and two of the three callers - # never run one), so reading it here would let a later marker-less gesture stamp - # over escalations nobody saw. A length, taken BEFORE the re-drive appends anything - # — `record_session` is the sole mutation of this list — and left where it stands - # when nothing was accepted, which reproduces the pre-DW-11 behavior for that - # gesture: everything shown, nothing reported withheld. - if resolution_recorded: - task.escalations_resolved_upto = len(task.sessions) task.review_cycle = 0 task.followup_reviews_spent = 0 # human-resolved re-drive gets a fresh damping budget task.defer_reason = None diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 6c96f528..b64cc1ff 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -969,19 +969,7 @@ def _do_rearm( before_entries = runs.journal_entries_or_none(run_dir) hold_resume = False try: - runs.rearm_escalation( - run_dir, - story_key, - isolated_redrive=isolation == "worktree", - # DW-11. This gesture runs no resolve session, so it accepted nothing: - # the escalation watermark must not advance. A `resolution.json` on - # disk is NOT evidence to the contrary here — `_restore_recorded` - # already records the governing fact for this surface, that a stale - # marker is indistinguishable from a fresh one, which is why this path - # declines the restore latch too. Stamping on its presence would bury - # escalations raised since the marker was written. - resolution_recorded=False, - ) + runs.rearm_escalation(run_dir, story_key, isolated_redrive=isolation == "worktree") except RearmError as e: self.notify(f"re-arm failed: {e}", severity="error") return diff --git a/tests/test_cli.py b/tests/test_cli.py index ba23f7f0..a4e4779c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2624,9 +2624,7 @@ def test_resolve_restamps_the_code_root_before_it_rearms(project, monkeypatch, c run_dir, moved, _ = _resolve_run_with_a_moved_code_root(project, monkeypatch) seen: list = [] - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): seen.append(load_state(rd).code_root) return key @@ -2750,9 +2748,7 @@ def test_resolve_echoes_this_rearms_stale_restore_events(tmp_path, monkeypatch, run_dir = _escalated_run(tmp_path, "r1") Journal(run_dir).append("stale-restore-excluded", story_key="s1", files=["FROM-LAST-TIME.txt"]) - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): journal = Journal(rd) journal.append("stale-restore-excluded", story_key=key, patch="a.patch", files=["new.txt"]) journal.append("stale-restore-unparseable", story_key=key, patch="b.patch", error="OSErr") @@ -2792,9 +2788,7 @@ def test_resolve_echoes_the_rearm_baseline_records(tmp_path, monkeypatch, capsys _escalated_run(tmp_path, "r1") - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): journal = Journal(rd) journal.append( "rearm-baseline-advance-failed", @@ -2845,9 +2839,7 @@ def test_resolve_restamp_echo_warns_on_both_legs(tmp_path, monkeypatch, capsys): from bmad_loop.journal import Journal def rearm_with(restore: bool): - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): Journal(rd).append( "rearm-baseline-restamped", story_key=key, @@ -2901,9 +2893,7 @@ def test_resolve_survives_a_corrupt_journal(tmp_path, monkeypatch, capsys, outco from bmad_loop import runs from bmad_loop.journal import JOURNAL_FILE - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): if outcome == "rearm-error": raise runs.RearmError("cannot re-open story spec /x/spec.md") return key @@ -2939,9 +2929,7 @@ def test_resolve_echoes_a_skipped_restamp(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): Journal(rd).append( "rearm-baseline-restamp-skipped", story_key=key, @@ -2989,9 +2977,7 @@ def test_resolve_echoes_the_residue_even_when_the_rearm_aborts(tmp_path, monkeyp _escalated_run(tmp_path, "r1") - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): # journalled first, exactly as the real residue pass is ordered Journal(rd).append( "stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c1", "c2"] @@ -3046,9 +3032,7 @@ def test_resolve_holds_the_resume_when_the_correction_cannot_reach_the_redrive( from bmad_loop.journal import Journal def rearm_journalling(kind, **fields): - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): Journal(rd).append(kind, story_key=key, **fields) return key @@ -3115,9 +3099,7 @@ def test_resolve_appends_the_next_step_imperative(tmp_path, monkeypatch, capsys) _escalated_run(tmp_path, "r1") - def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): journal = Journal(rd) journal.append( # table row with a next_step "rearm-baseline-advance-failed", @@ -3153,9 +3135,7 @@ def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): _escalated_run(tmp_path, "r1") calls = {} monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr( - resolve, "build_context", lambda *a, **k: (calls.setdefault("ctx", True), 0) - ) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: calls.setdefault("ctx", True)) monkeypatch.setattr( resolve, "run_session", lambda *a, **k: calls.setdefault("session", True) or True ) @@ -3196,7 +3176,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) monkeypatch.setattr(resolve, "run_session", fake_session) # --no-resume: re-arm only, so the bump this row contrasts against still runs assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 @@ -3210,14 +3190,7 @@ def test_resolve_interactive_unsupported_adapter(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - # DW-11: a NON-ZERO withheld count, deliberately. This command is about to fail, - # and an operator must not be told escalations were withheld from an agent that - # never launched — which is why the count is printed AFTER the adapter has proved - # it supports an interactive session, not beside the context build. - # - # Ablation: move the withheld print above the `try:` and this row reddens on the - # stdout assertion below. - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 3)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) def boom(*a, **k): raise NotImplementedError @@ -3225,232 +3198,7 @@ def boom(*a, **k): monkeypatch.setattr(resolve, "run_session", boom) rc = cli.main(["resolve", "--project", str(tmp_path), "r1"]) assert rc == 1 - captured = capsys.readouterr() - assert "no interactive session mode" in captured.err - assert "were not shown" not in captured.out - - -def _withheld_line(out: str) -> str: - (line,) = [ln for ln in out.splitlines() if "were not shown" in ln] - return line - - -def test_resolve_reports_the_escalations_it_withheld(tmp_path, monkeypatch, capsys): - """The number an operator reads is `build_context`'s OWN second member, not a - constant and not a re-derivation. Seeded to 3 so a hardcoded 1 (or a length of - something else) cannot pass, and worded for what the code can prove: these entries - were PRESENTED to an earlier cycle that recorded a resolution. - - Ablation: delete the `if withheld:` print from `cmd_resolve` and this reddens.""" - from bmad_loop import resolve - - _escalated_run(tmp_path, "r1") - monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 3)) - monkeypatch.setattr(resolve, "run_session", lambda *a, **k: True) - - assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 - - line = _withheld_line(capsys.readouterr().out) - assert line.startswith("3 earlier escalation(s) for s1 were not shown") - assert "recorded a resolution" in line - - -def test_resolve_says_nothing_when_it_withheld_nothing(tmp_path, monkeypatch, capsys): - """A first cycle, and every pre-upgrade `state.json`, withholds nothing — and must - print nothing, or the line becomes noise on the surface it exists to inform. - - `launching resolve agent` is the positive control: an absence assertion passes for - every reason stdout could be empty, including a command that returned before it - ever reached the print. - - Ablation: make the print unconditional (drop `if withheld:`) and this reddens.""" - from bmad_loop import resolve - - _escalated_run(tmp_path, "r1") - monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) - monkeypatch.setattr(resolve, "run_session", lambda *a, **k: True) - - assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 - - out = capsys.readouterr().out - assert "launching resolve agent for s1" in out # the path WAS taken - assert "were not shown" not in out - - -def test_resolve_no_interactive_builds_no_context_and_reports_nothing( - tmp_path, monkeypatch, capsys -): - """`--no-interactive` runs no agent, so there is no context to filter and no - audience for the count. It also accepted nothing IN THIS GESTURE, so the watermark - must stand — the human may have fixed the spec by hand, but nothing recorded which - escalations that answered. The generation bump is the positive control that the - re-arm really ran. - - The run carries a session record deliberately: on a task with an EMPTY `sessions` - list an unconditional stamp writes `len([]) == 0`, so `escalations_resolved_upto == - 0` would hold with the gate ablated and the assertion would grade nothing.""" - from bmad_loop import resolve - from bmad_loop.journal import load_state - - run_dir = _escalated_trail_run(tmp_path, "r1", details=("never answered",)) - built: list[int] = [] - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (built.append(1), (None, 5))[1]) - - assert ( - cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--no-resume"]) - == 0 - ) - - assert built == [] - assert "were not shown" not in capsys.readouterr().out - task = load_state(run_dir).tasks["s1"] - assert len(task.sessions) == 1 # a stamp here would be a VISIBLE 1 - assert task.escalations_resolved_upto == 0 - assert task.generation == 1 # positive control: the re-arm ran - - -def _escalated_trail_run(tmp_path, run_id="r1", *, details=("first cycle",)): - """An escalated run whose task carries one completed session record per entry in - `details`, each with the `tasks//escalation.json` the engine wrote when it - paused. Nothing about the escalation walk is stubbed by the rows that use it.""" - import json as _json - - from bmad_loop.engine import _session_task_id - from bmad_loop.journal import load_state, save_state - from bmad_loop.model import SessionRecord - - run_dir = _escalated_run(tmp_path, run_id) - state = load_state(run_dir) - task = state.tasks["s1"] - task.sessions.clear() - for seq, detail in enumerate(details, start=1): - task_id = _session_task_id("s1", "review", seq, 0) - task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) - d = run_dir / "tasks" / task_id - d.mkdir(parents=True, exist_ok=True) - (d / "escalation.json").write_text( - _json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), - encoding="utf-8", - ) - save_state(run_dir, state) - return run_dir - - -def _redrive_escalates(run_dir, detail): - """What a re-driven session that escalated again leaves behind, re-escalated so a - second `bmad-loop resolve` is legal on it.""" - import json as _json - - from bmad_loop.engine import _session_task_id - from bmad_loop.journal import load_state, save_state - from bmad_loop.model import Phase, SessionRecord - - state = load_state(run_dir) - task = state.tasks["s1"] - task_id = _session_task_id("s1", "review", 1, task.generation) - assert task_id not in {r.task_id for r in task.sessions} - task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) - d = run_dir / "tasks" / task_id - d.mkdir(parents=True, exist_ok=True) - (d / "escalation.json").write_text( - _json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), - encoding="utf-8", - ) - task.phase = Phase.ESCALATED - save_state(run_dir, state) - - -def _marker_writing_session(run_dir_marker=True): - from bmad_loop import resolve - - def fake_session(adapter, project, rd, story_key, *, generation, model=""): - marker = resolve.resolution_path(rd, story_key) - marker.parent.mkdir(parents=True, exist_ok=True) - if run_dir_marker: - marker.write_text("{}", encoding="utf-8") - return run_dir_marker - - return fake_session - - -def test_resolve_prints_the_number_the_real_walk_produced(tmp_path, monkeypatch, capsys): - """Every other CLI row here stubs `build_context` to a literal, so the number an - operator actually sees is otherwise never produced by the real walk. This row runs - two whole cycles with only `_make_adapters` and `run_session` stubbed: the first - shows both escalations and withholds nothing, the re-arm stamps the watermark, the - re-drive escalates again, and the second cycle prints the count `_gather_escalations` - computed — against a `context.json` that carries only the new entry. - - Ablation: revert `_gather_escalations` to the unsliced walk and the second cycle - prints nothing while `context.json` carries all three.""" - import json as _json - - from bmad_loop import resolve - from bmad_loop.journal import load_state - - run_dir = _escalated_trail_run(tmp_path, details=("older A", "older B")) - monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "run_session", _marker_writing_session()) - - argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] - assert cli.main(argv) == 0 - first = capsys.readouterr().out - assert "launching resolve agent for s1" in first - assert "were not shown" not in first # a first cycle withholds nothing - assert load_state(run_dir).tasks["s1"].escalations_resolved_upto == 2 - - _redrive_escalates(run_dir, "raised by the re-drive") - - assert cli.main(argv) == 0 - assert _withheld_line(capsys.readouterr().out).startswith( - "2 earlier escalation(s) for s1 were not shown" - ) - ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) - assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] - - -def test_resolve_leaves_the_watermark_when_the_agent_wrote_no_resolution( - tmp_path, monkeypatch, capsys -): - """`cmd_resolve` prints "no resolution recorded" and FALLS THROUGH — no `return` — - so an abandoned or crashed resolve session re-arms the story anyway. That gesture - accepted nothing, so it must not advance the watermark: the escalations the agent - walked away from would otherwise be invisible to every later cycle and reported to - the operator as already answered. - - Driven as a whole SECOND cycle through the real walk, because the consequence is - what the next `resolve` shows, not what one field reads. - - Ablation: remove the `if resolution_recorded:` gate in `rearm_escalation` and this - reddens on the watermark, then again on the second cycle's absent line.""" - import json as _json - - from bmad_loop import resolve - from bmad_loop.journal import load_state - - run_dir = _escalated_trail_run(tmp_path, details=("nobody ever answered this",)) - monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "run_session", _marker_writing_session(run_dir_marker=False)) - - argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] - assert cli.main(argv) == 0 - assert "no resolution recorded for s1" in capsys.readouterr().err - - task = load_state(run_dir).tasks["s1"] - assert task.escalations_resolved_upto == 0 # UNCHANGED - assert task.generation == 1 # positive control: the re-arm still ran - - _redrive_escalates(run_dir, "raised by the re-drive") - - assert cli.main(argv) == 0 - assert "were not shown" not in capsys.readouterr().out - ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) - assert [e["detail"] for e in ctx["escalations"]] == [ - "raised by the re-drive", - "nobody ever answered this", - ] + assert "no interactive session mode" in capsys.readouterr().err def test_resolve_in_ctl_session_detaches_before_resume(tmp_path, monkeypatch, capsys): @@ -3683,7 +3431,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) @@ -3867,7 +3615,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) monkeypatch.setattr(resolve, "run_session", fake_session) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) rc = cli.main(["resolve", "--project", str(tmp_path), "r1", "--resume"]) @@ -3927,14 +3675,12 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): seen: list[bool] = [] - def recording_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False - ): + def recording_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): seen.append(isolated_redrive) return key monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) monkeypatch.setattr(resolve, "run_session", fake_session) monkeypatch.setattr(runs, "rearm_escalation", recording_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -3967,7 +3713,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) @@ -3998,7 +3744,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 37136adc..07aab2ce 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1749,22 +1749,14 @@ def test_diag_surfaces_the_split_code_root_and_the_task_generation(project): `paused_reason_present` / `worktree_isolated` style, and a small counter. The path itself must NOT appear — that is what `_JOURNAL_DROP_FIELDS` drops. - `escalations_resolved_upto` (DW-11) is projected on the same warrant and asserted - here for the same reason: a task whose older escalations are filtered out of - `context.json` dumps identically to one that only ever raised the entries shown, - so a support bundle cannot explain a short resolve context without it. A counter - too — it indexes `task.sessions`, so it carries no customer content. - - Ablation: delete `repo_root_diverges=` from `collect_run` (or `generation=` / - `escalations_resolved_upto=` from `_task_diag`) and this reddens on the - corresponding assertion; deleting the field from the dataclass reddens as a - TypeError at construction. + Ablation: delete `repo_root_diverges=` from `collect_run` (or `generation=` from + `_task_diag`) and this reddens on the corresponding assertion; deleting the field + from the dataclass reddens as a TypeError at construction. """ run_dir = _seed_run(project.project) state = load_state(run_dir) state.repo_root = str(project.project / "code-tree") state.tasks[STORY_KEY].generation = 2 - state.tasks[STORY_KEY].escalations_resolved_upto = 3 save_state(run_dir, state) diag, _pseudo, combined = _render_all([run_dir]) @@ -1772,7 +1764,6 @@ def test_diag_surfaces_the_split_code_root_and_the_task_generation(project): assert run.repo_root_diverges is True assert run.tasks[0].generation == 2 - assert run.tasks[0].escalations_resolved_upto == 3 # a presence flag, never the path — the same rule `repo` is dropped under assert "code-tree" not in combined @@ -1789,7 +1780,6 @@ def test_diag_repo_root_diverges_is_false_for_the_ordinary_layout(project): assert run.repo_root_diverges is False assert run.tasks[0].generation == 0 - assert run.tasks[0].escalations_resolved_upto == 0 def _md_task_row(md: str) -> list[str]: @@ -1810,28 +1800,19 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): `generation` rides beside `attempt` because that is the column pair a #705-class replay turns on: a collided re-drive and a healthy post-re-arm task agree on every - other cell in this row. DW-11's `escalations_resolved_upto` rides beside it on the - same warrant, stated verbatim in its own field comment: it is the only field that - separates "this story raised one escalation" from "its earlier ones are filtered - out of `context.json` as already answered", and that question is asked of a bug - report. Seeded to a value that is neither the attempt, the generation nor the - review cycle, so a cell reading a NEIGHBOUR cannot pass. + other cell in this row. Ablation: drop the `code root differs from project` line from `render_markdown` and both this test and the sibling below redden on their first assertion. Drop `{t.generation}` from the row f-string together with its header and separator cells - and this test reddens at `names[4]` (`"esc-upto" != "gen"`) while the sibling - reddens at the row cell — the review cycle shifted left rather than a missing key, - which is why the cell is read positionally and the three widths are compared. Drop - `{t.escalations_resolved_upto}` the same way and this test reddens at `names[5]` - (`"rev" != "esc-upto"`); drop ONLY the row cell and it reddens on the width - comparison, which is what a skewed table actually looks like. + and this test reddens at `names[4]` (`"rev" != "gen"`) while the sibling reddens at + the row cell — as `"1" != "0"`, the review cycle shifted left rather than a missing + key, which is why the cell is read positionally and the three widths are compared. """ run_dir = _seed_run(project.project) state = load_state(run_dir) state.repo_root = str(project.project / "code-tree") state.tasks[STORY_KEY].generation = 2 - state.tasks[STORY_KEY].escalations_resolved_upto = 3 save_state(run_dir, state) pseudo = sanitize.Pseudonymizer() @@ -1844,13 +1825,11 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): (rule,) = [ln for ln in md.splitlines() if ln.startswith("|---|")] names = [c.strip() for c in header.strip("|").split("|")] assert names[4] == "gen" - assert names[5] == "esc-upto" # header, separator and row must agree on width or the table renders skewed - assert len(cells) == len(names) == len(rule.strip("|").split("|")) == 13 + assert len(cells) == len(names) == len(rule.strip("|").split("|")) == 12 assert cells[3] == "2" # attempt, seeded by `_seed_run` assert cells[4] == "2" # generation — NOT the review cycle, which is 1 - assert cells[5] == "3" # the DW-11 watermark, in its own column - assert cells[6] == "1" # review cycle, still in its own column + assert cells[5] == "1" # review cycle, still in its own column # still a flag and a counter: the path itself never renders assert "code-tree" not in md diff --git a/tests/test_engine.py b/tests/test_engine.py index e12b0bf8..4b95666e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5481,7 +5481,7 @@ def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): # the resolve workflow's re-arm: a resolved re-drive, which is precisely the # recovery that PRESERVES the artifact folders' tracked content through # `safe_reset` — so a close left standing here would never be reverted. - rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) + rearm_escalation(engine.run_dir, isolated_redrive=False) resumed, _ = resume_engine( project, @@ -9138,9 +9138,7 @@ def test_resolved_escalation_resume_skips_clean_rollback(project): assert summary.paused and summary.escalated == 1 assert load_state(engine.run_dir).tasks["1-1-a"].phase == Phase.ESCALATED - rearm_escalation( - engine.run_dir, isolated_redrive=False, resolution_recorded=True - ) # the resolve workflow's re-arm step + rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, @@ -9187,9 +9185,7 @@ def escalate_dirty(spec): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation( - engine.run_dir, isolated_redrive=False, resolution_recorded=True - ) # the resolve workflow's re-arm step + rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, @@ -9277,7 +9273,7 @@ def escalate_bound_repair(session): corrected = sp.read_text().replace("test spec", "human corrected frozen intent") sp.write_text(corrected) head_before_rearm = rev_parse_head(repo) - rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) + rearm_escalation(engine.run_dir, isolated_redrive=False) assert rev_parse_head(repo) == head_before_rearm # no correction commit at re-arm assert read_frontmatter(sp)["status"] == "ready-for-dev" @@ -9848,9 +9844,7 @@ def halt_blocked(spec): assert task.phase == Phase.ESCALATED assert task.spec_file and Path(task.spec_file).name == sp.name # recorded despite HALT - rearm_escalation( - engine.run_dir, isolated_redrive=False, resolution_recorded=True - ) # the resolve workflow's re-arm step + rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step assert read_frontmatter(sp)["status"] == "ready-for-dev" # re-drive will not HALT @@ -10086,7 +10080,7 @@ def test_intent_gap_restore_redrive_applies_patch_and_lands_done(project): assert engine.run().escalated == 1 rearm_escalation( - engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + engine.run_dir, restore_patch=str(patch), isolated_redrive=False ) # human confirmed the reading sp = spec_path(project, "1-1-a") assert read_frontmatter(sp)["status"] == "in-review" # routes step-01 -> step-04 @@ -10115,9 +10109,7 @@ def test_restore_redrive_prompt_points_at_the_spec(project): engine, _ = make_engine(project, [_escalate_with_patch(project, "1-1-a", patch)]) assert engine.run().escalated == 1 - rearm_escalation( - engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True - ) + rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) seen: list[str] = [] resumed, adapter = resume_engine( project, engine, [_restoring_dev_effect(project, "1-1-a", seen)] @@ -10138,9 +10130,7 @@ def test_intent_gap_restore_reapplies_after_mid_redrive_rollback(project): patch = project.implementation_artifacts / "attempt.patch" engine, _ = make_engine(project, [_escalate_with_patch(project, "1-1-a", patch)]) assert engine.run().escalated == 1 - rearm_escalation( - engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True - ) + rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) seen: list[str] = [] resumed, _ = resume_engine( @@ -10175,9 +10165,7 @@ def test_intent_gap_restore_escalates_when_resolution_commits_overlap(project): (repo / "src.txt").write_text("corrected by resolution\n") git(repo, "add", "src.txt") git(repo, "commit", "-q", "-m", "resolution: overlapping fix") - rearm_escalation( - engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True - ) + rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) seen: list[str] = [] resumed, _ = resume_engine(project, engine, [_restoring_dev_effect(project, "1-1-a", seen)]) @@ -10564,9 +10552,7 @@ def test_resume_re_gates_a_human_armed_re_drive(project): ) engine, _ = make_engine(project, [escalating]) assert engine.run().escalated == 1 - rearm_escalation( - engine.run_dir, isolated_redrive=False, resolution_recorded=True - ) # the resolve workflow's re-arm step + rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 # the confusable state # a gate lands on the story while the operator is resolving it write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) @@ -11082,7 +11068,7 @@ def test_session_env_fault_pauses_dev_without_burning_budget(project): assert end["env_fault_evidence"] == evidence # the resolve workflow's re-arm step restores the attempt budget - rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) + rearm_escalation(engine.run_dir, isolated_redrive=False) assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 @@ -12270,9 +12256,7 @@ def test_resume_with_epic_filter_stays_in_scoped_epic(project): assert summary.paused and summary.escalated == 1 assert engine.state.current_epic == 9 - rearm_escalation( - engine.run_dir, isolated_redrive=False, resolution_recorded=True - ) # the resolve workflow's re-arm step + rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, engine, @@ -12334,9 +12318,7 @@ def test_resolved_redrive_reescalates_instead_of_deferring(project): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation( - engine.run_dir, isolated_redrive=False, resolution_recorded=True - ) # human resolved; re-drive re-armed + rearm_escalation(engine.run_dir, isolated_redrive=False) # human resolved; re-drive re-armed # re-drive never reaches `done` (env still blocked): both attempts land at # in-progress with no escalation — the exact non-convergence that used to defer resumed, _ = resume_engine( diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index ec0d9412..3a3bf153 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2101,12 +2101,7 @@ def commit_fails(*_a, **_k): assert not project.deferred_work.exists() # the row is only in the doomed worktree monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert ( - runs.rearm_escalation( - engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True - ) - == "1-1-a" - ) + assert runs.rearm_escalation(engine.run_dir, "1-1-a", isolated_redrive=True) == "1-1-a" state = load_state(engine.run_dir) state.clear_pause() @@ -5767,12 +5762,7 @@ def commit_fails(*_a, **_k): assert _ledger_entry(project, "DW-1").open monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert ( - runs.rearm_escalation( - engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True - ) - == "1-1-a" - ) + assert runs.rearm_escalation(engine.run_dir, "1-1-a", isolated_redrive=True) == "1-1-a" state = load_state(engine.run_dir) state.clear_pause() diff --git a/tests/test_model.py b/tests/test_model.py index 50fd5ac0..1b264bd6 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -291,22 +291,6 @@ def test_generation_defaults_zero_for_legacy_state(): assert StoryTask.from_dict(doc).generation == 0 -def test_escalations_resolved_upto_round_trips(): - task = StoryTask(story_key="1-1-a", epic=1, escalations_resolved_upto=3) - assert StoryTask.from_dict(task.to_dict()).escalations_resolved_upto == 3 - - -def test_escalations_resolved_upto_defaults_zero_for_legacy_state(): - """A `state.json` written before DW-11 must resume UNFILTERED. 0 is the value - `resolve._gather_escalations` reads as "nothing answered yet", so every escalation - the run recorded is still shown and nothing is reported withheld — byte-for-byte - today's behavior. Any other default would hide entries the human never saw, on a - run that was mid-escalation across the upgrade.""" - doc = StoryTask(story_key="1-1-a", epic=1).to_dict() - del doc["escalations_resolved_upto"] # state.json from before the field existed - assert StoryTask.from_dict(doc).escalations_resolved_upto == 0 - - def test_resolved_redrive_round_trips(): task = StoryTask(story_key="1-1-a", epic=1, resolved_redrive=True) assert StoryTask.from_dict(task.to_dict()).resolved_redrive is True diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 15913b43..795600bc 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -92,20 +92,6 @@ def _escalated_run( return run.run_dir, run.state, run.task -def _context(state, run_dir, story_key, *, isolation): - """`build_context`'s Path alone, for the ~30 rows that assert on `context.json`. - - `build_context` returns `(path, withheld)` since DW-11, and the withheld count is - an OPERATOR-facing number the CLI prints — no row here is about it. Routing every - Path-only caller through one unpack pins the arity for all of them at once: grow - the tuple a third member and this helper fails, rather than every row silently - binding a longer tuple to `path` (which is what a bare `path, _ = ...` at each - site would do). The rows that ARE about the count call `resolve.build_context` - directly, so the number is never produced by this helper.""" - path, _withheld = resolve.build_context(state, run_dir, story_key, isolation=isolation) - return path - - # ------------------------------------------------------------ set_frontmatter_field # # `set_frontmatter_status`'s own tests live in tests/test_frontmatter.py, next to @@ -545,7 +531,7 @@ def test_build_context_gathers_critical_escalations(tmp_path): ), encoding="utf-8", ) - path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == "6-4-cli-list-command" assert ctx["spec_file"] == spec.as_posix() @@ -603,7 +589,7 @@ def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_ run_dir, state, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what the resolve session actually runs from - path = _context(state, run_dir, "6-4-cli-list-command", isolation="worktree") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="worktree") ctx = json.loads(path.read_text(encoding="utf-8")) assert Path(ctx["spec_file"]).is_absolute() # the worktree's copy, not the main checkout's twin — compared as posix, which is @@ -625,15 +611,17 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=None, worktree_path=str(wt)) ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( - encoding="utf-8" - ) + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) assert ctx["spec_file"] is None # task present, spec-less escalation assert "no-such-story" not in state.tasks ctx = json.loads( - _context(state, run_dir, "no-such-story", isolation="worktree").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, "no-such-story", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_file"] is None # no task at all # ... and the escalation gather degrades on the same absence rather than @@ -643,7 +631,7 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): def test_build_context_no_session_files(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, with_session=False) - path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["escalations"] == [] assert ctx["paused_reason"].startswith("CRITICAL") @@ -658,25 +646,25 @@ def test_build_context_restore_supported_signal(tmp_path): run_dir, state, task = _escalated_run(tmp_path, spec_file="/abs/spec.md", with_session=False) key = "6-4-cli-list-command" - path = _context(state, run_dir, key, isolation="") + path = resolve.build_context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is True - path = _context(state, run_dir, key, isolation="worktree") + path = resolve.build_context(state, run_dir, key, isolation="worktree") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.worktree_path = str(tmp_path / "wt") # recorded worktree execution - path = _context(state, run_dir, key, isolation="") + path = resolve.build_context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.worktree_path = "" task.spec_file = None # spec-less escalation: a restored patch has no review to resume - path = _context(state, run_dir, key, isolation="") + path = resolve.build_context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.spec_file = "/abs/spec.md" state.source = "stories" task.sentinel_kind = "missing-prd" # pre-planning wedge: nothing attempted to restore - path = _context(state, run_dir, key, isolation="") + path = resolve.build_context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False @@ -687,7 +675,7 @@ def test_build_context_sanitizes_dirty_story_key(tmp_path): dirty = "6-4:cli?list" seg = safe_segment(dirty) assert seg != dirty - path = _context(state, run_dir, dirty, isolation="") + path = resolve.build_context(state, run_dir, dirty, isolation="") assert path.parent.name == seg ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == dirty @@ -701,7 +689,7 @@ def test_rearm_flips_phase_and_spec_status(tmp_path): spec = tmp_path / "spec.md" spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - key = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + key = runs.rearm_escalation(run_dir, isolated_redrive=False) assert key == "6-4-cli-list-command" state = load_state(run_dir) task = state.tasks[key] @@ -725,7 +713,7 @@ def test_rearm_strips_stale_terminal_section(tmp_path): encoding="utf-8", ) run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) text = spec.read_text(encoding="utf-8") assert "Auto Run Result" not in text and "names not unique" not in text assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" @@ -768,7 +756,7 @@ def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive tmp_path, spec_file=str(spec), worktree_path=str(tmp_path / "wt" / "u1") ) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["story_key"] == "6-4-cli-list-command" @@ -790,7 +778,7 @@ def test_rearm_does_not_warn_about_unreachable_writes_without_a_worktree(tmp_pat spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -858,9 +846,9 @@ def test_rearm_journals_a_status_flip_that_silently_did_nothing(tmp_path, shape) if shape == "no-frontmatter": with pytest.raises(runs.RearmError, match="no frontmatter `status:`"): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) else: - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) records = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-flip-skipped"] if shape == "already-at-target": @@ -891,7 +879,7 @@ def test_rearm_journals_a_status_flip_that_silently_did_nothing(tmp_path, shape) def test_rearm_journals_event(tmp_path): run_dir, _, _ = _escalated_run(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) journal = (run_dir / "journal.jsonl").read_text(encoding="utf-8") assert "story-escalation-resolved" in journal @@ -910,7 +898,7 @@ def test_rearm_advances_baseline_to_resolved_head(project): # a file the resolve session (or the user) left untracked must enter the # snapshot, so the redrive reset treats it as pre-existing, not run-created (root / "leftover.txt").write_text("keep me\n", encoding="utf-8") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == git(root, "rev-parse", "HEAD") assert task.baseline_commit != old_head @@ -929,7 +917,7 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" assert task.baseline_untracked is None @@ -939,7 +927,7 @@ def test_rearm_keeps_stale_baseline_outside_a_repo(tmp_path): # best-effort contract: a project dir that is not a git repo (or a broken # one) must not make re-arm fail — the old baseline simply stands run_dir, _, _ = _escalated_run(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" @@ -959,7 +947,7 @@ def test_rearm_journals_a_failed_baseline_advance(tmp_path): """ run_dir, _, _ = _escalated_run(tmp_path) # tmp_path is not a git repo - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-advance-failed"] assert entry["story_key"] == "6-4-cli-list-command" @@ -987,7 +975,7 @@ def boom(repo): monkeypatch.setattr(runs.verify, "untracked_files", boom) with pytest.raises(MemoryError): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) @pytest.mark.parametrize("restore", [None, "artifacts/attempt.patch"]) @@ -1011,9 +999,7 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation( - run_dir, restore_patch=restore, isolated_redrive=False, resolution_recorded=True - ) + runs.rearm_escalation(run_dir, restore_patch=restore, isolated_redrive=False) fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == old_head # NOT re-stamped with the stale sha @@ -1038,7 +1024,7 @@ def test_rearm_bumps_the_task_generation(tmp_path): before = load_state(run_dir).tasks["6-4-cli-list-command"] assert before.generation == 0 and len(before.sessions) == 1 - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.generation == 1 @@ -1046,7 +1032,7 @@ def test_rearm_bumps_the_task_generation(tmp_path): assert len(task.sessions) == 1 # the audit trail survives the re-arm save_state(run_dir, _rearmable(run_dir)) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert load_state(run_dir).tasks["6-4-cli-list-command"].generation == 2 @@ -1071,7 +1057,7 @@ def test_rearm_advances_the_baseline_in_the_code_tree(tmp_path): git(code, "commit", "-q", "-m", "resolution fixture") (code / "leftover.txt").write_text("keep me\n") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == git(code, "rev-parse", "HEAD") != head @@ -1129,9 +1115,7 @@ def test_rearm_reads_stale_restore_residue_from_the_code_tree(tmp_path): git(code, "commit", "-q", "-m", "resolution fixture") new_head = git(code, "rev-parse", "HEAD") - runs.rearm_escalation( - run_dir, isolated_redrive=False, resolution_recorded=True - ) # from scratch: the latch is dropped + runs.rearm_escalation(run_dir, isolated_redrive=False) # from scratch: the latch is dropped task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == new_head @@ -1172,7 +1156,7 @@ def test_rearm_falls_back_to_project_when_no_code_root_was_recorded(tmp_path): (run_dir / "state.json").write_text(json.dumps(raw), encoding="utf-8") assert load_state(run_dir).repo_root == "" - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert load_state(run_dir).tasks["6-4-cli-list-command"].baseline_commit == head @@ -1219,7 +1203,7 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what `bmad-loop resolve` actually runs from - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) fm = verify.read_frontmatter(wt / rel) assert fm["status"] == "ready-for-dev" # the flip landed in the WORKTREE @@ -1256,7 +1240,7 @@ def test_rearm_journals_a_skip_when_the_recorded_spec_is_not_readable(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file="wt/_bmad-output/specs/gone.md") runs.rearm_escalation( - run_dir, isolated_redrive=False, resolution_recorded=True + run_dir, isolated_redrive=False ) # must not raise: the flip's no-op is not a refusal kinds = _kinds(run_dir) @@ -1296,7 +1280,7 @@ def test_rearm_records_an_unreachable_spec_even_when_the_advance_failed(tmp_path _resolve_repo(tmp_path) run_dir, _, _ = _escalated_run(tmp_path, spec_file="wt/_bmad-output/specs/gone.md") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) kinds = _kinds(run_dir) (skipped,) = [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] @@ -1324,7 +1308,7 @@ def test_rearm_restamps_normally_when_the_spec_resolves(tmp_path): spec.write_text("---\nstatus: 'escalated'\nbaseline_revision: 'old'\n---\n\nbody\n") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) kinds = _kinds(run_dir) assert [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] == [] @@ -1353,7 +1337,7 @@ def test_rearm_clears_sentinel_preserving_a_copy(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - returned = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + returned = runs.rearm_escalation(run_dir, isolated_redrive=False) assert returned == key # sentinel deleted from disk, a copy preserved under the run dir @@ -1393,7 +1377,7 @@ def test_rearm_non_sentinel_spec_still_flips_status(tmp_path): # detected as a sentinel) → status-flip, not delete. run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert spec.is_file() # not deleted assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1413,7 +1397,7 @@ def test_rearm_sentinel_named_spec_never_detected_is_not_deleted(tmp_path): # stories mode, but sentinel_kind unset — the run never classified it as a sentinel run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert spec.is_file() # NOT deleted despite the sentinel-shaped name assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1430,7 +1414,7 @@ def test_rearm_sprint_spec_named_like_a_sentinel_is_not_deleted(tmp_path): spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nreal work\n", encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) # sprint-status source - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert spec.is_file() # NOT deleted despite the sentinel-shaped name assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" # flipped like any spec assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1457,10 +1441,7 @@ def test_rearm_rejects_restore_patch_on_a_sentinel(tmp_path): with pytest.raises(runs.RearmError, match="sentinel"): runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False ) assert sentinel.is_file() # nothing deleted, copy NOT preserved — no clear happened @@ -1482,10 +1463,7 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): with pytest.raises(runs.RearmError, match="no recorded spec file"): runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False ) task = load_state(run_dir).tasks["6-4-cli-list-command"] @@ -1494,7 +1472,7 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): assert not (run_dir / "journal.jsonl").exists() # nothing journaled runs.rearm_escalation( - run_dir, isolated_redrive=False, resolution_recorded=True + run_dir, isolated_redrive=False ) # a from-scratch re-arm remains available assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.PENDING @@ -1512,20 +1490,14 @@ def test_rearm_rejects_restore_patch_for_a_worktree_executed_task(tmp_path): with pytest.raises(runs.RearmError, match="worktree-isolation"): runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=True, - resolution_recorded=True, + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=True ) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.phase == Phase.ESCALATED # nothing mutated; still armed for a re-resolve assert task.restore_patch is None # a from-scratch re-arm of the same task is unaffected — the guard is latch-only - assert ( - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) - == "6-4-cli-list-command" - ) + assert runs.rearm_escalation(run_dir, isolated_redrive=True) == "6-4-cli-list-command" def test_validate_restore_latch_passes_a_clean_in_place_escalation(tmp_path): @@ -1552,12 +1524,7 @@ def test_rearm_restore_patch_on_a_real_stories_spec_is_allowed(tmp_path): spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, - ) + runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) task = load_state(run_dir).tasks[key] assert task.phase == Phase.PENDING assert task.restore_patch == "artifacts/attempt.patch" @@ -1597,12 +1564,7 @@ def test_rearm_restore_patch_restamps_spec_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "resolution fixture") new_head = git(tmp_path, "rev-parse", "HEAD") - runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, - ) + runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head # step-04 diffs from the ADVANCED baseline @@ -1652,7 +1614,7 @@ def test_rearm_restamps_spec_baseline_on_the_from_scratch_leg_too(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, spec, new_head = _escalated_spec_run(tmp_path, old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # no restore + runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head @@ -1703,7 +1665,7 @@ def test_rearm_restores_the_spec_when_the_baseline_restamp_aborts(tmp_path): git(tmp_path, "commit", "-q", "-m", "resolution fixture") with pytest.raises(runs.RearmError, match="baseline_revision"): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert spec.read_bytes() == before # flip AND strip both undone # nothing was persisted either, so the escalation is still armed for a corrected spec @@ -1754,7 +1716,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert spec.read_bytes() == before # the published flip is rolled back assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED @@ -1800,7 +1762,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) assert spec.read_bytes() == before # the undo reached a spec outside the mount assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED @@ -1819,7 +1781,7 @@ def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, _spec, new_head = _escalated_spec_run(tmp_path, old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == old_head @@ -1828,7 +1790,7 @@ def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): # a second re-arm has nothing left to overwrite: no duplicate record save_state(run_dir, _rearmable(run_dir)) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert len([e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"]) == 1 @@ -1854,7 +1816,7 @@ def test_rearm_does_not_report_a_divergence_the_run_never_had(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, spec, new_head = _escalated_spec_run(tmp_path, old_head, recorded=old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) # the re-stamp itself ran: this row is about what was REPORTED, not what was skipped assert verify.read_frontmatter(spec)["baseline_revision"] == new_head @@ -1890,7 +1852,7 @@ def test_rearm_reports_a_claim_the_advanced_head_would_have_masked(tmp_path): encoding="utf-8", ) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == new_head # the claim, carried verbatim @@ -1907,7 +1869,7 @@ def test_rearm_prefers_the_fresh_revision_when_the_spec_carries_both_keys(tmp_pa tmp_path, old_head, extra=f"baseline_commit: {'a' * 40}\n" ) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == old_head # NOT the stale baseline_commit @@ -1943,7 +1905,7 @@ def test_build_context_tolerates_non_utf8_present_spec(tmp_path): (stories_dir / f"{key}-slug.md").write_bytes(_BAD_UTF8) # a real spec, undecodable run_dir, state, _ = _escalated_run(tmp_path, source="stories") - path = _context(state, run_dir, key, isolation="") # must not raise + path = resolve.build_context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["stories"]["spec_folder"] == "" # best-effort context still produced assert "sentinel" not in ctx["stories"] # the undecodable spec yields no sentinel @@ -1958,7 +1920,7 @@ def test_build_context_tolerates_non_utf8_sentinel(tmp_path): (stories_dir / f"{key}-unresolved.md").write_bytes(_BAD_UTF8) # undecodable sentinel run_dir, state, _ = _escalated_run(tmp_path, source="stories", sentinel_kind="unresolved") - path = _context(state, run_dir, key, isolation="") # must not raise + path = resolve.build_context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["stories"]["sentinel"]["kind"] == "unresolved" assert ctx["stories"]["sentinel"]["blocking_condition"] == "" # unreadable → empty @@ -1979,7 +1941,7 @@ def test_rearm_non_utf8_present_spec_fails_clean_and_stays_armed(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") with pytest.raises(runs.RearmError) as exc: - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert "UTF-8" in str(exc.value) and "resolve" in str(exc.value) assert spec.read_bytes() == _BAD_UTF8 # spec untouched task = load_state(run_dir).tasks[key] @@ -1999,9 +1961,7 @@ def test_rearm_tolerates_non_utf8_sentinel(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - assert ( - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) == key - ) # must not raise + assert runs.rearm_escalation(run_dir, isolated_redrive=False) == key # must not raise assert not sentinel.exists() # cleared by deletion assert (run_dir / "sentinels" / f"{key}-unresolved.md").is_file() # copy preserved assert load_state(run_dir).tasks[key].spec_file is None # cleared → PENDING re-dispatch @@ -2034,7 +1994,7 @@ def test_rearm_rejects_non_escalation_stage(tmp_path): ), ) with pytest.raises(runs.RearmError, match="not paused at an escalation"): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) def test_rearm_rejects_unescalated_story(tmp_path): @@ -2042,7 +2002,7 @@ def test_rearm_rejects_unescalated_story(tmp_path): task.phase = Phase.DONE # terminal but not escalated save_state(run_dir, state) with pytest.raises(runs.RearmError, match="not escalated"): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) # ------------------------------------------------- _gather_escalations @@ -2072,7 +2032,7 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): encoding="utf-8", ) - found, _ = resolve._gather_escalations(run_dir, state, key) + found = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["abandoned cycle"] # once, not twice # DW-71: the id bump only protects records minted AFTER it. State persisted @@ -2080,7 +2040,7 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): # directory's single mutable escalation.json — the reader itself has to return # the escalation once rather than attribute it to the fresh session too. task.sessions[1] = SessionRecord(task_id=abandoned, role="dev", status="completed") - collided, _ = resolve._gather_escalations(run_dir, state, key) + collided = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in collided] == ["abandoned cycle"] @@ -2122,7 +2082,7 @@ def counting_read_text(self, *args, **kwargs): # back the suite's `BMAD_LOOP_STATE_DIR` isolation too, mid-test. with monkeypatch.context() as mp: mp.setattr(Path, "read_text", counting_read_text) - found, _ = resolve._gather_escalations(run_dir, state, key) + found = resolve._gather_escalations(run_dir, state, key) assert reads.count(str(result_file)) == 1 # each artifact once, not once per record assert reads.count(str(esc_file)) == 1 @@ -2164,7 +2124,7 @@ def test_gather_escalations_dedupes_one_entry_across_two_sessions(tmp_path): for d in (older_dir, newer_dir): (d / "escalation.json").write_text(json.dumps({"escalations": [entry]}), encoding="utf-8") - found, _ = resolve._gather_escalations(run_dir, state, key) + found = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["unresolved across attempts"] @@ -2180,7 +2140,7 @@ def test_gather_escalations_orders_distinct_sessions_newest_first(tmp_path): encoding="utf-8", ) - found, _ = resolve._gather_escalations(run_dir, state, key) + found = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["newer", "older"] @@ -2213,7 +2173,9 @@ def test_gather_escalations_returns_a_mirrored_entry_once(tmp_path): (task_dir / fname).write_text(json.dumps({"escalations": [value]}), encoding="utf-8") ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert ctx["escalations"] == [entry] @@ -2230,7 +2192,7 @@ def test_gather_escalations_keeps_distinct_entries_from_both_files(tmp_path): (task_dir / "result.json").write_text(json.dumps({"escalations": [a]}), encoding="utf-8") (task_dir / "escalation.json").write_text(json.dumps({"escalations": [a, b]}), encoding="utf-8") - found, _ = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") + found = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") assert [e["detail"] for e in found] == ["A", "B"] @@ -2256,10 +2218,7 @@ def test_gather_escalations_keeps_full_objects_that_share_a_detail(tmp_path): json.dumps({"escalations": [first, second]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( - [first, second], - 0, - ) + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_path): @@ -2273,10 +2232,7 @@ def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_pa json.dumps({"escalations": [second]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( - [first, second], - 0, - ) + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): @@ -2290,10 +2246,7 @@ def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): json.dumps({"escalations": [second, first]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( - [first, second], - 0, - ) + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): @@ -2305,7 +2258,7 @@ def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): json.dumps({"escalations": [entry, entry]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ([entry], 0) + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [entry] def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_path): @@ -2318,7 +2271,7 @@ def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_pa json.dumps({"escalations": [None, "junk", preference, critical]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ([critical], 0) + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [critical] def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): @@ -2335,7 +2288,9 @@ def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): ) ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert [e["detail"] for e in ctx["escalations"]] == ["still readable"] @@ -2364,7 +2319,7 @@ def loads_with_digit_limit(data, *args, **kwargs): with monkeypatch.context() as mp: mp.setattr(resolve.json, "loads", loads_with_digit_limit) - path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] @@ -2391,7 +2346,9 @@ def test_gather_escalations_skips_a_json_recursion_error(tmp_path): ) ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] @@ -2415,7 +2372,7 @@ def dumps_with_recursion_error(value, *args, **kwargs): with monkeypatch.context() as mp: mp.setattr(resolve.json, "dumps", dumps_with_recursion_error) - path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["escalations"] == [sibling] @@ -2448,7 +2405,7 @@ def recording_critical_escalations(doc): with monkeypatch.context() as mp: mp.setattr(resolve, "critical_escalations", recording_critical_escalations) - path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert filtered == [ @@ -2478,310 +2435,15 @@ def test_gather_escalations_preference_only_yields_nothing(tmp_path): for fname in ("result.json", "escalation.json"): (task_dir / fname).write_text(json.dumps({"escalations": [pref]}), encoding="utf-8") - assert resolve._gather_escalations(run_dir, state, key) == ([], 0) + assert resolve._gather_escalations(run_dir, state, key) == [] crit = {"type": "spec-gap", "severity": "CRITICAL", "detail": "kept"} for fname in ("result.json", "escalation.json"): (task_dir / fname).write_text(json.dumps({"escalations": [pref, crit]}), encoding="utf-8") - found, _ = resolve._gather_escalations(run_dir, state, key) + found = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["kept"] # this directory IS read -# -------------------------------------- DW-11: the escalation watermark - - -def _watermarked_trail(tmp_path, per_session): - """A task whose append-only `sessions` list carries ONE record per element of - `per_session`, each with its own `tasks//escalation.json` holding that - record's CRITICAL details. Returns `(run_dir, state, task, key)` with the state - already saved, so a row can re-arm it without re-saving by hand. - - The ids are minted through `engine._session_task_id`, varying the SEQ inside - generation 0 — the trail one pre-re-arm cycle leaves behind. Distinctness is - asserted rather than assumed: a shared id collapses into the reader's `seen_ids` - guard, leaving one directory and one side to route to, and every row below would - then pass with the filter ablated. Varying the seq (not the generation) also - keeps the whole namespace clear of the ids a LATER re-arm mints, so a re-drive - record cannot silently overwrite a trail artifact. - """ - run_dir, state, task = _escalated_run(tmp_path) - key = "6-4-cli-list-command" - task.sessions.clear() - for seq, details in enumerate(per_session, start=1): - task_id = _session_task_id(key, "review", seq, 0) - assert task_id not in {r.task_id for r in task.sessions} - task.sessions.append(SessionRecord(task_id=task_id, role="dev", status="completed")) - d = run_dir / "tasks" / task_id - d.mkdir(parents=True, exist_ok=True) - (d / "escalation.json").write_text( - json.dumps( - { - "escalations": [ - {"type": "spec-gap", "severity": "CRITICAL", "detail": detail} - for detail in details - ] - } - ), - encoding="utf-8", - ) - save_state(run_dir, state) - return run_dir, state, task, key - - -def _redrive_escalates(run_dir, key, detail, *, escalated=False): - """Append the record + artifact a re-driven session that escalated again leaves - behind — through `record_session`, the SOLE mutation of `task.sessions` in - `src/`, which is what makes a length watermark meaningful. The id carries the - re-arm's own generation, exactly as `engine._session_task_id` would mint it.""" - state = load_state(run_dir) - task = state.tasks[key] - assert task.generation > 0 # a re-arm ran, so this id is in a fresh namespace - task_id = _session_task_id(key, "review", 1, task.generation) - assert task_id not in {r.task_id for r in task.sessions} - task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) - d = run_dir / "tasks" / task_id - d.mkdir(parents=True, exist_ok=True) - (d / "escalation.json").write_text( - json.dumps( - {"escalations": [{"type": "spec-gap", "severity": "CRITICAL", "detail": detail}]} - ), - encoding="utf-8", - ) - if escalated: - task.phase = Phase.ESCALATED - save_state(run_dir, state) - - -def test_gather_escalations_shows_the_whole_trail_at_watermark_zero(tmp_path): - """The default is the PRE-DW-11 walk, byte-for-byte. 0 is what a task that was - never resolved carries and what a pre-upgrade `state.json` deserializes to, so - this row is also the legacy-state contract at the reader.""" - run_dir, state, task, key = _watermarked_trail(tmp_path, [["older"], ["newer"]]) - assert task.escalations_resolved_upto == 0 - - found, withheld = resolve._gather_escalations(run_dir, state, key) - assert [e["detail"] for e in found] == ["newer", "older"] - assert withheld == 0 - - -def test_gather_escalations_hides_sessions_below_the_watermark(tmp_path): - """The defect DW-11 names. `task.sessions` is append-only and a re-arm - deliberately does not clear it, so a second resolve cycle re-presented every - escalation the story ever raised — interleaved with the new ones and with - nothing marking which was which, against a skill contract that is singular - ("present THE escalation"). - - Ablation: ignore `start` in `_gather_escalations` (route everything to `found`) - and this row fails by showing the answered entry again.""" - run_dir, state, _task, key = _watermarked_trail( - tmp_path, [["answered last cycle"], ["raised since"]] - ) - - found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) - assert [e["detail"] for e in found] == ["raised since"] - assert withheld == 1 - - -def test_gather_escalations_counts_the_entries_it_withheld(tmp_path): - """The number the operator is shown is the count of DISTINCT withheld entries, - not of sessions or of directories — and it comes from the same single walk that - produced the shown list, never a second call subtracting lengths.""" - run_dir, state, _task, key = _watermarked_trail(tmp_path, [["a", "b", "c"], ["new"]]) - - found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) - assert [e["detail"] for e in found] == ["new"] - assert withheld == 3 - - -def test_gather_escalations_does_not_count_an_entry_it_still_shows(tmp_path): - """ "Not shown" is the claim the number makes, so it must never count something - the operator can see. An escalation the re-drive re-raised appears on BOTH sides - of the watermark: it is shown once (the newest-first content map) and contributes - 0 to the count, while its answered-only sibling contributes 1. - - The sibling is the in-row positive control: an `assert withheld == 0` alone would - pass just as well if the answered directory were never read at all. - - Ablation: drop the `key not in found` clause from the count and this reddens at - 2 != 1.""" - run_dir, state, _task, key = _watermarked_trail( - tmp_path, - [["re-raised by the re-drive", "answered and gone"], ["re-raised by the re-drive"]], - ) - - found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) - assert [e["detail"] for e in found] == ["re-raised by the re-drive"] # once, not twice - assert withheld == 1 # "answered and gone" only - - -def test_gather_escalations_attributes_a_task_id_spanning_the_watermark_to_the_shown_side( - tmp_path, -): - """One `task_id` on an answered record AND an unanswered one — the shape the - pre-`generation` id namespace produced, which persisted state still carries. The - `seen_ids` guard opens that directory ONCE, at its newest occurrence, which is - the unanswered side: the entry is SHOWN. Over-showing is the conservative - direction; the alternative buries an escalation on an ambiguity. - - Ablation: walk the trail FORWARD — `for index, session in enumerate(task.sessions)` - with `target = found if index >= start else answered`, a rewrite that still reads - correct and leaves every other row in this block green except the ordering sibling - — and this reddens at `([], 1)`. The shared directory is then opened at its - ANSWERED occurrence, so the escalation is buried AND counted as already answered: - the second member is what catches that, which is why the assertion is a tuple and - not the shown list alone. MEASURED, and the recipe is specific for a reason: - deleting the `seen_ids` guard does NOT redden this row (the directory is read - twice, but the key lands in `found` first and the count's `key not in found` - clause absorbs the duplicate), so `seen_ids` is graded by its own siblings above, - not here.""" - run_dir, state, task, key = _watermarked_trail(tmp_path, [["spans the watermark"]]) - shared = task.sessions[0].task_id - task.sessions.append(SessionRecord(task_id=shared, role="dev", status="completed")) - save_state(run_dir, state) - - assert resolve._gather_escalations(run_dir, state, key, start=1) == ( - [{"type": "spec-gap", "severity": "CRITICAL", "detail": "spans the watermark"}], - 0, - ) - - -def test_gather_escalations_with_no_sessions_is_empty_and_reports_nothing(tmp_path): - run_dir, state, task, key = _watermarked_trail(tmp_path, []) - assert task.sessions == [] - assert resolve._gather_escalations(run_dir, state, key) == ([], 0) - - -def test_gather_escalations_past_the_end_of_the_trail_never_raises(tmp_path): - """A watermark beyond the list — hand-edited state, or a trail that shrank — - must yield an empty shown list, not an IndexError. `start` only SELECTS a map; - nothing is indexed with it, which is what makes that true structurally. - - The `2` is load-bearing: `== ([], 2)` proves both directories were READ and - filtered. An `== []` alone would pass equally if the walk had found nothing.""" - run_dir, state, _task, key = _watermarked_trail(tmp_path, [["first"], ["second"]]) - - assert resolve._gather_escalations(run_dir, state, key, start=9) == ([], 2) - - -def test_rearm_stamps_the_watermark_when_a_resolution_was_recorded(tmp_path): - """The stamp records how much of the audit trail the accepted resolution covered - — a LENGTH of `task.sessions`, taken before the re-drive appends anything. - - Ablation: drop the stamp from `rearm_escalation` and this reddens at 0 != 1, - taking the second-cycle rows below with it.""" - run_dir, _, _ = _escalated_run(tmp_path) - before = load_state(run_dir).tasks["6-4-cli-list-command"] - assert before.escalations_resolved_upto == 0 and len(before.sessions) == 1 - - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) - - task = load_state(run_dir).tasks["6-4-cli-list-command"] - assert task.escalations_resolved_upto == 1 - assert len(task.sessions) == 1 # the trail the watermark indexes still stands - assert task.generation == 1 # positive control: the bump ran on this gesture too - - -def test_rearm_leaves_the_watermark_where_it_was_when_nothing_was_recorded(tmp_path): - """`cmd_resolve` prints "no resolution recorded" and FALLS THROUGH to re-arm, and - both non-interactive re-arm gestures run no session at all. None of them accepted - anything, so none may advance the watermark: escalations no human answered would - otherwise become invisible to every later cycle and be reported as already - answered — the inverse of the defect. - - The generation assertion is the positive control and the discriminator: the bump - is UNCONDITIONAL (it answers session-id reuse, #705, which an abandoned attempt - needs just as much), so this row cannot pass by the re-arm having done nothing. - - Ablation: remove the `if resolution_recorded:` gate and this reddens at 1 != 0.""" - run_dir, _, _ = _escalated_run(tmp_path) - - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=False) - - task = load_state(run_dir).tasks["6-4-cli-list-command"] - assert task.escalations_resolved_upto == 0 - assert task.generation == 1 - - -def test_a_second_resolve_cycle_shows_only_what_the_redrive_raised(tmp_path): - """The whole chain with no seam hand-set: escalate, re-arm on a recorded - resolution, let the re-drive append its own session record and artifact, then - build the context a second time. `build_context` reads the watermark off the task - it loaded — nothing in this row passes `start`.""" - run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["the first cycle answered this"]]) - - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) - _redrive_escalates(run_dir, key, "raised by the re-drive") - - path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") - ctx = json.loads(path.read_text(encoding="utf-8")) - assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] - assert withheld == 1 - - -def test_a_rearm_over_a_surviving_marker_does_not_move_the_watermark(tmp_path): - """`resolution.json` SURVIVES the re-arm that consumed it: the only unlink in - `src/` is in `resolve.run_session`, which two of the three re-arm callers never - reach, and nothing deletes it at or after a re-arm. So a marker-presence gate - reads the PREVIOUS cycle's marker as this gesture's own, and a second re-arm - running no session would stamp over an escalation nobody has seen — hiding it - forever and reporting it as already answered. - - The marker is deliberately left on disk here and never removed, which is the - state a real second gesture opens on. - - Ablation: replace the `resolution_recorded` parameter with a - `resolution_path(run_dir, key).is_file()` read inside `rearm_escalation` and this - row reddens twice — the watermark advances to 2, and the context comes back - empty with the new escalation counted as withheld.""" - run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered in cycle 1"]]) - - marker = resolve.resolution_path(run_dir, key) - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text("{}", encoding="utf-8") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) - assert load_state(run_dir).tasks[key].escalations_resolved_upto == 1 - assert marker.is_file() # MEASURED: nothing deletes it at re-arm - - _redrive_escalates(run_dir, key, "raised after cycle 1", escalated=True) - - # the `--no-interactive` / TUI gesture: no session ran, so nothing was accepted - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=False) - - task = load_state(run_dir).tasks[key] - assert task.escalations_resolved_upto == 1 # NOT len(sessions) == 2 - assert task.generation == 2 # positive control: this gesture DID re-arm - path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") - ctx = json.loads(path.read_text(encoding="utf-8")) - assert [e["detail"] for e in ctx["escalations"]] == ["raised after cycle 1"] - assert withheld == 1 - - -def test_build_context_keeps_the_withheld_count_out_of_the_payload(tmp_path): - """The count is the OPERATOR's, not the agent's: `bmad-loop-resolve/SKILL.md` - documents `escalations` as the list to resolve, and a number for entries the - session cannot see is nothing it can act on. Any spelling of a leak reddens this, - because the key set is compared whole rather than probed for one name.""" - run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered"], ["new"]]) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) - _redrive_escalates(run_dir, key, "new one") - - path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") - assert withheld == 2 # the count exists... - ctx = json.loads(path.read_text(encoding="utf-8")) - assert set(ctx) == { - "story_key", - "run_id", - "spec_file", - "baseline_commit", - "paused_reason", - "escalations", - "resolution_path", - "restore_supported", - "spec_reaches_the_redrive", - "redrive_base_ref", - } # ...and reaches no field of the agent contract - - # ----------------------------------------------------------- run_session @@ -2798,7 +2460,7 @@ def interactive_env(self, spec): def test_run_session_detects_resolution(tmp_path, monkeypatch): run_dir, state, _ = _escalated_run(tmp_path) - _context(state, run_dir, "6-4-cli-list-command", isolation="") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") def fake_subprocess_run(argv, cwd, env): # simulate the agent writing the resolution marker @@ -2814,7 +2476,7 @@ def fake_subprocess_run(argv, cwd, env): def test_run_session_no_resolution(tmp_path, monkeypatch): run_dir, state, _ = _escalated_run(tmp_path) - _context(state, run_dir, "6-4-cli-list-command", isolation="") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") monkeypatch.setattr(resolve.subprocess, "run", lambda *a, **k: None) assert ( resolve.run_session( @@ -2828,7 +2490,7 @@ def test_run_session_clears_stale_marker(tmp_path, monkeypatch): """A marker left by a previous resolve of this story must not be read as this session's output (the agent that says 'already resolved' writes none).""" run_dir, state, _ = _escalated_run(tmp_path) - _context(state, run_dir, "6-4-cli-list-command", isolation="") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") stale = resolve.resolution_path(run_dir, "6-4-cli-list-command") stale.parent.mkdir(parents=True, exist_ok=True) stale.write_text('{"from": "last time"}', encoding="utf-8") @@ -2942,7 +2604,9 @@ def test_build_context_stories_carries_manifest_entry(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md", source="stories") state.spec_folder = "epic-1" - ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + ctx = json.loads( + resolve.build_context(state, run_dir, key, isolation="").read_text(encoding="utf-8") + ) st = ctx["stories"] assert st["spec_folder"] == "epic-1" assert st["story"]["title"] == "List command" @@ -2966,7 +2630,9 @@ def test_build_context_stories_sentinel_indicator(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") state.spec_folder = "epic-1" - ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + ctx = json.loads( + resolve.build_context(state, run_dir, key, isolation="").read_text(encoding="utf-8") + ) sent = ctx["stories"]["sentinel"] assert sent["kind"] == "unresolved" assert "intent too vague" in sent["blocking_condition"] @@ -2976,7 +2642,9 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): """Sprint mode leaves the context contract unchanged — no stories block.""" run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") # sprint source ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert "stories" not in ctx @@ -2998,9 +2666,9 @@ def test_build_context_leaves_an_out_of_mount_spec_unchanged(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(spec), worktree_path=str(wt)) ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( - encoding="utf-8" - ) + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) assert ctx["spec_file"] == spec.as_posix() @@ -3041,7 +2709,7 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): state.spec_folder = "epic-1" ctx = json.loads( - _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == (wt / rel).as_posix() sent = ctx["stories"]["sentinel"] @@ -3090,7 +2758,7 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec state.spec_folder = "epic-1" ctx = json.loads( - _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == outside.as_posix() # unchanged: absolute passes through sent = ctx["stories"]["sentinel"] @@ -3112,9 +2780,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): wt = tmp_path / ".bmad-loop" / "runs" / "20260613-111429-6a14" / "worktrees" / "1" run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( - encoding="utf-8" - ) + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) assert ctx["spec_reaches_the_redrive"] is False @@ -3122,9 +2790,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): tmp_path, "20260613-111429-6a15", spec_file=str(tmp_path / "specs" / "6-4.md") ) plain = json.loads( - _context(plain_state, plain_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + resolve.build_context( + plain_state, plain_dir, "6-4-cli-list-command", isolation="" + ).read_text(encoding="utf-8") ) assert plain["spec_reaches_the_redrive"] is True @@ -3147,9 +2815,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) state.target_branch = "feat/the-pinned-one" ctx = json.loads( - _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( - encoding="utf-8" - ) + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) # the paired claim: the edit has no future, and THIS is the tree that does assert ctx["spec_reaches_the_redrive"] is False @@ -3161,9 +2829,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat ) plain_state.target_branch = "feat/the-pinned-one" # set, but no mount to make it apply plain = json.loads( - _context(plain_state, plain_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + resolve.build_context( + plain_state, plain_dir, "6-4-cli-list-command", isolation="" + ).read_text(encoding="utf-8") ) assert plain["redrive_base_ref"] == "HEAD" @@ -3210,7 +2878,7 @@ def test_rearm_warns_about_an_unreachable_spec_write_only_when_it_is_actionable( run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) unreachable = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(unreachable) is warns @@ -3282,7 +2950,7 @@ def _commit(status, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) unreachable = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(unreachable) is warns @@ -3341,7 +3009,7 @@ def _sentinel_run( sentinel = folder / f"{key}-unresolved.md" sentinel.write_text( - "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\n" "Status: blocked\nintent too vague\n", encoding="utf-8", ) mount = tmp_path / "wt" @@ -3405,7 +3073,7 @@ def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redriv ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=isolated, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=isolated) assert not sentinel.exists() # the sentinel really was cleared on every row records = _upstream_records(run_dir) @@ -3494,7 +3162,7 @@ def _commit(intent, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) records = _upstream_records(run_dir) assert bool(records) is warns @@ -3529,7 +3197,7 @@ def test_rearm_exempts_a_stories_folder_configured_outside_the_project( ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) assert bool(_upstream_records(run_dir)) is not external @@ -3571,9 +3239,7 @@ def test_rearm_of_a_sentinel_survives_a_project_that_is_not_a_repository(tmp_pat ) monkeypatch.chdir(tmp_path) - assert ( - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) == key - ) # no GitError + assert runs.rearm_escalation(run_dir, isolated_redrive=True) == key # no GitError assert not sentinel.exists() # the destructive half still completed (rec,) = _upstream_records(run_dir) @@ -3625,7 +3291,7 @@ def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_pat monkeypatch.chdir(tmp_path) # the flip: policy now says `none`, while the recorded mount still says otherwise - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["redrive"] == "in-place" @@ -3682,7 +3348,7 @@ def test_rearm_in_place_proof_reads_the_working_tree_not_the_commit(tmp_path, mo root, spec_file=rel, worktree_path=str(mount), target_branch="main" ) monkeypatch.chdir(root) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) fired = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(fired) is warns, f"corrected={corrected}" @@ -3743,7 +3409,7 @@ def test_rearm_base_ref_degrades_to_head_for_a_run_that_pinned_no_target(tmp_pat run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -3796,7 +3462,7 @@ def test_rearm_does_not_refuse_a_flip_the_redrive_never_reads( monkeypatch.chdir(tmp_path) runs.rearm_escalation( - run_dir, isolated_redrive=True, resolution_recorded=True + run_dir, isolated_redrive=True ) # must not raise: this flip cannot reach the re-drive kinds = _kinds(run_dir) @@ -3829,7 +3495,7 @@ def test_rearm_suppresses_the_unreachable_warning_only_on_proof(tmp_path, monkey run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) kinds = _kinds(run_dir) (unreachable,) = [e for e in kinds if e["kind"] == "rearm-spec-write-unreachable"] @@ -3871,7 +3537,7 @@ def test_rearm_does_not_warn_when_the_spec_dir_is_shared_with_the_redrive(tmp_pa ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] # and the flip really landed on the shared file the re-drive will read @@ -3915,7 +3581,7 @@ def test_rearm_still_warns_for_a_spec_spelled_out_of_but_resolving_into_the_work run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spelled), worktree_path=str(wt)) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) (unreachable,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert unreachable["status"] == "ready-for-dev" @@ -3954,7 +3620,7 @@ def _refuse(self, *a, **kw): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True) (unreachable,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert unreachable["status"] == "ready-for-dev" @@ -3992,7 +3658,7 @@ def test_rearm_writes_the_project_rooted_spec_when_no_worktree_was_recorded(tmp_ run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel) # worktree_path="" -> the fallback monkeypatch.chdir(tmp_path / "elsewhere") - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) fm = verify.read_frontmatter(spec) assert fm["status"] == "ready-for-dev" # the project-rooted copy was flipped diff --git a/tests/test_runs.py b/tests/test_runs.py index b041b403..6eaf397d 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2791,12 +2791,7 @@ def test_rearm_restore_mode_sets_in_review_strips_arr_and_latches(tmp_path): from bmad_loop.model import Phase run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) - runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, - ) + runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING and task.attempt == 0 @@ -2814,9 +2809,7 @@ def test_rearm_plain_mode_sets_ready_for_dev_and_clears_stale_latch(tmp_path): # a stale latch from a prior restore attempt the human then chose to redo fresh run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, restore_patch_stale="old.patch") - runs.rearm_escalation( - run_dir, isolated_redrive=False, resolution_recorded=True - ) # no restore_patch => from-scratch + runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore_patch => from-scratch task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -2847,10 +2840,7 @@ def test_rearm_aborts_when_the_spec_status_cannot_be_reopened(tmp_path): with pytest.raises(runs.RearmError, match="re-open story spec"): runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False ) assert spec.read_text(encoding="utf-8") == spec_text # byte-identical @@ -2870,7 +2860,7 @@ def test_rearm_resets_followup_reviews_spent(tmp_path): state.tasks["1-1-a"].review_cycle = 2 save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.followup_reviews_spent == 0 @@ -2915,9 +2905,7 @@ def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): story's commit. The resolve session's own untracked file still is.""" run_dir, _spec, patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation( - run_dir, isolated_redrive=False, resolution_recorded=True - ) # from-scratch re-arm replaces the latch + runs.rearm_escalation(run_dir, isolated_redrive=False) # from-scratch re-arm replaces the latch task = load_state(run_dir).tasks["1-1-a"] assert "human.txt" in task.baseline_untracked @@ -2934,12 +2922,7 @@ def test_rearm_re_latching_the_same_patch_still_excludes_its_residue(tmp_path): still residue (and `git apply` would otherwise fail with 'already exists').""" run_dir, _spec, _patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, - ) + runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.restore_patch == "artifacts/attempt.patch" @@ -2957,9 +2940,7 @@ def test_rearm_missing_stale_patch_degrades_loudly_without_raising(tmp_path): git(tmp_path, "add", "committed.txt") git(tmp_path, "commit", "-q", "-m", "attempt commit") - runs.rearm_escalation( - run_dir, isolated_redrive=False, resolution_recorded=True - ) # must not raise RearmError + runs.rearm_escalation(run_dir, isolated_redrive=False) # must not raise RearmError task = load_state(run_dir).tasks["1-1-a"] assert {"human.txt", "newfile.txt"} <= set(task.baseline_untracked) # full snapshot @@ -2975,12 +2956,7 @@ def test_rearm_without_a_stale_latch_journals_no_stale_restore_events(tmp_path): run_dir, _spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, git_project=True) (tmp_path / "human.txt").write_text("from the resolve session\n") - runs.rearm_escalation( - run_dir, - restore_patch="artifacts/attempt.patch", - isolated_redrive=False, - resolution_recorded=True, - ) + runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) assert "human.txt" in load_state(run_dir).tasks["1-1-a"].baseline_untracked assert _kinds(run_dir) == [] @@ -2996,7 +2972,7 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "attempt commit") old_baseline = load_state(run_dir).tasks["1-1-a"].baseline_commit - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.baseline_commit != old_baseline # baseline advanced past the commit @@ -3022,7 +2998,7 @@ def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_p task.baseline_commit = "0" * 39 + "1" # sha-shaped, but names no object save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3053,7 +3029,7 @@ def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): with pytest.raises(verify.GitError): verify.commits_above(tmp_path, task.baseline_commit) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3078,7 +3054,7 @@ def boom(repo, baseline): monkeypatch.setattr(runs.verify, "commits_above", boom) with pytest.raises(MemoryError, match="not a git answer"): - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=False) def test_archive_run(tmp_path): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 63eac7f0..664a329d 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1365,7 +1365,7 @@ def test_blocked_resolve_rearm_then_redispatch_to_done(project): assert not any(s.role == "dev" for s in adapter.sessions) # story 2 not leapfrogged # human fixed the frozen spec → re-arm (must run while still escalation-paused) - runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False) assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev" # resume re-drives the re-armed story, then continues the schedule to story 2 @@ -1406,7 +1406,7 @@ def test_resolved_wedge_is_still_gated_on_redispatch(project): assert wedged.phase == Phase.ESCALATED and wedged.attempt == 0 and not wedged.sessions runs.rearm_escalation( - engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True + engine.run_dir, "1", isolated_redrive=False ) # human fixed the frozen spec assert load_state(engine.run_dir).tasks["1"].rearmed # ...and the re-drive is armed # a gate on story 1 lands while the run is down @@ -1441,7 +1441,7 @@ def test_sentinel_rearm_deletes_by_recorded_verdict_e2e(project): assert engine.run().paused assert load_state(engine.run_dir).tasks["1"].sentinel_kind == "unresolved" # recorded - runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True) + runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False) assert not sentinel.exists() # cleared by the recorded verdict assert (engine.run_dir / "sentinels" / "1-unresolved.md").is_file() # copy preserved reloaded = load_state(engine.run_dir) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 93cf106c..e1597e85 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -3467,11 +3467,7 @@ def test_sweep_bundle_restore_redrive_reaches_done_and_clears_latch(project, mon patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, - "dw-fix", - restore_patch=str(patch), - isolated_redrive=False, - resolution_recorded=True, + engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False ) resumed, adapter = resume_sweep( @@ -3512,11 +3508,7 @@ def test_sweep_restore_redrive_exhaustion_pauses_not_defers(project, monkeypatch patch.parent.mkdir(parents=True, exist_ok=True) patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, - "dw-fix", - restore_patch=str(patch), - isolated_redrive=False, - resolution_recorded=True, + engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False ) resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) @@ -3538,7 +3530,7 @@ def test_sweep_from_scratch_redrive_exhaustion_pauses_not_defers(project): ) engine = _run_to_dev_escalation(project, policy=policy) runs.rearm_escalation( - engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + engine.run_dir, "dw-fix", isolated_redrive=False ) # from-scratch, no restore resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) @@ -4686,9 +4678,7 @@ def test_rearmed_bundle_redrives_when_triage_json_lost(project): # cached triage plan reloaded and re-emitted its name. Recovery now keys on # the persisted task, so losing the cache changes nothing. engine = _run_to_dev_escalation(project) - runs.rearm_escalation( - engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True - ) + runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) _lose_triage(engine.run_dir) resumed, adapter = resume_sweep(project, engine, _redrive_script(project)) @@ -4710,9 +4700,7 @@ def test_fresh_triage_different_bundle_name_no_double_drive(project, corruption) # would orphan the re-armed one. It must re-drive by identity, and its ids # must have left the open set before the fresh triage sees them. engine = _run_two_bundle_dev_escalation(project) - runs.rearm_escalation( - engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True - ) + runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) _lose_triage(engine.run_dir, corruption) fresh = triage_result( @@ -4750,11 +4738,7 @@ def test_restore_patch_latch_honored_when_triage_json_lost(project, monkeypatch) patch.parent.mkdir(parents=True, exist_ok=True) patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, - "dw-fix", - restore_patch=str(patch), - isolated_redrive=False, - resolution_recorded=True, + engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False ) _lose_triage(engine.run_dir) @@ -4879,9 +4863,7 @@ def test_regenerated_intent_when_bundle_file_missing(project): # The triage session's authored prose is the one unrecoverable piece; the # verbatim ledger entries are re-attached and become the contract. engine = _run_to_dev_escalation(project) - runs.rearm_escalation( - engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True - ) + runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) _lose_triage(engine.run_dir) intent = Path(engine.state.tasks["dw-fix"].bundle_file) intent.unlink() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 3d0754bc..7bfbbc44 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4550,67 +4550,6 @@ async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypat await until(pilot, lambda: rearms == ["1"] and calls == ["20260611-100000-aaaa"]) -async def test_tui_rearm_does_not_move_the_escalation_watermark(project, monkeypatch): - """DW-11, on the one re-arm surface a stale `resolution.json` actively invites. - - Every other TUI row here monkeypatches `runs.rearm_escalation` away, so none can - observe what it stamps — this one lets the REAL function run. The marker on disk is - the shape that matters: `resolve.run_session` is the only thing in `src/` that - unlinks it and this gesture never calls it, so the marker survived the CLI cycle - that consumed it, and `resolution_ready` (the sole enabler of this button) still - reads True. `_do_rearm` therefore has to declare `resolution_recorded=False` from - what it KNOWS — it ran no session — rather than from what is on disk, which is - exactly the verdict `_restore_recorded` already records for this surface. - - The watermark is seeded to 1 over a two-record trail so "did not move" is - distinguishable from "was never set"; `generation` is the positive control that the - re-arm really ran. - - Ablation: pass `resolution_recorded=True` from `_do_rearm` (or gate the stamp on - `resolution_path(...).is_file()` inside `rearm_escalation`) and this reddens at - 2 != 1.""" - from bmad_loop import resolve - from bmad_loop.engine import _session_task_id - from bmad_loop.journal import load_state - - monkeypatch.setattr(launch, "mux_available", lambda: True) - monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: None) - monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - run_dir, _spec = _stories_paused_run( - project.project, - stage="escalation", - spec_status="blocked", - spec_checkpoint=False, - blocked_result="Blocked: needs a human decision on the auth scheme.", - ) - state = load_state(run_dir) - task = state.tasks["1"] - task.phase = Phase.ESCALATED - task.sessions.clear() - for seq in (1, 2): - task.record_session( - SessionRecord( - task_id=_session_task_id("1", "review", seq, 0), role="dev", status="completed" - ) - ) - task.escalations_resolved_upto = 1 # an earlier CLI cycle answered the first record - save_state(run_dir, state) - # the marker that cycle's agent wrote — nothing deleted it at its re-arm - marker = resolve.resolution_path(run_dir, "1") - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text("{}", encoding="utf-8") - - app = BmadLoopApp(project.project) - async with app.run_test() as pilot: - await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) - app._do_rearm("20260611-100000-aaaa", run_dir, "1") - await pilot.pause() - - rearmed = load_state(run_dir).tasks["1"] - assert rearmed.escalations_resolved_upto == 1 # NOT len(sessions) == 2 - assert rearmed.generation == 1 # positive control: the re-arm ran - - async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch): """The mode `runs.rearm_escalation` needs comes from policy.toml, read HERE. @@ -4637,8 +4576,7 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch.setattr( runs, "rearm_escalation", - lambda rd, sk, *, isolated_redrive, resolution_recorded: seen.append(isolated_redrive) - or "ready-for-dev", + lambda rd, sk, *, isolated_redrive: seen.append(isolated_redrive) or "ready-for-dev", ) run_dir, _spec = _stories_paused_run( project.project, @@ -4808,7 +4746,7 @@ async def test_escalation_rearm_surfaces_a_failed_baseline_advance(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False): Journal(rd).append( "rearm-baseline-advance-failed", story_key=sk, @@ -4868,7 +4806,7 @@ async def test_escalation_rearm_aims_the_code_root_before_it_rearms(project, mon monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") seen: list = [] - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False): seen.append(load_state(rd).code_root) return "ready-for-dev" @@ -5009,7 +4947,7 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False): journal = Journal(rd) journal.append( "stale-restore-commits", @@ -5104,7 +5042,7 @@ async def test_escalation_rearm_holds_the_resume_it_folds_in(project, monkeypatc monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False): Journal(rd).append( "rearm-spec-write-unreachable", story_key=sk, @@ -5176,7 +5114,7 @@ async def test_escalation_rearm_echoes_residue_when_the_rearm_aborts(project, mo monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False): # exactly the real ordering: residue journalled, THEN the abort Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] @@ -5237,7 +5175,7 @@ async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False): Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] ) From 79aac6ef5ad47cdc1d4d472b4f7df12233debf6c Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 18:19:49 -0700 Subject: [PATCH 12/45] sweep dw2-escalation-watermark: DW-11 via bmad-loop --- CHANGELOG.md | 3 + docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 33 +- src/bmad_loop/diagnostics.py | 19 +- src/bmad_loop/model.py | 15 + src/bmad_loop/resolve.py | 64 +++- src/bmad_loop/runs.py | 36 +- src/bmad_loop/tui/app.py | 14 +- tests/test_cli.py | 334 +++++++++++++++++- tests/test_diagnostics.py | 39 ++- tests/test_engine.py | 44 ++- tests/test_engine_worktree.py | 14 +- tests/test_model.py | 16 + tests/test_resolve.py | 630 +++++++++++++++++++++++++++------- tests/test_runs.py | 48 ++- tests/test_stories_engine.py | 6 +- tests/test_sweep.py | 32 +- tests/test_tui_app.py | 76 +++- 18 files changed, 1201 insertions(+), 224 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cb01cd..54f00234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,9 @@ breaking changes may land in a minor release. ### Fixed +- Stop a second resolve cycle re-presenting escalations the human already answered + (DW-11). Only a re-arm that accepted a `resolution.json` watermarks the session + trail; later cycles show what came after it and print how many were withheld. - Emit `diagnose --json` v2, replacing journal `patch` / `stashed_to` paths with `patch_present` / `stashed_to_present`, and silently degrade Git stale-commit probe failures while propagating non-Git faults. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 50bc2cb3..8fa8f714 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -68,7 +68,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. -- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. +- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second `bmad-loop resolve` used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a `resolution.json` now **watermarks** the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's `context.json` (the agent-facing contract is unchanged). The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, `resolve --no-interactive`, and the TUI's Re-arm button all leave it where it stands. Leaving a watermark is not clearing it: a watermark already standing still filters on those paths, which show everything recorded since the last accepted resolution rather than the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the `esc-upto` column of `bmad-loop diagnose`'s markdown task table, and as `escalations_resolved_upto` under `--json` (that is the key to grep in a support bundle), which is what explains a short `context.json` on a bug report. - A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most `max_dev_attempts` per story) and has no suppression knob of its own; it follows `[notify]` like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is reduced to its first line and capped, with a `[…]` marker when it was trimmed, because a `Decision.reason` routinely carries a verify-output tail that would otherwise spill into `ATTENTION` and a desktop bubble; the untruncated reason stays in the `dev-decision` journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices. - Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (`sh` reports rc `126`/`127`; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time `OSError` counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507); `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: `has_session` maps every nonzero backend result to False, so a negative lookup is "the backend did not confirm it" rather than proof the session is gone — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ef81a5a1..6a8a285a 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3098,10 +3098,21 @@ def cmd_resolve(args: argparse.Namespace) -> int: print(err, file=sys.stderr) return 1 + # DW-11: whether THIS gesture accepted a resolution, which is what gates the + # `escalations_resolved_upto` watermark in `runs.rearm_escalation`. False here + # covers `--no-interactive` deliberately: that path accepted nothing IN THIS + # GESTURE (the human may have fixed the spec by hand, but nothing recorded which + # escalations that answered), so the next cycle shows everything — today's + # behavior, and the safe direction. Not derived from `resolution.json`: the marker + # survives the re-arm that consumed it, so its presence says nothing about this + # gesture. + resolution_recorded = False if args.interactive: adapters = _make_adapters(project, run_dir, pol) model = pol.adapter.resolved("dev").model - resolve.build_context(state, run_dir, story_key, isolation=pol.scm.isolation) + _ctx_path, withheld = resolve.build_context( + state, run_dir, story_key, isolation=pol.scm.isolation + ) print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…") try: produced = resolve.run_session( @@ -3123,6 +3134,25 @@ def cmd_resolve(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 + resolution_recorded = bool(produced) + # DW-11. Reported to the operator, never into `context.json`: filtering the + # agent's list silently would trade one misleading surface for another — the + # human would have no way to tell "nothing else was ever raised" from "the rest + # is hidden". Worded for what the code can prove: these entries were PRESENTED + # to an earlier resolve cycle that recorded a resolution — not that any + # particular one of them was individually answered. + # + # Printed here rather than beside the context build, because until + # `run_session` returns without `NotImplementedError` this adapter is not known + # to support an interactive session at all — and an operator whose command is + # about to fail must not be told escalations were withheld from an agent that + # never launched. + if withheld: + print( + f"{withheld} earlier escalation(s) for {story_key} were not shown to the " + "agent: they were presented to an earlier resolve cycle that recorded a " + "resolution" + ) if not produced: print( f"no resolution recorded for {story_key} (agent did not write resolution.json)", @@ -3227,6 +3257,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: story_key, restore_patch=restore_patch, isolated_redrive=pol.scm.isolation == "worktree", + resolution_recorded=resolution_recorded, ) except runs.RearmError as e: print(f"error: {e}", file=sys.stderr) diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index cb7ac2fd..9e56549f 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -349,6 +349,11 @@ class TaskDiag: # dumps as `rearmed=True, attempt=1, n_sessions=2` — byte-identical to a HEALTHY # post-re-arm task. A counter, so it carries no customer content. generation: int + # DW-11's watermark: how far into the append-only `sessions` list an accepted + # resolution reached. Without it a support bundle cannot explain a SHORT + # `context.json` — a story whose older escalations are filtered out dumps + # identically to one that only ever raised the entries shown. A counter too. + escalations_resolved_upto: int dw_count: int n_sessions: int sessions: SessionTally @@ -630,6 +635,7 @@ def _task_diag(task: StoryTask, pseudo: sanitize.Pseudonymizer, weight: float) - spec_present=bool(task.spec_file), worktree_isolated=bool(task.worktree_path), generation=task.generation, + escalations_resolved_upto=task.escalations_resolved_upto, dw_count=len(task.dw_ids), n_sessions=len(task.sessions), sessions=_session_tally([task]), @@ -1045,15 +1051,20 @@ def render_markdown( # `gen` rides beside `att` because the pair is the discriminator: a # #705-class replay and a healthy post-re-arm task agree on every other # column here, so dropping it from the human report leaves the one field - # that separates them visible only under `--json`. + # that separates them visible only under `--json`. `esc-upto` rides beside + # `gen` on that same rule: DW-11's watermark is the only field separating + # "this story raised one escalation" from "its earlier ones are filtered + # out as already answered", and a short `context.json` is read off exactly + # this report. out.append( - "| alias | epic | phase | att | gen | rev | committed | spec | dw | sessions " - "| weighted | raw |" + "| alias | epic | phase | att | gen | esc-upto | rev | committed | spec | dw " + "| sessions | weighted | raw |" ) - out.append("|---|---|---|---|---|---|---|---|---|---|---|---|") + out.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|") for t in r.tasks: out.append( f"| `{t.alias}` | {t.epic} | {t.phase} | {t.attempt} | {t.generation} " + f"| {t.escalations_resolved_upto} " f"| {t.review_cycle} | {t.committed} | {t.spec_present} | {t.dw_count} " f"| {t.n_sessions} | {t.tokens.get('weighted', 0)} " f"| {t.tokens.get('total', 0)} |" diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 54923d36..d6b17f56 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -213,6 +213,19 @@ class StoryTask: # is deliberately NOT cleared when a task is reopened: the run-dir audit trail # it indexes is read by a later resolve cycle. generation: int = 0 + # How much of the append-only `sessions` list an accepted escalation resolution + # already covered: a LENGTH, i.e. an index INTO `task.sessions`, not a count of + # escalations and not a generation number. `resolve._gather_escalations` shows only + # the escalations recorded by sessions at or after this position, so a second + # resolve cycle does not re-present entries the human already disambiguated + # (DW-11). Stamped in `runs.rearm_escalation`, and only when its caller passes + # `resolution_recorded=True` — a re-arm that accepted nothing must not advance it, + # or escalations nobody answered become invisible forever. `record_session` is the + # sole mutation of `sessions` in `src/`, and a re-arm deliberately does NOT clear + # the list, which is what makes a length stable across cycles. 0 = nothing answered + # yet, which is also what a pre-upgrade `state.json` deserializes to (unfiltered, + # the pre-DW-11 behavior). + escalations_resolved_upto: int = 0 # set from the bmad-build-auto session's `followup_review_recommended` # frontmatter (PR #2505): when True and review.trigger = "recommended", the # orchestrator runs a follow-up review pass (bmad-build-auto re-invoked on the @@ -430,6 +443,7 @@ def to_dict(self) -> dict[str, Any]: "review_cycle": self.review_cycle, "followup_reviews_spent": self.followup_reviews_spent, "generation": self.generation, + "escalations_resolved_upto": self.escalations_resolved_upto, "followup_review_recommended": self.followup_review_recommended, "baseline_commit": self.baseline_commit, "baseline_untracked": self.baseline_untracked, @@ -598,6 +612,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": review_cycle=int(d.get("review_cycle", 0)), followup_reviews_spent=int(d.get("followup_reviews_spent", 0)), generation=int(d.get("generation", 0)), + escalations_resolved_upto=int(d.get("escalations_resolved_upto", 0)), followup_review_recommended=bool(d.get("followup_review_recommended", False)), baseline_commit=d.get("baseline_commit"), baseline_untracked=( diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 8a986b08..a734431c 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -77,9 +77,21 @@ def read_resolution(run_dir: Path, story_key: str) -> dict[str, Any] | None: return doc -def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[dict[str, Any]]: +def _gather_escalations( + run_dir: Path, state: RunState, story_key: str, *, start: int = 0 +) -> tuple[list[dict[str, Any]], int]: """The CRITICAL escalations recorded by this story's sessions, newest first, - each DISTINCT escalation exactly once. + each DISTINCT escalation exactly once, paired with how many DISTINCT entries + were withheld as already answered. + + ``start`` is ``task.escalations_resolved_upto`` — a position in the append-only + ``task.sessions`` list, stamped by ``runs.rearm_escalation`` when a resolve cycle + recorded a resolution (DW-11). Records BELOW it were already put to the human and + answered, so their escalations are not shown again; the count of those the human + can no longer see is returned for the operator, never written into + ``context.json`` (the agent-facing contract is the unanswered set alone). The + default 0 reproduces the pre-DW-11 walk byte-for-byte, which is what a + pre-upgrade ``state.json`` deserializes to. Reads each session's tasks//result.json (and escalation.json) — the same files the engine inspected when it decided to pause. Ordering is @@ -118,16 +130,31 @@ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[ ``critical_escalations`` iterates ``escalations`` with no list guard of its own, so a ``{"escalations": null}`` artifact would raise ``TypeError`` here. The guard belongs in this caller; the shared predicate stays the - single definition of CRITICAL.""" + single definition of CRITICAL. + + The watermark is a FOURTH concern layered onto that same single walk, not a + second pass: ``reversed(task.sessions)`` reaches the unanswered tail first, so + entries are routed into two content-keyed maps by the record's own index and the + suppressed count is the answered keys that never appeared in the shown map. Two + consequences are deliberate. An entry raised on BOTH sides of the watermark is + shown and counted 0 — "not shown" is the claim the number makes, so it must never + count something the operator can see. And ``start`` only SELECTS a map; nothing is + indexed with it, so a watermark past the end of the list yields an empty shown + list rather than an IndexError. A ``task_id`` repeated across the watermark is + opened once by ``seen_ids``, at its newest occurrence — the shown side, the + conservative direction.""" task = state.tasks.get(story_key) if task is None: - return [] + return [], 0 seen_ids: set[str] = set() found: dict[str, dict[str, Any]] = {} - for session in reversed(task.sessions): + answered: dict[str, dict[str, Any]] = {} + last = len(task.sessions) - 1 + for offset, session in enumerate(reversed(task.sessions)): if session.task_id in seen_ids: continue seen_ids.add(session.task_id) + target = found if last - offset >= start else answered task_dir = run_dir / "tasks" / session.task_id for fname in ("result.json", "escalation.json"): fpath = task_dir / fname @@ -143,12 +170,21 @@ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[ except (OSError, ValueError, RecursionError): continue for key, esc in artifact_entries.items(): - found.setdefault(key, esc) - return list(found.values()) + target.setdefault(key, esc) + return list(found.values()), sum(1 for key in answered if key not in found) -def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: str) -> Path: - """Write resolve//context.json for the resolve skill to read. +def build_context( + state: RunState, run_dir: Path, story_key: str, *, isolation: str +) -> tuple[Path, int]: + """Write resolve//context.json for the resolve skill to read, and + return it beside the number of already-answered escalations withheld from it. + + The count is for the OPERATOR's terminal (`cli.cmd_resolve` prints it) and is + deliberately not a `context.json` field: the skill's contract is singular — resolve + the escalation you are shown — and a count of things the agent cannot see is not + something it can act on. It comes from the same single walk that produced the shown + list, never from a second `_gather_escalations` call subtracting lengths. `isolation` is the LIVE policy's `scm.isolation`, and it is required rather than defaulted for the reason this surface exists at all: three of the fields below — @@ -177,6 +213,12 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # the main checkout while `stories_engine._stories_folder` was still the mount, so # one `context.json` could name two trees. stories_root = task_stories_root(task, state) + # DW-11: hide what an earlier resolve cycle already answered. `start` is the task's + # own watermark — 0 for a task never resolved, and for every pre-upgrade + # `state.json`, which is the unfiltered pre-DW-11 walk. + escalations, withheld = _gather_escalations( + run_dir, state, story_key, start=task.escalations_resolved_upto if task else 0 + ) context = { "story_key": story_key, "run_id": state.run_id, @@ -197,7 +239,7 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: "spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None), "baseline_commit": task.baseline_commit if task else None, "paused_reason": state.paused_reason, - "escalations": _gather_escalations(run_dir, state, story_key), + "escalations": escalations, # as_posix so the context contract is the same string on every OS (the # path is consumed by the agent, and Python/tools accept '/' on Windows). "resolution_path": resolution_path(run_dir, story_key).as_posix(), @@ -250,7 +292,7 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(context, indent=2), encoding="utf-8") - return path + return path, withheld def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 21b251ae..fa6df27f 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3657,6 +3657,7 @@ def rearm_escalation( *, restore_patch: str | None = None, isolated_redrive: bool, + resolution_recorded: bool, ) -> str: """Re-arm an escalation-paused story so the next resume re-drives it. @@ -3680,7 +3681,10 @@ def rearm_escalation( otherwise let the re-drive re-mint a session id byte-equal to one the abandoned attempt already recorded (#705). `task.sessions` is deliberately NOT cleared — a second resolve cycle reads that run-dir audit trail — so - the id is what has to change. + the id is what has to change. That preserved trail is also what + `resolution_recorded` watermarks: keeping it whole is what lets a later + cycle tell the answered prefix from the unanswered tail, instead of + choosing between re-presenting everything and losing the audit (DW-11). - The spec's `baseline_revision` is re-stamped on BOTH legs, and only when the advance above actually RAN — `advanced` records that both git reads succeeded, not that HEAD changed, so a resolve session that committed nothing still @@ -3723,6 +3727,25 @@ def rearm_escalation( defect this parameter exists to close. Both callers (`cli.cmd_resolve`, `tui.TuiApp._do_rearm`) hold a loaded policy already. + `resolution_recorded` says whether THIS gesture accepted a resolution, and it + alone gates the `escalations_resolved_upto` watermark (DW-11): the next resolve + cycle hides every escalation recorded below it, so advancing it over entries no + human answered would bury them forever and report them as already answered — the + inverse of the defect the watermark exists to fix. Keyword-only and REQUIRED for + the same reason as `isolated_redrive`: a default would be wrong in silence on + exactly the path that matters. It is a PARAMETER rather than a disk read because + the fact is not on disk. `resolution.json` is unlinked at one site in `src/` + (`resolve.run_session`, before it launches), which only `cli.cmd_resolve`'s + interactive arm reaches, and nothing deletes the marker at or after a re-arm — so + the marker survives the re-arm that consumed it, and `resolve --no-interactive` or + the TUI's Re-arm button would read the PREVIOUS cycle's marker as its own. The + caller already holds the answer: `cmd_resolve` binds it from `resolve.run_session`, + and both non-interactive callers know by construction that no session ran. Do not + unlink the marker here either — the TUI's Re-arm button is gated on its presence. + + The generation bump stays UNCONDITIONAL beside the gated stamp: it answers session-id + reuse (#705), which an abandoned attempt needs exactly as much as a resolved one. + Returns the re-armed story key. Raises RearmError when the run is not paused at the escalation stage, the target story is not escalated, or a supplied `restore_patch` fails `validate_restore_latch` (the shared precondition set — @@ -3769,6 +3792,17 @@ def rearm_escalation( # replay the abandoned verdict for the fresh attempt (#705). Bumped BEFORE any # dispatch, so the id is unique from the re-drive's first session onward. task.generation += 1 + # DW-11. How much of the preserved audit trail this resolution covered, so the next + # `resolve` shows the human only what they have not already answered. Gated on the + # CALLER's answer, never on `resolution.json`: the marker survives the re-arm that + # consumed it (only `resolve.run_session` unlinks it, and two of the three callers + # never run one), so reading it here would let a later marker-less gesture stamp + # over escalations nobody saw. A length, taken BEFORE the re-drive appends anything + # — `record_session` is the sole mutation of this list — and left where it stands + # when nothing was accepted, which reproduces the pre-DW-11 behavior for that + # gesture: everything shown, nothing reported withheld. + if resolution_recorded: + task.escalations_resolved_upto = len(task.sessions) task.review_cycle = 0 task.followup_reviews_spent = 0 # human-resolved re-drive gets a fresh damping budget task.defer_reason = None diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index b64cc1ff..6c96f528 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -969,7 +969,19 @@ def _do_rearm( before_entries = runs.journal_entries_or_none(run_dir) hold_resume = False try: - runs.rearm_escalation(run_dir, story_key, isolated_redrive=isolation == "worktree") + runs.rearm_escalation( + run_dir, + story_key, + isolated_redrive=isolation == "worktree", + # DW-11. This gesture runs no resolve session, so it accepted nothing: + # the escalation watermark must not advance. A `resolution.json` on + # disk is NOT evidence to the contrary here — `_restore_recorded` + # already records the governing fact for this surface, that a stale + # marker is indistinguishable from a fresh one, which is why this path + # declines the restore latch too. Stamping on its presence would bury + # escalations raised since the marker was written. + resolution_recorded=False, + ) except RearmError as e: self.notify(f"re-arm failed: {e}", severity="error") return diff --git a/tests/test_cli.py b/tests/test_cli.py index a4e4779c..e7acd64b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2624,7 +2624,9 @@ def test_resolve_restamps_the_code_root_before_it_rearms(project, monkeypatch, c run_dir, moved, _ = _resolve_run_with_a_moved_code_root(project, monkeypatch) seen: list = [] - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): seen.append(load_state(rd).code_root) return key @@ -2748,7 +2750,9 @@ def test_resolve_echoes_this_rearms_stale_restore_events(tmp_path, monkeypatch, run_dir = _escalated_run(tmp_path, "r1") Journal(run_dir).append("stale-restore-excluded", story_key="s1", files=["FROM-LAST-TIME.txt"]) - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): journal = Journal(rd) journal.append("stale-restore-excluded", story_key=key, patch="a.patch", files=["new.txt"]) journal.append("stale-restore-unparseable", story_key=key, patch="b.patch", error="OSErr") @@ -2788,7 +2792,9 @@ def test_resolve_echoes_the_rearm_baseline_records(tmp_path, monkeypatch, capsys _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): journal = Journal(rd) journal.append( "rearm-baseline-advance-failed", @@ -2839,7 +2845,9 @@ def test_resolve_restamp_echo_warns_on_both_legs(tmp_path, monkeypatch, capsys): from bmad_loop.journal import Journal def rearm_with(restore: bool): - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): Journal(rd).append( "rearm-baseline-restamped", story_key=key, @@ -2893,7 +2901,9 @@ def test_resolve_survives_a_corrupt_journal(tmp_path, monkeypatch, capsys, outco from bmad_loop import runs from bmad_loop.journal import JOURNAL_FILE - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): if outcome == "rearm-error": raise runs.RearmError("cannot re-open story spec /x/spec.md") return key @@ -2929,7 +2939,9 @@ def test_resolve_echoes_a_skipped_restamp(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): Journal(rd).append( "rearm-baseline-restamp-skipped", story_key=key, @@ -2977,7 +2989,9 @@ def test_resolve_echoes_the_residue_even_when_the_rearm_aborts(tmp_path, monkeyp _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): # journalled first, exactly as the real residue pass is ordered Journal(rd).append( "stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c1", "c2"] @@ -3032,7 +3046,9 @@ def test_resolve_holds_the_resume_when_the_correction_cannot_reach_the_redrive( from bmad_loop.journal import Journal def rearm_journalling(kind, **fields): - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): Journal(rd).append(kind, story_key=key, **fields) return key @@ -3099,7 +3115,9 @@ def test_resolve_appends_the_next_step_imperative(tmp_path, monkeypatch, capsys) _escalated_run(tmp_path, "r1") - def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): journal = Journal(rd) journal.append( # table row with a next_step "rearm-baseline-advance-failed", @@ -3135,7 +3153,9 @@ def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): _escalated_run(tmp_path, "r1") calls = {} monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: calls.setdefault("ctx", True)) + monkeypatch.setattr( + resolve, "build_context", lambda *a, **k: (calls.setdefault("ctx", True), 0) + ) monkeypatch.setattr( resolve, "run_session", lambda *a, **k: calls.setdefault("session", True) or True ) @@ -3176,7 +3196,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) # --no-resume: re-arm only, so the bump this row contrasts against still runs assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 @@ -3190,7 +3210,14 @@ def test_resolve_interactive_unsupported_adapter(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + # DW-11: a NON-ZERO withheld count, deliberately. This command is about to fail, + # and an operator must not be told escalations were withheld from an agent that + # never launched — which is why the count is printed AFTER the adapter has proved + # it supports an interactive session, not beside the context build. + # + # Ablation: move the withheld print above the `try:` and this row reddens on the + # stdout assertion below. + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 3)) def boom(*a, **k): raise NotImplementedError @@ -3198,7 +3225,274 @@ def boom(*a, **k): monkeypatch.setattr(resolve, "run_session", boom) rc = cli.main(["resolve", "--project", str(tmp_path), "r1"]) assert rc == 1 - assert "no interactive session mode" in capsys.readouterr().err + captured = capsys.readouterr() + assert "no interactive session mode" in captured.err + assert "were not shown" not in captured.out + + +def _withheld_line(out: str) -> str: + (line,) = [ln for ln in out.splitlines() if "were not shown" in ln] + return line + + +def test_resolve_reports_the_escalations_it_withheld(tmp_path, monkeypatch, capsys): + """The number an operator reads is `build_context`'s OWN second member, not a + constant and not a re-derivation. Seeded to 3 so a hardcoded 1 (or a length of + something else) cannot pass, and worded for what the code can prove: these entries + were PRESENTED to an earlier cycle that recorded a resolution. + + Ablation: delete the `if withheld:` print from `cmd_resolve` and this reddens.""" + from bmad_loop import resolve + + _escalated_run(tmp_path, "r1") + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 3)) + monkeypatch.setattr(resolve, "run_session", lambda *a, **k: True) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + line = _withheld_line(capsys.readouterr().out) + assert line.startswith("3 earlier escalation(s) for s1 were not shown") + assert "recorded a resolution" in line + + +def test_resolve_says_nothing_when_it_withheld_nothing(tmp_path, monkeypatch, capsys): + """A first cycle, and every pre-upgrade `state.json`, withholds nothing — and must + print nothing, or the line becomes noise on the surface it exists to inform. + + `launching resolve agent` is the positive control: an absence assertion passes for + every reason stdout could be empty, including a command that returned before it + ever reached the print. + + Ablation: make the print unconditional (drop `if withheld:`) and this reddens.""" + from bmad_loop import resolve + + _escalated_run(tmp_path, "r1") + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "run_session", lambda *a, **k: True) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + out = capsys.readouterr().out + assert "launching resolve agent for s1" in out # the path WAS taken + assert "were not shown" not in out + + +def test_resolve_no_interactive_builds_no_context_and_reports_nothing( + tmp_path, monkeypatch, capsys +): + """`--no-interactive` runs no agent, so there is no context to filter and no + audience for the count. It also accepted nothing IN THIS GESTURE, so the watermark + must stand — the human may have fixed the spec by hand, but nothing recorded which + escalations that answered. The generation bump is the positive control that the + re-arm really ran. + + The run carries a session record deliberately: on a task with an EMPTY `sessions` + list an unconditional stamp writes `len([]) == 0`, so `escalations_resolved_upto == + 0` would hold with the gate ablated and the assertion would grade nothing.""" + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, "r1", details=("never answered",)) + built: list[int] = [] + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (built.append(1), (None, 5))[1]) + + assert ( + cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--no-resume"]) + == 0 + ) + + assert built == [] + assert "were not shown" not in capsys.readouterr().out + task = load_state(run_dir).tasks["s1"] + assert len(task.sessions) == 1 # a stamp here would be a VISIBLE 1 + assert task.escalations_resolved_upto == 0 + assert task.generation == 1 # positive control: the re-arm ran + + +def _escalated_trail_run(tmp_path, run_id="r1", *, details=("first cycle",)): + """An escalated run whose task carries one completed session record per entry in + `details`, each with the `tasks//escalation.json` the engine wrote when it + paused. Nothing about the escalation walk is stubbed by the rows that use it.""" + import json as _json + + from bmad_loop.engine import _session_task_id + from bmad_loop.journal import load_state, save_state + from bmad_loop.model import SessionRecord + + run_dir = _escalated_run(tmp_path, run_id) + state = load_state(run_dir) + task = state.tasks["s1"] + task.sessions.clear() + for seq, detail in enumerate(details, start=1): + task_id = _session_task_id("s1", "review", seq, 0) + task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + _json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), + encoding="utf-8", + ) + save_state(run_dir, state) + return run_dir + + +def _redrive_escalates(run_dir, detail): + """What a re-driven session that escalated again leaves behind, re-escalated so a + second `bmad-loop resolve` is legal on it.""" + import json as _json + + from bmad_loop.engine import _session_task_id + from bmad_loop.journal import load_state, save_state + from bmad_loop.model import Phase, SessionRecord + + state = load_state(run_dir) + task = state.tasks["s1"] + task_id = _session_task_id("s1", "review", 1, task.generation) + assert task_id not in {r.task_id for r in task.sessions} + task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + _json.dumps({"escalations": [{"severity": "CRITICAL", "detail": detail}]}), + encoding="utf-8", + ) + task.phase = Phase.ESCALATED + save_state(run_dir, state) + + +def _marker_writing_session(run_dir_marker=True): + from bmad_loop import resolve + + def fake_session(adapter, project, rd, story_key, *, generation, model=""): + marker = resolve.resolution_path(rd, story_key) + marker.parent.mkdir(parents=True, exist_ok=True) + if run_dir_marker: + marker.write_text("{}", encoding="utf-8") + return run_dir_marker + + return fake_session + + +def test_resolve_prints_the_number_the_real_walk_produced(tmp_path, monkeypatch, capsys): + """Every other CLI row here stubs `build_context` to a literal, so the number an + operator actually sees is otherwise never produced by the real walk. This row runs + two whole cycles with only `_make_adapters` and `run_session` stubbed: the first + shows both escalations and withholds nothing, the re-arm stamps the watermark, the + re-drive escalates again, and the second cycle prints the count `_gather_escalations` + computed — against a `context.json` that carries only the new entry. + + Ablation: revert `_gather_escalations` to the unsliced walk and the second cycle + prints nothing while `context.json` carries all three.""" + import json as _json + + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, details=("older A", "older B")) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "run_session", _marker_writing_session()) + + argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] + assert cli.main(argv) == 0 + first = capsys.readouterr().out + assert "launching resolve agent for s1" in first + assert "were not shown" not in first # a first cycle withholds nothing + assert load_state(run_dir).tasks["s1"].escalations_resolved_upto == 2 + + _redrive_escalates(run_dir, "raised by the re-drive") + + assert cli.main(argv) == 0 + assert _withheld_line(capsys.readouterr().out).startswith( + "2 earlier escalation(s) for s1 were not shown" + ) + ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] + + +def test_resolve_reports_the_withheld_count_when_this_cycle_records_nothing( + tmp_path, monkeypatch, capsys +): + """A watermark already standing filters whatever THIS gesture accepts. The two + halves are independent — `withheld` comes from the walk over what an EARLIER cycle + answered, the stamp from what this one did — but no row paired them: the rows that + assert a number run a marker-writing resolver, and the abandoned-session row asserts + the line's ABSENCE at watermark 0. So a print gated on `resolution_recorded`, or a + count recomputed after the stamp, went ungraded. + + Ablation: gate the withheld print on `resolution_recorded` in `cmd_resolve` and this + row reddens on the missing line while every existing count row stays green.""" + import json as _json + + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, details=("older A", "older B")) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "run_session", _marker_writing_session()) + + argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] + assert cli.main(argv) == 0 # cycle 1 accepts, stamping the watermark at 2 + capsys.readouterr() + _redrive_escalates(run_dir, "raised by the re-drive") + + # cycle 2 walks away without writing `resolution.json`. The marker cycle 1 wrote is + # still on disk — nothing unlinks it at re-arm — which is the state this path opens + # on for real, and the stub does not clear it either. + monkeypatch.setattr(resolve, "run_session", _marker_writing_session(run_dir_marker=False)) + assert cli.main(argv) == 0 + out = capsys.readouterr() + assert _withheld_line(out.out).startswith("2 earlier escalation(s) for s1 were not shown") + assert "no resolution recorded for s1" in out.err + + task = load_state(run_dir).tasks["s1"] + assert task.escalations_resolved_upto == 2 # UNCHANGED by a gesture that accepted none + assert task.generation == 2 # positive control: it still re-armed + ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] + + +def test_resolve_leaves_the_watermark_when_the_agent_wrote_no_resolution( + tmp_path, monkeypatch, capsys +): + """`cmd_resolve` prints "no resolution recorded" and FALLS THROUGH — no `return` — + so an abandoned or crashed resolve session re-arms the story anyway. That gesture + accepted nothing, so it must not advance the watermark: the escalations the agent + walked away from would otherwise be invisible to every later cycle and reported to + the operator as already answered. + + Driven as a whole SECOND cycle through the real walk, because the consequence is + what the next `resolve` shows, not what one field reads. + + Ablation: remove the `if resolution_recorded:` gate in `rearm_escalation` and this + reddens on the watermark, then again on the second cycle's absent line.""" + import json as _json + + from bmad_loop import resolve + from bmad_loop.journal import load_state + + run_dir = _escalated_trail_run(tmp_path, details=("nobody ever answered this",)) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "run_session", _marker_writing_session(run_dir_marker=False)) + + argv = ["resolve", "--project", str(tmp_path), "r1", "--no-resume"] + assert cli.main(argv) == 0 + assert "no resolution recorded for s1" in capsys.readouterr().err + + task = load_state(run_dir).tasks["s1"] + assert task.escalations_resolved_upto == 0 # UNCHANGED + assert task.generation == 1 # positive control: the re-arm still ran + + _redrive_escalates(run_dir, "raised by the re-drive") + + assert cli.main(argv) == 0 + assert "were not shown" not in capsys.readouterr().out + ctx = _json.loads(resolve.context_path(run_dir, "s1").read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == [ + "raised by the re-drive", + "nobody ever answered this", + ] def test_resolve_in_ctl_session_detaches_before_resume(tmp_path, monkeypatch, capsys): @@ -3431,7 +3725,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) @@ -3615,7 +3909,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) rc = cli.main(["resolve", "--project", str(tmp_path), "r1", "--resume"]) @@ -3675,12 +3969,14 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): seen: list[bool] = [] - def recording_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): + def recording_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): seen.append(isolated_redrive) return key monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) monkeypatch.setattr(runs, "rearm_escalation", recording_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -3713,7 +4009,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) @@ -3744,7 +4040,7 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): return True monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: None) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) monkeypatch.setattr(resolve, "run_session", fake_session) called: list = [] monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: called.append(rd) or 0) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 07aab2ce..37136adc 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1749,14 +1749,22 @@ def test_diag_surfaces_the_split_code_root_and_the_task_generation(project): `paused_reason_present` / `worktree_isolated` style, and a small counter. The path itself must NOT appear — that is what `_JOURNAL_DROP_FIELDS` drops. - Ablation: delete `repo_root_diverges=` from `collect_run` (or `generation=` from - `_task_diag`) and this reddens on the corresponding assertion; deleting the field - from the dataclass reddens as a TypeError at construction. + `escalations_resolved_upto` (DW-11) is projected on the same warrant and asserted + here for the same reason: a task whose older escalations are filtered out of + `context.json` dumps identically to one that only ever raised the entries shown, + so a support bundle cannot explain a short resolve context without it. A counter + too — it indexes `task.sessions`, so it carries no customer content. + + Ablation: delete `repo_root_diverges=` from `collect_run` (or `generation=` / + `escalations_resolved_upto=` from `_task_diag`) and this reddens on the + corresponding assertion; deleting the field from the dataclass reddens as a + TypeError at construction. """ run_dir = _seed_run(project.project) state = load_state(run_dir) state.repo_root = str(project.project / "code-tree") state.tasks[STORY_KEY].generation = 2 + state.tasks[STORY_KEY].escalations_resolved_upto = 3 save_state(run_dir, state) diag, _pseudo, combined = _render_all([run_dir]) @@ -1764,6 +1772,7 @@ def test_diag_surfaces_the_split_code_root_and_the_task_generation(project): assert run.repo_root_diverges is True assert run.tasks[0].generation == 2 + assert run.tasks[0].escalations_resolved_upto == 3 # a presence flag, never the path — the same rule `repo` is dropped under assert "code-tree" not in combined @@ -1780,6 +1789,7 @@ def test_diag_repo_root_diverges_is_false_for_the_ordinary_layout(project): assert run.repo_root_diverges is False assert run.tasks[0].generation == 0 + assert run.tasks[0].escalations_resolved_upto == 0 def _md_task_row(md: str) -> list[str]: @@ -1800,19 +1810,28 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): `generation` rides beside `attempt` because that is the column pair a #705-class replay turns on: a collided re-drive and a healthy post-re-arm task agree on every - other cell in this row. + other cell in this row. DW-11's `escalations_resolved_upto` rides beside it on the + same warrant, stated verbatim in its own field comment: it is the only field that + separates "this story raised one escalation" from "its earlier ones are filtered + out of `context.json` as already answered", and that question is asked of a bug + report. Seeded to a value that is neither the attempt, the generation nor the + review cycle, so a cell reading a NEIGHBOUR cannot pass. Ablation: drop the `code root differs from project` line from `render_markdown` and both this test and the sibling below redden on their first assertion. Drop `{t.generation}` from the row f-string together with its header and separator cells - and this test reddens at `names[4]` (`"rev" != "gen"`) while the sibling reddens at - the row cell — as `"1" != "0"`, the review cycle shifted left rather than a missing - key, which is why the cell is read positionally and the three widths are compared. + and this test reddens at `names[4]` (`"esc-upto" != "gen"`) while the sibling + reddens at the row cell — the review cycle shifted left rather than a missing key, + which is why the cell is read positionally and the three widths are compared. Drop + `{t.escalations_resolved_upto}` the same way and this test reddens at `names[5]` + (`"rev" != "esc-upto"`); drop ONLY the row cell and it reddens on the width + comparison, which is what a skewed table actually looks like. """ run_dir = _seed_run(project.project) state = load_state(run_dir) state.repo_root = str(project.project / "code-tree") state.tasks[STORY_KEY].generation = 2 + state.tasks[STORY_KEY].escalations_resolved_upto = 3 save_state(run_dir, state) pseudo = sanitize.Pseudonymizer() @@ -1825,11 +1844,13 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): (rule,) = [ln for ln in md.splitlines() if ln.startswith("|---|")] names = [c.strip() for c in header.strip("|").split("|")] assert names[4] == "gen" + assert names[5] == "esc-upto" # header, separator and row must agree on width or the table renders skewed - assert len(cells) == len(names) == len(rule.strip("|").split("|")) == 12 + assert len(cells) == len(names) == len(rule.strip("|").split("|")) == 13 assert cells[3] == "2" # attempt, seeded by `_seed_run` assert cells[4] == "2" # generation — NOT the review cycle, which is 1 - assert cells[5] == "1" # review cycle, still in its own column + assert cells[5] == "3" # the DW-11 watermark, in its own column + assert cells[6] == "1" # review cycle, still in its own column # still a flag and a counter: the path itself never renders assert "code-tree" not in md diff --git a/tests/test_engine.py b/tests/test_engine.py index 4b95666e..e12b0bf8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5481,7 +5481,7 @@ def test_closes_deferred_lands_once_when_a_failed_commit_is_re_driven(project): # the resolve workflow's re-arm: a resolved re-drive, which is precisely the # recovery that PRESERVES the artifact folders' tracked content through # `safe_reset` — so a close left standing here would never be reverted. - rearm_escalation(engine.run_dir, isolated_redrive=False) + rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) resumed, _ = resume_engine( project, @@ -9138,7 +9138,9 @@ def test_resolved_escalation_resume_skips_clean_rollback(project): assert summary.paused and summary.escalated == 1 assert load_state(engine.run_dir).tasks["1-1-a"].phase == Phase.ESCALATED - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, @@ -9185,7 +9187,9 @@ def escalate_dirty(spec): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, @@ -9273,7 +9277,7 @@ def escalate_bound_repair(session): corrected = sp.read_text().replace("test spec", "human corrected frozen intent") sp.write_text(corrected) head_before_rearm = rev_parse_head(repo) - rearm_escalation(engine.run_dir, isolated_redrive=False) + rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) assert rev_parse_head(repo) == head_before_rearm # no correction commit at re-arm assert read_frontmatter(sp)["status"] == "ready-for-dev" @@ -9844,7 +9848,9 @@ def halt_blocked(spec): assert task.phase == Phase.ESCALATED assert task.spec_file and Path(task.spec_file).name == sp.name # recorded despite HALT - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step assert read_frontmatter(sp)["status"] == "ready-for-dev" # re-drive will not HALT @@ -10080,7 +10086,7 @@ def test_intent_gap_restore_redrive_applies_patch_and_lands_done(project): assert engine.run().escalated == 1 rearm_escalation( - engine.run_dir, restore_patch=str(patch), isolated_redrive=False + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True ) # human confirmed the reading sp = spec_path(project, "1-1-a") assert read_frontmatter(sp)["status"] == "in-review" # routes step-01 -> step-04 @@ -10109,7 +10115,9 @@ def test_restore_redrive_prompt_points_at_the_spec(project): engine, _ = make_engine(project, [_escalate_with_patch(project, "1-1-a", patch)]) assert engine.run().escalated == 1 - rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) + rearm_escalation( + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + ) seen: list[str] = [] resumed, adapter = resume_engine( project, engine, [_restoring_dev_effect(project, "1-1-a", seen)] @@ -10130,7 +10138,9 @@ def test_intent_gap_restore_reapplies_after_mid_redrive_rollback(project): patch = project.implementation_artifacts / "attempt.patch" engine, _ = make_engine(project, [_escalate_with_patch(project, "1-1-a", patch)]) assert engine.run().escalated == 1 - rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) + rearm_escalation( + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + ) seen: list[str] = [] resumed, _ = resume_engine( @@ -10165,7 +10175,9 @@ def test_intent_gap_restore_escalates_when_resolution_commits_overlap(project): (repo / "src.txt").write_text("corrected by resolution\n") git(repo, "add", "src.txt") git(repo, "commit", "-q", "-m", "resolution: overlapping fix") - rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) + rearm_escalation( + engine.run_dir, restore_patch=str(patch), isolated_redrive=False, resolution_recorded=True + ) seen: list[str] = [] resumed, _ = resume_engine(project, engine, [_restoring_dev_effect(project, "1-1-a", seen)]) @@ -10552,7 +10564,9 @@ def test_resume_re_gates_a_human_armed_re_drive(project): ) engine, _ = make_engine(project, [escalating]) assert engine.run().escalated == 1 - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 # the confusable state # a gate lands on the story while the operator is resolving it write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) @@ -11068,7 +11082,7 @@ def test_session_env_fault_pauses_dev_without_burning_budget(project): assert end["env_fault_evidence"] == evidence # the resolve workflow's re-arm step restores the attempt budget - rearm_escalation(engine.run_dir, isolated_redrive=False) + rearm_escalation(engine.run_dir, isolated_redrive=False, resolution_recorded=True) assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 @@ -12256,7 +12270,9 @@ def test_resume_with_epic_filter_stays_in_scoped_epic(project): assert summary.paused and summary.escalated == 1 assert engine.state.current_epic == 9 - rearm_escalation(engine.run_dir, isolated_redrive=False) # the resolve workflow's re-arm step + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # the resolve workflow's re-arm step resumed, _ = resume_engine( project, engine, @@ -12318,7 +12334,9 @@ def test_resolved_redrive_reescalates_instead_of_deferring(project): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation(engine.run_dir, isolated_redrive=False) # human resolved; re-drive re-armed + rearm_escalation( + engine.run_dir, isolated_redrive=False, resolution_recorded=True + ) # human resolved; re-drive re-armed # re-drive never reaches `done` (env still blocked): both attempts land at # in-progress with no escalation — the exact non-convergence that used to defer resumed, _ = resume_engine( diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 3a3bf153..ec0d9412 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2101,7 +2101,12 @@ def commit_fails(*_a, **_k): assert not project.deferred_work.exists() # the row is only in the doomed worktree monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert runs.rearm_escalation(engine.run_dir, "1-1-a", isolated_redrive=True) == "1-1-a" + assert ( + runs.rearm_escalation( + engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True + ) + == "1-1-a" + ) state = load_state(engine.run_dir) state.clear_pause() @@ -5762,7 +5767,12 @@ def commit_fails(*_a, **_k): assert _ledger_entry(project, "DW-1").open monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert runs.rearm_escalation(engine.run_dir, "1-1-a", isolated_redrive=True) == "1-1-a" + assert ( + runs.rearm_escalation( + engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True + ) + == "1-1-a" + ) state = load_state(engine.run_dir) state.clear_pause() diff --git a/tests/test_model.py b/tests/test_model.py index 1b264bd6..50fd5ac0 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -291,6 +291,22 @@ def test_generation_defaults_zero_for_legacy_state(): assert StoryTask.from_dict(doc).generation == 0 +def test_escalations_resolved_upto_round_trips(): + task = StoryTask(story_key="1-1-a", epic=1, escalations_resolved_upto=3) + assert StoryTask.from_dict(task.to_dict()).escalations_resolved_upto == 3 + + +def test_escalations_resolved_upto_defaults_zero_for_legacy_state(): + """A `state.json` written before DW-11 must resume UNFILTERED. 0 is the value + `resolve._gather_escalations` reads as "nothing answered yet", so every escalation + the run recorded is still shown and nothing is reported withheld — byte-for-byte + today's behavior. Any other default would hide entries the human never saw, on a + run that was mid-escalation across the upgrade.""" + doc = StoryTask(story_key="1-1-a", epic=1).to_dict() + del doc["escalations_resolved_upto"] # state.json from before the field existed + assert StoryTask.from_dict(doc).escalations_resolved_upto == 0 + + def test_resolved_redrive_round_trips(): task = StoryTask(story_key="1-1-a", epic=1, resolved_redrive=True) assert StoryTask.from_dict(task.to_dict()).resolved_redrive is True diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 795600bc..53f48e86 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -92,6 +92,20 @@ def _escalated_run( return run.run_dir, run.state, run.task +def _context(state, run_dir, story_key, *, isolation): + """`build_context`'s Path alone, for the ~30 rows that assert on `context.json`. + + `build_context` returns `(path, withheld)` since DW-11, and the withheld count is + an OPERATOR-facing number the CLI prints — no row here is about it. Routing every + Path-only caller through one unpack pins the arity for all of them at once: grow + the tuple a third member and this helper fails, rather than every row silently + binding a longer tuple to `path` (which is what a bare `path, _ = ...` at each + site would do). The rows that ARE about the count call `resolve.build_context` + directly, so the number is never produced by this helper.""" + path, _withheld = resolve.build_context(state, run_dir, story_key, isolation=isolation) + return path + + # ------------------------------------------------------------ set_frontmatter_field # # `set_frontmatter_status`'s own tests live in tests/test_frontmatter.py, next to @@ -531,7 +545,7 @@ def test_build_context_gathers_critical_escalations(tmp_path): ), encoding="utf-8", ) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == "6-4-cli-list-command" assert ctx["spec_file"] == spec.as_posix() @@ -589,7 +603,7 @@ def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_ run_dir, state, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what the resolve session actually runs from - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="worktree") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="worktree") ctx = json.loads(path.read_text(encoding="utf-8")) assert Path(ctx["spec_file"]).is_absolute() # the worktree's copy, not the main checkout's twin — compared as posix, which is @@ -611,17 +625,15 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=None, worktree_path=str(wt)) ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_file"] is None # task present, spec-less escalation assert "no-such-story" not in state.tasks ctx = json.loads( - resolve.build_context(state, run_dir, "no-such-story", isolation="worktree").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "no-such-story", isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] is None # no task at all # ... and the escalation gather degrades on the same absence rather than @@ -631,7 +643,7 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): def test_build_context_no_session_files(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, with_session=False) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["escalations"] == [] assert ctx["paused_reason"].startswith("CRITICAL") @@ -646,25 +658,25 @@ def test_build_context_restore_supported_signal(tmp_path): run_dir, state, task = _escalated_run(tmp_path, spec_file="/abs/spec.md", with_session=False) key = "6-4-cli-list-command" - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is True - path = resolve.build_context(state, run_dir, key, isolation="worktree") + path = _context(state, run_dir, key, isolation="worktree") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.worktree_path = str(tmp_path / "wt") # recorded worktree execution - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.worktree_path = "" task.spec_file = None # spec-less escalation: a restored patch has no review to resume - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False task.spec_file = "/abs/spec.md" state.source = "stories" task.sentinel_kind = "missing-prd" # pre-planning wedge: nothing attempted to restore - path = resolve.build_context(state, run_dir, key, isolation="") + path = _context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False @@ -675,7 +687,7 @@ def test_build_context_sanitizes_dirty_story_key(tmp_path): dirty = "6-4:cli?list" seg = safe_segment(dirty) assert seg != dirty - path = resolve.build_context(state, run_dir, dirty, isolation="") + path = _context(state, run_dir, dirty, isolation="") assert path.parent.name == seg ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == dirty @@ -689,7 +701,7 @@ def test_rearm_flips_phase_and_spec_status(tmp_path): spec = tmp_path / "spec.md" spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - key = runs.rearm_escalation(run_dir, isolated_redrive=False) + key = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert key == "6-4-cli-list-command" state = load_state(run_dir) task = state.tasks[key] @@ -713,7 +725,7 @@ def test_rearm_strips_stale_terminal_section(tmp_path): encoding="utf-8", ) run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) text = spec.read_text(encoding="utf-8") assert "Auto Run Result" not in text and "names not unique" not in text assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" @@ -756,7 +768,7 @@ def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive tmp_path, spec_file=str(spec), worktree_path=str(tmp_path / "wt" / "u1") ) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["story_key"] == "6-4-cli-list-command" @@ -778,7 +790,7 @@ def test_rearm_does_not_warn_about_unreachable_writes_without_a_worktree(tmp_pat spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -846,9 +858,9 @@ def test_rearm_journals_a_status_flip_that_silently_did_nothing(tmp_path, shape) if shape == "no-frontmatter": with pytest.raises(runs.RearmError, match="no frontmatter `status:`"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) else: - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) records = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-flip-skipped"] if shape == "already-at-target": @@ -879,7 +891,7 @@ def test_rearm_journals_a_status_flip_that_silently_did_nothing(tmp_path, shape) def test_rearm_journals_event(tmp_path): run_dir, _, _ = _escalated_run(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) journal = (run_dir / "journal.jsonl").read_text(encoding="utf-8") assert "story-escalation-resolved" in journal @@ -898,7 +910,7 @@ def test_rearm_advances_baseline_to_resolved_head(project): # a file the resolve session (or the user) left untracked must enter the # snapshot, so the redrive reset treats it as pre-existing, not run-created (root / "leftover.txt").write_text("keep me\n", encoding="utf-8") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == git(root, "rev-parse", "HEAD") assert task.baseline_commit != old_head @@ -917,7 +929,7 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" assert task.baseline_untracked is None @@ -927,7 +939,7 @@ def test_rearm_keeps_stale_baseline_outside_a_repo(tmp_path): # best-effort contract: a project dir that is not a git repo (or a broken # one) must not make re-arm fail — the old baseline simply stands run_dir, _, _ = _escalated_run(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" @@ -947,7 +959,7 @@ def test_rearm_journals_a_failed_baseline_advance(tmp_path): """ run_dir, _, _ = _escalated_run(tmp_path) # tmp_path is not a git repo - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-advance-failed"] assert entry["story_key"] == "6-4-cli-list-command" @@ -975,7 +987,7 @@ def boom(repo): monkeypatch.setattr(runs.verify, "untracked_files", boom) with pytest.raises(MemoryError): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) @pytest.mark.parametrize("restore", [None, "artifacts/attempt.patch"]) @@ -999,7 +1011,9 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir, restore_patch=restore, isolated_redrive=False) + runs.rearm_escalation( + run_dir, restore_patch=restore, isolated_redrive=False, resolution_recorded=True + ) fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == old_head # NOT re-stamped with the stale sha @@ -1024,7 +1038,7 @@ def test_rearm_bumps_the_task_generation(tmp_path): before = load_state(run_dir).tasks["6-4-cli-list-command"] assert before.generation == 0 and len(before.sessions) == 1 - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.generation == 1 @@ -1032,7 +1046,7 @@ def test_rearm_bumps_the_task_generation(tmp_path): assert len(task.sessions) == 1 # the audit trail survives the re-arm save_state(run_dir, _rearmable(run_dir)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert load_state(run_dir).tasks["6-4-cli-list-command"].generation == 2 @@ -1057,7 +1071,7 @@ def test_rearm_advances_the_baseline_in_the_code_tree(tmp_path): git(code, "commit", "-q", "-m", "resolution fixture") (code / "leftover.txt").write_text("keep me\n") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == git(code, "rev-parse", "HEAD") != head @@ -1115,7 +1129,9 @@ def test_rearm_reads_stale_restore_residue_from_the_code_tree(tmp_path): git(code, "commit", "-q", "-m", "resolution fixture") new_head = git(code, "rev-parse", "HEAD") - runs.rearm_escalation(run_dir, isolated_redrive=False) # from scratch: the latch is dropped + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # from scratch: the latch is dropped task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == new_head @@ -1156,7 +1172,7 @@ def test_rearm_falls_back_to_project_when_no_code_root_was_recorded(tmp_path): (run_dir / "state.json").write_text(json.dumps(raw), encoding="utf-8") assert load_state(run_dir).repo_root == "" - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert load_state(run_dir).tasks["6-4-cli-list-command"].baseline_commit == head @@ -1203,7 +1219,7 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what `bmad-loop resolve` actually runs from - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) fm = verify.read_frontmatter(wt / rel) assert fm["status"] == "ready-for-dev" # the flip landed in the WORKTREE @@ -1240,7 +1256,7 @@ def test_rearm_journals_a_skip_when_the_recorded_spec_is_not_readable(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file="wt/_bmad-output/specs/gone.md") runs.rearm_escalation( - run_dir, isolated_redrive=False + run_dir, isolated_redrive=False, resolution_recorded=True ) # must not raise: the flip's no-op is not a refusal kinds = _kinds(run_dir) @@ -1280,7 +1296,7 @@ def test_rearm_records_an_unreachable_spec_even_when_the_advance_failed(tmp_path _resolve_repo(tmp_path) run_dir, _, _ = _escalated_run(tmp_path, spec_file="wt/_bmad-output/specs/gone.md") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) kinds = _kinds(run_dir) (skipped,) = [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] @@ -1308,7 +1324,7 @@ def test_rearm_restamps_normally_when_the_spec_resolves(tmp_path): spec.write_text("---\nstatus: 'escalated'\nbaseline_revision: 'old'\n---\n\nbody\n") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) kinds = _kinds(run_dir) assert [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] == [] @@ -1337,7 +1353,7 @@ def test_rearm_clears_sentinel_preserving_a_copy(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - returned = runs.rearm_escalation(run_dir, isolated_redrive=False) + returned = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert returned == key # sentinel deleted from disk, a copy preserved under the run dir @@ -1377,7 +1393,7 @@ def test_rearm_non_sentinel_spec_still_flips_status(tmp_path): # detected as a sentinel) → status-flip, not delete. run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.is_file() # not deleted assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1397,7 +1413,7 @@ def test_rearm_sentinel_named_spec_never_detected_is_not_deleted(tmp_path): # stories mode, but sentinel_kind unset — the run never classified it as a sentinel run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.is_file() # NOT deleted despite the sentinel-shaped name assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1414,7 +1430,7 @@ def test_rearm_sprint_spec_named_like_a_sentinel_is_not_deleted(tmp_path): spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nreal work\n", encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) # sprint-status source - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.is_file() # NOT deleted despite the sentinel-shaped name assert verify.read_frontmatter(spec)["status"] == "ready-for-dev" # flipped like any spec assert load_state(run_dir).tasks[key].spec_file == str(spec) # kept @@ -1441,7 +1457,10 @@ def test_rearm_rejects_restore_patch_on_a_sentinel(tmp_path): with pytest.raises(runs.RearmError, match="sentinel"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, ) assert sentinel.is_file() # nothing deleted, copy NOT preserved — no clear happened @@ -1463,7 +1482,10 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): with pytest.raises(runs.RearmError, match="no recorded spec file"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, ) task = load_state(run_dir).tasks["6-4-cli-list-command"] @@ -1472,7 +1494,7 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): assert not (run_dir / "journal.jsonl").exists() # nothing journaled runs.rearm_escalation( - run_dir, isolated_redrive=False + run_dir, isolated_redrive=False, resolution_recorded=True ) # a from-scratch re-arm remains available assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.PENDING @@ -1490,14 +1512,20 @@ def test_rearm_rejects_restore_patch_for_a_worktree_executed_task(tmp_path): with pytest.raises(runs.RearmError, match="worktree-isolation"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=True + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=True, + resolution_recorded=True, ) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.phase == Phase.ESCALATED # nothing mutated; still armed for a re-resolve assert task.restore_patch is None # a from-scratch re-arm of the same task is unaffected — the guard is latch-only - assert runs.rearm_escalation(run_dir, isolated_redrive=True) == "6-4-cli-list-command" + assert ( + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + == "6-4-cli-list-command" + ) def test_validate_restore_latch_passes_a_clean_in_place_escalation(tmp_path): @@ -1524,7 +1552,12 @@ def test_rearm_restore_patch_on_a_real_stories_spec_is_allowed(tmp_path): spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) task = load_state(run_dir).tasks[key] assert task.phase == Phase.PENDING assert task.restore_patch == "artifacts/attempt.patch" @@ -1564,7 +1597,12 @@ def test_rearm_restore_patch_restamps_spec_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "resolution fixture") new_head = git(tmp_path, "rev-parse", "HEAD") - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head # step-04 diffs from the ADVANCED baseline @@ -1614,7 +1652,7 @@ def test_rearm_restamps_spec_baseline_on_the_from_scratch_leg_too(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, spec, new_head = _escalated_spec_run(tmp_path, old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # no restore fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head @@ -1665,7 +1703,7 @@ def test_rearm_restores_the_spec_when_the_baseline_restamp_aborts(tmp_path): git(tmp_path, "commit", "-q", "-m", "resolution fixture") with pytest.raises(runs.RearmError, match="baseline_revision"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.read_bytes() == before # flip AND strip both undone # nothing was persisted either, so the escalation is still armed for a corrected spec @@ -1716,7 +1754,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert spec.read_bytes() == before # the published flip is rolled back assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED @@ -1762,7 +1800,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert spec.read_bytes() == before # the undo reached a spec outside the mount assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED @@ -1781,7 +1819,7 @@ def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, _spec, new_head = _escalated_spec_run(tmp_path, old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == old_head @@ -1790,7 +1828,7 @@ def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): # a second re-arm has nothing left to overwrite: no duplicate record save_state(run_dir, _rearmable(run_dir)) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert len([e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"]) == 1 @@ -1816,7 +1854,7 @@ def test_rearm_does_not_report_a_divergence_the_run_never_had(tmp_path): old_head = _resolve_repo(tmp_path) run_dir, spec, new_head = _escalated_spec_run(tmp_path, old_head, recorded=old_head) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # the re-stamp itself ran: this row is about what was REPORTED, not what was skipped assert verify.read_frontmatter(spec)["baseline_revision"] == new_head @@ -1852,7 +1890,7 @@ def test_rearm_reports_a_claim_the_advanced_head_would_have_masked(tmp_path): encoding="utf-8", ) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == new_head # the claim, carried verbatim @@ -1869,7 +1907,7 @@ def test_rearm_prefers_the_fresh_revision_when_the_spec_carries_both_keys(tmp_pa tmp_path, old_head, extra=f"baseline_commit: {'a' * 40}\n" ) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (entry,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"] assert entry["overwritten"] == old_head # NOT the stale baseline_commit @@ -1905,7 +1943,7 @@ def test_build_context_tolerates_non_utf8_present_spec(tmp_path): (stories_dir / f"{key}-slug.md").write_bytes(_BAD_UTF8) # a real spec, undecodable run_dir, state, _ = _escalated_run(tmp_path, source="stories") - path = resolve.build_context(state, run_dir, key, isolation="") # must not raise + path = _context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["stories"]["spec_folder"] == "" # best-effort context still produced assert "sentinel" not in ctx["stories"] # the undecodable spec yields no sentinel @@ -1920,7 +1958,7 @@ def test_build_context_tolerates_non_utf8_sentinel(tmp_path): (stories_dir / f"{key}-unresolved.md").write_bytes(_BAD_UTF8) # undecodable sentinel run_dir, state, _ = _escalated_run(tmp_path, source="stories", sentinel_kind="unresolved") - path = resolve.build_context(state, run_dir, key, isolation="") # must not raise + path = _context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["stories"]["sentinel"]["kind"] == "unresolved" assert ctx["stories"]["sentinel"]["blocking_condition"] == "" # unreadable → empty @@ -1941,7 +1979,7 @@ def test_rearm_non_utf8_present_spec_fails_clean_and_stays_armed(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), source="stories") with pytest.raises(runs.RearmError) as exc: - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) assert "UTF-8" in str(exc.value) and "resolve" in str(exc.value) assert spec.read_bytes() == _BAD_UTF8 # spec untouched task = load_state(run_dir).tasks[key] @@ -1961,7 +1999,9 @@ def test_rearm_tolerates_non_utf8_sentinel(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - assert runs.rearm_escalation(run_dir, isolated_redrive=False) == key # must not raise + assert ( + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) == key + ) # must not raise assert not sentinel.exists() # cleared by deletion assert (run_dir / "sentinels" / f"{key}-unresolved.md").is_file() # copy preserved assert load_state(run_dir).tasks[key].spec_file is None # cleared → PENDING re-dispatch @@ -1994,7 +2034,7 @@ def test_rearm_rejects_non_escalation_stage(tmp_path): ), ) with pytest.raises(runs.RearmError, match="not paused at an escalation"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) def test_rearm_rejects_unescalated_story(tmp_path): @@ -2002,7 +2042,7 @@ def test_rearm_rejects_unescalated_story(tmp_path): task.phase = Phase.DONE # terminal but not escalated save_state(run_dir, state) with pytest.raises(runs.RearmError, match="not escalated"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) # ------------------------------------------------- _gather_escalations @@ -2032,7 +2072,7 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): encoding="utf-8", ) - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["abandoned cycle"] # once, not twice # DW-71: the id bump only protects records minted AFTER it. State persisted @@ -2040,7 +2080,7 @@ def test_gather_escalations_reads_one_escalation_once_per_distinct_id(tmp_path): # directory's single mutable escalation.json — the reader itself has to return # the escalation once rather than attribute it to the fresh session too. task.sessions[1] = SessionRecord(task_id=abandoned, role="dev", status="completed") - collided = resolve._gather_escalations(run_dir, state, key) + collided, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in collided] == ["abandoned cycle"] @@ -2082,7 +2122,7 @@ def counting_read_text(self, *args, **kwargs): # back the suite's `BMAD_LOOP_STATE_DIR` isolation too, mid-test. with monkeypatch.context() as mp: mp.setattr(Path, "read_text", counting_read_text) - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert reads.count(str(result_file)) == 1 # each artifact once, not once per record assert reads.count(str(esc_file)) == 1 @@ -2124,7 +2164,7 @@ def test_gather_escalations_dedupes_one_entry_across_two_sessions(tmp_path): for d in (older_dir, newer_dir): (d / "escalation.json").write_text(json.dumps({"escalations": [entry]}), encoding="utf-8") - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["unresolved across attempts"] @@ -2140,7 +2180,7 @@ def test_gather_escalations_orders_distinct_sessions_newest_first(tmp_path): encoding="utf-8", ) - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["newer", "older"] @@ -2173,9 +2213,7 @@ def test_gather_escalations_returns_a_mirrored_entry_once(tmp_path): (task_dir / fname).write_text(json.dumps({"escalations": [value]}), encoding="utf-8") ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert ctx["escalations"] == [entry] @@ -2192,7 +2230,7 @@ def test_gather_escalations_keeps_distinct_entries_from_both_files(tmp_path): (task_dir / "result.json").write_text(json.dumps({"escalations": [a]}), encoding="utf-8") (task_dir / "escalation.json").write_text(json.dumps({"escalations": [a, b]}), encoding="utf-8") - found = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") + found, _ = resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") assert [e["detail"] for e in found] == ["A", "B"] @@ -2218,7 +2256,10 @@ def test_gather_escalations_keeps_full_objects_that_share_a_detail(tmp_path): json.dumps({"escalations": [first, second]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( + [first, second], + 0, + ) def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_path): @@ -2232,7 +2273,10 @@ def test_gather_escalations_preserves_result_before_escalation_file_order(tmp_pa json.dumps({"escalations": [second]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( + [first, second], + 0, + ) def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): @@ -2246,7 +2290,10 @@ def test_gather_escalations_keeps_a_duplicates_first_position(tmp_path): json.dumps({"escalations": [second, first]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [first, second] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ( + [first, second], + 0, + ) def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): @@ -2258,7 +2305,7 @@ def test_gather_escalations_dedupes_repeats_inside_one_list(tmp_path): json.dumps({"escalations": [entry, entry]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [entry] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ([entry], 0) def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_path): @@ -2271,7 +2318,7 @@ def test_gather_escalations_keeps_mixed_case_critical_and_drops_non_dicts(tmp_pa json.dumps({"escalations": [None, "junk", preference, critical]}), encoding="utf-8" ) - assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == [critical] + assert resolve._gather_escalations(run_dir, state, "6-4-cli-list-command") == ([critical], 0) def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): @@ -2288,9 +2335,7 @@ def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): ) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert [e["detail"] for e in ctx["escalations"]] == ["still readable"] @@ -2319,7 +2364,7 @@ def loads_with_digit_limit(data, *args, **kwargs): with monkeypatch.context() as mp: mp.setattr(resolve.json, "loads", loads_with_digit_limit) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] @@ -2346,9 +2391,7 @@ def test_gather_escalations_skips_a_json_recursion_error(tmp_path): ) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] @@ -2372,7 +2415,7 @@ def dumps_with_recursion_error(value, *args, **kwargs): with monkeypatch.context() as mp: mp.setattr(resolve.json, "dumps", dumps_with_recursion_error) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["escalations"] == [sibling] @@ -2405,7 +2448,7 @@ def recording_critical_escalations(doc): with monkeypatch.context() as mp: mp.setattr(resolve, "critical_escalations", recording_critical_escalations) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert filtered == [ @@ -2435,15 +2478,340 @@ def test_gather_escalations_preference_only_yields_nothing(tmp_path): for fname in ("result.json", "escalation.json"): (task_dir / fname).write_text(json.dumps({"escalations": [pref]}), encoding="utf-8") - assert resolve._gather_escalations(run_dir, state, key) == [] + assert resolve._gather_escalations(run_dir, state, key) == ([], 0) crit = {"type": "spec-gap", "severity": "CRITICAL", "detail": "kept"} for fname in ("result.json", "escalation.json"): (task_dir / fname).write_text(json.dumps({"escalations": [pref, crit]}), encoding="utf-8") - found = resolve._gather_escalations(run_dir, state, key) + found, _ = resolve._gather_escalations(run_dir, state, key) assert [e["detail"] for e in found] == ["kept"] # this directory IS read +# -------------------------------------- DW-11: the escalation watermark + + +def _watermarked_trail(tmp_path, per_session): + """A task whose append-only `sessions` list carries ONE record per element of + `per_session`, each with its own `tasks//escalation.json` holding that + record's CRITICAL details. Returns `(run_dir, state, task, key)` with the state + already saved, so a row can re-arm it without re-saving by hand. + + The ids are minted through `engine._session_task_id`, varying the SEQ inside + generation 0 — the trail one pre-re-arm cycle leaves behind. Distinctness is + asserted rather than assumed: a shared id collapses into the reader's `seen_ids` + guard, leaving one directory and one side to route to, and every row below would + then pass with the filter ablated. Varying the seq (not the generation) also + keeps the whole namespace clear of the ids a LATER re-arm mints, so a re-drive + record cannot silently overwrite a trail artifact. + """ + run_dir, state, task = _escalated_run(tmp_path) + key = "6-4-cli-list-command" + task.sessions.clear() + for seq, details in enumerate(per_session, start=1): + task_id = _session_task_id(key, "review", seq, 0) + assert task_id not in {r.task_id for r in task.sessions} + task.sessions.append(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + json.dumps( + { + "escalations": [ + {"type": "spec-gap", "severity": "CRITICAL", "detail": detail} + for detail in details + ] + } + ), + encoding="utf-8", + ) + save_state(run_dir, state) + return run_dir, state, task, key + + +def _redrive_escalates(run_dir, key, detail, *, escalated=False): + """Append the record + artifact a re-driven session that escalated again leaves + behind — through `record_session`, the SOLE mutation of `task.sessions` in + `src/`, which is what makes a length watermark meaningful. The id carries the + re-arm's own generation, exactly as `engine._session_task_id` would mint it.""" + state = load_state(run_dir) + task = state.tasks[key] + assert task.generation > 0 # a re-arm ran, so this id is in a fresh namespace + task_id = _session_task_id(key, "review", 1, task.generation) + assert task_id not in {r.task_id for r in task.sessions} + task.record_session(SessionRecord(task_id=task_id, role="dev", status="completed")) + d = run_dir / "tasks" / task_id + d.mkdir(parents=True, exist_ok=True) + (d / "escalation.json").write_text( + json.dumps( + {"escalations": [{"type": "spec-gap", "severity": "CRITICAL", "detail": detail}]} + ), + encoding="utf-8", + ) + if escalated: + task.phase = Phase.ESCALATED + save_state(run_dir, state) + + +def test_gather_escalations_shows_the_whole_trail_at_watermark_zero(tmp_path): + """The default is the PRE-DW-11 walk, byte-for-byte. 0 is what a task that was + never resolved carries and what a pre-upgrade `state.json` deserializes to, so + this row is also the legacy-state contract at the reader.""" + run_dir, state, task, key = _watermarked_trail(tmp_path, [["older"], ["newer"]]) + assert task.escalations_resolved_upto == 0 + + found, withheld = resolve._gather_escalations(run_dir, state, key) + assert [e["detail"] for e in found] == ["newer", "older"] + assert withheld == 0 + + +def test_gather_escalations_hides_sessions_below_the_watermark(tmp_path): + """The defect DW-11 names. `task.sessions` is append-only and a re-arm + deliberately does not clear it, so a second resolve cycle re-presented every + escalation the story ever raised — interleaved with the new ones and with + nothing marking which was which, against a skill contract that is singular + ("present THE escalation"). + + Ablation: ignore `start` in `_gather_escalations` (route everything to `found`) + and this row fails by showing the answered entry again.""" + run_dir, state, _task, key = _watermarked_trail( + tmp_path, [["answered last cycle"], ["raised since"]] + ) + + found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) + assert [e["detail"] for e in found] == ["raised since"] + assert withheld == 1 + + +def test_gather_escalations_counts_the_entries_it_withheld(tmp_path): + """The number the operator is shown is the count of DISTINCT withheld entries, + not of sessions or of directories — and it comes from the same single walk that + produced the shown list, never a second call subtracting lengths.""" + run_dir, state, _task, key = _watermarked_trail(tmp_path, [["a", "b", "c"], ["new"]]) + + found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) + assert [e["detail"] for e in found] == ["new"] + assert withheld == 3 + + +def test_gather_escalations_does_not_count_an_entry_it_still_shows(tmp_path): + """ "Not shown" is the claim the number makes, so it must never count something + the operator can see. An escalation the re-drive re-raised appears on BOTH sides + of the watermark: it is shown once (the newest-first content map) and contributes + 0 to the count, while its answered-only sibling contributes 1. + + The sibling is the in-row positive control: an `assert withheld == 0` alone would + pass just as well if the answered directory were never read at all. + + Ablation: drop the `key not in found` clause from the count and this reddens at + 2 != 1.""" + run_dir, state, _task, key = _watermarked_trail( + tmp_path, + [["re-raised by the re-drive", "answered and gone"], ["re-raised by the re-drive"]], + ) + + found, withheld = resolve._gather_escalations(run_dir, state, key, start=1) + assert [e["detail"] for e in found] == ["re-raised by the re-drive"] # once, not twice + assert withheld == 1 # "answered and gone" only + + +def test_gather_escalations_attributes_a_task_id_spanning_the_watermark_to_the_shown_side( + tmp_path, +): + """One `task_id` on an answered record AND an unanswered one — the shape the + pre-`generation` id namespace produced, which persisted state still carries. The + `seen_ids` guard opens that directory ONCE, at its newest occurrence, which is + the unanswered side: the entry is SHOWN. Over-showing is the conservative + direction; the alternative buries an escalation on an ambiguity. + + Ablation: walk the trail FORWARD — `for index, session in enumerate(task.sessions)` + with `target = found if index >= start else answered`, a rewrite that still reads + correct and leaves every other row in this block green except the ordering sibling + — and this reddens at `([], 1)`. The shared directory is then opened at its + ANSWERED occurrence, so the escalation is buried AND counted as already answered: + the second member is what catches that, which is why the assertion is a tuple and + not the shown list alone. MEASURED, and the recipe is specific for a reason: + deleting the `seen_ids` guard does NOT redden this row (the directory is read + twice, but the key lands in `found` first and the count's `key not in found` + clause absorbs the duplicate), so `seen_ids` is graded by its own siblings above, + not here.""" + run_dir, state, task, key = _watermarked_trail(tmp_path, [["spans the watermark"]]) + shared = task.sessions[0].task_id + task.sessions.append(SessionRecord(task_id=shared, role="dev", status="completed")) + save_state(run_dir, state) + + assert resolve._gather_escalations(run_dir, state, key, start=1) == ( + [{"type": "spec-gap", "severity": "CRITICAL", "detail": "spans the watermark"}], + 0, + ) + + +def test_gather_escalations_with_no_sessions_is_empty_and_reports_nothing(tmp_path): + run_dir, state, task, key = _watermarked_trail(tmp_path, []) + assert task.sessions == [] + assert resolve._gather_escalations(run_dir, state, key) == ([], 0) + + +def test_gather_escalations_past_the_end_of_the_trail_never_raises(tmp_path): + """A watermark beyond the list — hand-edited state, or a trail that shrank — + must yield an empty shown list, not an IndexError. `start` only SELECTS a map; + nothing is indexed with it, which is what makes that true structurally. + + The `2` is load-bearing: `== ([], 2)` proves both directories were READ and + filtered. An `== []` alone would pass equally if the walk had found nothing.""" + run_dir, state, _task, key = _watermarked_trail(tmp_path, [["first"], ["second"]]) + + assert resolve._gather_escalations(run_dir, state, key, start=9) == ([], 2) + + +def test_rearm_stamps_the_watermark_when_a_resolution_was_recorded(tmp_path): + """The stamp records how much of the audit trail the accepted resolution covered + — a LENGTH of `task.sessions`, taken before the re-drive appends anything. + + Ablation: drop the stamp from `rearm_escalation` and this reddens at 0 != 1, + taking the second-cycle rows below with it.""" + run_dir, _, _ = _escalated_run(tmp_path) + before = load_state(run_dir).tasks["6-4-cli-list-command"] + assert before.escalations_resolved_upto == 0 and len(before.sessions) == 1 + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.escalations_resolved_upto == 1 + assert len(task.sessions) == 1 # the trail the watermark indexes still stands + assert task.generation == 1 # positive control: the bump ran on this gesture too + + +def test_rearm_leaves_the_watermark_where_it_was_when_nothing_was_recorded(tmp_path): + """`cmd_resolve` prints "no resolution recorded" and FALLS THROUGH to re-arm, and + both non-interactive re-arm gestures run no session at all. None of them accepted + anything, so none may advance the watermark: escalations no human answered would + otherwise become invisible to every later cycle and be reported as already + answered — the inverse of the defect. + + The generation assertion is the positive control and the discriminator: the bump + is UNCONDITIONAL (it answers session-id reuse, #705, which an abandoned attempt + needs just as much), so this row cannot pass by the re-arm having done nothing. + + Ablation: remove the `if resolution_recorded:` gate and this reddens at 1 != 0.""" + run_dir, _, _ = _escalated_run(tmp_path) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=False) + + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.escalations_resolved_upto == 0 + assert task.generation == 1 + + +def test_a_second_resolve_cycle_shows_only_what_the_redrive_raised(tmp_path): + """The whole chain with no seam hand-set: escalate, re-arm on a recorded + resolution, let the re-drive append its own session record and artifact, then + build the context a second time. `build_context` reads the watermark off the task + it loaded — nothing in this row passes `start`.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["the first cycle answered this"]]) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + _redrive_escalates(run_dir, key, "raised by the re-drive") + + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + ctx = json.loads(path.read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised by the re-drive"] + assert withheld == 1 + + +def test_a_third_cycle_stamps_again_over_the_second(tmp_path): + """TWO accepted cycles in sequence. Every other multi-cycle row stops after one + accepted cycle (`..._shows_only_what_the_redrive_raised`) or pairs an accepted one + with a declining one (`..._over_a_surviving_marker_...`), so nothing pinned that the + watermark keeps ADVANCING. A stamp that fires once and then sticks passes both of + those rows and re-presents cycle 2's answered escalation to every later cycle — + DW-11 itself, surviving one cycle further along. + + Ablation: make the stamp `max(task.escalations_resolved_upto, 1)` and this row + reddens on the third cycle's shown list and its count, while both existing + multi-cycle rows stay green.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered in cycle 1"]]) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + assert load_state(run_dir).tasks[key].escalations_resolved_upto == 1 + _redrive_escalates(run_dir, key, "answered in cycle 2", escalated=True) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + task = load_state(run_dir).tasks[key] + assert task.escalations_resolved_upto == 2 # ADVANCED again, over cycle 2's record + assert task.generation == 2 # positive control: both gestures re-armed + + _redrive_escalates(run_dir, key, "raised after cycle 2") + + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + ctx = json.loads(path.read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised after cycle 2"] + assert withheld == 2 # each answered cycle counted once + + +def test_a_rearm_over_a_surviving_marker_does_not_move_the_watermark(tmp_path): + """`resolution.json` SURVIVES the re-arm that consumed it: the only unlink in + `src/` is in `resolve.run_session`, which two of the three re-arm callers never + reach, and nothing deletes it at or after a re-arm. So a marker-presence gate + reads the PREVIOUS cycle's marker as this gesture's own, and a second re-arm + running no session would stamp over an escalation nobody has seen — hiding it + forever and reporting it as already answered. + + The marker is deliberately left on disk here and never removed, which is the + state a real second gesture opens on. + + Ablation: replace the `resolution_recorded` parameter with a + `resolution_path(run_dir, key).is_file()` read inside `rearm_escalation` and this + row reddens twice — the watermark advances to 2, and the context comes back + empty with the new escalation counted as withheld.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered in cycle 1"]]) + + marker = resolve.resolution_path(run_dir, key) + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + assert load_state(run_dir).tasks[key].escalations_resolved_upto == 1 + assert marker.is_file() # MEASURED: nothing deletes it at re-arm + + _redrive_escalates(run_dir, key, "raised after cycle 1", escalated=True) + + # the `--no-interactive` / TUI gesture: no session ran, so nothing was accepted + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=False) + + task = load_state(run_dir).tasks[key] + assert task.escalations_resolved_upto == 1 # NOT len(sessions) == 2 + assert task.generation == 2 # positive control: this gesture DID re-arm + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + ctx = json.loads(path.read_text(encoding="utf-8")) + assert [e["detail"] for e in ctx["escalations"]] == ["raised after cycle 1"] + assert withheld == 1 + + +def test_build_context_keeps_the_withheld_count_out_of_the_payload(tmp_path): + """The count is the OPERATOR's, not the agent's: `bmad-loop-resolve/SKILL.md` + documents `escalations` as the list to resolve, and a number for entries the + session cannot see is nothing it can act on. Any spelling of a leak reddens this, + because the key set is compared whole rather than probed for one name.""" + run_dir, _state, _task, key = _watermarked_trail(tmp_path, [["answered"], ["new"]]) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + _redrive_escalates(run_dir, key, "new one") + + path, withheld = resolve.build_context(load_state(run_dir), run_dir, key, isolation="") + assert withheld == 2 # the count exists... + ctx = json.loads(path.read_text(encoding="utf-8")) + assert set(ctx) == { + "story_key", + "run_id", + "spec_file", + "baseline_commit", + "paused_reason", + "escalations", + "resolution_path", + "restore_supported", + "spec_reaches_the_redrive", + "redrive_base_ref", + } # ...and reaches no field of the agent contract + + # ----------------------------------------------------------- run_session @@ -2460,7 +2828,7 @@ def interactive_env(self, spec): def test_run_session_detects_resolution(tmp_path, monkeypatch): run_dir, state, _ = _escalated_run(tmp_path) - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + _context(state, run_dir, "6-4-cli-list-command", isolation="") def fake_subprocess_run(argv, cwd, env): # simulate the agent writing the resolution marker @@ -2476,7 +2844,7 @@ def fake_subprocess_run(argv, cwd, env): def test_run_session_no_resolution(tmp_path, monkeypatch): run_dir, state, _ = _escalated_run(tmp_path) - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + _context(state, run_dir, "6-4-cli-list-command", isolation="") monkeypatch.setattr(resolve.subprocess, "run", lambda *a, **k: None) assert ( resolve.run_session( @@ -2490,7 +2858,7 @@ def test_run_session_clears_stale_marker(tmp_path, monkeypatch): """A marker left by a previous resolve of this story must not be read as this session's output (the agent that says 'already resolved' writes none).""" run_dir, state, _ = _escalated_run(tmp_path) - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + _context(state, run_dir, "6-4-cli-list-command", isolation="") stale = resolve.resolution_path(run_dir, "6-4-cli-list-command") stale.parent.mkdir(parents=True, exist_ok=True) stale.write_text('{"from": "last time"}', encoding="utf-8") @@ -2604,9 +2972,7 @@ def test_build_context_stories_carries_manifest_entry(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md", source="stories") state.spec_folder = "epic-1" - ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="").read_text(encoding="utf-8") - ) + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) st = ctx["stories"] assert st["spec_folder"] == "epic-1" assert st["story"]["title"] == "List command" @@ -2630,9 +2996,7 @@ def test_build_context_stories_sentinel_indicator(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") state.spec_folder = "epic-1" - ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="").read_text(encoding="utf-8") - ) + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) sent = ctx["stories"]["sentinel"] assert sent["kind"] == "unresolved" assert "intent too vague" in sent["blocking_condition"] @@ -2642,9 +3006,7 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): """Sprint mode leaves the context contract unchanged — no stories block.""" run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") # sprint source ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="").read_text( - encoding="utf-8" - ) + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") ) assert "stories" not in ctx @@ -2666,9 +3028,9 @@ def test_build_context_leaves_an_out_of_mount_spec_unchanged(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(spec), worktree_path=str(wt)) ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_file"] == spec.as_posix() @@ -2709,7 +3071,7 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): state.spec_folder = "epic-1" ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") + _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == (wt / rel).as_posix() sent = ctx["stories"]["sentinel"] @@ -2758,7 +3120,7 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec state.spec_folder = "epic-1" ctx = json.loads( - resolve.build_context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") + _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == outside.as_posix() # unchanged: absolute passes through sent = ctx["stories"]["sentinel"] @@ -2780,9 +3142,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): wt = tmp_path / ".bmad-loop" / "runs" / "20260613-111429-6a14" / "worktrees" / "1" run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_reaches_the_redrive"] is False @@ -2790,9 +3152,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): tmp_path, "20260613-111429-6a15", spec_file=str(tmp_path / "specs" / "6-4.md") ) plain = json.loads( - resolve.build_context( - plain_state, plain_dir, "6-4-cli-list-command", isolation="" - ).read_text(encoding="utf-8") + _context(plain_state, plain_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert plain["spec_reaches_the_redrive"] is True @@ -2815,9 +3177,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) state.target_branch = "feat/the-pinned-one" ctx = json.loads( - resolve.build_context( - state, run_dir, "6-4-cli-list-command", isolation="worktree" - ).read_text(encoding="utf-8") + _context(state, run_dir, "6-4-cli-list-command", isolation="worktree").read_text( + encoding="utf-8" + ) ) # the paired claim: the edit has no future, and THIS is the tree that does assert ctx["spec_reaches_the_redrive"] is False @@ -2829,9 +3191,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat ) plain_state.target_branch = "feat/the-pinned-one" # set, but no mount to make it apply plain = json.loads( - resolve.build_context( - plain_state, plain_dir, "6-4-cli-list-command", isolation="" - ).read_text(encoding="utf-8") + _context(plain_state, plain_dir, "6-4-cli-list-command", isolation="").read_text( + encoding="utf-8" + ) ) assert plain["redrive_base_ref"] == "HEAD" @@ -2878,7 +3240,7 @@ def test_rearm_warns_about_an_unreachable_spec_write_only_when_it_is_actionable( run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) unreachable = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(unreachable) is warns @@ -2950,7 +3312,7 @@ def _commit(status, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) unreachable = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(unreachable) is warns @@ -3009,7 +3371,7 @@ def _sentinel_run( sentinel = folder / f"{key}-unresolved.md" sentinel.write_text( - "---\nstatus: blocked\n---\n\n## Auto Run Result\n\n" "Status: blocked\nintent too vague\n", + "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", encoding="utf-8", ) mount = tmp_path / "wt" @@ -3073,7 +3435,7 @@ def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redriv ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=isolated) + runs.rearm_escalation(run_dir, isolated_redrive=isolated, resolution_recorded=True) assert not sentinel.exists() # the sentinel really was cleared on every row records = _upstream_records(run_dir) @@ -3162,7 +3524,7 @@ def _commit(intent, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) records = _upstream_records(run_dir) assert bool(records) is warns @@ -3197,7 +3559,7 @@ def test_rearm_exempts_a_stories_folder_configured_outside_the_project( ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert bool(_upstream_records(run_dir)) is not external @@ -3239,7 +3601,9 @@ def test_rearm_of_a_sentinel_survives_a_project_that_is_not_a_repository(tmp_pat ) monkeypatch.chdir(tmp_path) - assert runs.rearm_escalation(run_dir, isolated_redrive=True) == key # no GitError + assert ( + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) == key + ) # no GitError assert not sentinel.exists() # the destructive half still completed (rec,) = _upstream_records(run_dir) @@ -3291,7 +3655,7 @@ def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_pat monkeypatch.chdir(tmp_path) # the flip: policy now says `none`, while the recorded mount still says otherwise - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["redrive"] == "in-place" @@ -3348,7 +3712,7 @@ def test_rearm_in_place_proof_reads_the_working_tree_not_the_commit(tmp_path, mo root, spec_file=rel, worktree_path=str(mount), target_branch="main" ) monkeypatch.chdir(root) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) fired = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert bool(fired) is warns, f"corrected={corrected}" @@ -3409,7 +3773,7 @@ def test_rearm_base_ref_degrades_to_head_for_a_run_that_pinned_no_target(tmp_pat run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -3462,7 +3826,7 @@ def test_rearm_does_not_refuse_a_flip_the_redrive_never_reads( monkeypatch.chdir(tmp_path) runs.rearm_escalation( - run_dir, isolated_redrive=True + run_dir, isolated_redrive=True, resolution_recorded=True ) # must not raise: this flip cannot reach the re-drive kinds = _kinds(run_dir) @@ -3495,7 +3859,7 @@ def test_rearm_suppresses_the_unreachable_warning_only_on_proof(tmp_path, monkey run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) kinds = _kinds(run_dir) (unreachable,) = [e for e in kinds if e["kind"] == "rearm-spec-write-unreachable"] @@ -3537,7 +3901,7 @@ def test_rearm_does_not_warn_when_the_spec_dir_is_shared_with_the_redrive(tmp_pa ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] # and the flip really landed on the shared file the re-drive will read @@ -3581,7 +3945,7 @@ def test_rearm_still_warns_for_a_spec_spelled_out_of_but_resolving_into_the_work run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spelled), worktree_path=str(wt)) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (unreachable,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert unreachable["status"] == "ready-for-dev" @@ -3620,7 +3984,7 @@ def _refuse(self, *a, **kw): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (unreachable,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert unreachable["status"] == "ready-for-dev" @@ -3658,7 +4022,7 @@ def test_rearm_writes_the_project_rooted_spec_when_no_worktree_was_recorded(tmp_ run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel) # worktree_path="" -> the fallback monkeypatch.chdir(tmp_path / "elsewhere") - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) fm = verify.read_frontmatter(spec) assert fm["status"] == "ready-for-dev" # the project-rooted copy was flipped diff --git a/tests/test_runs.py b/tests/test_runs.py index 6eaf397d..b041b403 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2791,7 +2791,12 @@ def test_rearm_restore_mode_sets_in_review_strips_arr_and_latches(tmp_path): from bmad_loop.model import Phase run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING and task.attempt == 0 @@ -2809,7 +2814,9 @@ def test_rearm_plain_mode_sets_ready_for_dev_and_clears_stale_latch(tmp_path): # a stale latch from a prior restore attempt the human then chose to redo fresh run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, restore_patch_stale="old.patch") - runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore_patch => from-scratch + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # no restore_patch => from-scratch task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -2840,7 +2847,10 @@ def test_rearm_aborts_when_the_spec_status_cannot_be_reopened(tmp_path): with pytest.raises(runs.RearmError, match="re-open story spec"): runs.rearm_escalation( - run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, ) assert spec.read_text(encoding="utf-8") == spec_text # byte-identical @@ -2860,7 +2870,7 @@ def test_rearm_resets_followup_reviews_spent(tmp_path): state.tasks["1-1-a"].review_cycle = 2 save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.followup_reviews_spent == 0 @@ -2905,7 +2915,9 @@ def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): story's commit. The resolve session's own untracked file still is.""" run_dir, _spec, patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=False) # from-scratch re-arm replaces the latch + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # from-scratch re-arm replaces the latch task = load_state(run_dir).tasks["1-1-a"] assert "human.txt" in task.baseline_untracked @@ -2922,7 +2934,12 @@ def test_rearm_re_latching_the_same_patch_still_excludes_its_residue(tmp_path): still residue (and `git apply` would otherwise fail with 'already exists').""" run_dir, _spec, _patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) task = load_state(run_dir).tasks["1-1-a"] assert task.restore_patch == "artifacts/attempt.patch" @@ -2940,7 +2957,9 @@ def test_rearm_missing_stale_patch_degrades_loudly_without_raising(tmp_path): git(tmp_path, "add", "committed.txt") git(tmp_path, "commit", "-q", "-m", "attempt commit") - runs.rearm_escalation(run_dir, isolated_redrive=False) # must not raise RearmError + runs.rearm_escalation( + run_dir, isolated_redrive=False, resolution_recorded=True + ) # must not raise RearmError task = load_state(run_dir).tasks["1-1-a"] assert {"human.txt", "newfile.txt"} <= set(task.baseline_untracked) # full snapshot @@ -2956,7 +2975,12 @@ def test_rearm_without_a_stale_latch_journals_no_stale_restore_events(tmp_path): run_dir, _spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, git_project=True) (tmp_path / "human.txt").write_text("from the resolve session\n") - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False) + runs.rearm_escalation( + run_dir, + restore_patch="artifacts/attempt.patch", + isolated_redrive=False, + resolution_recorded=True, + ) assert "human.txt" in load_state(run_dir).tasks["1-1-a"].baseline_untracked assert _kinds(run_dir) == [] @@ -2972,7 +2996,7 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "attempt commit") old_baseline = load_state(run_dir).tasks["1-1-a"].baseline_commit - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.baseline_commit != old_baseline # baseline advanced past the commit @@ -2998,7 +3022,7 @@ def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_p task.baseline_commit = "0" * 39 + "1" # sha-shaped, but names no object save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3029,7 +3053,7 @@ def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): with pytest.raises(verify.GitError): verify.commits_above(tmp_path, task.baseline_commit) - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3054,7 +3078,7 @@ def boom(repo, baseline): monkeypatch.setattr(runs.verify, "commits_above", boom) with pytest.raises(MemoryError, match="not a git answer"): - runs.rearm_escalation(run_dir, isolated_redrive=False) + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) def test_archive_run(tmp_path): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 664a329d..63eac7f0 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1365,7 +1365,7 @@ def test_blocked_resolve_rearm_then_redispatch_to_done(project): assert not any(s.role == "dev" for s in adapter.sessions) # story 2 not leapfrogged # human fixed the frozen spec → re-arm (must run while still escalation-paused) - runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False) + runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True) assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev" # resume re-drives the re-armed story, then continues the schedule to story 2 @@ -1406,7 +1406,7 @@ def test_resolved_wedge_is_still_gated_on_redispatch(project): assert wedged.phase == Phase.ESCALATED and wedged.attempt == 0 and not wedged.sessions runs.rearm_escalation( - engine.run_dir, "1", isolated_redrive=False + engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True ) # human fixed the frozen spec assert load_state(engine.run_dir).tasks["1"].rearmed # ...and the re-drive is armed # a gate on story 1 lands while the run is down @@ -1441,7 +1441,7 @@ def test_sentinel_rearm_deletes_by_recorded_verdict_e2e(project): assert engine.run().paused assert load_state(engine.run_dir).tasks["1"].sentinel_kind == "unresolved" # recorded - runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False) + runs.rearm_escalation(engine.run_dir, "1", isolated_redrive=False, resolution_recorded=True) assert not sentinel.exists() # cleared by the recorded verdict assert (engine.run_dir / "sentinels" / "1-unresolved.md").is_file() # copy preserved reloaded = load_state(engine.run_dir) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index e1597e85..93cf106c 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -3467,7 +3467,11 @@ def test_sweep_bundle_restore_redrive_reaches_done_and_clears_latch(project, mon patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + engine.run_dir, + "dw-fix", + restore_patch=str(patch), + isolated_redrive=False, + resolution_recorded=True, ) resumed, adapter = resume_sweep( @@ -3508,7 +3512,11 @@ def test_sweep_restore_redrive_exhaustion_pauses_not_defers(project, monkeypatch patch.parent.mkdir(parents=True, exist_ok=True) patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + engine.run_dir, + "dw-fix", + restore_patch=str(patch), + isolated_redrive=False, + resolution_recorded=True, ) resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) @@ -3530,7 +3538,7 @@ def test_sweep_from_scratch_redrive_exhaustion_pauses_not_defers(project): ) engine = _run_to_dev_escalation(project, policy=policy) runs.rearm_escalation( - engine.run_dir, "dw-fix", isolated_redrive=False + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True ) # from-scratch, no restore resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) @@ -4678,7 +4686,9 @@ def test_rearmed_bundle_redrives_when_triage_json_lost(project): # cached triage plan reloaded and re-emitted its name. Recovery now keys on # the persisted task, so losing the cache changes nothing. engine = _run_to_dev_escalation(project) - runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + ) _lose_triage(engine.run_dir) resumed, adapter = resume_sweep(project, engine, _redrive_script(project)) @@ -4700,7 +4710,9 @@ def test_fresh_triage_different_bundle_name_no_double_drive(project, corruption) # would orphan the re-armed one. It must re-drive by identity, and its ids # must have left the open set before the fresh triage sees them. engine = _run_two_bundle_dev_escalation(project) - runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + ) _lose_triage(engine.run_dir, corruption) fresh = triage_result( @@ -4738,7 +4750,11 @@ def test_restore_patch_latch_honored_when_triage_json_lost(project, monkeypatch) patch.parent.mkdir(parents=True, exist_ok=True) patch.write_text("dummy\n") runs.rearm_escalation( - engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + engine.run_dir, + "dw-fix", + restore_patch=str(patch), + isolated_redrive=False, + resolution_recorded=True, ) _lose_triage(engine.run_dir) @@ -4863,7 +4879,9 @@ def test_regenerated_intent_when_bundle_file_missing(project): # The triage session's authored prose is the one unrecoverable piece; the # verbatim ledger entries are re-attached and become the contract. engine = _run_to_dev_escalation(project) - runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False, resolution_recorded=True + ) _lose_triage(engine.run_dir) intent = Path(engine.state.tasks["dw-fix"].bundle_file) intent.unlink() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 7bfbbc44..3d0754bc 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4550,6 +4550,67 @@ async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypat await until(pilot, lambda: rearms == ["1"] and calls == ["20260611-100000-aaaa"]) +async def test_tui_rearm_does_not_move_the_escalation_watermark(project, monkeypatch): + """DW-11, on the one re-arm surface a stale `resolution.json` actively invites. + + Every other TUI row here monkeypatches `runs.rearm_escalation` away, so none can + observe what it stamps — this one lets the REAL function run. The marker on disk is + the shape that matters: `resolve.run_session` is the only thing in `src/` that + unlinks it and this gesture never calls it, so the marker survived the CLI cycle + that consumed it, and `resolution_ready` (the sole enabler of this button) still + reads True. `_do_rearm` therefore has to declare `resolution_recorded=False` from + what it KNOWS — it ran no session — rather than from what is on disk, which is + exactly the verdict `_restore_recorded` already records for this surface. + + The watermark is seeded to 1 over a two-record trail so "did not move" is + distinguishable from "was never set"; `generation` is the positive control that the + re-arm really ran. + + Ablation: pass `resolution_recorded=True` from `_do_rearm` (or gate the stamp on + `resolution_path(...).is_file()` inside `rearm_escalation`) and this reddens at + 2 != 1.""" + from bmad_loop import resolve + from bmad_loop.engine import _session_task_id + from bmad_loop.journal import load_state + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: None) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: needs a human decision on the auth scheme.", + ) + state = load_state(run_dir) + task = state.tasks["1"] + task.phase = Phase.ESCALATED + task.sessions.clear() + for seq in (1, 2): + task.record_session( + SessionRecord( + task_id=_session_task_id("1", "review", seq, 0), role="dev", status="completed" + ) + ) + task.escalations_resolved_upto = 1 # an earlier CLI cycle answered the first record + save_state(run_dir, state) + # the marker that cycle's agent wrote — nothing deleted it at its re-arm + marker = resolve.resolution_path(run_dir, "1") + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + app._do_rearm("20260611-100000-aaaa", run_dir, "1") + await pilot.pause() + + rearmed = load_state(run_dir).tasks["1"] + assert rearmed.escalations_resolved_upto == 1 # NOT len(sessions) == 2 + assert rearmed.generation == 1 # positive control: the re-arm ran + + async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch): """The mode `runs.rearm_escalation` needs comes from policy.toml, read HERE. @@ -4576,7 +4637,8 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch.setattr( runs, "rearm_escalation", - lambda rd, sk, *, isolated_redrive: seen.append(isolated_redrive) or "ready-for-dev", + lambda rd, sk, *, isolated_redrive, resolution_recorded: seen.append(isolated_redrive) + or "ready-for-dev", ) run_dir, _spec = _stories_paused_run( project.project, @@ -4746,7 +4808,7 @@ async def test_escalation_rearm_surfaces_a_failed_baseline_advance(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): Journal(rd).append( "rearm-baseline-advance-failed", story_key=sk, @@ -4806,7 +4868,7 @@ async def test_escalation_rearm_aims_the_code_root_before_it_rearms(project, mon monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") seen: list = [] - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): seen.append(load_state(rd).code_root) return "ready-for-dev" @@ -4947,7 +5009,7 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): journal = Journal(rd) journal.append( "stale-restore-commits", @@ -5042,7 +5104,7 @@ async def test_escalation_rearm_holds_the_resume_it_folds_in(project, monkeypatc monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): Journal(rd).append( "rearm-spec-write-unreachable", story_key=sk, @@ -5114,7 +5176,7 @@ async def test_escalation_rearm_echoes_residue_when_the_rearm_aborts(project, mo monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): # exactly the real ordering: residue journalled, THEN the abort Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] @@ -5175,7 +5237,7 @@ async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] ) From abc239d3b2133500cbe7c9bd544618f6ee9be192 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 19:30:55 -0700 Subject: [PATCH 13/45] Add source-scan parity guards for three prose-only invariants DW-65: extract journal.TASK_CYCLE_ARTIFACTS as the one list both adapters' start_session unlinks and resolve._gather_escalations share, and guard against a bare artifact literal re-introducing the drift. DW-66: guard that a session task id is composed only in engine._session_task_id, so a fifth hand-mint cannot omit the -g re-arm suffix and re-open #705. DW-82: guard that every journal field name is routed by diagnostics' redaction tables -- by name, and by kind where the table is kind-scoped -- or declared benign. The measured premise (renaming patch to patch_path leaves all 57 test_diagnostics.py rows green while the dump leaks) now reddens. Each detector rides the existing single-pass _scan_source and carries positive and negative probes, so a detector that stops detecting cannot read as green. Also fixes a leak the inventory exposed: sweep-inflight-stranded journalled story_keys as a list of raw bundle story keys, which fell through to scrub_json verbatim while the singular story_key beside it was aliased. --- CHANGELOG.md | 16 + src/bmad_loop/adapters/generic.py | 11 +- src/bmad_loop/adapters/opencode_http.py | 11 +- src/bmad_loop/diagnostics.py | 16 +- src/bmad_loop/journal.py | 38 + src/bmad_loop/resolve.py | 12 +- tests/test_diagnostics.py | 55 + tests/test_generic_tmux.py | 40 +- tests/test_opencode_http.py | 34 +- tests/test_portability_guard.py | 1806 ++++++++++++++++++++++- 10 files changed, 1992 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f00234..9f2c76bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,18 @@ breaking changes may land in a minor release. line. Deliberately not widened to `run_verify_commands`, which has three legitimate callers on two roots. +- **Source-scan parity guards for three invariants previously held only by docstring prose** + (DW-65, DW-66, DW-82). The task-directory artifact names move to one shared + `journal.TASK_CYCLE_ARTIFACTS` that both adapters and `resolve._gather_escalations` iterate, + and `tests/test_portability_guard.py` gains three detectors: a bare artifact literal outside + that constant, a session task id composed outside `engine._session_task_id`, and a journal + field name that neither `diagnostics`' redaction tables route nor the benign inventory + declares. Each carries positive and negative probes so a detector that stops detecting cannot + read as green, and an unresolvable `journal.append(**splat)`, a journal write whose kind is + not a string literal, and a benign entry whose producer has been deleted all fail loud rather + than being skipped. Journal field routing is graded per kind where `diagnostics` routes per + kind, and a declared forwarder's call sites (`plugins/bus.py::_log`) enter the inventory. + - **`repo_root` in run `state.json`** (#716). A run records the git root its code work happens in, so an out-of-process reader — `bmad-loop resolve`'s re-arm — uses the tree the run measured instead of re-deriving one. A `state.json` written before the field existed degrades to the @@ -207,6 +219,10 @@ breaking changes may land in a minor release. ### Fixed +- Alias the `story_keys` list on the `sweep-inflight-stranded` journal record. It carried raw + bundle story keys into `diagnose --dump`: the value fell through to `scrub_json`, which is the + identity on a list of identifier-shaped strings, while the singular `story_key` beside it was + already aliased. - Stop a second resolve cycle re-presenting escalations the human already answered (DW-11). Only a re-arm that accepted a `resolution.json` watermarks the session trail; later cycles show what came after it and print how many were withheld. diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 643445b2..4071976f 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -32,7 +32,7 @@ from .. import devcontract, gates, runs from ..bmadconfig import ProjectPaths -from ..journal import LOGS_DIR +from ..journal import LOGS_DIR, TASK_CYCLE_ARTIFACTS from ..model import TokenUsage from ..policy import Policy from ..process_host import ProcessHostError, get_process_host @@ -543,10 +543,11 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8") # Task ids are supplied by the caller, so defensively reset cycle-scoped # outputs if one is reused. A silent session must not inherit a stale result. - (task_dir / "result.json").unlink(missing_ok=True) - # The sweep skill also writes escalation.json here, and - # `resolve._gather_escalations` reads it alongside result.json. - (task_dir / "escalation.json").unlink(missing_ok=True) + # The list is `journal.TASK_CYCLE_ARTIFACTS` rather than two literals here: + # `resolve._gather_escalations` reads the same names back, so a third + # artifact must not be able to reach the reader while missing this adapter. + for artifact in TASK_CYCLE_ARTIFACTS: + (task_dir / artifact).unlink(missing_ok=True) self._ensure_session(spec.cwd) # Stamped before launch: hook events carry wall-clock ns, and diff --git a/src/bmad_loop/adapters/opencode_http.py b/src/bmad_loop/adapters/opencode_http.py index df13fe3b..032c4f81 100644 --- a/src/bmad_loop/adapters/opencode_http.py +++ b/src/bmad_loop/adapters/opencode_http.py @@ -146,7 +146,7 @@ from .. import gates from ..bmadconfig import ProjectPaths -from ..journal import LOGS_DIR +from ..journal import LOGS_DIR, TASK_CYCLE_ARTIFACTS from ..model import TokenUsage from ..policy import Policy from ..process_host import ProcessHostError, get_process_host @@ -624,10 +624,11 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8") # Task ids are supplied by the caller, so defensively reset cycle-scoped # outputs if one is reused. A silent session must not inherit a stale result. - (task_dir / "result.json").unlink(missing_ok=True) - # The sweep skill also writes escalation.json here, and - # `resolve._gather_escalations` reads it alongside result.json. - (task_dir / "escalation.json").unlink(missing_ok=True) + # Iterating `journal.TASK_CYCLE_ARTIFACTS` is what makes the parity with + # GenericAdapter.start_session structural instead of a claim in a test + # docstring: both adapters and `resolve._gather_escalations` share one list. + for artifact in TASK_CYCLE_ARTIFACTS: + (task_dir / artifact).unlink(missing_ok=True) # Same hazard, same reason, for the file the #194 tail scan reads (mirrors # GenericAdapter.start_session, which unlinks its pane tee here). This one # bites hardest on the path the classifier exists to serve: an env fault diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 9e56549f..fbd5da5d 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -276,8 +276,15 @@ "stashed_to", } ) -# Journal fields whose value is a LIST of story keys (sprint unknown-keys). -_JOURNAL_KEYLIST_FIELDS = frozenset({"keys", "dw_ids"}) +# Journal fields whose value is a LIST of identifiers, aliased element-wise rather +# than dropped so a dump stays correlatable. Two namespaces live here: `keys` +# (sprint unknown-keys) and `story_keys` (`sweep._warn_stranded_bundles`, the +# bundle keys a cycle left in flight) are story keys; `dw_ids` are deferred-work +# ids. The fallback is what makes this routing necessary rather than cosmetic — +# `scrub_json` is the IDENTITY on a list of identifier-shaped strings, so an +# unrouted `story_keys` shipped its keys verbatim while the singular `story_key` +# beside it in the neighbouring record was aliased. +_JOURNAL_KEYLIST_FIELDS = frozenset({"keys", "dw_ids", "story_keys"}) # Policy keys whose values can carry secrets/paths/free text. Dropped or reduced # rather than scrubbed, since a single-token API key or repo name could be @@ -735,7 +742,10 @@ def _scrub_entry( if k in _JOURNAL_DROP_FIELDS: out[f"{k}_present"] = v is not None and v != "" elif k in _JOURNAL_KEYLIST_FIELDS and isinstance(v, list): - ns = "story" if k == "keys" else "dw" + # Namespace by field, not by "everything that is not `keys`": both + # story-key list fields must land in the SAME namespace as the singular + # `story_key`, or one dump would carry two aliases for one story. + ns = "dw" if k == "dw_ids" else "story" out[k] = [pseudo.alias(x, ns=ns, epic=epic_by_key.get(str(x))) for x in v] elif kind_ns is not None or k in _JOURNAL_ALIAS_FIELDS: ns = kind_ns or _JOURNAL_ALIAS_FIELDS[k] diff --git a/src/bmad_loop/journal.py b/src/bmad_loop/journal.py index 169517cf..4e8297e0 100644 --- a/src/bmad_loop/journal.py +++ b/src/bmad_loop/journal.py @@ -24,6 +24,44 @@ # Verifier subprocess streams, deliberately NOT under LOGS_DIR — see # Journal.write_verify_stream for why sharing that directory is a TUI bug. VERIFY_DIR = "verify" +# The cycle-scoped artifacts a session writes into ``tasks//``: the ONE +# list the three sites that touch them share. Both adapters clear these in +# ``start_session`` (a caller-supplied task_id may be reused, and a silent session +# must not inherit a stale predecessor's outputs) and +# ``resolve._gather_escalations`` reads them back. Spelled here rather than three +# times, because a fourth artifact added to the reader alone would silently miss +# both adapters — which is the shape the parity was in before. +# +# ``result.json`` is the dev/review contract's own result file. ``escalation.json`` +# is the SWEEP SKILL's: its automation contract +# (``data/skills/bmad-loop-sweep/automation-mode.md``) tells a sweep session to +# write that file and then mirror the same entries into ``result.json``'s +# ``escalations``. That sentence lived in both adapters' comments and nowhere else, +# and it is the whole reason the reader opens two names rather than one. +# +# ORDER IS LOAD-BEARING — but NOT because of the mirroring, which is the obvious +# reading and the wrong one: ``_gather_escalations`` keys its map on canonical +# JSON, so a mirrored entry's STORED value is byte-identical whichever copy is read +# first. What the order fixes is the POSITION of DISTINCT entries in the +# newest-first list the operator is shown — result.json's entries precede +# escalation.json's, and a repeat keeps its first occurrence's slot. Swap these two +# and ``tests/test_resolve.py``'s +# ``test_gather_escalations_preserves_result_before_escalation_file_order`` and +# ``test_gather_escalations_keeps_a_duplicates_first_position`` redden (measured, +# not reasoned about). +# +# Appending a name is bounded twice, so it is not free. ``_gather_escalations`` +# JSON-parses every name here and skips anything that is not an +# ``{"escalations": [...]}`` document, so a name that does not carry that shape +# buys the reader nothing. And both adapters run this unlink loop AFTER +# ``start_session`` has already written ``prompt.txt`` into the same directory, so +# a name an earlier step of that method writes would be deleted on the way out. +# +# Four other cycle-scoped files live in ``tasks//`` and are deliberately +# NOT here, because each is owned and read by ONE adapter rather than shared: +# ``heartbeat.json``, ``resultless-stops.jsonl`` and ``session-lifecycle.jsonl`` +# (``adapters/generic.py``) and ``messages.json`` (``adapters/opencode_http.py``). +TASK_CYCLE_ARTIFACTS: tuple[str, ...] = ("result.json", "escalation.json") class Journal: diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index a734431c..addc4d56 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -23,6 +23,7 @@ from .adapters.base import SessionSpec from .engine import _session_task_id from .escalation import critical_escalations +from .journal import TASK_CYCLE_ARTIFACTS from .model import RunState from .platform_util import safe_segment from .runs import ( @@ -93,9 +94,12 @@ def _gather_escalations( default 0 reproduces the pre-DW-11 walk byte-for-byte, which is what a pre-upgrade ``state.json`` deserializes to. - Reads each session's tasks//result.json (and escalation.json) — the - same files the engine inspected when it decided to pause. Ordering is - `reversed(task.sessions)` and, within a directory, result.json before + Reads each session's tasks// artifacts — the same files the engine + inspected when it decided to pause. WHICH files is not spelled here: it is + ``journal.TASK_CYCLE_ARTIFACTS``, the one list this reader shares with the two + adapters that clear the same directory in ``start_session``, so a name added + there reaches all three sites at once. Ordering is `reversed(task.sessions)` + and, within a directory, that constant's own order — result.json before escalation.json; a duplicate keeps its FIRST occurrence's position, which is what preserves "newest first". Three guards, each for a defect this reader hit on the way to the operator: @@ -156,7 +160,7 @@ def _gather_escalations( seen_ids.add(session.task_id) target = found if last - offset >= start else answered task_dir = run_dir / "tasks" / session.task_id - for fname in ("result.json", "escalation.json"): + for fname in TASK_CYCLE_ARTIFACTS: fpath = task_dir / fname if not fpath.is_file(): continue diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 37136adc..99ea0c9a 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -895,6 +895,61 @@ def test_target_field_routes_by_kind_because_it_carries_two_kinds_of_value(): assert canary not in rendered, f"LEAK: {canary!r}" +def test_stranded_bundle_story_keys_are_aliased_element_wise(): + """`sweep-inflight-stranded` carries a LIST of story keys, and a list of + identifier-shaped strings is the one shape `scrub_json` passes through + untouched — `scrub_json(["1-1-acme-auth"]) == ["1-1-acme-auth"]`, verbatim. + + So the plural field needs the same routing as the singular `story_key` beside + it, which was already aliased: `_JOURNAL_KEYLIST_FIELDS` reduces a list + element-wise, and the namespace selection has to send this one to `story` (not + to `dw`, which is only `dw_ids`) or one dump would carry two different aliases + for the same story. The epic lookup rides along, exactly as it does for `keys`. + + Ablation: drop `story_keys` from `_JOURNAL_KEYLIST_FIELDS` and the alias + assertions redden with the raw keys coming back verbatim; flip the namespace + selection back to `"story" if k == "keys" else "dw"` and the cross-field + identity assertion reddens (a `dw-` alias for a story key). + """ + other_key = "3.4-AcmeVaultRotation" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + { + "ts": 2.0, + "kind": "sweep-inflight-stranded", + "story_keys": [STORY_KEY, other_key], + }, + pseudo, + {STORY_KEY: 1, other_key: 3}, + 1.0, + ) + # the SAME story, journalled singular by a neighbouring record, must resolve to + # the same alias — that identity is the whole reason this is aliased not dropped + singular = diagnostics._scrub_entry( + {"ts": 3.0, "kind": "sweep-bundle-recovered", "story_key": STORY_KEY}, + pseudo, + {STORY_KEY: 1}, + 1.0, + ) + + assert scrubbed["story_keys"] == [singular["story_key"], scrubbed["story_keys"][1]] + assert STORY_KEY not in scrubbed["story_keys"] + assert other_key not in scrubbed["story_keys"] + assert scrubbed["story_keys"][0] != scrubbed["story_keys"][1] + # the epic lookup still applies: `Pseudonymizer.alias` prefixes a story alias + # with `s`, so each element carries the epic it was looked up under — + # drop the `epic=` argument from the keylist branch and both prefixes become a + # bare `story-` + assert [a.split("-")[0] for a in scrubbed["story_keys"]] == ["s1", "s3"] + # …and nothing landed in the deferred-work namespace, which is where the old + # `"story" if k == "keys" else "dw"` selection would have put both of them + assert not [orig for ns, orig, _a in pseudo.entries() if ns == "dw"] + + rendered = json.dumps([scrubbed, singular]) + for canary in (STORY_KEY, other_key, *CANARIES): + assert canary not in rendered, f"LEAK: {canary!r}" + + def test_structure_is_preserved(project): run_dir = _seed_run(project.project) diag, _pseudo, _combined = _render_all([run_dir]) diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index ea8e4800..d7737e1c 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -29,6 +29,7 @@ from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.adapters.profile import get_profile from bmad_loop.bmadconfig import ProjectPaths +from bmad_loop.journal import TASK_CYCLE_ARTIFACTS from bmad_loop.model import TokenUsage from bmad_loop.policy import LimitsPolicy, NotifyPolicy, Policy from bmad_loop.signals import HookEvent @@ -3137,30 +3138,39 @@ def test_start_session_resets_reused_task_log(tmp_path): assert _classify(adapter, "timeout", task_id=task_id).env_fault is False -def test_start_session_drops_a_reused_task_dirs_escalation(tmp_path): - """The sweep skill writes `escalation.json` into tasks// and - `resolve._gather_escalations` reads it beside result.json. A re-armed run reuses - task_ids, so a prior cycle's escalation left there is handed to whatever session - lands on the id next — the same reuse hazard result.json's unlink already covers, - against a third reader. An ABSENT file must still start cleanly (missing_ok).""" +def test_start_session_drops_every_reused_task_cycle_artifact(tmp_path): + """A re-armed run reuses task_ids, so anything a prior cycle left in + tasks// is handed to whatever session lands on the id next — and + `resolve._gather_escalations` reads those files back to decide what to show the + operator. An ABSENT file must still start cleanly (missing_ok). + + Iterates `journal.TASK_CYCLE_ARTIFACTS` rather than naming the files, so this + covers the list rather than today's two entries: a third artifact added to the + constant is asserted here with no edit. The parity with + `OpencodeHTTPAdapter.start_session` used to be a claim in a docstring — both + adapters now loop over the same constant, and + `test_portability_guard.test_task_cycle_artifacts_named_only_through_the_constant` + refuses a bare literal that would let one drift from the other.""" mux = _StartSessionMux() adapter = make_adapter(tmp_path, mux=mux) adapter._ensure_session = lambda cwd: None # skip the tmux server plumbing task_id = _ENV_FAULT_TASK task_dir = adapter.tasks_dir / task_id task_dir.mkdir(parents=True, exist_ok=True) - stale = task_dir / "escalation.json" - stale.write_text( - json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "last cycle"}]}), - encoding="utf-8", - ) + assert TASK_CYCLE_ARTIFACTS, "the constant is the list under test; an empty one is vacuous" + stale = [task_dir / name for name in TASK_CYCLE_ARTIFACTS] + for path in stale: + path.write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "last cycle"}]}), + encoding="utf-8", + ) adapter.start_session(make_spec(tmp_path, task_id=task_id)) - assert not stale.exists() + assert [p for p in stale if p.exists()] == [] - # ...and with the file already gone the unlink is a no-op, not an error. What this - # second call asserts is that it RETURNS (the missing_ok path); re-asserting the - # file's absence would only restate the line above, since nothing re-created it. + # ...and with the files already gone the unlinks are no-ops, not errors. What this + # second call asserts is that it RETURNS (the missing_ok path); re-asserting their + # absence would only restate the line above, since nothing re-created them. assert adapter.start_session(make_spec(tmp_path, task_id=task_id)) is not None diff --git a/tests/test_opencode_http.py b/tests/test_opencode_http.py index d2d61d9c..0088f48a 100644 --- a/tests/test_opencode_http.py +++ b/tests/test_opencode_http.py @@ -44,6 +44,7 @@ ) from bmad_loop.adapters.profile import get_profile from bmad_loop.bmadconfig import ProjectPaths +from bmad_loop.journal import TASK_CYCLE_ARTIFACTS from bmad_loop.model import TokenUsage from bmad_loop.policy import LimitsPolicy, NotifyPolicy, Policy from bmad_loop.process_host import ProcessHostError, get_process_host @@ -1290,28 +1291,35 @@ def test_missing_binary_is_a_clean_error(tmp_path): adapter.start_session(spec) -def test_start_session_drops_a_reused_task_dirs_escalation(tmp_path): +def test_start_session_drops_every_reused_task_cycle_artifact(tmp_path): """Parity with GenericAdapter: both adapters own a tasks// dir, so both must - drop a prior cycle's `escalation.json` — the file the sweep skill writes and - `resolve._gather_escalations` reads beside result.json — before a re-armed run - reusing the id lands there. No fake server needed: the unlink runs BEFORE - _spawn_server's PATH check raises, so a missing binary still exercises it.""" + drop a prior cycle's artifacts before a re-armed run reusing the id lands there. + No fake server needed: the unlinks run BEFORE _spawn_server's PATH check raises, + so a missing binary still exercises them. + + That parity is now STRUCTURAL rather than asserted twice in prose: both adapters + loop over `journal.TASK_CYCLE_ARTIFACTS`, this test iterates the same constant, + and `test_portability_guard.test_task_cycle_artifacts_named_only_through_the_constant` + refuses the bare literal that would let one adapter drift from the other. A third + artifact added to the constant is covered here with no edit.""" adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") spec = SessionSpec(task_id="t-1", role="triage", prompt="p", cwd=tmp_path) task_dir = adapter.tasks_dir / "t-1" task_dir.mkdir(parents=True, exist_ok=True) - stale = task_dir / "escalation.json" - stale.write_text( - json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "last cycle"}]}), - encoding="utf-8", - ) + assert TASK_CYCLE_ARTIFACTS, "the constant is the list under test; an empty one is vacuous" + stale = [task_dir / name for name in TASK_CYCLE_ARTIFACTS] + for path in stale: + path.write_text( + json.dumps({"escalations": [{"severity": "CRITICAL", "detail": "last cycle"}]}), + encoding="utf-8", + ) with pytest.raises(OpencodeServerError, match="not found on PATH"): adapter.start_session(spec) - assert not stale.exists() + assert [p for p in stale if p.exists()] == [] - # ...and the ordinary case — no prior escalation — reaches the same spawn error, - # i.e. the unlink is missing_ok and did not become the failure itself + # ...and the ordinary case — nothing left behind — reaches the same spawn error, + # i.e. the unlinks are missing_ok and did not become the failure themselves with pytest.raises(OpencodeServerError, match="not found on PATH"): adapter.start_session(spec) diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 6102045a..c9645960 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -7,13 +7,27 @@ in an allowlisted file and — outside the wholesale tmux quarantine — carries a ``# portability:`` ack on its line, so exceptions stay deliberate. -The same single-pass scan also carries the two non-POSIX quarantines that have the +The same single-pass scan also carries the non-POSIX quarantines that have the identical shape: AGENTS.md's "New core env vars register in ``envvars.py``; plugin-owned env-var families stay with their plugin" — see ``test_bmad_loop_env_reads_only_in_the_registry`` — and its "all git subprocess calls go through the ``_run_git`` chokepoint in ``verify.py``" — see ``test_no_git_invocation_outside_verify``. +Three later invariants ride the same machinery, each one previously held by +docstring prose alone: + +* the task-directory artifact names are ``journal.TASK_CYCLE_ARTIFACTS`` and not a + literal repeated per reader/writer — ``test_task_cycle_artifacts_named_only_through_the_constant`` +* a session task id is composed only in ``engine._session_task_id`` — + ``test_session_task_id_composed_only_at_the_chokepoint`` +* every journal field name a call spells is either routed by ``diagnostics``' + redaction tables — by name, or by name-and-kind — or declared benign: + ``test_journal_fields_are_routed_or_declared_benign``, with + ``test_journal_kinds_are_literal_or_the_position_is_declared`` holding the kind + half readable and ``test_journal_append_writes_only_accounted_fields`` covering + the two names ``Journal.append`` mints itself, which no call site spells. + If this test flags something unexpected, fix the source (route it through the seam / a platform helper) rather than widening an allowlist. """ @@ -21,12 +35,14 @@ from __future__ import annotations import ast +import json from pathlib import Path import pytest import bmad_loop -from bmad_loop import envvars +from bmad_loop import diagnostics, envvars +from bmad_loop.journal import JOURNAL_FILE, TASK_CYCLE_ARTIFACTS, Journal SRC = Path(bmad_loop.__file__).resolve().parent # Marker an allowlisted exception line must carry. Written as ``# portability: …``; @@ -119,6 +135,374 @@ SPEC_ANCHOR_CHOKEPOINT = {"runs.py", "engine.py", "verify.py", "recovery_flow.py"} SPEC_PATH_FIELDS = {"spec_file", "dispatched_spec_file"} +# ``(file, name)`` of the ONE assignment that may spell the task-directory artifact +# names as literals: ``journal.TASK_CYCLE_ARTIFACTS`` itself. Constants inside that +# assignment's value are the definition, not a copy, so the scan skips them — the +# position idiom the git and verify exemptions use, rather than an allowlist entry +# that would also wave through a bare literal anywhere else in journal.py. +# +# Paired with the FILE on purpose: the same tuple re-declared in another module is a +# second copy, which is exactly what the guard exists to refuse. +TASK_ARTIFACT_DEFINITION = ("journal.py", "TASK_CYCLE_ARTIFACTS") + +# ``rel -> enclosing function -> the artifact names it may still spell as a bare +# literal``. Keyed by FUNCTION as well as by file — ``VERIFY_CLASSIFY_CHOKEPOINT``'s +# idiom — because the sanction is a POSITION: a second bare `"result.json"` grown +# anywhere else in `adapters/generic.py` would inherit a file-keyed exemption on its +# path alone, which is both the drift the guard exists to catch and the thing this +# comment used to claim was already impossible. +# +# Scoped by NAME inside that, for `ENV_READ_ALLOW`'s reason: being the sanctioned +# position buys `_result_path` the one name it declares and nothing wider. +# +# `adapters/generic.py::_result_path` is the one sanctioned single-name read: it +# answers "where does THIS task's result.json live", a genuinely single-artifact +# question that folding into the loop would not express. It carries no claim about +# `escalation.json`, so that name stays refused inside it. +TASK_ARTIFACT_LITERAL_ALLOW = { + "adapters/generic.py": {"_result_path": frozenset({"result.json"})}, +} + +# The one file allowed to COMPOSE a session task id, and within it only inside +# ``_session_task_id`` — keyed file -> the ONE enclosing function, like +# ``VERIFY_CLASSIFY_CHOKEPOINT``. Every mint site (`engine.py` ×3, `resolve.py`) +# calls it; none spells the format itself. +# +# The sanction is a POSITION, not the file: engine.py is where a fifth mint would +# most naturally be written (it already binds `task_id` three times), so a file-wide +# exemption would leave the invariant unguarded exactly where it matters. The +# function's own docstring states why every caller must be byte-identical — +# ``_resumable_session``'s resume match, and the ``-g`` re-arm discriminator that +# a hand-rolled fourth mint would omit (#705). +SESSION_TASK_ID_CHOKEPOINT = {"engine.py": "_session_task_id"} + +# The journal field names ``diagnostics`` routes BY NAME, read off the live module +# rather than copied, so the guard cannot drift from the tables it grades: add a row +# there and the corresponding producer stops being an offender with no edit here. +# Three tables, because these three are the by-name routing decisions — an alias, a +# drop, or a key-list reduction. Anything else falls through to +# ``sanitize.scrub_json``, which fails closed only by accident of a value's shape. +# +# ``_JOURNAL_KIND_ALIAS_FIELDS`` is deliberately NOT flattened in here. It routes by +# ``(kind, name)``, and folding it into a by-name union says ``target`` is routed +# everywhere — including on the ``board-advance-*`` family, where that module's own +# comment says by-name routing would be WRONG. Flattened, the guard read +# ``journal.append("unit-merge-failed", target=branch)`` — a NEW kind reusing the +# name — as routed, while ``_scrub_entry`` handed it to ``scrub_json`` and shipped +# the branch verbatim. See ``JOURNAL_KIND_ROUTED_FIELDS`` for the scoped form. +JOURNAL_ROUTED_FIELDS = ( + frozenset(diagnostics._JOURNAL_ALIAS_FIELDS) + | diagnostics._JOURNAL_DROP_FIELDS + | diagnostics._JOURNAL_KEYLIST_FIELDS +) + +# ``kind -> the field names routed on THAT kind only``, read off the same module so +# the guard still cannot drift from it. A name here is routed on its own kinds and +# unrouted everywhere else, which is the distinction the flattened union destroyed. +JOURNAL_KIND_ROUTED_FIELDS = { + kind: frozenset(row) for kind, row in diagnostics._JOURNAL_KIND_ALIAS_FIELDS.items() +} + +# ``kind -> field names declared benign on that kind alone`` — the kind-scoped twin of +# ``JOURNAL_BENIGN_FIELDS``, and it exists for the same field the routing table does. +# ``engine``'s board-advance carry paths journal ``target`` carrying a sprint STATUS +# ("done"), not a branch; ``diagnostics``' ``_JOURNAL_KIND_ALIAS_FIELDS`` comment is +# explicit that aliasing those would destroy the field a maintainer reads the record +# for. Declared per kind rather than by adding ``target`` to the by-name benign set, +# which would also wave through a branch-carrying ``target`` on a kind nobody has +# looked at — exactly the hole the flattening left. +JOURNAL_KIND_BENIGN_FIELDS = { + "board-advance-carried": frozenset({"target"}), + "board-advance-carry-failed": frozenset({"target"}), + "board-advance-carry-foreign-dirt": frozenset({"target"}), + "board-advance-carry-uncommitted": frozenset({"target"}), +} + +# Every OTHER field name journalled today: a declared inventory, not a per-name +# audit. Nobody has argued each of these is safe unrouted; what the list records is +# that they are the set that existed when the guard landed. That is the whole claim, +# and it is worth making — field name #132 cannot appear without someone deciding +# whether it needs routing, which is the decision DW-82 measured nothing forcing. +# +# ⚠️ Adding a name here is that decision, made in the "no routing needed" direction. +# Make it deliberately: a name carrying a story key, a branch, a sha, a spec +# filename, a path, or free text belongs in a `diagnostics` table instead. Adding a +# routing row there for a field that does not need one is equally wrong — it would +# pseudonymize a value a maintainer reads the record for (see +# `_JOURNAL_KIND_ALIAS_FIELDS`' `target` for that failure in the other direction). +# +# ⚠️ STATED BOUND, so nobody reads more into this than it says: the guard catches a +# rename OUT of the tables into unclaimed space — the measured `patch` → `patch_path` +# ablation. It does NOT catch a rename INTO a name one of these sets already holds. +# Respell `recovery_flow.py`'s `patch=` as `path=`, `ref=` or `name=` and every +# assertion here stays green while the value stops being dropped, because the guard +# grades the NAME against a set and all three of those names are in it. Only +# `tests/test_diagnostics.py` can see that, and only if it has a row for the record. +JOURNAL_BENIGN_FIELDS = frozenset( + { + "action", + "actions", + "adapter", + "adapter_dev", + "adapter_review", + "already_resolved", + "attempt", + "blocked", + "blocking", + "budget", + "budget_mode", + "budget_weighted", + "bundles", + "bundles_not_run", + "cache_read_weight", + "cache_read_weight_was", + "cap", + "checkout_dirty", + "checkpoint", + "code_root_changed", + "command_index", + "commits", + "condition", + "contradiction", + "converted", + "count", + "cycle", + "cycles", + "decision", + "decisions", + "deduped", + "dropped", + "dw_id", + "effect", + "entries", + "entries_now", + "env_fault", + "env_fault_evidence", + "epic", + "errors", + "expired_clock", + "failed", + "field", + "files", + "finished", + "fired_at", + "flat_remainder", + "followup_damped", + "followup_review_recommended", + "frm", + "graceful", + "harvest_attempt", + "head", + "id_collisions", + "items", + "kept", + "key", + "ledger", + "log_pos", + "malformed", + "mode", + "model", + "name", + "next", + "normalized", + "ok", + "old_baseline", + "open", + "open_now", + "original", + "owed_after_implement", + "path", + "paths", + "phase", + "platform", + "plugin", + "plugins", + "policy_changed", + "preserve_ref", + "problem", + "question", + "rc", + "re_review_capped", + "rearmed", + "record", + "redrive", + "ref", + "refiled", + "refs", + "refused", + "remaining", + "reset_from", + "restore", + "returncode", + "role", + "run_id", + "run_type", + "security_config_changed", + "sentinel", + "sentinel_kind", + "session_status", + "session_vanished", + "signum", + "site", + "skip", + "source", + "spec_folder", + "stage", + "state_kind", + "status", + "stderr_bytes", + "stderr_captured_bytes", + "stderr_truncated", + "stdout_bytes", + "stdout_captured_bytes", + "stdout_truncated", + "strategy", + "teardown_s", + "to", + "tokens", + "tokens_weighted", + "tolerated", + "total", + "trigger", + "verification_sequence", + "verification_stage", + "via", + "weighted", + "workflow", + "worktree", + "zero_diff", + } +) + +# Field names NO call site spells as a keyword, because ``Journal.append`` mints them +# itself: ``entry.setdefault("log_task", …)`` and ``entry.setdefault("log_pos", size)`` +# on every entry written while a pane log is active. ``log_task`` is routed (a story +# alias); ``log_pos`` is a byte offset and is declared benign above. +# +# The static scan reads CALL SITES, so it cannot see either of them — which means the +# sibling guard's "every field name a journal producer writes" claim is true only of +# the fields a call spells. ``test_journal_append_writes_only_accounted_fields`` +# closes that from the other side by RUNNING an append and reading the entry back; +# this set is what stops the staleness check below from calling ``log_pos`` dead. +JOURNAL_SELF_MINTED_FIELDS = frozenset({"log_task", "log_pos"}) + +# ``(file, enclosing function) -> the field names that actually flow through it`` for +# every ``journal.append(**name)`` whose keys are NOT statically resolvable. An +# unresolved splat is a HOLE in the inventory above — the guard cannot tell whether a +# new field arrived through it — so it fails loud and each hole is declared here with +# why it is one, rather than being silently skipped. A new splat site anywhere else +# reddens the guard until someone either makes its keys resolvable or adds a line here. +# +# All four are unresolvable for the same structural reason: the dict is not built +# from literals in the calling function. The VALUES are an inventory read off the +# producer, not an assertion the scan can check — they are what keeps the staleness +# check on ``JOURNAL_BENIGN_FIELDS`` from calling a splat-borne name dead, and they +# are the honest answer to "which names does this hole let through". +JOURNAL_SPLAT_ALLOW = { + # `streams` keys are computed — `f"{kind}_path"` and its three siblings over a + # fixed (stdout, stderr) loop — so the resolver cannot read them and the argument + # for the hole is the POSITION. Said plainly because the previous comment argued + # by VALUE TYPE ("numbers and booleans") while the invariant it exempts is + # NAME-based: the two `*_path` names are routed (`_JOURNAL_DROP_FIELDS`); the + # other six are declared benign BY NAME, below. ⚠️ A NEW key added inside this + # `streams` dict is still invisible to the guard — that is what the hole IS, and + # no property of its value changes it. + ("engine.py", "_journal_verify_command_results"): frozenset( + { + "stdout_path", + "stderr_path", + "stdout_bytes", + "stderr_bytes", + "stdout_captured_bytes", + "stderr_captured_bytes", + "stdout_truncated", + "stderr_truncated", + } + ), + # `pref` comes from `preference_escalations(result_json)` — LLM-authored keys out + # of a session's own result.json. Not statically knowable in principle, not just + # in this scan; the redaction fallback is what covers it, and no inventory can be + # written for it at all. + ("engine.py", "_review_and_commit"): frozenset(), + # `self._session_end_extras(result)` is a method call, and that method builds its + # dict with `extras.update(...)` — unresolvable at the call site and at the + # definition. The names below are read off `engine._session_end_extras`, and five + # of them (`fired_at`, `teardown_s`, `expired_clock`, `budget_weighted`, + # `budget_mode`) have NO other producer anywhere: the previous comment's claim + # that these keys "are in the benign inventory because other sites journal them + # explicitly" was simply false. They are in it because THIS declaration puts them + # there. ⚠️ A new key added inside `_session_end_extras` is still invisible. + ("engine.py", "_run_session"): frozenset( + { + "fired_at", + "teardown_s", + "expired_clock", + "budget_weighted", + "budget", + "budget_mode", + "env_fault", + "env_fault_evidence", + "session_vanished", + } + ), + # The plugin bus's `_log` forwards its OWN `**fields` parameter, so the keys + # belong to each CALLER and there is no store in this function to resolve. The + # callers' keywords are read at their own sites — but ONLY because + # `JOURNAL_FORWARDERS` declares `_log` a journal write. Before that they were + # unreachable: `_is_journal_write` matched `.append(...)` alone, the four + # `self._log(...)` sites were never read, and `rc` and `blocking` sat in neither + # routing set with this guard green. That is what the old comment's "the scan + # reads them directly at their own sites" asserted and did not do. + ("plugins/bus.py", "_log"): frozenset(), +} + +# ``(file, function name)`` of every helper that FORWARDS to ``journal.append`` with a +# ``**kwargs`` of its own. A call to that NAME inside that FILE counts as a journal +# write, so the forwarder's callers put their explicit keywords into the inventory +# instead of stopping at a wall. +# +# The forwarder's own `self._journal.append(kind, **fields)` stays an unresolvable +# splat — its parameter has no store to resolve — so both this entry and the +# `JOURNAL_SPLAT_ALLOW` one are needed, and they say different things: this one makes +# the CALLERS visible, that one declares the forwarder's own hole. +JOURNAL_FORWARDERS = {("plugins/bus.py", "_log")} + +# ``(file, enclosing function)`` of every journal write whose KIND is not a string +# literal. Kind-scoped routing (`JOURNAL_KIND_ROUTED_FIELDS` / +# `JOURNAL_KIND_BENIGN_FIELDS`) cannot be evaluated at such a call, so — exactly like +# an unresolvable splat — the site fails loud rather than being graded against a kind +# the scan had to guess. +# +# Declaring a position waives the KIND resolution and NOTHING else: a kind-scoped +# name at one of these sites is still refused, because nothing here can prove which +# kind it lands on. +JOURNAL_DYNAMIC_KIND_ALLOW = { + # `kind` is a keyword parameter defaulting to `review-skipped`, flipped to + # `review-skipped-awaiting-operator` by the park path. Journals `story_key` only. + ("engine.py", "_skip_review_and_commit"), + # `kind` is chosen by the two ledger-close call sites. Journals `story_key` and + # `dw_ids` only. + ("sweep.py", "_close_bundle_ledger_when_spec_status"), + # Four writes, each an f-string over the `family` loop variable: + # `attempt-preserve` / `attempt-preserve-dirty` × `-pruned` / `-prune-failed`. + ("recovery_flow.py", "prune_preserve_refs"), + # The forwarder passes its caller's `kind` straight through; every CALLER spells + # a literal, and `JOURNAL_FORWARDERS` is what lets the scan read them there. + ("plugins/bus.py", "_log"), +} + +# The receivers a ``.append(...)`` call must hang off to be a journal write. Matched +# on the trailing name so `self.journal`, a bare `journal` parameter and +# `self._journal` (the plugin bus's optional handle) all resolve — the three +# spellings in the tree. +# +# ⚠️ STATED BOUND: a LOCALLY ALIASED handle is invisible. `j = self.journal` followed +# by `j.append(kind, customer_email=x)` produces no finding (verified by running it +# through `_scan_source`). No such site exists in the tree today, and resolving the +# binding would be `_verify_call_aliases`' shape rather than a new idea — but the +# guard does not do it, and a reader must not assume it does. +JOURNAL_RECEIVERS = {"journal", "_journal"} + # Files that may name a bare POSIX path, each on a line carrying a `# portability:` # ack. process_host.py's Linux identity reader walks `/proc//stat` behind a # sys.platform branch; the Unity teardown scripts are POSIX-only. verify.py is the @@ -534,6 +918,225 @@ def _names_verify_classifier(func: ast.expr, aliases: frozenset[str] = frozenset return _names_guarded_verify_call(func, "verify_command_results_outcome", aliases) +def _is_str_composition(node: ast.expr) -> bool: + """Whether this expression BUILDS a string rather than naming one, in three + spellings — NOT "the three spellings a hand-minted task id can take", which is + an overclaim the shapes below cannot support. + + ``JoinedStr`` is the f-string. ``BinOp`` with a str ``Constant`` on either side + covers both concatenation (``story + "-review-1"``) and percent formatting + (``"%s-dev-%d" % (key, n)``), whose operator is also a ``BinOp``. The third is + ``"…".format(…)`` on a literal receiver. + + Three real compositions this deliberately does NOT recognise, verified silent: + ``"-".join([key, "dev", "1"])``, ``fmt % (key, n)`` where ``fmt`` is a Name bound + to the format string, and any of the three assembled a statement earlier and + forwarded through a variable. See the ``NOT COVERED`` note on the detector for + why the boundary sits where it does. + + A ``Name``, ``Attribute``, ``Subscript`` or ordinary ``Call`` is deliberately NOT + a composition: those FORWARD a string someone else made, which is what every + sanctioned mint site does with the chokepoint's return value.""" + if isinstance(node, ast.JoinedStr): + return True + if isinstance(node, ast.BinOp) and any( + isinstance(side, ast.Constant) and isinstance(side.value, str) + for side in (node.left, node.right) + ): + return True + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "format" + and isinstance(node.func.value, ast.Constant) + and isinstance(node.func.value.value, str) + ) + + +def _is_bare_str(node: ast.expr) -> bool: + return isinstance(node, ast.Constant) and isinstance(node.value, str) + + +def _mint_candidates(node: ast.expr, depth: int = 0): + """``(sub-expression, depth)`` for every value position that could be minting a + string here, where depth counts the CALL boundaries crossed to reach it. + + Conditionals and boolean fallbacks are descended at the same depth, since both + branches are the same value position (``task_id = f"…" if x else base``). + + Call arguments are descended too, in EVERY position, because a call is the shape + a mint hides behind in both of them. In a return it is the sanitizer the + chokepoint itself uses — ``return safe_segment(f"{story_key}-{part}-{seq}{gen}")`` + — and in a binding it is the same line copied into one: ``task_id = + safe_segment(f"{key}-dev-1")`` is the most likely fifth mint precisely because it + is the chokepoint's own body moved. Refusing to descend there left that shape + silent (verified), and it omits the ``-g`` suffix, which is #705 re-opened. + + Depth is what makes descending safe. A bare string Constant is a mint only at + depth 0 (``task_id = "triage-1"``); at depth it is an ARGUMENT and flagging it + would hit ``os.environ.get("BMAD_LOOP_TASK_ID")`` and the ``"dev"`` part in every + sanctioned ``_session_task_id(key, "dev", seq, gen)`` call. A COMPOSITION is a + mint at any depth: nothing legitimate hands a freshly built string to a call in a + ``task_id`` position.""" + yield node, depth + if isinstance(node, ast.IfExp): + yield from _mint_candidates(node.body, depth) + yield from _mint_candidates(node.orelse, depth) + elif isinstance(node, ast.BoolOp): + for value in node.values: + yield from _mint_candidates(value, depth) + elif isinstance(node, ast.Call): + for arg in [*node.args, *(kw.value for kw in node.keywords)]: + yield from _mint_candidates(arg, depth + 1) + + +def _is_journal_write(node: ast.AST, rel: str) -> bool: + """Whether this node writes a journal entry — a ``.append(...)`` call in + each of the three receiver spellings the tree uses (see ``JOURNAL_RECEIVERS``), + or a call to one of this file's declared ``JOURNAL_FORWARDERS``. + + The forwarder half is not a convenience. ``plugins/bus.py::_log`` takes its own + ``**fields`` and hands them to ``self._journal.append``, so its four call sites + spell keywords that reach the journal while matching nothing the ``.append`` + scan looks at — `rc` and `blocking` were in neither routing set with this guard + green. Keyed ``(file, name)``: a ``_log`` elsewhere forwards to something else. + + The receiver's qualifier is ignored for ``_called_name``'s reason: an aliased + MODULE handle reaches the same method. A locally aliased receiver is a stated + bound — see ``JOURNAL_RECEIVERS``.""" + if not isinstance(node, ast.Call): + return False + name = _called_name(node.func) + if name is None: + return False + if (rel, name) in JOURNAL_FORWARDERS: + return True + return ( + isinstance(node.func, ast.Attribute) + and name == "append" + and _called_name(node.func.value) in JOURNAL_RECEIVERS + ) + + +def _dict_literal_keys(value: ast.expr) -> set[str] | None: + """The string keys of a dict literal, or None when any key is not a static + string. ``{**other}`` yields a ``None`` key node and is unresolvable by + definition; a conditional between two literals resolves to their union, which is + how ``engine._run_inner`` builds its ``extras``.""" + if isinstance(value, ast.Dict): + keys: set[str] = set() + for key in value.keys: + if not (isinstance(key, ast.Constant) and isinstance(key.value, str)): + return None + keys.add(key.value) + return keys + if isinstance(value, ast.IfExp): + body, orelse = _dict_literal_keys(value.body), _dict_literal_keys(value.orelse) + return None if body is None or orelse is None else body | orelse + return None + + +def _journal_splat_keys(fn: ast.AST | None, name: str) -> set[str] | None: + """The keys a ``**name`` splat can carry, resolved through the same-function + literal stores that build it, or None when ANY store is not statically + resolvable. + + Fails closed on purpose, in four directions, because a partially-resolved + splat would under-report and read as green: an augmented assignment + (``fields += …``), a method mutation (``fields.update(…)``, + ``fields.setdefault(…)``), a non-literal store (a computed subscript key, a + dict built from a call), and a SECOND NAME bound to the same dict + (``alias = fields``) each return None rather than the keys seen so far. A + splat with no store in the function at all — the forwarder shape, where ``name`` + is a parameter — is unresolvable too, not vacuously empty. + + The alias direction was the fourth leak in a docstring that claimed three: + ``fields = {"a": 1}`` / ``alias = fields`` / ``alias["customer_email"] = 2`` + resolved to ``{"a"}``, because every store the resolver looks for is spelled on + the OTHER name. Matched narrowly — the assigned value must BE ``Name(name)``, + not merely mention it — so a read (``n = len(fields)``) still resolves.""" + if fn is None: + return None + keys: set[str] = set() + stored = False + for node in ast.walk(fn): + if isinstance(node, (ast.Assign, ast.AnnAssign)): + if isinstance(node.value, ast.Name) and node.value.id == name: + return None + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Name) and target.id == name: + stored = True + resolved = None if node.value is None else _dict_literal_keys(node.value) + if resolved is None: + return None + keys |= resolved + elif ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == name + ): + stored = True + if not ( + isinstance(target.slice, ast.Constant) + and isinstance(target.slice.value, str) + ): + return None + keys.add(target.slice.value) + elif isinstance(node, ast.AugAssign): + if isinstance(node.target, ast.Name) and node.target.id == name: + return None + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == name + ): + return None + return keys if stored else None + + +def _enclosing_function_names(tree: ast.AST) -> dict[int, str | None]: + """``id(node) -> the name of the INNERMOST function definition containing it`` + (None at module level). + + ``ast`` nodes carry no parent link and ``ast.walk`` hands them out flat, so the + journal detector — whose splat resolution and whose ``JOURNAL_SPLAT_ALLOW`` key + are both scoped to the function a call sits in — has to build the mapping + itself. Innermost rather than outermost, because that is the scope a ``**name`` + is stored in. + + Deliberately different from the sanctioned-position sets built inside + ``_scan_source``: those use ``ast.walk(fn)``, which descends into nested defs so + a closure inside a sanctioned helper stays sanctioned. Here the innermost answer + is the correct one, and the two uses are not interchangeable.""" + names: dict[int, str | None] = {id(tree): None} + + def descend(node: ast.AST, fn: str | None) -> None: + for child in ast.iter_child_nodes(node): + names[id(child)] = fn + inner = child.name if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) else fn + descend(child, inner) + + descend(tree, None) + return names + + +def _enclosing_function_nodes(tree: ast.AST) -> dict[int, ast.AST | None]: + """The node-valued twin of :func:`_enclosing_function_names`, for the splat + resolver, which must WALK the enclosing function rather than name it.""" + nodes: dict[int, ast.AST | None] = {id(tree): None} + + def descend(node: ast.AST, fn: ast.AST | None) -> None: + for child in ast.iter_child_nodes(node): + nodes[id(child)] = fn + inner = child if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) else fn + descend(child, inner) + + descend(tree, None) + return nodes + + def _scan(): """Single pass over the tree → list of (kind, rel, lineno, line_text).""" findings = [] @@ -609,6 +1212,52 @@ def _scan_source(src: str, rel: str): and _names_verify_classifier(call.func, verify_classifier_aliases) } + # String Constants that ARE the task-artifact list rather than a copy of it: the + # elements of `journal.TASK_CYCLE_ARTIFACTS`' own assignment. Skipped by id, so + # the definition needs no allowlist entry and a bare literal elsewhere in the + # same file is still refused (see TASK_ARTIFACT_DEFINITION). + artifact_definition_rel, artifact_definition_name = TASK_ARTIFACT_DEFINITION + artifact_definition_nodes = { + id(const) + for stmt in ast.walk(tree) + if rel == artifact_definition_rel + and isinstance(stmt, (ast.Assign, ast.AnnAssign)) + and stmt.value is not None + and any( + isinstance(target, ast.Name) and target.id == artifact_definition_name + for target in (stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]) + ) + for const in ast.walk(stmt.value) + if isinstance(const, ast.Constant) + } + + # Everything inside this file's ONE sanctioned task-id composition point, if it + # has one. Same `ast.walk(fn)` shape as the verify sets above — a nested def + # inside the chokepoint is still inside it — and empty in every other file, + # since `.get(rel)` is None there and no function is named None. + sanctioned_task_id_nodes = { + id(inner) + for fn in ast.walk(tree) + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + and fn.name == SESSION_TASK_ID_CHOKEPOINT.get(rel) + for inner in ast.walk(fn) + } + + # `return` statements inside a function whose NAME contains `task_id` — the + # second position a mint can hide in, and the one a helper like + # `_sweep_task_id` would use. Matched on the name substring rather than on a + # fixed list: naming the function after what it returns is the whole tell. + task_id_returns = { + id(ret) + for fn in ast.walk(tree) + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and "task_id" in fn.name + for ret in ast.walk(fn) + if isinstance(ret, ast.Return) and ret.value is not None + } + + enclosing_names = _enclosing_function_names(tree) + enclosing_nodes = _enclosing_function_nodes(tree) + def line_at(lineno: int) -> str: return lines[lineno - 1] if 1 <= lineno <= len(lines) else "" @@ -713,6 +1362,94 @@ def line_at(lineno: int) -> str: ): findings.append(("path", rel, node.lineno, line_at(node.lineno))) + # A task-directory artifact name spelled as a literal, outside the one + # assignment that defines the list. Matched by string EQUALITY, never by + # containment: the dev/sweep prompts name `result.json` inside a sentence + # ("…write tasks//result.json, then end your turn"), and flagging prose + # would get the allowlist widened until it meant nothing. Docstrings are + # skipped for the same reason the POSIX-path scan skips them. The finding + # carries `(name, enclosing function)`: the exemption is per-name AND per + # position, so a second literal in another function of an allowlisted file + # is still refused. + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in docs + and id(node) not in artifact_definition_nodes + and node.value in TASK_CYCLE_ARTIFACTS + ): + findings.append( + ( + "taskartifact", + rel, + node.lineno, + line_at(node.lineno), + (node.value, enclosing_names.get(id(node))), + ) + ) + + # A journal write's field names. Explicit keywords are read straight off the + # call; a `**name` splat is resolved through the literal stores that built it + # in the same function, and emits ONE finding with a None name when it + # cannot be — an unresolvable splat is a hole in the inventory, so it fails + # loud rather than being skipped. Each finding carries + # `(field_or_None, enclosing_function, kind_or_None)`: the benign inventory + # is keyed by field, the splat exemption by position, and the KIND is what + # makes `diagnostics`' kind-scoped routing checkable at all. + # + # The kind is the first positional argument when it is a string literal, and + # None otherwise. None is not "no kind": it is "this scan cannot tell", and + # it emits its own `journalkind` finding so the site fails loud rather than + # being graded against a kind that had to be guessed. + if _is_journal_write(node, rel): + fn_name = enclosing_names.get(id(node)) + first = node.args[0] if node.args else None + kind = ( + first.value + if isinstance(first, ast.Constant) and isinstance(first.value, str) + else None + ) + if kind is None: + findings.append(("journalkind", rel, node.lineno, line_at(node.lineno), fn_name)) + for kw in node.keywords: + if kw.arg is not None: + findings.append( + ( + "journalfield", + rel, + node.lineno, + line_at(node.lineno), + (kw.arg, fn_name, kind), + ) + ) + continue + resolved = ( + _journal_splat_keys(enclosing_nodes.get(id(node)), kw.value.id) + if isinstance(kw.value, ast.Name) + else None + ) + if resolved is None: + findings.append( + ( + "journalfield", + rel, + node.lineno, + line_at(node.lineno), + (None, fn_name, kind), + ) + ) + else: + for field in sorted(resolved): + findings.append( + ( + "journalfield", + rel, + node.lineno, + line_at(node.lineno), + (field, fn_name, kind), + ) + ) + # signal.SIGKILL attribute access (the guarded form is a "SIGKILL" # *string* passed to getattr — not an attribute access — so it's clean) if ( @@ -840,6 +1577,74 @@ def line_at(lineno: int) -> str: ): findings.append(("specanchor", rel, node.lineno, line_at(node.lineno))) + # A session task id COMPOSED rather than obtained from `engine._session_task_id`. + # Two value positions, because those are the two a fifth mint can occupy: a + # binding (`task_id = …`, `SessionSpec(task_id=…)`) and a return from a function + # named for what it returns. A forward — `task_id=spec.task_id`, + # `task_id=str(d["task_id"])`, `task_id=task_id` — reaches neither predicate, + # which is the distinction the whole detector rests on. + # + # Collected into a dict keyed by node id so a value matching through two + # candidate paths (a `.format()` call is both the candidate itself and the + # parent of its arguments) reports once. + # + # NOT COVERED, deliberately, and stated rather than implied. This is a review + # tripwire on the shapes the real mint sites use, not a sandbox; widening it is a + # decision, not a bug fix. Each of these was run through `_scan_source` and + # confirmed silent: + # + # * a store into a dict or an attribute — `record["task_id"] = f"…"`, + # `self.task_id = f"…"`. Neither is a Name binding, a `task_id=` keyword, nor a + # return from a `*task_id*` function. + # * an INTERMEDIATE VARIABLE: `tid = f"{key}-dev-1"` on one line and + # `task_id=tid` on the next. The binding position holds a Name, which is a + # forward as far as this detector can see; following it would mean the + # flow-sensitive resolution `_journal_splat_keys` does for one dict, across + # every string in the file. + # * `"-".join([key, "dev", "1"])` and `fmt % (key, n)` where `fmt` is a Name + # bound to the format string — two more real ways to build a string that + # `_is_str_composition` does not recognise (its own docstring lists them). + minted: dict[int, ast.expr] = {} + + def record_mint(value: ast.expr, *, bare_at_depth: bool) -> None: + for candidate, depth in _mint_candidates(value): + if _is_str_composition(candidate) or ( + _is_bare_str(candidate) and (depth == 0 or bare_at_depth) + ): + minted.setdefault(id(candidate), candidate) + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + if any(isinstance(t, ast.Name) and t.id == "task_id" for t in node.targets): + record_mint(node.value, bare_at_depth=False) + elif isinstance(node, ast.AnnAssign): + if ( + isinstance(node.target, ast.Name) + and node.target.id == "task_id" + and node.value is not None + ): + record_mint(node.value, bare_at_depth=False) + elif isinstance(node, ast.keyword) and node.arg == "task_id": + record_mint(node.value, bare_at_depth=False) + elif isinstance(node, ast.Return) and id(node) in task_id_returns: + # A function NAMED for the id it returns is already the whole tell, so a + # bare literal stays a finding at depth there (`return safe_segment("x")`) + # — unlike a binding, where a literal argument is the sanctioned + # chokepoint call's own `"dev"` part. + assert node.value is not None # task_id_returns only holds valued returns + record_mint(node.value, bare_at_depth=True) + + for mint in minted.values(): + findings.append( + ( + "taskid", + rel, + mint.lineno, + line_at(mint.lineno), + id(mint) in sanctioned_task_id_nodes, + ) + ) + return findings @@ -1019,6 +1824,310 @@ def test_spec_anchor_detector_stays_silent_on_the_anchored_form(): assert not [f for f in _scan_source(src, "tui/app.py") if f[0] == "specanchor"] +def _task_artifact_offenders(findings) -> list[tuple[str, int, str, str]]: + """The artifact-name literals no declared POSITION covers — the assertion's + whole policy, factored out so it can be graded on synthetic findings rather than + only on today's tree (the file's ``_env_read_offenders`` idiom). + + Both halves of the key bite: the file, then the enclosing function inside it. + Dropping the function half exempts every ``"result.json"`` in + ``adapters/generic.py``, which is what the allowlist's comment already said was + not the case.""" + return [ + (rel, ln, txt, name) + for _, rel, ln, txt, (name, fn) in findings + if name not in TASK_ARTIFACT_LITERAL_ALLOW.get(rel, {}).get(fn, frozenset()) + ] + + +def test_task_cycle_artifacts_named_only_through_the_constant(): + """The task-directory artifact names live in ``journal.TASK_CYCLE_ARTIFACTS``, + not as a literal in each site that touches them. + + Three sites share the list: both adapters clear it in ``start_session`` (a + caller-supplied task_id may be reused, so a silent session must not inherit its + predecessor's outputs) and ``resolve._gather_escalations`` reads it back. They + were three independent literals, and the only parity claim was a sentence in a + test docstring — so a third artifact added to the reader would silently miss + both adapters, which is exactly how ``escalation.json`` reached the reader + before either adapter cleared it. + + The exemption is per-POSITION and per-NAME, never per-file: + ``adapters/generic.py::_result_path`` answers a genuinely single-artifact + question and keeps ``"result.json"``, while ``"escalation.json"`` stays refused + inside it and BOTH names stay refused in every other function of that file. + + ⚠️ What this assertion is worth on today's tree, said as candidly as its DW-66 + sibling says it: almost nothing. There is exactly ONE `taskartifact` finding in + the whole tree and it is allowlisted, so the offender list is empty and would + stay empty with the detector deleted. ``TASK_ARTIFACT_PROBES`` and + ``TASK_ARTIFACT_SCOPE_CASES`` are what grade the detector and the scoping; this + row grades the tree, and the tree is currently clean. + + ⚠️ And what it protects is narrower than "the constant is the list". It refuses + the constant being UN-DONE — a name pulled back out into a literal at any of the + three sites. It does NOT catch the constant being OUT-GROWN: a genuinely new + artifact spelled only in the reader produces no finding at all, because the + detector matches the names the constant already holds. Verified — a + ``(task_dir / "verdict.json")`` added to ``resolve.py`` is silent here, and the + parity it would break is the parity this guard exists for. + + Ablation: respell either adapter's loop as + ``(task_dir / "escalation.json").unlink(missing_ok=True)`` and this reddens + naming that file and line.""" + offenders = _task_artifact_offenders(_of("taskartifact")) + assert offenders == [], ( + "a tasks// artifact named as a bare literal — iterate " + "journal.TASK_CYCLE_ARTIFACTS so the readers and both adapters cannot " + "drift apart on the list:\n" + + "\n".join(f" {rel}:{ln}: {name!r} — {txt.strip()}" for rel, ln, txt, name in offenders) + ) + + +def _session_task_id_offenders(findings) -> list[tuple[str, int, str]]: + """The chokepoint invariant as a filter: a composed task id is sanctioned only + in a ``SESSION_TASK_ID_CHOKEPOINT`` file AND only inside that file's one listed + enclosing function — the file alone is not enough, for the reason the git and + verify-classifier exemptions are not file-wide.""" + return [(rel, ln, txt) for _, rel, ln, txt, at_chokepoint in findings if not at_chokepoint] + + +def test_session_task_id_composed_only_at_the_chokepoint(): + """Every session task id is composed in ``engine._session_task_id`` and nowhere + else. + + The four mint sites (``engine.py`` ×3, ``resolve.py``) all call it and bind or + pass the result; none spells the format. That is what makes + ``_resumable_session``'s resume match byte-identical to what ``_run_session`` + stored, and what carries the ``-g`` re-arm generation discriminator a + hand-rolled fifth mint would omit — silently re-opening #705, correctly + everywhere it was exercised and wrong only on a re-armed run. + + Nothing forbade a fifth. This does: a composition or a bare literal in a + ``task_id`` binding, or returned from a function named for the id it makes, is + refused wherever it is spelled. A FORWARD is not a mint and stays silent — see + ``SESSION_TASK_ID_PROBES`` / ``SESSION_TASK_ID_NON_PROBES`` for that boundary as + rows rather than prose. + + ⚠️ What this assertion grades, precisely — the halves are NOT the same, and + both ablations were run rather than reasoned about: + + * the SANCTION, yes. The chokepoint's own ``return safe_segment(f"…")`` is a + real finding on today's tree, cleared only by its position, so emptying + ``SESSION_TASK_ID_CHOKEPOINT`` reddens this naming ``engine.py:393``. That is + more than the sibling guards' repo-wide assertions can say for themselves. + * the DETECTOR, no. Delete the ``taskid`` emit and this goes green with an empty + finding list — indistinguishable from an invariant that holds. + ``SESSION_TASK_ID_PROBES`` is where that is caught, and the two are not + interchangeable. + + Ablation: respell ``resolve.py``'s mint as + ``task_id=f"{story_key}-resolve-1"`` and this reddens naming that line.""" + offenders = _session_task_id_offenders(_of("taskid")) + assert offenders == [], ( + "a session task id composed outside engine._session_task_id — call that " + "function instead, so the id keeps its whole-composition sanitize and its " + "-g re-arm generation suffix (#705):\n" + + "\n".join(f" {rel}:{ln}: {txt.strip()}" for rel, ln, txt in offenders) + ) + + +def _journal_field_offenders(findings) -> list[tuple[str, int, str, str]]: + """The routing invariant as a filter, in the two directions a finding can fail: + a field name that neither ``diagnostics`` nor the benign inventory accounts for, + and a ``**splat`` whose keys could not be resolved at a position that has not + declared itself a hole. + + Routing is checked BY NAME first and then BY KIND, mirroring ``_scrub_entry``'s + own order rather than a flattened union of the two. A kind-scoped name — today + only ``target`` — is routed on its own kinds, declared benign on the + ``board-advance-*`` family that carries a sprint status under the same name, and + an offender everywhere else, INCLUDING at a call whose kind the scan could not + resolve. That is the case a by-name union got wrong in the dangerous direction: + ``journal.append("unit-merge-failed", target=branch)`` read as routed.""" + offenders: list[tuple[str, int, str, str]] = [] + for _, rel, ln, txt, (field, fn, kind) in findings: + where = f"{fn}()" if fn else "" + if field is None: + if (rel, fn) not in JOURNAL_SPLAT_ALLOW: + offenders.append((rel, ln, txt, f"unresolvable **splat in {where}")) + continue + if field in JOURNAL_ROUTED_FIELDS or field in JOURNAL_BENIGN_FIELDS: + continue + if kind is not None and ( + field in JOURNAL_KIND_ROUTED_FIELDS.get(kind, frozenset()) + or field in JOURNAL_KIND_BENIGN_FIELDS.get(kind, frozenset()) + ): + continue + on = f"on {kind!r}" if kind is not None else "on a non-literal kind" + offenders.append((rel, ln, txt, f"{field!r} {on} in {where}")) + return offenders + + +def _journal_kind_offenders(findings) -> list[tuple[str, int, str]]: + """Journal writes whose KIND is not a string literal, at a position that has not + declared itself one. Their fields cannot be graded against kind-scoped routing at + all, so — like an unresolvable splat — they fail loud rather than pass by + default.""" + return [ + (rel, ln, txt) + for _, rel, ln, txt, fn in findings + if (rel, fn) not in JOURNAL_DYNAMIC_KIND_ALLOW + ] + + +def test_journal_fields_are_routed_or_declared_benign(): + """Every field name a journal producer SPELLS AT A CALL is either routed by + ``diagnostics`` — by name, or by name-and-kind — or listed in the benign + inventory. + + Two bounds on "every field name a journal producer writes", which is what this + docstring used to claim, and neither is a detail. ``Journal.append`` mints + ``log_task`` and ``log_pos`` itself with ``setdefault``, so no call spells them + and this scan cannot see them (``JOURNAL_SELF_MINTED_FIELDS``; + ``test_journal_append_writes_only_accounted_fields`` is the row that actually + covers them). And a field arriving through a declared ``JOURNAL_SPLAT_ALLOW`` + hole is inventoried there by hand, not observed here. + + Routing is NOT flat, which the earlier wording implied by folding + ``_JOURNAL_KIND_ALIAS_FIELDS`` into one by-name union. ``target`` is aliased on + three merge kinds and deliberately left alone on the ``board-advance-*`` family, + where it carries a sprint status — so it is checked per kind, and + ``journal.append("unit-merge-failed", target=branch)``, a new kind reusing the + name, is refused here rather than sailing through to ``scrub_json``. + + Nothing coupled the producers to the tables, and the tables route by field NAME. + A measured ablation — renaming ``recovery_flow.py``'s ``patch=`` to + ``patch_path=`` — left every row of ``tests/test_diagnostics.py`` green while + the field dropped out of ``_JOURNAL_DROP_FIELDS`` and started shipping in + ``--dump`` output. That is the failure this refuses, and it is a rename rather + than an exotic shape. + + Direction matters, and the reverse would not work. Several routing rows are + deliberately defensive (``paused_story_key``, ``bundle``, ``detail``, + ``suggestion``, ``blocker``, ``stdout_path``) and have no static kwarg producer, + so a "no dead row" assertion would need a large allowlist of CORRECT entries + while catching nothing this direction misses. A rename shows up here as a NEW + unrouted name — which is precisely the measured ablation. + + What the benign inventory claims is narrow and stated plainly on + ``JOURNAL_BENIGN_FIELDS``: it is the set of unrouted names that existed when the + guard landed, not a per-name safety audit. The guard's real assertion is that + the NEXT name cannot appear without someone deciding which side it belongs on. + + A ``**splat`` is resolved through the literal stores that build it; when it + cannot be, the site fails loud unless ``JOURNAL_SPLAT_ALLOW`` declares it a + known hole with a reason. A silently-skipped splat would be a standing hole in + the inventory — the guard would keep passing while new fields arrived through + it. + + Ablation: rename ``recovery_flow.py``'s ``patch=`` to ``patch_path=`` and this + reddens naming the new field.""" + offenders = _journal_field_offenders(_of("journalfield")) + assert offenders == [], ( + "a journal field is neither routed by diagnostics' redaction tables nor " + "declared benign — decide which it is: add a row to the right table in " + "diagnostics.py if it carries an identifier, a path or free text, or list " + "it in JOURNAL_BENIGN_FIELDS if it does not:\n" + + "\n".join(f" {rel}:{ln}: {what} — {txt.strip()}" for rel, ln, txt, what in offenders) + ) + + +def test_journal_kinds_are_literal_or_the_position_is_declared(): + """A journal write whose KIND is not a string literal cannot be graded against + ``diagnostics``' kind-scoped routing, so it fails loud at an undeclared position + — the same stance the guard takes on an unresolvable ``**splat``, and for the + same reason: a site the scan cannot read must not read as clean. + + Seven such writes exist, at four positions, and all four journal only by-name + routed fields today (``JOURNAL_DYNAMIC_KIND_ALLOW`` records which). Declaring one + waives the kind resolution and nothing else: a kind-scoped name at one of them is + still refused by the sibling assertion, because nothing can prove which kind it + lands on. + + Ablation: empty ``JOURNAL_DYNAMIC_KIND_ALLOW`` and this reddens naming all four + positions.""" + offenders = _journal_kind_offenders(_of("journalkind")) + assert offenders == [], ( + "a journal write whose kind is not a string literal, at a position that has " + "not declared itself one — pass a literal kind, or add the position to " + "JOURNAL_DYNAMIC_KIND_ALLOW with what it journals:\n" + + "\n".join(f" {rel}:{ln}: {txt.strip()}" for rel, ln, txt in offenders) + ) + + +def test_journal_field_guard_actually_saw_the_producers(): + """The sibling assertion is an ABSENCE, so it is green both when every field is + accounted for and when the scan stopped finding journal writes at all. This is + the half that cannot be: empty the inventories and the guard must name a real + producer, which proves the scan reached them. + + Also pins the three shapes the scan must not lose — the routed names really are + produced (so ``JOURNAL_ROUTED_FIELDS`` is coupled to live producers rather than + to a copied list), every declared splat hole still exists (so a stale + ``JOURNAL_SPLAT_ALLOW`` entry cannot sit there sanctioning nothing), and every + declared BENIGN name still has a producer. + + That last one is the direction nothing held before. The benign inventory is a + pre-approval list, so a name whose producer was deleted does not just sit there + inertly: it pre-approves a future, unrelated field that happens to reuse the + spelling, with no one making the decision the inventory exists to force. The two + exemptions are the names no CALL can spell — what ``Journal.append`` mints itself + and what arrives through a declared splat hole.""" + findings = _of("journalfield") + produced = {field for _, _, _, _, (field, _, _) in findings if field is not None} + assert len(produced) > 100, f"the scan found only {len(produced)} journal fields" + assert produced & JOURNAL_ROUTED_FIELDS, "no routed field has a static producer" + holes = {(rel, fn) for _, rel, _, _, (field, fn, _) in findings if field is None} + assert holes == set(JOURNAL_SPLAT_ALLOW), ( + "JOURNAL_SPLAT_ALLOW no longer matches the unresolvable splats in the tree; " + f"undeclared: {sorted(holes - set(JOURNAL_SPLAT_ALLOW))}, " + f"stale: {sorted(set(JOURNAL_SPLAT_ALLOW) - holes)}" + ) + unscannable = JOURNAL_SELF_MINTED_FIELDS.union(*JOURNAL_SPLAT_ALLOW.values()) + stale = JOURNAL_BENIGN_FIELDS - produced - unscannable + assert stale == set(), ( + "JOURNAL_BENIGN_FIELDS names fields no producer writes any more — a benign " + "entry outlives its producer as a standing pre-approval for the next field " + "that reuses the name. Delete them, or record where they now come from in " + f"JOURNAL_SPLAT_ALLOW / JOURNAL_SELF_MINTED_FIELDS: {sorted(stale)}" + ) + + +def test_journal_append_writes_only_accounted_fields(tmp_path): + """The static guard reads CALL SITES, and ``Journal.append`` adds two field names + that no call site spells: ``entry.setdefault("log_task", …)`` and + ``entry.setdefault("log_pos", size)``. Both were invisible to it, and ``log_pos`` + was in neither routing set while the guard stayed green — so the sibling's claim + about "every field a producer writes" was false by two names. + + This closes it from the only side that can: RUN an append, read the JSONL line + back, and hold every key it actually contains to the same two inventories. A + third ``setdefault`` cannot be added to ``Journal.append`` without landing in one + of them. + + Ablation: add ``entry.setdefault("log_seq", 0)`` to ``Journal.append`` and this + row reddens naming ``log_seq`` while every static assertion above stays green — + which is the whole point of the row existing beside them.""" + run_dir = tmp_path / "run" + j = Journal(run_dir) + j.set_active_log("1-1-story-dev-1") + j.append("run-start", run_type="stories") + + lines = (run_dir / JOURNAL_FILE).read_text(encoding="utf-8").splitlines() + entry = json.loads(lines[-1]) + minted = set(entry) - {"ts", "kind"} + assert ( + "log_pos" in minted and "log_task" in minted + ), f"Journal.append stopped stamping the pane-log pointer: {sorted(minted)}" + unaccounted = minted - JOURNAL_ROUTED_FIELDS - JOURNAL_BENIGN_FIELDS + assert unaccounted == set(), ( + "Journal.append writes a field name neither routed by diagnostics nor " + "declared benign — the static guard cannot see a field the append mints " + f"itself, so decide which side it belongs on here: {sorted(unaccounted)}" + ) + + def test_no_hardcoded_posix_paths(): """No bare ``/tmp`` / ``/proc`` / ``/dev/null`` literal outside the allowlisted platform-guarded Unity files; each allowed line carries a `# portability:` ack. @@ -1823,6 +2932,699 @@ def test_env_read_allowlist_is_scoped_by_family_not_by_file(label, rel, key, is_ ) +# The artifact-literal detector's probe matrix. Today's tree has exactly ONE +# `taskartifact` finding (generic.py's `_result_path`, allowlisted), so deleting the +# detector branch leaves every tree-wide assertion green — only these rows redden. +# +# Every source below is BUILT BY ITERATING `TASK_CYCLE_ARTIFACTS` rather than by +# indexing it. Two reasons, and the second is the load-bearing one: a renamed +# artifact cannot leave a probe grading a string nothing produces any more, and a +# constant that SHRINKS cannot raise `IndexError` while this module is being +# imported. That error arrives at COLLECTION and takes every guard in this file down +# with it — the POSIX, git, env-read and spec-anchor ones included — which is a very +# large blast radius for a one-line edit in `journal.py`. Iteration degrades to +# fewer rows instead, and `test_artifact_probe_tables_are_not_empty` states the floor. +_ARTIFACT_TUPLE_SRC = ", ".join(f'"{name}"' for name in TASK_CYCLE_ARTIFACTS) +TASK_ARTIFACT_PROBES = [ + *( + (f"unlink-literal:{name}", f'(task_dir / "{name}").unlink(missing_ok=True)\n') + for name in TASK_CYCLE_ARTIFACTS + ), + *( + (f"read-literal:{name}", f'doc = json.loads((d / "{name}").read_text())\n') + for name in TASK_CYCLE_ARTIFACTS + ), + # The re-introduced pair, in the shape the extraction removed: an inline tuple + # in a for-loop, which is how the reader spelled it. + ("inline-tuple-loop", f"for fname in ({_ARTIFACT_TUPLE_SRC}):\n pass\n"), + # A second module re-declaring the constant is a COPY, not the definition — the + # definition skip is keyed to journal.py (see TASK_ARTIFACT_DEFINITION). + ("constant-redeclared-elsewhere", f"TASK_CYCLE_ARTIFACTS = ({_ARTIFACT_TUPLE_SRC})\n"), +] +TASK_ARTIFACT_NON_PROBES = [ + # The detector matches string EQUALITY, never containment: the dev and sweep + # prompts name the artifact inside a sentence, and flagging prose is how a + # tripwire gets allowlisted into meaninglessness. + *( + ( + f"prompt-prose:{name}", + f'PROMPT = "Write your verdict to tasks//{name}, then stop."\n', + ) + for name in TASK_CYCLE_ARTIFACTS + ), + *( + (f"docstring-prose:{name}", f'def f():\n """Reads {name} beside it."""\n return 1\n') + for name in TASK_CYCLE_ARTIFACTS + ), + # A different artifact in the same directory: the guard's claim is about the + # SHARED list, not about every filename a task dir holds. `heartbeat.json` and + # `messages.json` are real siblings that stay outside it (see the constant). + ("sibling-artifact", 'p = task_dir / "prompt.txt"\n'), + ("adapter-owned-sibling", 'p = task_dir / "heartbeat.json"\n'), + # The sanctioned spelling everywhere: iterate the constant. + ( + "iterating-the-constant", + "for artifact in TASK_CYCLE_ARTIFACTS:\n (task_dir / artifact).unlink(missing_ok=True)\n", + ), +] + + +def test_artifact_probe_tables_are_not_empty(): + """The tables above are derived from `TASK_CYCLE_ARTIFACTS` by iteration, which + is what stops a shrunk constant erroring this module's collection — but the same + derivation would quietly EMPTY a parametrized table, and an empty parametrize + passes for exactly the reason an empty scan does. This is that floor, stated as + a requirement rather than left to an `IndexError` nobody would read as one.""" + assert len(TASK_CYCLE_ARTIFACTS) >= 2, ( + "TASK_CYCLE_ARTIFACTS is down to " + f"{list(TASK_CYCLE_ARTIFACTS)}; the scope cases below need one allowlisted " + "name and one refused name to tell a name-scoped exemption from a file-wide one" + ) + assert TASK_ARTIFACT_PROBES and TASK_ARTIFACT_NON_PROBES and TASK_ARTIFACT_SCOPE_CASES + + +@pytest.mark.parametrize( + ("label", "source"), TASK_ARTIFACT_PROBES, ids=[p[0] for p in TASK_ARTIFACT_PROBES] +) +def test_task_artifact_detector_flags_every_literal_spelling(label, source): + """Each way of re-introducing a literal produces a `taskartifact` finding, driven + through the same `_scan_source` the real scan uses.""" + found = [f for f in _scan_source(source, "sweep.py") if f[0] == "taskartifact"] + assert found, f"the {label!r} spelling produced no `taskartifact` finding:\n{source}" + + +@pytest.mark.parametrize( + ("label", "source"), TASK_ARTIFACT_NON_PROBES, ids=[p[0] for p in TASK_ARTIFACT_NON_PROBES] +) +def test_task_artifact_detector_stays_silent_on_lookalikes(label, source): + """The complement: prose that CONTAINS the name, a docstring, a sibling + filename, and the sanctioned loop over the constant are all silent.""" + found = [f for f in _scan_source(source, "sweep.py") if f[0] == "taskartifact"] + assert not found, f"the {label!r} shape was flagged; it is not a copied list:\n{source}" + + +# The artifact exemption's scoping, as rows: `(rel, source, is_offender)`. On the +# real tree a file-scoped allowlist and this position-and-name-scoped one are +# indistinguishable — generic.py's single literal is the only finding — so only +# synthetic sources can tell them apart, and only they carry the drift that does not +# exist yet. Built by iterating the allowlist and the constant, so a rename cannot +# leave a row grading a name nothing declares. +_ALLOWED_IN_GENERIC = TASK_ARTIFACT_LITERAL_ALLOW["adapters/generic.py"]["_result_path"] +TASK_ARTIFACT_SCOPE_CASES = [ + # The sanctioned single-name read: one artifact, named because the question is + # about that one artifact — and named INSIDE the one function that asks it. + *( + ( + f"generic-result-path:{name}", + "adapters/generic.py", + f'def _result_path(self, task_id):\n return self.tasks_dir / task_id / "{name}"\n', + False, + ) + for name in sorted(_ALLOWED_IN_GENERIC) + ), + # …which buys that file NOTHING about the other name. This is the case a + # file-wide allowlist drops on the path alone — and it is the exact drift the + # extraction removed. + *( + ( + f"generic-other-name:{name}", + "adapters/generic.py", + f'def _result_path(self, task_id):\n (task_dir / "{name}").unlink(missing_ok=True)\n', + True, + ) + for name in TASK_CYCLE_ARTIFACTS + if name not in _ALLOWED_IN_GENERIC + ), + # …and it buys no OTHER FUNCTION of that file the allowlisted name either. A + # file-keyed allowlist waves this through on the path alone, which is what the + # allowlist's comment claimed was already impossible and was not. + *( + ( + f"generic-other-function:{name}", + "adapters/generic.py", + f'def start_session(self, spec):\n (task_dir / "{name}").unlink(missing_ok=True)\n', + True, + ) + for name in sorted(_ALLOWED_IN_GENERIC) + ), + # A module-level literal in the allowlisted file has no enclosing function at + # all, so it cannot inherit a function-keyed exemption. + *( + (f"generic-module-level:{name}", "adapters/generic.py", f'STALE = "{name}"\n', True) + for name in sorted(_ALLOWED_IN_GENERIC) + ), + # The twin adapter has no entry at all, so even the allowlisted NAME is refused + # there: nothing in it answers a single-artifact question. + *( + ( + f"opencode-literal:{name}", + "adapters/opencode_http.py", + f'def _result_path(self, task_id):\n return self.tasks_dir / task_id / "{name}"\n', + True, + ) + for name in sorted(_ALLOWED_IN_GENERIC) + ), + # journal.py's own definition is not a copy — skipped by POSITION, so it needs + # no allowlist entry and cannot cover a literal elsewhere in the file. + ( + "journal-definition", + "journal.py", + f"TASK_CYCLE_ARTIFACTS: tuple[str, ...] = ({_ARTIFACT_TUPLE_SRC})\n", + False, + ), + *( + ( + f"journal-bare-literal-beside-it:{name}", + "journal.py", + f"TASK_CYCLE_ARTIFACTS: tuple[str, ...] = ({_ARTIFACT_TUPLE_SRC})\n" + f'STALE = "{name}"\n', + True, + ) + for name in TASK_CYCLE_ARTIFACTS + ), +] + + +@pytest.mark.parametrize( + ("label", "rel", "source", "is_offender"), + TASK_ARTIFACT_SCOPE_CASES, + ids=[c[0] for c in TASK_ARTIFACT_SCOPE_CASES], +) +def test_task_artifact_allowlist_is_scoped_by_position_and_name(label, rel, source, is_offender): + """Being allowlisted buys a file's ONE declared function the artifact NAMES it + declares, and nothing wider. Without this, `TASK_ARTIFACT_LITERAL_ALLOW` could go + back to a set of paths — or to a file -> names map — and every assertion in this + file would stay green.""" + findings = [f for f in _scan_source(source, rel) if f[0] == "taskartifact"] + offenders = _task_artifact_offenders(findings) + assert bool(offenders) is is_offender, ( + f"an artifact literal in {rel} here should " + f"{'be refused' if is_offender else 'be allowed'}:\n{source}" + ) + + +# The task-id detector's probe matrix. Today's tree has exactly one `taskid` +# finding — the chokepoint's own return — so the tree-wide guard would stay green +# with the composition branches deleted; only these rows redden. +SESSION_TASK_ID_PROBES = [ + ("fstring-assignment", 'task_id = f"{task.story_key}-dev-{task.attempt}"\n'), + ("concat-in-keyword", 'spec = SessionSpec(task_id=story + "-review-1", prompt=p)\n'), + ("percent-format", 'task_id = "%s-dev-%d" % (key, seq)\n'), + ("str-format", 'task_id = "{}-dev-1".format(key)\n'), + ("bare-literal-keyword", 'spec = SessionSpec(task_id="triage-1", prompt=p)\n'), + ("annotated-assignment", 'task_id: str = f"{key}-sweep-1"\n'), + # A helper named for what it returns, in both the bare and the wrapped shape — + # the wrapped one is how a fifth mint copied from the chokepoint would look. + ("returned-from-task_id_fn", 'def _sweep_task_id(key):\n return f"{key}-sweep"\n'), + ( + "returned-through-sanitizer", + 'def _sweep_task_id(key):\n return safe_segment(f"{key}-sweep")\n', + ), + # Both branches of a conditional are the same value position. + ("conditional-branch", 'task_id = base if base else f"{key}-dev-1"\n'), + # The chokepoint's own `return safe_segment(f"…")` copied into a BINDING and into + # a KEYWORD — the most likely fifth mint, because it is the sanctioned line moved + # rather than a new idea, and the one that silently drops the `-g` re-arm + # suffix (#705). Both were silent before the binding and keyword legs descended + # through call arguments. + ("binding-wrapped-in-sanitizer", 'task_id = safe_segment(f"{key}-dev-1")\n'), + ( + "keyword-wrapped-in-sanitizer", + 'spec = SessionSpec(task_id=safe_segment(f"{key}-dev-1"), prompt=p)\n', + ), + # …and one level further in, since a wrapper can nest. + ("binding-wrapped-twice", 'task_id = safe_segment(str(f"{key}-dev-1"))\n'), +] +SESSION_TASK_ID_NON_PROBES = [ + # The sanctioned call, and the three FORWARD shapes. A forward is not a mint, + # and this is the distinction the whole detector rests on. + ("chokepoint-call", 'task_id = _session_task_id(key, "dev", 1, gen)\n'), + ("forward-attribute", "handle = SessionHandle(task_id=spec.task_id, native_id=w)\n"), + ("forward-coerced", 'task_id = str(entry.get("task_id", ""))\n'), + ("forward-name", "handle = SessionHandle(task_id=task_id, native_id=w)\n"), + # The parts handed TO the chokepoint are not the id. The binding leg DOES descend + # into call arguments now, so this row is what makes the depth rule load-bearing: + # a bare literal is a mint only at depth 0, or the `"dev"` in every sanctioned + # mint site becomes a finding. + ("chokepoint-call-with-literal-part", 'task_id = _session_task_id(k, "dev", n, gen)\n'), + ( + "chokepoint-keyword-with-literal-part", + 'spec = SessionSpec(task_id=_session_task_id(k, "dev", n, gen), prompt=p)\n', + ), + # The same rule is what keeps the env read silent — `events.py` and both hook + # scripts spell exactly this, and the variable name is a `task_id` binding. + ("env-read", 'task_id = os.environ.get("BMAD_LOOP_TASK_ID")\n'), + ("env-read-with-default", 'task_id = os.environ.get("BMAD_LOOP_TASK_ID", "probe")\n'), + # The shapes the detector deliberately does not reach, pinned as rows so the + # boundary is executed rather than only described in the `NOT COVERED` comment. + ("intermediate-variable", 'tid = f"{key}-dev-1"\nspec = SessionSpec(task_id=tid)\n'), + ("join-composition", 'task_id = "-".join([key, "dev", "1"])\n'), + ("percent-against-a-name", "task_id = fmt % (key, seq)\n"), + # A composition bound to something else entirely — the detector is scoped to the + # `task_id` positions, not to f-strings at large. + ("composition-elsewhere", 'log_name = f"{task_id}.log"\n'), + # A *task_id* function that FORWARDS: its returned literal-keyed subscript is + # not a string Constant in a value position (`tui.data.active_task_id`). + ( + "task_id_fn-forwards", + 'def active_task_id(entries):\n return str(entries[-1]["task_id"])\n', + ), + # Prose is a docstring Expr, never a binding or a return value. + ( + "prose-in-docstring", + 'def f():\n """Ids look like task_id = f\'{key}-dev-1\'."""\n return 1\n', + ), +] + + +@pytest.mark.parametrize( + ("label", "source"), SESSION_TASK_ID_PROBES, ids=[p[0] for p in SESSION_TASK_ID_PROBES] +) +def test_session_task_id_detector_flags_every_mint_shape(label, source): + """Each spelling of a hand-minted id produces a `taskid` finding. `sweep.py` is + an unsanctioned file, so a finding here is also an offender.""" + found = [f for f in _scan_source(source, "sweep.py") if f[0] == "taskid"] + assert found, f"the {label!r} shape produced no `taskid` finding:\n{source}" + + +@pytest.mark.parametrize( + ("label", "source"), + SESSION_TASK_ID_NON_PROBES, + ids=[p[0] for p in SESSION_TASK_ID_NON_PROBES], +) +def test_session_task_id_detector_stays_silent_on_forwards(label, source): + """The complement: the chokepoint call, the three forward shapes, the literal + PARTS handed to the chokepoint, the environment read every hook script uses, a + composition bound elsewhere, and prose are all silent — a guard that flags + forwards would be allowlisted away within a week. + + The last three rows are the DISCLOSED gaps rather than desired silences: + an intermediate variable, `str.join`, and `%` against a Name-bound format + string. They are here so the boundary is executed and cannot drift into a + coverage claim the detector does not make.""" + found = [f for f in _scan_source(source, "sweep.py") if f[0] == "taskid"] + assert not found, f"the {label!r} shape was flagged; it is not a mint:\n{source}" + + +# The task-id exemption's scoping, as rows: `(rel, source, is_offender)`. +SESSION_TASK_ID_SCOPE_CASES = [ + # The real chokepoint, in the shape it ships. + ( + "sanctioned-chokepoint", + "engine.py", + "def _session_task_id(story_key, part, seq, generation):\n" + ' gen = f"-g{generation}" if generation > 0 else ""\n' + ' return safe_segment(f"{story_key}-{part}-{seq}{gen}")\n', + False, + ), + # Being engine.py is NOT enough: it already binds `task_id` three times, so a + # file-wide exemption would leave the invariant unguarded exactly where a fifth + # mint would be written. + ( + "engine-other-function", + "engine.py", + "def _run_sweep(self, task):\n" + ' task_id = f"{task.story_key}-sweep-{task.attempt}"\n' + " return task_id\n", + True, + ), + # The name does not travel: the same function grown in another module cannot + # sanction itself, which is why the sanction pairs the function with the FILE. + ( + "chokepoint-name-in-another-file", + "sweep.py", + "def _session_task_id(story_key, part, seq, generation):\n" + ' return safe_segment(f"{story_key}-{part}-{seq}")\n', + True, + ), + # The measured ablation: resolve.py's mint respelled as an f-string. + ( + "resolve-respelled", + "resolve.py", + 'spec = SessionSpec(task_id=f"{story_key}-resolve-1", prompt=p)\n', + True, + ), + # …and its real spelling stays silent there. + ( + "resolve-real-spelling", + "resolve.py", + 'spec = SessionSpec(task_id=_session_task_id(story_key, "resolve", 1, generation), prompt=p)\n', + False, + ), + # A nested def inside the chokepoint is still inside it (`ast.walk` descends), + # matching how the verify sanctions treat closures. + ( + "nested-inside-chokepoint", + "engine.py", + "def _session_task_id(story_key, part, seq, generation):\n" + " def compose():\n" + ' return f"{story_key}-{part}-{seq}"\n' + " return safe_segment(compose())\n", + False, + ), +] + + +@pytest.mark.parametrize( + ("label", "rel", "source", "is_offender"), + SESSION_TASK_ID_SCOPE_CASES, + ids=[c[0] for c in SESSION_TASK_ID_SCOPE_CASES], +) +def test_session_task_id_exemption_is_scoped_to_the_chokepoint(label, rel, source, is_offender): + """Being engine.py buys the file its `_session_task_id` body and nothing wider. + Without this, the sanction could go back to a bare file set and every assertion + here would stay green — the difference only shows up on a fifth mint, which is + the only kind a tripwire is for.""" + findings = [f for f in _scan_source(source, rel) if f[0] == "taskid"] + offenders = _session_task_id_offenders(findings) + assert bool(offenders) is is_offender, ( + f"a composed task id in {rel} here should " + f"{'be refused' if is_offender else 'be allowed'}:\n{source}" + ) + + +# The journal detector's probe matrix, as `(label, source, expected)` where +# `expected` is the exact set of field names the scan must extract — `None` standing +# for an unresolvable splat. Asserting the SET rather than "something was found" is +# what makes a partial splat resolution fail here instead of quietly under-reporting. +JOURNAL_FIELD_PROBES = [ + # The three receiver spellings in the tree. + ("self-journal", 'self.journal.append("k", story_key=s, patch=p)\n', {"story_key", "patch"}), + ("bare-journal", 'journal.append("k", branch=b)\n', {"branch"}), + ("private-journal", "self._journal.append(kind, plugin=name)\n", {"plugin"}), + # A splat resolved through the literal stores that build it, in both store + # shapes and across the conditional-dict form `engine._run_inner` uses. + ( + "splat-dict-literal", + "def f(self):\n" + ' fields = {"story_key": k, "checkpoint": "story"}\n' + ' self.journal.append("k", **fields)\n', + {"story_key", "checkpoint"}, + ), + ( + "splat-subscript-store", + "def f(self):\n" + ' fields = {"story_key": k}\n' + ' fields["reason"] = "graceful-stop"\n' + ' self.journal.append("k", **fields)\n', + {"story_key", "reason"}, + ), + ( + "splat-conditional-dict", + "def f(self):\n" + ' extras = {"via": stop.via} if stop.via is not None else {}\n' + ' self.journal.append("k", **extras)\n', + {"via"}, + ), + # Explicit keywords and a splat on the SAME call: both halves are collected, so + # a resolvable splat does not shadow its siblings and vice versa. + ( + "splat-mixed-with-explicit", + 'def f(self):\n d = {"a": 1}\n self.journal.append("k", b=2, **d)\n', + {"a", "b"}, + ), + # The unresolvable shapes, each of which must fail LOUD rather than resolve to + # the keys seen so far — a partially-resolved splat is a silent hole. + ( + "splat-computed-key", + 'def f(self):\n d = {}\n d[f"{kind}_path"] = p\n self.journal.append("k", **d)\n', + {None}, + ), + ( + "splat-update-mutation", + 'def f(self):\n d = {"a": 1}\n d.update(b=2)\n self.journal.append("k", **d)\n', + {None}, + ), + ( + "splat-augmented-store", + 'def f(self):\n d = {"a": 1}\n d += other\n self.journal.append("k", **d)\n', + {None}, + ), + ( + "splat-nested-splat", + 'def f(self):\n d = {"a": 1, **other}\n self.journal.append("k", **d)\n', + {None}, + ), + ( + "splat-from-call", + 'def f(self):\n self.journal.append("k", **self._extras(result))\n', + {None}, + ), + ( + "splat-parameter-forwarder", + "def _log(self, kind, **fields):\n self._journal.append(kind, **fields)\n", + {None}, + ), + ("splat-at-module-level", 'journal.append("k", **fields)\n', {None}), + # The fourth direction the resolver has to fail closed in: a SECOND NAME bound to + # the same dict, mutated through the alias. Every store the resolver looks for is + # spelled on `alias`, so the tracked name resolves to `{"a"}` and the new field + # is invisible — a partially-resolved splat reading as green, which is precisely + # what the other three rows exist to prevent. + ( + "splat-aliased-then-mutated", + "def f(self):\n" + ' fields = {"a": 1}\n' + " alias = fields\n" + ' alias["customer_email"] = 2\n' + ' self.journal.append("k", **fields)\n', + {None}, + ), + # …and a plain READ of the dict is not an alias, so it still resolves. + ( + "splat-read-not-aliased", + 'def f(self):\n fields = {"a": 1}\n n = len(fields)\n' + ' self.journal.append("k", **fields)\n', + {"a"}, + ), +] +# The forwarder leg, which needs its own `rel` because `JOURNAL_FORWARDERS` is keyed +# `(file, name)`: `(label, rel, source, expected)`. Without the declaration the plugin +# bus's four `self._log(...)` sites were a wall — the scan saw only the `.append` +# inside `_log`, which is an unresolvable splat, so `rc` and `blocking` reached the +# journal while sitting in neither routing set with the guard green. +JOURNAL_FORWARDER_PROBES = [ + ( + "declared-forwarder-call", + "plugins/bus.py", + 'self._log("plugin-hook", plugin=lp.name, stage=hook.stage, rc=rc, blocking=True)\n', + {"plugin", "stage", "rc", "blocking"}, + ), + # The declaration is keyed by FILE as well as name: a `_log` in another module + # forwards to something else entirely and must stay invisible. + ("forwarder-name-in-another-file", "stories_engine.py", 'self._log("k", rc=rc)\n', set()), + # …and it does not turn every call in the declared file into a journal write. + ("other-call-in-forwarder-file", "plugins/bus.py", 'self._emit("k", rc=rc)\n', set()), +] + + +@pytest.mark.parametrize( + ("label", "rel", "source", "expected"), + JOURNAL_FORWARDER_PROBES, + ids=[p[0] for p in JOURNAL_FORWARDER_PROBES], +) +def test_journal_forwarder_calls_enter_the_inventory(label, rel, source, expected): + """A declared forwarder's CALL SITES are journal writes, so their explicit + keywords are graded like any other producer's — and the declaration is scoped to + the one file that owns the forwarder.""" + found = {f[4][0] for f in _scan_source(source, rel) if f[0] == "journalfield"} + assert found == expected, f"the {label!r} shape resolved to {sorted(found, key=str)}:\n{source}" + + +JOURNAL_FIELD_NON_PROBES = [ + # `.append` on anything that is not a journal handle — the method name alone is + # the most common in the language, so anchoring on the receiver is load-bearing. + ("list-append", "results.append(SessionResult(status=s, stop_seen=True))\n"), + ("attribute-list-append", "self.entries.append(dict(kind=k, story_key=s))\n"), + # A journal write with no fields at all produces nothing to route. + ("kind-only", 'self.journal.append("run-start")\n'), + # Prose naming the call is a Constant, not a Call. + ("prose-in-docstring", 'def f():\n """Calls journal.append(patch=p)."""\n return 1\n'), +] + + +@pytest.mark.parametrize( + ("label", "source", "expected"), + JOURNAL_FIELD_PROBES, + ids=[p[0] for p in JOURNAL_FIELD_PROBES], +) +def test_journal_field_detector_extracts_the_declared_names(label, source, expected): + """The names (and the unresolvable-splat marker) the scan must extract from each + producer shape. Deleting the splat resolver, or letting it return the keys it + managed to see, reddens exactly the rows that describe that behaviour — which + the tree-wide assertion cannot, since it is an absence.""" + found = {f[4][0] for f in _scan_source(source, "sweep.py") if f[0] == "journalfield"} + assert found == expected, f"the {label!r} shape resolved to {sorted(found, key=str)}:\n{source}" + + +@pytest.mark.parametrize( + ("label", "source"), + JOURNAL_FIELD_NON_PROBES, + ids=[p[0] for p in JOURNAL_FIELD_NON_PROBES], +) +def test_journal_field_detector_stays_silent_on_non_journal_appends(label, source): + """The complement: `.append` on a list, on some other attribute, a kind-only + journal write, and prose are all silent. Without this the detector could pass + every row above by flagging every `.append` in the tree.""" + found = [f for f in _scan_source(source, "sweep.py") if f[0] == "journalfield"] + assert not found, f"the {label!r} shape was flagged as a journal field:\n{source}" + + +# The journal offender filter's scoping, as rows: +# `(rel, fn, field, kind, is_offender)`. On the real tree every field is accounted +# for, so a filter that accepted EVERYTHING would look identical — only synthetic +# findings separate them. +JOURNAL_FIELD_SCOPE_CASES = [ + # The measured DW-82 ablation: a routed field renamed by its producer. `patch` + # is routed (dropped); `patch_path` is nothing, and the dump leaks. + ("routed-name", "recovery_flow.py", "_restore", "patch", "stale-restore", False), + ("renamed-off-the-table", "recovery_flow.py", "_restore", "patch_path", "stale-restore", True), + # A declared-benign name stays silent, and a name in neither set is refused + # wherever it appears — the inventory is global, not per-file. + ("declared-benign", "engine.py", "_run_inner", "attempt", "run-start", False), + ("undeclared-new-field", "engine.py", "_run_inner", "customer_email", "run-start", True), + ("undeclared-in-another-file", "sweep.py", "_triage", "customer_email", "sweep-start", True), + # KIND-SCOPED routing, which a flattened by-name union got wrong in the dangerous + # direction. `target` is aliased to a branch on exactly three merge kinds … + ("kind-alias-on-its-own-kind", "worktree_flow.py", "_merge", "target", "unit-merged", False), + # … and is NOT routed on a new kind that reuses the name. Flattened, this passed + # while `_scrub_entry` handed the branch to `scrub_json` verbatim. + ("kind-alias-on-a-new-kind", "worktree_flow.py", "_merge", "target", "unit-merge-failed", True), + # … nor at a call whose kind the scan could not resolve: nothing there can prove + # which kind it lands on, so the name is not routed by default. + ("kind-alias-on-a-non-literal-kind", "worktree_flow.py", "_merge", "target", None, True), + # The board-advance family carries a sprint STATUS under the same name, declared + # benign per kind rather than by widening the by-name set. + ( + "kind-benign-on-its-own-kind", + "engine.py", + "_advance_board", + "target", + "board-advance-carried", + False, + ), + # …and that declaration does not travel to a kind outside the family either. + ( + "kind-benign-on-another-kind", + "engine.py", + "_advance_board", + "target", + "board-advance-invented", + True, + ), + # An unresolvable splat is refused unless its POSITION is a declared hole … + ("undeclared-splat", "sweep.py", "_triage", None, "sweep-start", True), + ("declared-splat-hole", "plugins/bus.py", "_log", None, None, False), + # … and the declaration does not travel: the same function name in another + # module, or another function in the same module, is still a hole. + ("declared-hole-wrong-file", "stories_engine.py", "_log", None, None, True), + ("declared-hole-wrong-function", "plugins/bus.py", "_dispatch", None, None, True), +] + + +@pytest.mark.parametrize( + ("label", "rel", "fn", "field", "kind", "is_offender"), + JOURNAL_FIELD_SCOPE_CASES, + ids=[c[0] for c in JOURNAL_FIELD_SCOPE_CASES], +) +def test_journal_field_offenders_split_routed_benign_and_holes( + label, rel, fn, field, kind, is_offender +): + """The filter's decision, as rows: routed by name, routed on THIS kind, declared + benign globally or on this kind, or an offender — and, for a splat, whether its + `(file, function)` is a declared hole. + + Pins two scopings the real tree cannot show. `JOURNAL_SPLAT_ALLOW` is keyed by + POSITION rather than by function name (no two of its four holes share a name), + and kind-scoped routing is keyed by KIND rather than flattened by name (every + `target` in the tree today sits on a kind that routes or declares it).""" + offenders = _journal_field_offenders( + [("journalfield", rel, 1, f"journal.append(k, {field}=v)", (field, fn, kind))] + ) + assert bool(offenders) is is_offender, ( + f"{rel}::{fn} journalling {field!r} on kind {kind!r} should " + f"{'be refused' if is_offender else 'be allowed'}" + ) + + +# The dynamic-kind declaration's scoping, as rows: `(rel, fn, is_offender)`. +JOURNAL_KIND_SCOPE_CASES = [ + ("declared-position", "plugins/bus.py", "_log", False), + ("declared-position-recovery", "recovery_flow.py", "prune_preserve_refs", False), + # The declaration does not travel by function name, nor by file. + ("undeclared-function-same-file", "plugins/bus.py", "_dispatch", True), + ("declared-name-another-file", "stories_engine.py", "_log", True), + ("undeclared-position", "sweep.py", "_triage", True), +] + + +@pytest.mark.parametrize( + ("label", "rel", "fn", "is_offender"), + JOURNAL_KIND_SCOPE_CASES, + ids=[c[0] for c in JOURNAL_KIND_SCOPE_CASES], +) +def test_journal_kind_declaration_is_scoped_by_position(label, rel, fn, is_offender): + """A non-literal kind is waived at the exact `(file, function)` that declared + itself, and nowhere else — the `JOURNAL_SPLAT_ALLOW` idiom, for the same reason: + a site the scan cannot read must not read as clean because a same-named function + elsewhere is allowed to be unreadable.""" + offenders = _journal_kind_offenders([("journalkind", rel, 1, "journal.append(kind)", fn)]) + assert bool(offenders) is is_offender, ( + f"a non-literal kind in {rel}::{fn} should " + f"{'be refused' if is_offender else 'be allowed'}" + ) + + +def test_journal_kind_probes_flag_a_non_literal_kind(): + """The detector half: a journal write whose kind is a Name, an f-string or a + call emits a `journalkind` finding, and a literal one does not. Without this the + tree-wide assertion is green with the emit deleted.""" + for source in ( + "def f(self):\n self.journal.append(kind, story_key=s)\n", + 'def f(self):\n self.journal.append(f"{family}-pruned", count=n)\n', + "def f(self):\n self.journal.append(_kind_for(x), count=n)\n", + "def f(self):\n self.journal.append(**everything)\n", + ): + assert [f for f in _scan_source(source, "sweep.py") if f[0] == "journalkind"], source + for source in ( + 'def f(self):\n self.journal.append("run-start", story_key=s)\n', + "def f(self):\n results.append(kind)\n", + ): + assert not [f for f in _scan_source(source, "sweep.py") if f[0] == "journalkind"], source + + +def test_journal_routing_tables_are_read_from_diagnostics(): + """`JOURNAL_ROUTED_FIELDS` and `JOURNAL_KIND_ROUTED_FIELDS` are built from the + live `diagnostics` tables, not copied, so the guard cannot drift from the module + it grades. Asserted rather than left to the comment: a future refactor that + inlined the names would pass every other test here while quietly freezing the + routing set.""" + for table in ( + diagnostics._JOURNAL_ALIAS_FIELDS, + diagnostics._JOURNAL_DROP_FIELDS, + diagnostics._JOURNAL_KEYLIST_FIELDS, + ): + assert set(table) <= JOURNAL_ROUTED_FIELDS + assert JOURNAL_KIND_ROUTED_FIELDS == { + kind: frozenset(row) for kind, row in diagnostics._JOURNAL_KIND_ALIAS_FIELDS.items() + } + # …and the kind-scoped names are deliberately NOT in the by-name union. This is + # the assertion that would have caught the flattening: `target` routed by name + # says the board-advance family is covered when `_scrub_entry` does not cover it. + for row in diagnostics._JOURNAL_KIND_ALIAS_FIELDS.values(): + assert not set(row) & JOURNAL_ROUTED_FIELDS, ( + "a kind-scoped field name leaked into the by-name routed union; " + "`_scrub_entry` consults `_JOURNAL_KIND_ALIAS_FIELDS` per kind, so a " + "by-name claim about it is false on every other kind" + ) + # and the sets are disjoint: a routed name must never also be declared + # benign, which would make the routing row unfalsifiable from this side. + assert not JOURNAL_ROUTED_FIELDS & JOURNAL_BENIGN_FIELDS + for kind, row in JOURNAL_KIND_ROUTED_FIELDS.items(): + assert not row & JOURNAL_KIND_BENIGN_FIELDS.get(kind, frozenset()) + assert not row & JOURNAL_BENIGN_FIELDS + + def test_guard_actually_scanned_files(): """Sanity: the scan walked a non-trivial number of files (catches a broken SRC root silently passing every assertion).""" From 359bee87f8b5591e37dac59ca40ce577c9ed0a65 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 21:35:37 -0700 Subject: [PATCH 14/45] sweep dw2-source-scan-parity-guards: DW-65, DW-66, DW-82 via bmad-loop --- CHANGELOG.md | 29 +++++- src/bmad_loop/diagnostics.py | 148 ++++++++++++++++++++++++-- src/bmad_loop/engine.py | 31 +++++- src/bmad_loop/journal.py | 14 +++ tests/test_cli.py | 4 +- tests/test_diagnostics.py | 179 ++++++++++++++++++++++++++++++++ tests/test_engine.py | 76 ++++++++++++++ tests/test_portability_guard.py | 90 ++++++++++++++-- 8 files changed, 548 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2c76bb..4d059ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,12 @@ breaking changes may land in a minor release. ### Changed +- **`bmad-loop diagnose --json` reports `schema_version: 3`.** Replacing a journal-entry value + with a presence key is a payload break under the additive-only rule, and the redaction fixes + above make two: a consumer reading `entry["question"]` on `decision-pending`, or an + off-schema key on `preference-escalation`, finds a `_present` boolean instead. Exactly + what minted v2 for `patch` / `stashed_to`. Structure is otherwise unchanged. + - **A story's `verification_sequence` now numbers its review passes too**, so the ordinals a `post_dev_verify` handler receives shift: for an unchanged run whose review gate sits between the dev and repair legs, the `fix` pass moves from 2 to 3. The ordinal was always documented @@ -219,10 +225,25 @@ breaking changes may land in a minor release. ### Fixed -- Alias the `story_keys` list on the `sweep-inflight-stranded` journal record. It carried raw - bundle story keys into `diagnose --dump`: the value fell through to `scrub_json`, which is the - identity on a list of identifier-shaped strings, while the singular `story_key` beside it was - already aliased. +- Stop an LLM-authored preference escalation from aborting the review leg. `_review_and_commit` + splats a review session's own `result.json` escalation entries into `journal.append`, so a + result.json carrying a `kind` or `story_key` key raised `TypeError: got multiple values for +argument` and failed the story; a `ts` key did not raise and instead silently replaced the + entry's real timestamp, skewing every relative offset a diagnostic dump derives from it. Those + three journal-owned names are now dropped before the splat. + +- Close three journal-field leaks into `diagnose`, all found by the new field-routing guard and + each reproduced through `diagnostics._scrub_entry`. On `preference-escalation`, whose keys are + LLM-authored (`engine._review_and_commit` splats a session's own `result.json` entries into + the journal), every key outside the record's declared `{type, severity, detail}` schema now + renders as `_present`; the key NAME still ships, a bound stated on the routing entry and + accepted rather than collapsed. `decision-pending`'s `question` joins the free-text drop set — + a multi-word question collapsed only by accident of the fallback forbidding spaces, while a + one-token one shipped verbatim; no operator surface loses it, since the TUI reads the raw + journal. And `story_keys` is aliased element-wise on `sweep-inflight-stranded`, which carried + raw bundle story keys because the value fell through to `scrub_json` — the identity on a list + of identifier-shaped strings — while the singular `story_key` beside it was already aliased; + a non-list value on any key-list field now fails closed instead of taking that same path. - Stop a second resolve cycle re-presenting escalations the human already answered (DW-11). Only a re-arm that accepted a `resolution.json` watermarks the session trail; later cycles show what came after it and print how many were withheld. diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index fbd5da5d..358d8566 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -51,7 +51,7 @@ from typing import Any from . import __version__, sanitize -from .journal import VERIFY_DIR, Journal, load_state +from .journal import SELF_MINTED_FIELDS, VERIFY_DIR, Journal, load_state from .model import RunState, StoryTask from .platform_util import walk_files_unlinked @@ -71,7 +71,22 @@ # the fence is gone and json.loads fails. Bump only on a payload break. # v2 replaces journal-entry `patch` / `stashed_to` values with the presence keys # `patch_present` / `stashed_to_present`. -SCHEMA_VERSION = 2 +# v3 does the same thing to two more journal-entry values, which is the same +# payload break for the same reason: `question` (on `decision-pending`) becomes +# `question_present`, and on `preference-escalation` EVERY key outside the +# `{type, severity, detail}` schema becomes `_present` — see +# `_JOURNAL_KIND_SCHEMAS`. A consumer reading `entry["question"]` finds it gone, +# so this is a break under `machine.py`'s additive-only rule, exactly as v2 was. +# The third change shipping with it — `_JOURNAL_KEYLIST_FIELDS` failing closed on a +# non-list value — is deliberately NOT part of this rationale, but the reason is the +# JSONL ROUND-TRIP, not the call sites. Two producers do pass a tuple +# (`sweep.py`'s `dw_ids=(decision.id,)` and `dw_ids=tuple(task.dw_ids)`), so a survey +# of producers would be the wrong argument and is false as such. What makes it a +# non-break is that `_scrub_entry` never sees a producer's object: entries are +# serialized to `journal.jsonl` and read back, and JSON has no tuple type, so every +# sequence arrives as a `list` and takes the same arm it always did. The new arm is +# reachable only by a shape no round-tripped entry can hold. +SCHEMA_VERSION = 3 DEFAULT_JOURNAL_CAP = 200 # Subdirectories whose mere existence/size is diagnostic but whose CONTENTS are @@ -234,6 +249,21 @@ "message", "note", "blocker", + # The deferred-work decision text a sweep parks on (`sweep.py`'s + # `decision-pending`) — operator-facing prose about the customer's own + # work, so it belongs beside `detail`/`reason`/`blocker`/`suggestion`/`note` + # under this set's free-text rule. Routed rather than left to `scrub_json` + # for the reason stated above that fallback: it collapses a MULTI-WORD + # question only by accident of `_IDENTIFIER_RE` forbidding spaces, and a + # one-token question (`"AcmeVault"`) is identifier-shaped and shipped + # verbatim (reproduced against `_scrub_entry`, 2026-08-30). No user-facing + # surface loses the text: both operator-facing readers take it from the RAW + # journal on the operator's own machine, not from this dump — + # `tui.data.pending_decision` (which returns the `(dw_id, question)` pair) + # and `tui.launch.decision_pending` (which answers the boolean). Named + # exactly, in both directions: an earlier version of this comment attributed + # `decision_pending` to `tui/data.py`, where no such symbol exists. + "question", "commit_message", "was_paused", "command", @@ -286,6 +316,66 @@ # beside it in the neighbouring record was aliased. _JOURNAL_KEYLIST_FIELDS = frozenset({"keys", "dw_ids", "story_keys"}) +# ``kind -> the field names that kind's record is DECLARED to carry``. On a kind +# listed here the usual ``scrub_json`` fallback is replaced by a fail-closed one: +# any key outside its declared set renders as ``_present`` rather than as a +# value. Every other routing rule above still runs first and still wins, so a +# declared-schema kind's ``story_key`` is aliased and its ``detail`` is dropped +# exactly as on any other kind — this table only decides what happens to the names +# nothing else claimed. +# +# It exists because ONE kind's field names are not authored by this codebase at all. +# ``engine._review_and_commit`` splats ``escalation.preference_escalations(rj)`` — +# entries lifted straight out of a session's own ``result.json`` — into +# ``journal.append``, so an LLM chooses the journal FIELD NAMES. The by-name tables +# cannot route a name nobody can enumerate, and the ``scrub_json`` fallback is the +# IDENTITY on an identifier-shaped scalar: an entry carrying +# ``customer="AcmeVault"`` came back byte-identical (reproduced against +# ``_scrub_entry``, 2026-08-30). +# +# The declared shape is ``{type, severity, detail}``. Its PROVENANCE, stated +# precisely because an earlier version of this comment got it wrong: the sole +# producer of this kind is ``engine._review_and_commit``, splatting a +# bmad-build-auto REVIEW session's ``result.json`` entries. Sweep does not journal +# ``preference-escalation`` at all. ``data/skills/bmad-loop-sweep/automation-mode.md`` +# declares the same three-key shape for a DIFFERENT session type, so it corroborates +# the shape and is not the contract that produces this record. Anything outside those +# three keys is off-schema and has no diagnostic claim on being shown verbatim. +# +# ⚠️ ACCEPTED RESIDUAL — decided 2026-08-30, not an oversight, and NOT to be +# re-filed or "fixed" as a fresh finding. This table closes the NAME axis only, and +# three things still reach the dump. Stated in full, because a disclosure that +# understates its own scope is the same defect as a comment naming the wrong +# mechanism. +# +# 1. The key NAME. ``AcmeVaultTenant=1`` renders as ``AcmeVaultTenant_present``. +# A name-free collapse (a single ``unrouted_field_count`` integer) closes this +# and was OFFERED AND DECLINED, in favour of the per-key marker's diagnostic +# value — a maintainer can see WHICH off-schema key a session invented, which is +# most of why the record is read. +# 2. Arbitrary key SHAPES, which follows from 1 and is easy to miss: nothing +# constrains an LLM-authored key to be identifier-shaped, so a free-text key +# survives as a JSON key with the suffix glued on — +# ``"customer AcmeVault owes 5k"`` renders as +# ``"customer AcmeVault owes 5k_present"`` (reproduced). +# 3. The VALUES of the three DECLARED names, which this table does not touch at +# all: they stay on the ``scrub_json`` fallback, so +# ``type="acmevault-tenant-isolation"`` and ``severity="AcmeVaultHigh"`` both come +# back byte-identical, as does a container in a declared name +# (``type={"customer": "AcmeVault"}``) — all reproduced. +# +# Do NOT "fix" 3 by routing ``type``/``severity``: the intent this table implements +# requires the declared schema to be emitted as it is today, on the ground that +# collapsing it destroys the field the record is read for. A further leak here is +# DISCLOSED — which is what this comment is — never routed. +# +# ``detail`` is named here for completeness even though ``_JOURNAL_DROP_FIELDS`` +# reaches it first: the set states the record's schema, and a schema that omitted a +# field because some other table happened to cover it would mislead the next reader. +_JOURNAL_KIND_SCHEMAS: dict[str, frozenset[str]] = { + "preference-escalation": frozenset({"type", "severity", "detail"}), +} + # Policy keys whose values can carry secrets/paths/free text. Dropped or reduced # rather than scrubbed, since a single-token API key or repo name could be # identifier-shaped and survive a plain scrub. @@ -723,8 +813,14 @@ def _scrub_entry( first_ts: float | None, ) -> dict: """One journal entry reduced to a shareable form: relative timestamp, kind - verbatim, identifier fields aliased, free-text fields collapsed to a - presence boolean, and every remaining/unknown field scrub_json'd.""" + verbatim, identifier fields aliased, free-text fields collapsed to a presence + boolean, and every remaining/unknown field scrub_json'd. + + Two kinds of field never reach that last fallback, because for them + ``scrub_json`` fails closed only by accident of a value's shape. A name in + ``_JOURNAL_KEYLIST_FIELDS`` carrying something other than a list collapses to a + presence key, and on a kind with a declared schema + (``_JOURNAL_KIND_SCHEMAS``) so does every key the schema does not name.""" out: dict[str, Any] = {} ts = entry.get("ts") if isinstance(ts, (int, float)) and first_ts is not None: @@ -735,23 +831,55 @@ def _scrub_entry( # `looks_like_identifier` is not one of the three below anyway, and keying on the # placeholder would silently unroute every entry in a dump that had one. by_kind = _JOURNAL_KIND_ALIAS_FIELDS.get(kind, {}) + declared = _JOURNAL_KIND_SCHEMAS.get(kind) for k, v in entry.items(): if k in ("ts", "kind"): continue kind_ns = by_kind.get(k) if k in _JOURNAL_DROP_FIELDS: out[f"{k}_present"] = v is not None and v != "" - elif k in _JOURNAL_KEYLIST_FIELDS and isinstance(v, list): - # Namespace by field, not by "everything that is not `keys`": both - # story-key list fields must land in the SAME namespace as the singular - # `story_key`, or one dump would carry two aliases for one story. - ns = "dw" if k == "dw_ids" else "story" - out[k] = [pseudo.alias(x, ns=ns, epic=epic_by_key.get(str(x))) for x in v] + elif k in _JOURNAL_KEYLIST_FIELDS: + if isinstance(v, list): + # Namespace by field, not by "everything that is not `keys`": both + # story-key list fields must land in the SAME namespace as the + # singular `story_key`, or one dump would carry two aliases for one + # story. + ns = "dw" if k == "dw_ids" else "story" + out[k] = [pseudo.alias(x, ns=ns, epic=epic_by_key.get(str(x))) for x in v] + else: + # A name in this set is DECLARED to carry a list of identifiers, so a + # non-list is an unknown shape — and falling through to `scrub_json` + # for it is exactly the accident this whole set exists to stop: a + # scalar `story_keys="1-1-acme-auth"` is identifier-shaped and ships + # verbatim (reproduced, 2026-08-30). Every producer passes a list + # today, so this is latent rather than live — which is the reason to + # fail closed on it rather than to rest on that survey staying true. + # A presence marker rather than an alias because the shape is + # genuinely unknown: a dict or an int has no sensible alias, and the + # one thing worth reporting is that the field was set. + out[f"{k}_present"] = v is not None and v != "" elif kind_ns is not None or k in _JOURNAL_ALIAS_FIELDS: ns = kind_ns or _JOURNAL_ALIAS_FIELDS[k] v = _alias_input(v, ns) epic = epic_by_key.get(str(v)) if ns == "story" else None out[k] = pseudo.alias(v, ns=ns, epic=epic) + elif declared is not None and k not in declared and k not in SELF_MINTED_FIELDS: + # A kind with a declared schema (`_JOURNAL_KIND_SCHEMAS`) replaces the + # `scrub_json` fallback with a fail-closed one, because on such a kind an + # unclaimed name is not a field nobody has routed yet — it is a field + # nobody in this codebase NAMED. See that table for the accepted residual: + # the key name itself still ships. + # + # `SELF_MINTED_FIELDS` is exempt because the premise does not hold for it. + # `Journal.append` stamps `log_task`/`log_pos` onto EVERY entry, including + # this kind's, so they are engine-authored and arrive on a record whose + # other keys are not. Collapsing them buys no safety — `log_task` is + # aliased before it ever reaches here, and `log_pos` is a byte offset — + # while `log_pos_present: true` would silently destroy the pane-log + # pointer on precisely the records an operator opens a dump to trace. + # Imported from `journal` rather than restated, so this exemption cannot + # drift from the `setdefault` pair that creates the fields. + out[f"{k}_present"] = v is not None and v != "" else: out[k] = sanitize.scrub_json(v) return out diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index c43ded5f..8ea90b1b 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -40,7 +40,7 @@ session_failure_reason, ) from .install import dev_primitive_or_default -from .journal import Journal, save_state +from .journal import SELF_MINTED_FIELDS, Journal, save_state from .model import ( PAUSE_EPIC_BOUNDARY, PAUSE_ESCALATION, @@ -105,6 +105,16 @@ # 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: @@ -2811,6 +2821,25 @@ def _review_and_commit( rj = result.result_json or {} 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), diff --git a/src/bmad_loop/journal.py b/src/bmad_loop/journal.py index 4e8297e0..4ef72b3b 100644 --- a/src/bmad_loop/journal.py +++ b/src/bmad_loop/journal.py @@ -63,6 +63,20 @@ # (``adapters/generic.py``) and ``messages.json`` (``adapters/opencode_http.py``). TASK_CYCLE_ARTIFACTS: tuple[str, ...] = ("result.json", "escalation.json") +# The field names ``Journal.append`` stamps onto an entry ITSELF, rather than taking +# from its caller's keywords — see the ``setdefault`` pair in that method. No call +# site spells either one, which makes them invisible to anything reading call sites +# and easy for a consumer to mistake for a producer-supplied field. +# +# Spelled here, at the minting site, because two consumers need exactly this set and +# a third copy is how they drift: ``diagnostics._scrub_entry`` must exempt them from +# the fail-closed arm it applies to a declared-schema kind (they are engine-minted, +# never LLM-authored, so collapsing ``log_pos`` to a presence marker would throw away +# a byte offset for no safety gain), and ``tests/test_portability_guard.py`` needs +# them to keep its static call-site scan from calling them dead. Both import this +# name; neither restates the pair. +SELF_MINTED_FIELDS: frozenset[str] = frozenset({"log_task", "log_pos"}) + class Journal: def __init__(self, run_dir: Path): diff --git a/tests/test_cli.py b/tests/test_cli.py index e7acd64b..ebba95a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5352,7 +5352,7 @@ def test_diagnose_json_emits_pure_document(project, capsys): _seed_run(project.project) doc = machine_json(["diagnose", "--project", str(project.project), "--json"], capsys) - assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 2 + assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 3 assert doc["runs"], "the document carries the run it resolved" for canary in CANARIES: assert canary not in json.dumps(doc), f"LEAK via CLI: {canary!r}" @@ -5372,7 +5372,7 @@ def test_diagnose_json_out_writes_document_and_keeps_stdout_empty(project, tmp_p assert "written to" in err # the confirmation moved to stderr written = out_file.read_text() doc = json.loads(written) - assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 2 + assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 3 assert "```" not in written # no fences in a file written in JSON mode for canary in CANARIES: assert canary not in written, f"LEAK via CLI: {canary!r}" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 99ea0c9a..fc869863 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -950,6 +950,185 @@ def test_stranded_bundle_story_keys_are_aliased_element_wise(): assert canary not in rendered, f"LEAK: {canary!r}" +def test_scalar_story_keys_fails_closed_instead_of_shipping_verbatim(): + """`_JOURNAL_KEYLIST_FIELDS` routing was gated on `isinstance(v, list)`, so a + SCALAR value on one of those names fell straight through to `scrub_json` — which + is the identity on an identifier-shaped string. `story_keys="1-1-acme-auth"` came + back verbatim. + + Every producer passes a list today, so this is latent rather than live. That is + the argument FOR closing it rather than against: the routing decision would + otherwise rest on a survey of producers staying true, and the neighbouring + `story_keys` row above is graded on lists only, so nothing would notice. + + Asserted on the raw value's ABSENCE, not on the presence key alone — a + presence-key assertion passes for every reason a value could be missing, + including the field never having been read. + + Ablation: restore the `and isinstance(v, list)` gate on the `elif` and the + absence assertions redden with the raw key coming back.""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + {"ts": 2.0, "kind": "sweep-inflight-stranded", "story_keys": STORY_KEY}, + pseudo, + {STORY_KEY: 1}, + 1.0, + ) + + assert "story_keys" not in scrubbed, "the unknown-shaped value survived under its own name" + assert scrubbed["story_keys_present"] is True + rendered = json.dumps(scrubbed) + assert STORY_KEY not in rendered + for canary in CANARIES: + assert canary not in rendered, f"LEAK: {canary!r}" + + +def test_off_schema_preference_escalation_keys_are_collapsed_to_presence(): + """`engine._review_and_commit` splats `escalation.preference_escalations(rj)` + into `journal.append`, and those entries come out of a session's own + `result.json` — so an LLM chooses the journal FIELD NAMES. No by-name table can + route a name nobody can enumerate, and `scrub_json` is the identity on an + identifier-shaped scalar, so `customer="AcmeVault"` shipped byte-identical into + a dump whose module docstring ends "the dump will be posted publicly". + + `_JOURNAL_KIND_SCHEMAS` declares the record to be `{type, severity, detail}` and + collapses everything else on that kind. Both halves are graded here: the + off-schema value must be GONE, and the declared fields must NOT be — a policy + that flattened the whole record would pass an absence-only assertion while + destroying the field the record is read for. + + ACCEPTED RESIDUAL, asserted so it stays honest rather than drifting: the key + NAME still reaches the dump as `_present`. That was decided on 2026-08-30 + over a name-free `unrouted_field_count` collapse; this row PINS it, so a future + reader finds it recorded as a decision rather than re-discovering it as a bug. + + Ablation: drop the `preference-escalation` row from `_JOURNAL_KIND_SCHEMAS` and + the `AcmeVault` absence assertions redden.""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + { + "ts": 2.0, + "kind": "preference-escalation", + "story_key": STORY_KEY, + "type": "preference", + "severity": "MEDIUM", + "detail": "CANARY_ESCALATION prose about " + PROPRIETARY, + "customer": "AcmeVault", + }, + pseudo, + {STORY_KEY: 1}, + 1.0, + ) + + # the off-schema key: collapsed, and its VALUE gone from the entry entirely + assert "customer" not in scrubbed + assert scrubbed["customer_present"] is True + assert "AcmeVault" not in json.dumps(scrubbed), "LEAK: off-schema preference value" + # the declared schema is NOT collapsed — these are why the record is read + assert scrubbed["type"] == "preference" + assert scrubbed["severity"] == "MEDIUM" + # `detail` is in the schema but `_JOURNAL_DROP_FIELDS` reaches it first, which is + # the intended precedence: a stricter table always wins over this one + assert "detail" not in scrubbed and scrubbed["detail_present"] is True + # the entry stays correlatable — the kind policy replaces the FALLBACK only, so + # every routing rule above it still runs + assert scrubbed["story_key"] != STORY_KEY and scrubbed["story_key"].startswith("s1-") + + rendered = json.dumps(scrubbed) + for canary in (STORY_KEY, *CANARIES): + assert canary not in rendered, f"LEAK: {canary!r}" + + +def test_self_minted_fields_survive_a_declared_schema_kind(): + """`Journal.append` stamps `log_task`/`log_pos` onto EVERY entry with + `setdefault`, including one whose kind carries a declared schema. They are + engine-authored, not LLM-authored, so the fail-closed arm must not touch them. + + It did: `log_pos` is outside `{type, severity, detail}`, so a real + `preference-escalation` rendered `log_pos_present: true` and the pane-log byte + offset was gone — on exactly the records an operator opens a dump to trace. + `log_task` was never affected, since aliasing reaches it first; `log_pos` was the + only casualty, which is why a test naming the pair would have stayed green. + + The exemption reads `journal.SELF_MINTED_FIELDS` rather than restating the pair, + so it cannot drift from the `setdefault` calls that create the fields. + + Ablation: drop the `k not in SELF_MINTED_FIELDS` clause from `_scrub_entry`'s + fail-closed arm and the integer assertion reddens with `log_pos_present`.""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + { + "ts": 2.0, + "kind": "preference-escalation", + "log_task": STORY_KEY, + "log_pos": 4096, + "type": "preference", + "customer": "AcmeVault", + }, + pseudo, + {STORY_KEY: 1}, + 1.0, + ) + + # the byte offset survives as an INTEGER — the whole point of the exemption + assert scrubbed["log_pos"] == 4096 + assert "log_pos_present" not in scrubbed + # the pane-log task pointer is still aliased, not dropped and not raw + assert scrubbed["log_task"] != STORY_KEY and scrubbed["log_task"].startswith("s1-") + # ...while the LLM-authored key on the same record is still collapsed, so the + # exemption did not widen into a general escape from the fail-closed arm + assert "customer" not in scrubbed and scrubbed["customer_present"] is True + + rendered = json.dumps(scrubbed) + for canary in (STORY_KEY, *CANARIES): + assert canary not in rendered, f"LEAK: {canary!r}" + + +def test_decision_pending_question_is_dropped_not_scrubbed(): + """`sweep.py` journals `question=decision.question` on `decision-pending`, and it + was declared benign. A MULTI-WORD question does collapse — but only by accident + of `_IDENTIFIER_RE` forbidding spaces, which is not a property to route on. A + ONE-TOKEN question is identifier-shaped and shipped verbatim. + + So it joins `detail`/`reason`/`blocker`/`suggestion`/`note` in + `_JOURNAL_DROP_FIELDS`, under the same free-text rule. No user-facing surface + loses the text: `tui/data.py`'s `decision_pending` reads the RAW journal on the + operator's own machine, not this dump. + + The one-token case is the load-bearing one — grade the multi-word case alone and + the row stays green with the routing deleted, because the fallback happens to + redact it. + + Ablation: drop `question` from `_JOURNAL_DROP_FIELDS` and the one-token absence + assertion reddens (the multi-word one does not — which is the point).""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + one_token = diagnostics._scrub_entry( + {"ts": 2.0, "kind": "decision-pending", "dw_id": "DW-7", "question": "AcmeVault"}, + pseudo, + {}, + 1.0, + ) + + assert "question" not in one_token + assert one_token["question_present"] is True + assert "AcmeVault" not in json.dumps(one_token), "LEAK: one-token decision question" + # the dw id beside it is untouched — it is the record's correlation handle + assert one_token["dw_id"] == "DW-7" + + # an unset question still reports as absent rather than as set + empty = diagnostics._scrub_entry( + {"ts": 2.0, "kind": "decision-pending", "dw_id": "DW-7", "question": ""}, + pseudo, + {}, + 1.0, + ) + assert empty["question_present"] is False + + rendered = json.dumps([one_token, empty]) + for canary in CANARIES: + assert canary not in rendered, f"LEAK: {canary!r}" + + def test_structure_is_preserved(project): run_dir = _seed_run(project.project) diag, _pseudo, _combined = _render_all([run_dir]) diff --git a/tests/test_engine.py b/tests/test_engine.py index e12b0bf8..6e4ba95f 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -16794,3 +16794,79 @@ def test_notice_reason_bound_is_an_upper_bound_not_an_equality(): short = _notice_reason("short first line\nthe evidence lives here") assert short == "short first line […]" # marked well under the cap assert len(short) < NOTICE_REASON_MAX + + +def test_llm_authored_preference_keys_cannot_hijack_journal_reserved_names(project): + """`_review_and_commit` splats a review session's own `result.json` escalation + entries into `journal.append`, so an LLM chooses the journal FIELD NAMES. Some + collide with the bound call, while others can replace metadata `append` owns. + + Reproduced before the fix: an entry carrying `kind` raised + `TypeError: Journal.append() got multiple values for argument 'kind'`, and + `story_key` the same, so a single invented key ABORTED THE WHOLE REVIEW LEG. + `self` collides with the bound-method receiver. `ts` did not raise and was worse + for it — `append` builds + `{"ts": now, "kind": kind, **fields}`, so a supplied `ts: 0` silently replaced + the real clock and every relative offset a diagnostic dump computes off it. + `log_task` and `log_pos` also did not raise: `append` stamps them with + `setdefault`, so caller values silently won, forged the pane-log pointer, and let + an identifier-shaped `log_pos` survive the diagnostic scrubber verbatim. + + Driven through a real run rather than by calling the filter directly: the + defect was the CALL SITE forwarding unfiltered keys, and a unit test over + `_JOURNAL_RESERVED_KEYS` would pass with the site left unpatched. + + Ablation: drop the `_JOURNAL_RESERVED_KEYS` comprehension in + `_review_and_commit` and this reddens with the TypeError above.""" + + def hostile_review_effect(spec): + sp = spec_path(project, "1-1-a") + baseline = _spec_baseline(sp) + write_spec(sp, "done", baseline) + set_sprint(project, "1-1-a", "done") + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": "1-1-a", + "spec_file": str(sp), + "baseline_commit": baseline, + "status": "done", + "followup_review_recommended": False, + "escalations": [ + { + "type": "preference", + "severity": "PREFERENCE", + "detail": "prose", + # journal-owned names, all LLM-authored here + "self": "hijacked-receiver", + "kind": "hijacked-kind", + "story_key": "9-9-not-this-story", + "ts": 0, + "log_task": "9-9-forged-story", + "log_pos": "AcmeVault", + } + ], + }, + ) + + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [dev_effect(project, "1-1-a"), hostile_review_effect]) + + summary = engine.run() # the TypeError made this raise + + assert summary.done == 1 + entries = [ + json.loads(ln) + for ln in (engine.run_dir / "journal.jsonl").read_text(encoding="utf-8").splitlines() + ] + (pref,) = [e for e in entries if e["kind"] == "preference-escalation"] + # the journal's own names survived, none of them the LLM's + assert pref["kind"] == "preference-escalation" + assert pref["story_key"] == "1-1-a" + assert pref["ts"] > 1_000_000_000, "an LLM-supplied ts replaced the real clock" + assert pref["log_task"] != "9-9-forged-story" + assert isinstance(pref["log_pos"], int) + assert "AcmeVault" not in json.dumps(pref), "an LLM-supplied log_pos reached the journal" + # ...and the declared schema still rode through untouched + assert pref["type"] == "preference" and pref["detail"] == "prose" diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index c9645960..4835e976 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -42,7 +42,12 @@ import bmad_loop from bmad_loop import diagnostics, envvars -from bmad_loop.journal import JOURNAL_FILE, TASK_CYCLE_ARTIFACTS, Journal +from bmad_loop.journal import ( + JOURNAL_FILE, + SELF_MINTED_FIELDS, + TASK_CYCLE_ARTIFACTS, + Journal, +) SRC = Path(bmad_loop.__file__).resolve().parent # Marker an allowlisted exception line must carry. Written as ``# portability: …``; @@ -320,7 +325,11 @@ "policy_changed", "preserve_ref", "problem", - "question", + # `question` is NOT here any more: it moved to `_JOURNAL_DROP_FIELDS` + # (schema v3) once a one-token `decision-pending` question was shown to + # ship verbatim. Left as a note rather than a silent deletion, because a + # name leaving this set is the guard working — a benign declaration that + # turned out to be wrong. "rc", "re_review_capped", "rearmed", @@ -384,7 +393,12 @@ # the fields a call spells. ``test_journal_append_writes_only_accounted_fields`` # closes that from the other side by RUNNING an append and reading the entry back; # this set is what stops the staleness check below from calling ``log_pos`` dead. -JOURNAL_SELF_MINTED_FIELDS = frozenset({"log_task", "log_pos"}) +# +# READ FROM ``journal``, not restated: ``diagnostics._scrub_entry`` exempts the same +# pair from the fail-closed arm it applies to a declared-schema kind, and a literal +# copy here would let this guard and that exemption drift apart silently — which is +# the failure mode DW-82 exists to remove, applied to the guard itself. +JOURNAL_SELF_MINTED_FIELDS = SELF_MINTED_FIELDS # ``(file, enclosing function) -> the field names that actually flow through it`` for # every ``journal.append(**name)`` whose keys are NOT statically resolvable. An @@ -421,9 +435,30 @@ ), # `pref` comes from `preference_escalations(result_json)` — LLM-authored keys out # of a session's own result.json. Not statically knowable in principle, not just - # in this scan; the redaction fallback is what covers it, and no inventory can be - # written for it at all. - ("engine.py", "_review_and_commit"): frozenset(), + # in this scan, so the OFF-SCHEMA half of this hole can never be inventoried. + # + # The three names below are the half that can: they are the record's declared + # schema, and they are the names whose VALUES still reach the dump (everything + # else on this kind collapses to a presence marker). Asserted against + # `diagnostics._JOURNAL_KIND_SCHEMAS` by + # `test_journal_routing_tables_are_read_from_diagnostics`, so this inventory and + # that table cannot disagree. + # + # What covers it is `diagnostics._JOURNAL_KIND_SCHEMAS`, which declares + # `preference-escalation`'s record to be `{type, severity, detail}` and collapses + # every other key on that kind to `_present`. This comment used to say the + # REDACTION FALLBACK covered it, which was verified false: `scrub_json` is the + # IDENTITY on an identifier-shaped scalar, so `customer="AcmeVault"` came back + # byte-identical while this allowlist entry read as accounted for. A comment that + # names the wrong mechanism is how the next reader concludes a hole is closed + # when it is not. + # + # The hole this entry declares is therefore narrower than it looks, and it is + # still a hole: the key NAMES remain LLM-authored and still reach the dump as + # `_present` markers. That residual was weighed against a name-free + # `unrouted_field_count` collapse and DELIBERATELY ACCEPTED on 2026-08-30 — see + # `_JOURNAL_KIND_SCHEMAS`. It is decided, not outstanding. + ("engine.py", "_review_and_commit"): frozenset({"type", "severity", "detail"}), # `self._session_end_extras(result)` is a method call, and that method builds its # dict with `extras.update(...)` — unresolvable at the call site and at the # definition. The names below are read off `engine._session_end_extras`, and five @@ -3617,6 +3652,49 @@ def test_journal_routing_tables_are_read_from_diagnostics(): "`_scrub_entry` consults `_JOURNAL_KIND_ALIAS_FIELDS` per kind, so a " "by-name claim about it is false on every other kind" ) + # `_JOURNAL_KIND_SCHEMAS` is the FOURTH table `_scrub_entry` consults, and it was + # coupled to this guard by prose alone: deleting its `preference-escalation` row + # left every assertion here green while the fail-closed arm stopped running and + # `customer="AcmeVault"` went back to shipping verbatim (measured). Read it here + # so that cannot recur. + schemas = diagnostics._JOURNAL_KIND_SCHEMAS + assert schemas, ( + "`_JOURNAL_KIND_SCHEMAS` is empty — `_scrub_entry`'s fail-closed arm is now " + "unreachable and every off-schema key falls back to `scrub_json`" + ) + # The kind whose keys are LLM-authored is the reason the table exists, and + # `JOURNAL_SPLAT_ALLOW`'s comment for `engine.py::_review_and_commit` names this + # table as the mechanism that covers that hole. Pinned rather than trusted: a + # comment naming a mechanism that is not there is the failure this file exists + # to refuse. + assert "preference-escalation" in schemas + assert ( + schemas["preference-escalation"] == JOURNAL_SPLAT_ALLOW[("engine.py", "_review_and_commit")] + ), ( + "the declared schema and the splat inventory that cites it disagree — one " + "of the two was edited alone" + ) + for kind, names in schemas.items(): + # An empty declared set would collapse a record ENTIRELY, presence-marking + # every field including the ones the record is read for. Never the intent: + # a kind with nothing worth showing should not be in this table at all. + assert names, f"{kind} declares an empty schema, which collapses its whole record" + # Every declared name is accounted for on the guard's side too, so a schema + # can neither name a field nothing produces nor quietly introduce one that + # bypassed the routed/benign decision. `type` and `severity` reach the + # journal only through the allowlisted splat, so the inventory there is + # where they are declared. + unaccounted = names - JOURNAL_ROUTED_FIELDS - JOURNAL_BENIGN_FIELDS + unaccounted -= frozenset().union(*JOURNAL_SPLAT_ALLOW.values()) + assert unaccounted == set(), ( + f"{kind}'s declared schema names fields the guard does not account for: " + f"{sorted(unaccounted)}" + ) + # A declared name must not also be kind-aliased on the same kind: the alias + # arm runs FIRST, so such a name would never reach the schema arm and the + # declaration would be a dead letter that reads as live. + assert not names & JOURNAL_KIND_ROUTED_FIELDS.get(kind, frozenset()) + # and the sets are disjoint: a routed name must never also be declared # benign, which would make the routing row unfalsifiable from this side. assert not JOURNAL_ROUTED_FIELDS & JOURNAL_BENIGN_FIELDS From 63ddb1f9209ca7048f9bb18b4556fc9f7f4a732a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 22:32:30 -0700 Subject: [PATCH 15/45] Make rearm_escalation a transaction from the spec flip to save_state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rearm_escalation` published the status flip and stripped the stale `## Auto Run Result` about 250 lines before `save_state`, and only two of the aborts in that window undid those writes. A failing `journal.append` from the stale-restore residue pass, a non-git fault from the commits probe, or a failing `save_state` each escaped with the spec re-armed on disk while persisted state still said ESCALATED (DW-79, DW-83). Guard the whole window instead, with `save_state` as its single commit point: any fault rolls the spec back to the bytes the re-arm found and re-raises the original fault unchanged. `BaseException` rather than `Exception`, because the window is mostly blocking I/O an operator can interrupt. The rollback journals `rearm-aborted` carrying `rollback` (`restored` / `unchanged` / `unknown` / `failed`), which `resolve` and the TUI render through the one shared routing table — so the residue notices they echo from a `finally` can no longer be the whole account of a re-arm that left nothing behind (DW-85). Only `restored` and `unchanged` license "left exactly as the re-arm found it"; the default arm claims nothing, so the sentinel-clear leg no longer describes a file it deleted as untouched. The two hand-placed `_restore_rearmed_spec` calls are gone — the guard covers every fault source in the window, so the undo no longer depends on someone remembering to place it. --- CHANGELOG.md | 19 + docs/FEATURES.md | 22 + src/bmad_loop/diagnostics.py | 21 +- src/bmad_loop/runs.py | 1289 +++++++++++++++++-------------- tests/test_cli.py | 50 +- tests/test_diagnostics.py | 22 +- tests/test_portability_guard.py | 6 + tests/test_resolve.py | 93 ++- tests/test_runs.py | 322 +++++++- tests/test_tui_app.py | 26 +- 10 files changed, 1277 insertions(+), 593 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d059ecf..486173fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,25 @@ breaking changes may land in a minor release. ### Fixed +- **An aborted re-arm no longer leaves the spec re-armed against an escalated task** + (DW-79, DW-83, DW-85). `runs.rearm_escalation` published the status flip and stripped the + stale `## Auto Run Result` about 250 lines before `save_state`, and only two of the aborts + in that window undid those writes — a failing `journal.append` from the stale-restore + residue pass, a non-git fault from the commits probe, or a failing `save_state` each escaped + with the spec flipped on disk while persisted state still said ESCALATED. The window is now + one transaction with `save_state` as its single commit point: any fault — an interrupt + included, which is why the guard catches `BaseException` — restores the spec's BYTES to what + the re-arm found and re-raises the original fault unchanged. Clearing a stories sentinel is + deliberately outside that scope: it unlinks a file rather than writing spec bytes, and + `_clear_sentinel` already preserves a copy and is idempotent on retry. The rollback journals + `rearm-aborted` carrying `restored`, `unchanged` (proved byte-identical), `failed` or + `unknown`, which `resolve` and the TUI render through the one shared routing table — so the + residue notices they echo from a `finally` can no longer describe files as excluded from a + baseline that was never saved, and an outcome the undo could not confirm (the cleared + sentinel among them) reports the abort without claiming the file is intact. A rollback that + cannot write still raises, naming the possibly part-written spec, with the original fault + kept in the exception chain. + - Stop an LLM-authored preference escalation from aborting the review leg. `_review_and_commit` splats a review session's own `result.json` escalation entries into `journal.append`, so a result.json carrying a `kind` or `story_key` key raised `TypeError: got multiple values for diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8fa8f714..d8212406 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -126,6 +126,28 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w the re-drive mounts from does not already hold this checkout's copy of those two files, so a correction already committed there resumes in one gesture, and an in-place re-drive never records at all — it reads the main checkout, which is where the resolve session runs. + The whole re-arm is one **transaction**, and what it covers is stated narrowly: the SPEC's + BYTES, from the first spec write to `save_state`. That save is the commit point — until it + returns the run still calls the story escalated, so any fault escaping the window in between + (a journal write that fails, a non-git fault from the stale-restore commits probe, an + interrupt during one of the three git probes, or the state write itself) used to leave a spec + flipped to the re-drive's status and stripped of its `## Auto Run Result` against a task + nothing had moved. Every one of them now restores the spec to the bytes the re-arm found and + re-raises the original fault unchanged, so the escalation stays armed. One in-window tree + change is deliberately outside that scope: clearing a **sentinel** unlinks the file rather + than writing spec bytes, and it is not re-created, because `_clear_sentinel` already + preserves a copy under `{run_dir}/sentinels/` and a retried resolve re-clears it + idempotently. The re-arm says which of those it did. `rearm-aborted` is journalled from the + rollback and echoed by both surfaces, carrying `restored` (a write had landed and was put + back), `unchanged` (the file was read and PROVED byte-identical — a refusal sequenced ahead + of every write), `failed` (the restore itself could not write, so the spec may be part-written + — that one raises rather than degrading, keeps the original fault in the exception chain, and + names the file to restore from git in both the message and its next step), or `unknown`. The + last covers everything the undo could not confirm, the cleared sentinel among them, and the + surfaces then report that nothing was persisted and the story is still escalated WITHOUT + claiming the file on disk is intact — an unconfirmed outcome must never render as the + reassuring one. Without this record the surfaces described the residue of a re-arm that had + been rolled back — files "excluded from the re-drive baseline" for a baseline never saved. All of these warnings reach the TUI's re-arm as well as `resolve`'s — both route every kind through one shared table, so neither surface can silently learn a kind the other drops, though each still owns where it calls the echo from and the TUI drops the trailing "before diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 358d8566..87b943d3 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -146,18 +146,25 @@ # whose epic could not be resolved. See `_JOURNAL_BASENAME_NAMESPACES` for why # the value is normalized first: the producers do NOT agree on a bare basename. "spec": "spec", - # The same value also arrives under a second field NAME. `runs.rearm_escalation` - # is the only journal producer of `spec_file`, across FOUR kinds — - # `rearm-spec-write-unreachable`, `rearm-spec-flip-skipped`, - # `rearm-baseline-restamp-skipped` and `rearm-baseline-restamped`. Routing is by - # field NAME, not by kind, so the list is documentation rather than a gate — but an - # enumeration that undercounts is how the next reader concludes a kind is unrouted. + # The same value also arrives under a second field NAME, from the re-arm family in + # `runs` — FIVE kinds, and no longer from a single function. `rearm_escalation` + # writes four of them (`rearm-spec-write-unreachable`, `rearm-spec-flip-skipped`, + # `rearm-baseline-restamp-skipped`, `rearm-baseline-restamped`); the fifth, + # `rearm-aborted`, is written by `runs._rollback_rearm` from the transaction guard's + # error path — a DIFFERENT function, which is why "the only producer" is no longer + # the right shape for this note. Routing is by field NAME, not by kind, so the list + # is documentation rather than a gate — but an enumeration that undercounts is how + # the next reader concludes a kind is unrouted, so it is corrected rather than + # left to age. `rearm-aborted` also carries `error` (dropped as free text) and + # `rollback`, a literal enum string declared benign in the routing guard. # `engine._park_awaiting_operator` passes # `spec_file=` to `operatoractions.record_park`, which is a record file, not the # journal. So the divergence is BETWEEN FIELDS, not between two producers of this # one — but BOTH fields are mixed-shape, and neither is the reliable one: # Both fields now journal an absolute path wherever they carry one: `spec_file` - # through `str(task_spec_path(...))` on all four kinds, and `spec` through + # through `str(task_spec_path(...))` on all five kinds — `rearm-aborted` forwards + # the same already-anchored value, and writes `""` rather than a story key when the + # aborted re-arm never resolved a spec path — and `spec` through # `engine._operator_spec_path` (which anchors `checkpoint-pause` the same # way) alongside engine's already-absolute reconcile and marker-repair kinds. Same # value, same namespace. Do NOT read that convergence as "both fields are diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index fa6df27f..678d195b 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3482,37 +3482,42 @@ def _redrive_reads_the_upstream_artifacts(state: RunState) -> bool: def _restore_rearmed_spec( spec_path: Path, original: bytes | None, task: StoryTask, state: RunState -) -> None: - """Put back the bytes a re-arm FOUND on the spec, for the aborts that can fire after - a write has already landed. +) -> Literal["restored", "unchanged", "unknown"]: + """Put back the bytes a re-arm FOUND on the spec, and say what is now on disk. `rearm_escalation` holds an invariant its own refusals depend on: an aborted re-arm leaves the spec byte-identical, so the escalation stays armed and the human can fix - the file and re-run resolve. TWO of its four refusals earn that by SEQUENCING alone — - the flip's read-back check and the `FrontmatterWriteError` arm both raise before - `devcontract.strip_auto_run_result` runs, which is why that strip is deliberately - ordered after them, and `set_frontmatter_status` decides it cannot move a `status:` - before it writes anything. The other two cannot be sequenced out of the hazard, and - both call this: - - * The baseline re-stamp needs `task.baseline_commit` from the advance, and the - advance must itself run after the spec block (a just-cleared stories sentinel would - otherwise be captured into `baseline_untracked` as phantom pre-existing residue). - * The `(OSError, UnicodeDecodeError)` arm spans BOTH spec helpers, and the strip is - the later one — a fault raised inside it is raised after the flip published. - - By the time either can fail, the status flip has landed and `save_state` has not — so - the abort would otherwise leave the run's task ESCALATED against a spec already - flipped to the re-drive's status and (for the re-stamp) stripped of the terminal - `## Auto Run Result` the next resolve session reads as its context. That is exactly - the "one edit nothing else records" the sequencing exists to prevent. - - Writes only what it can prove it changed. `original` is `None` when the spec was - unreadable before the first write (there is then nothing to restore, and nothing - could have been written either), and a spec that is gone or unreadable NOW is not a - state this undo can improve — recreating a file another process removed would fight - a concurrent actor rather than restore this function's own edit. Bytes equal to - `original` mean nothing landed, so nothing is rewritten and the mtime is left alone. + the file and re-run resolve. That used to be earned twice over — by SEQUENCING for + the two refusals that raise before `devcontract.strip_auto_run_result` runs (which is + why that strip is still deliberately ordered after the flip's read-back check), and + by two hand-placed calls to this function for the two that could not be sequenced out + of the hazard. Neither half covered the rest of the window: a `journal.append` + OSError from the residue pass, a non-Git fault from the commits probe, or a failing + `save_state` each escaped with the flip published and the task still ESCALATED. + + So there is now ONE caller, `_rollback_rearm`, invoked from the transaction guard + that spans the whole window from the first spec write to `save_state`. The sequencing + is not redundant — it is what keeps those two refusals from ever writing in the first + place — but the undo no longer depends on someone remembering to place it. + + THREE outcomes rather than a bool, because the operator surfaces make a CLAIM about + the file and only one of the non-restoring cases entitles them to it: + + * ``"restored"`` — a write had landed and was put back. + * ``"unchanged"`` — the file was READ and PROVED byte-equal to `original`, so nothing + landed, nothing is rewritten, and the mtime is left alone. This is the only answer + that licenses "the spec was left exactly as the re-arm found it". + * ``"unknown"`` — this undo cannot say. Two shapes reach it. `original` is `None`, + meaning the spec was unreadable at capture time or the re-arm never entered the + spec block at all (the sentinel-clear leg, which has already UNLINKED the file — + so a reader told "unchanged" there would be told a deleted file proves the tree is + untouched). Or the spec is gone or unreadable NOW, in which case the undo could not + even look: recreating a file another process removed would fight a concurrent actor + rather than restore this function's own edit, so it declines — but declining is not + the same as proving nothing landed. + + Folding those into one "nothing had to be put back" answer is what made the notice + overclaim, so the distinction lives in the return type rather than in a comment. Byte-verbatim and CONFINED, matching the writes it undoes: `atomic_write_text_confined` would re-encode and translate newlines, so a CRLF spec would come back subtly @@ -3523,21 +3528,86 @@ def _restore_rearmed_spec( be. `UnconfinedWriteError` is an `OSError`, so the one arm covers both. """ if original is None: - return + return "unknown" try: if spec_path.read_bytes() == original: - return + return "unchanged" except OSError: - return + return "unknown" try: atomic_write_bytes_confined(spec_path, original, confine_root=task_spec_root(task, state)) except OSError as e: raise RearmError( f"cannot restore {spec_path} after a failed re-arm " - f"({e.__class__.__name__}: {e}) — the spec carries this re-arm's status flip " - "and has lost its `## Auto Run Result` section, while the story is still " - "escalated; restore the spec from git, then re-run resolve" + f"({e.__class__.__name__}: {e}) — the spec may carry this re-arm's status " + "flip and may have lost its `## Auto Run Result` section, while the story is " + "still escalated; restore the spec from git, then re-run resolve" ) from e + return "restored" + + +def _rollback_rearm( + journal: Journal, + story_key: str, + spec_path: Path | None, + spec_before: bytes | None, + task: StoryTask, + state: RunState, + error: BaseException, +) -> None: + """Undo an aborted re-arm's spec writes and RECORD that the re-arm aborted. + + The error-path half of `rearm_escalation`'s transaction. Its caller re-raises the + original fault immediately after, so nothing here may return a verdict or swallow + one: this function's whole job is to leave the spec's bytes as the re-arm found them + and put the fact on the run's audit trail. + + `rollback` goes ON the record, not left to be re-derived, because every reader is + OUT of process: `rearm_event_notice` renders from a journal line alone, with neither + the task nor the tree to consult, and the outcomes need different sentences. + `restored` and `unchanged` are `_restore_rearmed_spec`'s own answers and both mean + the spec on disk is what the re-arm found. `failed` means the restore itself could + not write, the one outcome that can leave a HALF-WRITTEN spec — recorded from a + `finally` for exactly that reason, since that arm re-raises. `unknown` means no such + claim is available: either this re-arm never resolved a spec path at all, or the undo + could not read the file to prove anything. + + The sentinel-clear leg lands in `unknown` and that is the point. It runs INSIDE the + guarded window but writes no spec bytes — it UNLINKS the sentinel — and the + transaction deliberately does not undo it (`_clear_sentinel` preserves a copy under + `{run_dir}/sentinels/` and a retried resolve re-clears it idempotently). What the + transaction covers is the spec's BYTES from the first spec write onward; the deletion + is outside that, so the record must not claim the tree is as the re-arm found it. + + The record is journalled through a `finally` and only its OSError is suppressed. + Recording an abort is an OBSERVATION, and an observation that cannot be made must + not replace the fault the operator is being told about — while a restore failure is + a repair write, and repair writes raise. + """ + # `unknown` is the floor, not `unchanged`: a re-arm that resolved no spec path made + # no claim about any file, and the surfaces must not manufacture one for it. + rollback = "unknown" + try: + if spec_path is not None: + rollback = _restore_rearmed_spec(spec_path, spec_before, task, state) + except BaseException: + rollback = "failed" + raise + finally: + try: + journal.append( + "rearm-aborted", + story_key=story_key, + # `""` rather than the key again when the re-arm never resolved a spec + # path: the field is the SPEC's locator on every other `rearm-*` kind, + # and a reader that finds a story key there would alias it into the wrong + # namespace and render it as a spec that does not exist. + spec_file=str(spec_path) if spec_path is not None else "", + error=f"{error.__class__.__name__}: {error}", + rollback=rollback, + ) + except OSError: + pass def _redrive_spec_status(state: RunState, task: StoryTask, *, isolated_redrive: bool) -> str: @@ -3812,550 +3882,591 @@ def rearm_escalation( # a prior restore attempt the human then chose to redo from scratch. task.restore_patch = restore_patch - # The bytes this re-arm found on the spec, for `_restore_rearmed_spec`. Declared out - # here because the baseline re-stamp that consumes it sits in a SECOND - # `if task.spec_file:` block, past the advance it depends on. + # The spec this re-arm writes to and the bytes it FOUND there — the two inputs the + # rollback below needs. Declared out here because their consumers sit past every + # block that sets them: the baseline re-stamp's SECOND `if task.spec_file:` block, + # and the transaction guard's `except` arm, which has to name them from outside all + # of them. spec_before: bytes | None = None - if task.spec_file: - spec_path = task_spec_path(task, state) - # Stories mode only: a fixed-slug pre-planning-halt sentinel - # (`-unresolved.md` / `-ambiguous.md`) is cleared by deletion, not a - # status flip. Clear it ONLY when the run recorded this task AS a sentinel at - # detection time (`task.sentinel_kind`, stamped by StoriesEngine's pick-time - # wedge / post-dev read-back) — never by re-deriving from the basename. That - # keeps a real story spec that merely happens to be named `-unresolved.md`, - # or a *non-sentinel* escalation whose spec matches the convention, on the - # status-flip path so it is kept, not deleted. Gate on the run source too (the - # convention exists only in stories mode) and defensively re-confirm the - # on-disk name still matches the recorded slug before deleting. - sentinel_kind = task.sentinel_kind if state.source == "stories" else "" - if sentinel_kind and _sentinel_condition(spec_path, key) == sentinel_kind: - # a sentinel is cleared by deletion, not a status flip; drop the stale - # spec_file so the re-dispatch starts from PENDING (clean re-plan). - _clear_sentinel(run_dir, journal, spec_path, key, sentinel_kind) - task.spec_file = None - task.sentinel_kind = "" # verdict discharged; the re-dispatch is clean - # Deleting the sentinel does not make the re-plan produce a different one: - # the correction that does lives UPSTREAM, in the `SPEC.md` / `stories.yaml` - # the resolve skill sends the agent to instead of this file. That correction - # faces the same reachability gap the spec arm below measures, and faced NO - # gate at all — this arm cleared `spec_file` and fell through, so - # `write_reaches_the_redrive` was never computed and the resume was never - # held for a sentinel. An isolated re-drive then mounts fresh from - # `redrive_base_ref`, re-plans from a committed tree that never saw the - # edit, mints the same sentinel again, and the escalation is spent. - # - # Narrowed by PROOF for the reason the spec record below is, and the need is - # sharper here: `stories_reach_the_redrive` answers "unreachable" for EVERY - # isolated stories run whose spec folder sits inside the project, which is - # every one we author. Gating on it alone would fire — and hold the resume — - # on 100% of isolated sentinel re-arms, a per-configuration constant rather - # than an event. `_redrive_reads_the_upstream_artifacts` is what makes it an - # event: it fires only while this checkout still holds upstream bytes the - # ref the re-drive mounts from does not. - # - # No `redrive` discriminator, unlike the spec record: this one has a single - # remedy because it has a single reachable shape. An in-place re-drive reads - # the main checkout's working tree, which is exactly where `cwd=project` put - # the correction, so `stories_reach_the_redrive` short-circuits that leg to - # reachable and no record is written for it at all. - if not stories_reach_the_redrive( - task, state, isolated_redrive=isolated_redrive - ) and not _redrive_reads_the_upstream_artifacts(state): - journal.append( - "rearm-upstream-write-unreachable", - story_key=key, - # `task_stories_root` names the tree the RUN owns; the correction - # lands in the checkout the resolve session ran in. Both are the - # project on this leg unless a mount is recorded, and the operator - # needs the folder to act, so the record carries the folder the - # remedy is about rather than the run's read locator. - stories_root=str(_upstream_artifacts_folder(state)), - target_branch=state.target_branch, - ) - else: - # A WORKTREE-LOCAL spec's writes below land in the unit's worktree - # (`task_spec_path`) — which the re-drive destroys before reading anything. - # A re-armed task (phase PENDING, `defer_reason` cleared, and no resumable - # session because `generation` was just bumped) falls to - # `engine._finish_inflight`'s final arm, which calls `discard_worktree` and - # lets `_run_story` mount a fresh one. The re-driven session then resolves - # its spec through `verify.resolve_spec_path(task.spec_file, - # workspace.paths)` (`engine._dispatched_spec_for_attempt`), and under - # isolation `workspace.paths` is rebased onto that FRESH worktree, which - # checks out TRACKED files only. So the re-drive reads the COMMITTED spec. - # - # No working-tree write reaches it — not this one, and not a write to the - # main checkout either: the fresh worktree comes from git rather than from a - # copy of that tree, and `seed_adapter_defaults` seeds adapter config files, - # not the output folder. The channel that DOES work is the human committing - # the corrected spec from the resolve session, which runs with `cwd=project`. - # The writes below are kept (they are correct for the in-place case, and - # harmless here), but the operator is told — a flip that cannot land is - # exactly the silent re-wedge #640(b) exists to end. - # - # "Worktree-local" is the load-bearing qualifier, and isolation does not - # imply it: an artifact dir configured OUTSIDE the project tree is shared - # across checkouts by `ProjectPaths.rebased`, so a spec that landed there is - # one file the fresh worktree reads through the very absolute path this - # writes to. `_spec_is_shared_with_the_redrive` carves out that case, and only - # that one: the main checkout's copy is outside the worktree too, and stays - # unreachable because the re-drive measures it against worktree-local roots. - # Route /bmad-build-auto via the spec's frontmatter status (decision - # table): patch-restore -> in-review -> step-04 (resume review on - # the restored diff); from-scratch -> ready-for-dev -> step-03 - # (re-implement). Independent of the resolve agent having set it. - target_status = "in-review" if restore_patch else "ready-for-dev" - # Whether the writes below are the copy the re-driven session actually - # reads. Hoisted out of the record's condition because TWO decisions turn on - # it, and only one of them used to: the warning below, and the flip's - # REFUSAL one screen down, which was gated on `spec_path.is_file()` alone. - # Under isolation that readable file is the doomed worktree copy, so the - # refusal demanded a repair to the one file the re-drive destroys before - # reading anything — and demanded it even when `_redrive_spec_status` had - # already proven the committed spec carries the status the re-drive routes - # on. See `_spec_is_shared_with_the_redrive` for why an isolated unit's spec - # is nevertheless reachable when it sits in an artifact dir configured - # outside the project tree. - write_reaches_the_redrive = spec_reaches_the_redrive( - task, state, isolated_redrive=isolated_redrive - ) - # Narrowed to the case an operator can ACT on. Every isolated escalation - # carries a mounted `worktree_path` — `worktree_flow.escalate_unit` never - # clears it, and `keep_branch_and_escalate` deliberately leaves the worktree - # up — so gating on that alone fired this warning on 100% of re-arms under - # `isolation = "worktree"`: a per-configuration constant, not an event, and - # the same "trains the operator to scroll past the meaningful one" failure - # that the `flipped` read-back below and the `overwritten != old_baseline` - # guard were each narrowed to avoid. The remedy it prints ("commit the - # corrected spec") is already a no-op once the committed spec carries the - # target status, which is precisely when the re-drive reads what it needs. - # Suppression requires PROOF: an unreadable blob, a non-repo project, or any - # git fault leaves `""` and the record fires. The proof is read at - # `redrive_base_ref`, NOT at the code root's current `HEAD` — the two part - # company as soon as the operator checks out another branch while the - # escalation is paused, and this record now holds the resume. - # - # The branch rides along because the remedy needs it: on exactly the shape - # the ref fix rescues, "commit the corrected spec" without a branch sends - # the operator to commit again on the branch the re-drive does not read, and - # the next re-arm prints the same sentence. Empty for the migrated shape - # `redrive_base_ref` degrades to `HEAD` for, and the notice drops the - # clause rather than naming a ref it cannot source — and empty for an - # IN-PLACE re-drive, which has no branch to name at all. - # - # `redrive` is that second shape's discriminator, and it goes ON the record - # because the reader is out of process: `rearm_event_notice` renders from a - # journal line alone and cannot re-read the policy that produced it. One - # kind, two remedies. Isolated: the writes landed in a mount the re-drive - # discards, so the correction must be COMMITTED on the named branch. In - # place: the writes landed in the mount the escalated attempt recorded while - # the re-drive now reads the main checkout, so the correction must be made - # THERE — a commit is neither required nor sufficient. Telling the second - # operator to commit sends them to the wrong tree, which is the same class - # of silent loss this whole record exists to end. - # - # Spelled `target_branch` and NOT `base`, because `diagnostics` routes the - # scrub by field NAME: `target_branch` is already in `_JOURNAL_ALIAS_FIELDS` - # under the `branch` namespace (with no journal producer until now), while - # any new spelling falls through to `scrub_json`, which waves an - # identifier-shaped branch name through verbatim. In a normal run - # `ensure_target_branch` has already journalled the same string as `branch`, - # so the egress backstop would repair it and disclose a `backstop_repairs` - # routing gap; in a truncated journal missing that event nothing would catch - # it and the branch would ship in a shareable bundle. `target` — the - # spelling the merge kinds use — is NOT available: `board-advance-*` puts a - # sprint STATUS in that same field, and routing is by name, so aliasing it - # to `branch` would pseudonymize statuses as branches. - if ( - not write_reaches_the_redrive - and _redrive_spec_status(state, task, isolated_redrive=isolated_redrive) - != target_status - ): - journal.append( - "rearm-spec-write-unreachable", - story_key=key, - spec_file=str(spec_path), - status=target_status, - target_branch=state.target_branch if isolated_redrive else "", - redrive="isolated" if isolated_redrive else "in-place", - ) - # Captured immediately before the FIRST write, so an abort further down can - # put the spec back exactly as found. Unreadable degrades to `None`: the - # writes below answer such a path with `False` rather than an exception, so - # there would be nothing to undo either. - try: - spec_before = spec_path.read_bytes() - except OSError: - spec_before = None - try: - flipped = verify.set_frontmatter_status( - spec_path, target_status, confine_root=task_spec_root(task, state) + spec_path: Path | None = None + + # ONE transaction, from the first spec write to the commit point. `save_state` IS + # that commit point: until it returns the run still calls this story ESCALATED, so + # any fault escaping this window left a spec re-armed on disk against a task that is + # not — the "one edit nothing else records" each sequenced refusal was written to + # avoid, reached instead by a `journal.append` OSError from the residue pass, a + # non-Git fault from the commits probe, or `save_state` itself failing. Only two of + # the aborts in here ever undid their own writes; guarding the window replaces both + # of those per-arm undos with one rule that covers every fault source in it. + # + # `except BaseException: ...; raise` rather than a `finally` with a flag, because + # the rollback must run on the ERROR path only and a bare `raise` re-raises the + # ORIGINAL fault untouched — the narrowed `verify.GitError` taxonomies inside stay + # narrowed, and a non-git fault from either probe still escapes as itself. + # + # The guard opens one line above the FIRST write rather than at the `spec_before` + # capture, because the residue pass, the advance and `save_state` all have to be + # covered too and they live outside that block. + # + # STATE THE SCOPE PRECISELY, because one branch in here is the counterexample to the + # loose reading: what this transaction restores is the SPEC's BYTES, from the first + # spec write onward. It is not "the tree as the re-arm found it". The sentinel-clear + # branch sits INSIDE the guard and UNLINKS a file, and that deletion is deliberately + # NOT undone — `_clear_sentinel` preserves a copy under `{run_dir}/sentinels/` and a + # retried resolve re-clears it idempotently, so re-creating it here would fight a + # gesture that is already safe to repeat. `spec_before` is `None` on that leg, so + # `_rollback_rearm` records `unknown` rather than `unchanged` and the operator + # surfaces make no claim about the file. Recording it as `unchanged` is precisely + # the bug that reading would produce: a notice naming a file this re-arm DELETED as + # proof the tree is untouched. + try: + if task.spec_file: + spec_path = task_spec_path(task, state) + # Stories mode only: a fixed-slug pre-planning-halt sentinel + # (`-unresolved.md` / `-ambiguous.md`) is cleared by deletion, not a + # status flip. Clear it ONLY when the run recorded this task AS a sentinel at + # detection time (`task.sentinel_kind`, stamped by StoriesEngine's pick-time + # wedge / post-dev read-back) — never by re-deriving from the basename. That + # keeps a real story spec that merely happens to be named `-unresolved.md`, + # or a *non-sentinel* escalation whose spec matches the convention, on the + # status-flip path so it is kept, not deleted. Gate on the run source too (the + # convention exists only in stories mode) and defensively re-confirm the + # on-disk name still matches the recorded slug before deleting. + sentinel_kind = task.sentinel_kind if state.source == "stories" else "" + if sentinel_kind and _sentinel_condition(spec_path, key) == sentinel_kind: + # a sentinel is cleared by deletion, not a status flip; drop the stale + # spec_file so the re-dispatch starts from PENDING (clean re-plan). + _clear_sentinel(run_dir, journal, spec_path, key, sentinel_kind) + task.spec_file = None + task.sentinel_kind = "" # verdict discharged; the re-dispatch is clean + # Deleting the sentinel does not make the re-plan produce a different one: + # the correction that does lives UPSTREAM, in the `SPEC.md` / `stories.yaml` + # the resolve skill sends the agent to instead of this file. That correction + # faces the same reachability gap the spec arm below measures, and faced NO + # gate at all — this arm cleared `spec_file` and fell through, so + # `write_reaches_the_redrive` was never computed and the resume was never + # held for a sentinel. An isolated re-drive then mounts fresh from + # `redrive_base_ref`, re-plans from a committed tree that never saw the + # edit, mints the same sentinel again, and the escalation is spent. + # + # Narrowed by PROOF for the reason the spec record below is, and the need is + # sharper here: `stories_reach_the_redrive` answers "unreachable" for EVERY + # isolated stories run whose spec folder sits inside the project, which is + # every one we author. Gating on it alone would fire — and hold the resume — + # on 100% of isolated sentinel re-arms, a per-configuration constant rather + # than an event. `_redrive_reads_the_upstream_artifacts` is what makes it an + # event: it fires only while this checkout still holds upstream bytes the + # ref the re-drive mounts from does not. + # + # No `redrive` discriminator, unlike the spec record: this one has a single + # remedy because it has a single reachable shape. An in-place re-drive reads + # the main checkout's working tree, which is exactly where `cwd=project` put + # the correction, so `stories_reach_the_redrive` short-circuits that leg to + # reachable and no record is written for it at all. + if not stories_reach_the_redrive( + task, state, isolated_redrive=isolated_redrive + ) and not _redrive_reads_the_upstream_artifacts(state): + journal.append( + "rearm-upstream-write-unreachable", + story_key=key, + # `task_stories_root` names the tree the RUN owns; the correction + # lands in the checkout the resolve session ran in. Both are the + # project on this leg unless a mount is recorded, and the operator + # needs the folder to act, so the record carries the folder the + # remedy is about rather than the run's read locator. + stories_root=str(_upstream_artifacts_folder(state)), + target_branch=state.target_branch, + ) + else: + # A WORKTREE-LOCAL spec's writes below land in the unit's worktree + # (`task_spec_path`) — which the re-drive destroys before reading anything. + # A re-armed task (phase PENDING, `defer_reason` cleared, and no resumable + # session because `generation` was just bumped) falls to + # `engine._finish_inflight`'s final arm, which calls `discard_worktree` and + # lets `_run_story` mount a fresh one. The re-driven session then resolves + # its spec through `verify.resolve_spec_path(task.spec_file, + # workspace.paths)` (`engine._dispatched_spec_for_attempt`), and under + # isolation `workspace.paths` is rebased onto that FRESH worktree, which + # checks out TRACKED files only. So the re-drive reads the COMMITTED spec. + # + # No working-tree write reaches it — not this one, and not a write to the + # main checkout either: the fresh worktree comes from git rather than from a + # copy of that tree, and `seed_adapter_defaults` seeds adapter config files, + # not the output folder. The channel that DOES work is the human committing + # the corrected spec from the resolve session, which runs with `cwd=project`. + # The writes below are kept (they are correct for the in-place case, and + # harmless here), but the operator is told — a flip that cannot land is + # exactly the silent re-wedge #640(b) exists to end. + # + # "Worktree-local" is the load-bearing qualifier, and isolation does not + # imply it: an artifact dir configured OUTSIDE the project tree is shared + # across checkouts by `ProjectPaths.rebased`, so a spec that landed there is + # one file the fresh worktree reads through the very absolute path this + # writes to. `_spec_is_shared_with_the_redrive` carves out that case, and only + # that one: the main checkout's copy is outside the worktree too, and stays + # unreachable because the re-drive measures it against worktree-local roots. + # Route /bmad-build-auto via the spec's frontmatter status (decision + # table): patch-restore -> in-review -> step-04 (resume review on + # the restored diff); from-scratch -> ready-for-dev -> step-03 + # (re-implement). Independent of the resolve agent having set it. + target_status = "in-review" if restore_patch else "ready-for-dev" + # Whether the writes below are the copy the re-driven session actually + # reads. Hoisted out of the record's condition because TWO decisions turn on + # it, and only one of them used to: the warning below, and the flip's + # REFUSAL one screen down, which was gated on `spec_path.is_file()` alone. + # Under isolation that readable file is the doomed worktree copy, so the + # refusal demanded a repair to the one file the re-drive destroys before + # reading anything — and demanded it even when `_redrive_spec_status` had + # already proven the committed spec carries the status the re-drive routes + # on. See `_spec_is_shared_with_the_redrive` for why an isolated unit's spec + # is nevertheless reachable when it sits in an artifact dir configured + # outside the project tree. + write_reaches_the_redrive = spec_reaches_the_redrive( + task, state, isolated_redrive=isolated_redrive ) - # `set_frontmatter_status` answers "nothing to change" with `False` - # for FOUR causes, not three — its own docstring lists them: no file, - # no frontmatter block, no top-level `status:`, and ALREADY AT THE - # TARGET (`_edit_frontmatter_block` returns None on - # `original[key] == value`). Only the first three are failures. The - # fourth is an ordinary, fully-successful re-arm: a second resolve - # cycle on an already-flipped spec, or the documented - # `resolve --no-interactive` flow where a human fixed the spec - # themselves — the case the comment above calls "Independent of the - # resolve agent having set it". Journalling it fired the operator - # warning ("could not be re-opened … may re-wedge on it") on a spec - # that was byte-identical and CORRECT, which is the "trains the - # operator to scroll past the meaningful one" failure the re-stamp's - # `overwritten != old_baseline` guard exists to prevent one screen - # below. Read the status back to tell the two apart: `read_frontmatter` - # degrades a missing/unreadable/unparseable spec to `{}` and `status_of` - # then answers `""`, so all three real failures still record. - if not flipped and verify.status_of(verify.read_frontmatter(spec_path)) != ( - target_status + # Narrowed to the case an operator can ACT on. Every isolated escalation + # carries a mounted `worktree_path` — `worktree_flow.escalate_unit` never + # clears it, and `keep_branch_and_escalate` deliberately leaves the worktree + # up — so gating on that alone fired this warning on 100% of re-arms under + # `isolation = "worktree"`: a per-configuration constant, not an event, and + # the same "trains the operator to scroll past the meaningful one" failure + # that the `flipped` read-back below and the `overwritten != old_baseline` + # guard were each narrowed to avoid. The remedy it prints ("commit the + # corrected spec") is already a no-op once the committed spec carries the + # target status, which is precisely when the re-drive reads what it needs. + # Suppression requires PROOF: an unreadable blob, a non-repo project, or any + # git fault leaves `""` and the record fires. The proof is read at + # `redrive_base_ref`, NOT at the code root's current `HEAD` — the two part + # company as soon as the operator checks out another branch while the + # escalation is paused, and this record now holds the resume. + # + # The branch rides along because the remedy needs it: on exactly the shape + # the ref fix rescues, "commit the corrected spec" without a branch sends + # the operator to commit again on the branch the re-drive does not read, and + # the next re-arm prints the same sentence. Empty for the migrated shape + # `redrive_base_ref` degrades to `HEAD` for, and the notice drops the + # clause rather than naming a ref it cannot source — and empty for an + # IN-PLACE re-drive, which has no branch to name at all. + # + # `redrive` is that second shape's discriminator, and it goes ON the record + # because the reader is out of process: `rearm_event_notice` renders from a + # journal line alone and cannot re-read the policy that produced it. One + # kind, two remedies. Isolated: the writes landed in a mount the re-drive + # discards, so the correction must be COMMITTED on the named branch. In + # place: the writes landed in the mount the escalated attempt recorded while + # the re-drive now reads the main checkout, so the correction must be made + # THERE — a commit is neither required nor sufficient. Telling the second + # operator to commit sends them to the wrong tree, which is the same class + # of silent loss this whole record exists to end. + # + # Spelled `target_branch` and NOT `base`, because `diagnostics` routes the + # scrub by field NAME: `target_branch` is already in `_JOURNAL_ALIAS_FIELDS` + # under the `branch` namespace (with no journal producer until now), while + # any new spelling falls through to `scrub_json`, which waves an + # identifier-shaped branch name through verbatim. In a normal run + # `ensure_target_branch` has already journalled the same string as `branch`, + # so the egress backstop would repair it and disclose a `backstop_repairs` + # routing gap; in a truncated journal missing that event nothing would catch + # it and the branch would ship in a shareable bundle. `target` — the + # spelling the merge kinds use — is NOT available: `board-advance-*` puts a + # sprint STATUS in that same field, and routing is by name, so aliasing it + # to `branch` would pseudonymize statuses as branches. + if ( + not write_reaches_the_redrive + and _redrive_spec_status(state, task, isolated_redrive=isolated_redrive) + != target_status ): - # Discarding that return is how the flip - # became a SILENT no-op: the re-drive is dispatched anyway, step-01 - # reads the unchanged terminal status, routes the session to "ingest - # as context, do not resume", and the story re-wedges with nothing on - # the record. The `FrontmatterWriteError` arm below covers only the - # shapes that RAISE; this covers the ones that lie quietly. - # `refused` is written ON the record because ONE kind now covers - # two outcomes and the operator surfaces must tell them apart — - # they read the journal OUT OF PROCESS, with neither the task nor - # the tree to re-derive it from. Printing the refusal's remedy - # ("add a top-level `status:`") for a re-arm that COMPLETED sends - # the human to repair a file nothing will read. - refused = spec_path.is_file() and write_reaches_the_redrive journal.append( - "rearm-spec-flip-skipped", + "rearm-spec-write-unreachable", story_key=key, spec_file=str(spec_path), status=target_status, - refused=refused, + target_branch=state.target_branch if isolated_redrive else "", + redrive="isolated" if isolated_redrive else "in-place", ) - # ...and then ABORT — but only for a spec that IS a readable file - # here AND is the copy the re-drive reads. The first half is the same - # `is_file` split the baseline re-stamp below already draws, and for - # the same reason. On THAT shape the failure is - # a REPAIR that did not land on the very file the re-drive reads, so it - # aborts for the same reason the `FrontmatterWriteError` arm does: - # journalling alone left the two default surfaces telling the operator - # "re-armed " and resuming in the same gesture, so the record's - # own imperative was already unactionable when it rendered — while - # step-01's contract for what reaches here is not a maybe. A spec with - # no `status:` HALTs blocked on `unrecognized status in existing story - # file`; one still carrying the escalated attempt's terminal status - # routes to "ingest as context, do not resume". Either way the re-drive - # re-wedges and the escalation is burned. Refusing keeps it armed: nothing - # is persisted yet (`save_state` runs below), the spec is byte-identical - # (the `## Auto Run Result` strip is deliberately sequenced AFTER this - # check so an abort leaves nothing half-done), and the human fixes the - # frontmatter and re-runs resolve. - # - # A spec that is NOT a file from here keeps warn-and-continue, because - # there the flip's failure says nothing about what the re-drive will - # read: `spec_file` is persisted RELATIVE to a worktree, an isolated - # task's worktree may already be gone, and the re-drive mounts a fresh - # one and reads the COMMITTED spec regardless. Aborting on it would - # refuse the re-arms that the `rearm-baseline-restamp-skipped` and - # `rearm-spec-write-unreachable` records exist to report rather than - # prevent — an unreadable path is an observation, and observations - # degrade. + # Captured immediately before the FIRST write, so an abort further down can + # put the spec back exactly as found. Unreadable degrades to `None`: the + # writes below answer such a path with `False` rather than an exception, so + # there would be nothing to undo either. + try: + spec_before = spec_path.read_bytes() + except OSError: + spec_before = None + try: + flipped = verify.set_frontmatter_status( + spec_path, target_status, confine_root=task_spec_root(task, state) + ) + # `set_frontmatter_status` answers "nothing to change" with `False` + # for FOUR causes, not three — its own docstring lists them: no file, + # no frontmatter block, no top-level `status:`, and ALREADY AT THE + # TARGET (`_edit_frontmatter_block` returns None on + # `original[key] == value`). Only the first three are failures. The + # fourth is an ordinary, fully-successful re-arm: a second resolve + # cycle on an already-flipped spec, or the documented + # `resolve --no-interactive` flow where a human fixed the spec + # themselves — the case the comment above calls "Independent of the + # resolve agent having set it". Journalling it fired the operator + # warning ("could not be re-opened … may re-wedge on it") on a spec + # that was byte-identical and CORRECT, which is the "trains the + # operator to scroll past the meaningful one" failure the re-stamp's + # `overwritten != old_baseline` guard exists to prevent one screen + # below. Read the status back to tell the two apart: `read_frontmatter` + # degrades a missing/unreadable/unparseable spec to `{}` and `status_of` + # then answers `""`, so all three real failures still record. + if not flipped and verify.status_of(verify.read_frontmatter(spec_path)) != ( + target_status + ): + # Discarding that return is how the flip + # became a SILENT no-op: the re-drive is dispatched anyway, step-01 + # reads the unchanged terminal status, routes the session to "ingest + # as context, do not resume", and the story re-wedges with nothing on + # the record. The `FrontmatterWriteError` arm below covers only the + # shapes that RAISE; this covers the ones that lie quietly. + # `refused` is written ON the record because ONE kind now covers + # two outcomes and the operator surfaces must tell them apart — + # they read the journal OUT OF PROCESS, with neither the task nor + # the tree to re-derive it from. Printing the refusal's remedy + # ("add a top-level `status:`") for a re-arm that COMPLETED sends + # the human to repair a file nothing will read. + refused = spec_path.is_file() and write_reaches_the_redrive + journal.append( + "rearm-spec-flip-skipped", + story_key=key, + spec_file=str(spec_path), + status=target_status, + refused=refused, + ) + # ...and then ABORT — but only for a spec that IS a readable file + # here AND is the copy the re-drive reads. The first half is the same + # `is_file` split the baseline re-stamp below already draws, and for + # the same reason. On THAT shape the failure is + # a REPAIR that did not land on the very file the re-drive reads, so it + # aborts for the same reason the `FrontmatterWriteError` arm does: + # journalling alone left the two default surfaces telling the operator + # "re-armed " and resuming in the same gesture, so the record's + # own imperative was already unactionable when it rendered — while + # step-01's contract for what reaches here is not a maybe. A spec with + # no `status:` HALTs blocked on `unrecognized status in existing story + # file`; one still carrying the escalated attempt's terminal status + # routes to "ingest as context, do not resume". Either way the re-drive + # re-wedges and the escalation is burned. Refusing keeps it armed: nothing + # is persisted yet (`save_state` runs below), the spec is byte-identical + # (the `## Auto Run Result` strip is deliberately sequenced AFTER this + # check so an abort leaves nothing half-done), and the human fixes the + # frontmatter and re-runs resolve. + # + # A spec that is NOT a file from here keeps warn-and-continue, because + # there the flip's failure says nothing about what the re-drive will + # read: `spec_file` is persisted RELATIVE to a worktree, an isolated + # task's worktree may already be gone, and the re-drive mounts a fresh + # one and reads the COMMITTED spec regardless. Aborting on it would + # refuse the re-arms that the `rearm-baseline-restamp-skipped` and + # `rearm-spec-write-unreachable` records exist to report rather than + # prevent — an unreadable path is an observation, and observations + # degrade. + # + # A worktree-local spec that IS readable takes that same lane, for a + # sharper version of the same reason: `task_spec_root` anchors this + # write on the mounted worktree, so the readable file is the copy the + # re-drive DISCARDS. The refusal's own remedy could not fix anything + # there — an operator who added a `status:` to that file and re-ran + # resolve would flip a spec that is deleted before it is read, while + # the committed spec, the one thing that decides routing, went + # untouched. Worse, the refusal fired even when the correction was + # already committed: `_redrive_spec_status` had just PROVEN the + # re-drive routes correctly, and the re-arm was refused anyway over an + # obsolete copy. The real remedy on that shape is + # `rearm-spec-write-unreachable`'s ("commit the corrected spec"), + # which fires from the block above on exactly the legs that need it + # and now holds the resume rather than merely printing. + # + # The record is written on BOTH sides of that split: the abort message + # reaches stderr only, and the journal is the run's audit trail — + # `_echo_rearm_events` surfaces it from a `finally` on this path. + if refused: + raise RearmError( + f"cannot re-open story spec {spec_path} to `{target_status}` " + "for the re-drive: it has no frontmatter `status:` this re-arm " + "can set, so the re-driven session would wedge on the status " + "it reads — add a top-level `status:` to the spec's " + "frontmatter block, then re-run resolve" + ) + # drop the stale `## Auto Run Result` section along with the status flip + # (mirrors engine._reset_spec_for_repair): find_result_artifact keys on + # that heading, so leaving it would let the re-driven session's first + # save of the spec parse as the prior attempt's terminal outcome. # - # A worktree-local spec that IS readable takes that same lane, for a - # sharper version of the same reason: `task_spec_root` anchors this - # write on the mounted worktree, so the readable file is the copy the - # re-drive DISCARDS. The refusal's own remedy could not fix anything - # there — an operator who added a `status:` to that file and re-ran - # resolve would flip a spec that is deleted before it is read, while - # the committed spec, the one thing that decides routing, went - # untouched. Worse, the refusal fired even when the correction was - # already committed: `_redrive_spec_status` had just PROVEN the - # re-drive routes correctly, and the re-arm was refused anyway over an - # obsolete copy. The real remedy on that shape is - # `rearm-spec-write-unreachable`'s ("commit the corrected spec"), - # which fires from the block above on exactly the legs that need it - # and now holds the resume rather than merely printing. + # Sequenced AFTER the read-back check above, not with the flip it mirrors: + # that check now raises, and an aborted re-arm must leave the spec exactly + # as it found it — a stripped result section on a spec the re-arm then + # refused would be the one edit nothing else records. + devcontract.strip_auto_run_result( + spec_path, confine_root=task_spec_root(task, state) + ) + except verify.FrontmatterWriteError as e: + # The spec reads fine but carries `status:` in a shape no line + # edit can move (a block scalar, a flow mapping, a value continued + # on the next line). This used to be a silent no-op on a bool + # nobody read: the re-drive was dispatched anyway, step-01 saw the + # unchanged terminal status and routed the session to "ingest as + # context, do not resume", and the story re-wedged with nothing on + # the record explaining why. Abort here for the same reason as + # below, with the remedy this cause actually has. + raise RearmError( + f"cannot re-open story spec {spec_path} for the re-drive: {e} " + f"— the re-drive would repeat the wedge it is meant to clear" + ) from e + except (OSError, UnicodeDecodeError) as e: + # Both helpers re-read the spec as UTF-8; an undecodable PRESENT + # spec is a first-class escalation state (resolve_story_spec + # degrades it to a wedge), so it can reach this flip. Without the + # flip the re-drive would just re-wedge — abort BEFORE any state + # is persisted (save_state runs below) with an actionable error + # instead of a traceback; the escalation stays armed for a retry. # - # The record is written on BOTH sides of that split: the abort message - # reaches stderr only, and the journal is the run's audit trail — - # `_echo_rearm_events` surfaces it from a `finally` on this path. - if refused: - raise RearmError( - f"cannot re-open story spec {spec_path} to `{target_status}` " - "for the re-drive: it has no frontmatter `status:` this re-arm " - "can set, so the re-driven session would wedge on the status " - "it reads — add a top-level `status:` to the spec's " - "frontmatter block, then re-run resolve" - ) - # drop the stale `## Auto Run Result` section along with the status flip - # (mirrors engine._reset_spec_for_repair): find_result_artifact keys on - # that heading, so leaving it would let the re-driven session's first - # save of the spec parse as the prior attempt's terminal outcome. - # - # Sequenced AFTER the read-back check above, not with the flip it mirrors: - # that check now raises, and an aborted re-arm must leave the spec exactly - # as it found it — a stripped result section on a spec the re-arm then - # refused would be the one edit nothing else records. - devcontract.strip_auto_run_result( - spec_path, confine_root=task_spec_root(task, state) - ) - except verify.FrontmatterWriteError as e: - # The spec reads fine but carries `status:` in a shape no line - # edit can move (a block scalar, a flow mapping, a value continued - # on the next line). This used to be a silent no-op on a bool - # nobody read: the re-drive was dispatched anyway, step-01 saw the - # unchanged terminal status and routed the session to "ingest as - # context, do not resume", and the story re-wedged with nothing on - # the record explaining why. Abort here for the same reason as - # below, with the remedy this cause actually has. - raise RearmError( - f"cannot re-open story spec {spec_path} for the re-drive: {e} " - f"— the re-drive would repeat the wedge it is meant to clear" - ) from e - except (OSError, UnicodeDecodeError) as e: - # Both helpers re-read the spec as UTF-8; an undecodable PRESENT - # spec is a first-class escalation state (resolve_story_spec - # degrades it to a wedge), so it can reach this flip. Without the - # flip the re-drive would just re-wedge — abort BEFORE any state - # is persisted (save_state runs below) with an actionable error - # instead of a traceback; the escalation stays armed for a retry. - # - # ...and this arm is the SECOND refusal that can fire after a write has - # landed, which the sequencing argument above does not cover. It guards - # BOTH helpers, and `strip_auto_run_result` is the later one: by the - # time its own read/decode or its atomic write faults (an - # `atomic_write_bytes_confined` that cannot land — ENOSPC, EIO, a - # component swapped for a link under the `O_NOFOLLOW` walk — or a spec - # replaced under us between the two writes), the flip has already been - # published and `save_state` has not. Ordering the strip after the - # read-back check bought that check its byte-identical abort; it buys - # this one nothing, because the fault is IN the strip. So the same undo - # the re-stamp carries applies here, on the same terms. - # - # On the arm's other shape — the flip itself faulting on an - # unreadable/undecodable spec — nothing was written, `spec_before` still - # equals the bytes on disk, and `_restore_rearmed_spec` proves that and - # returns without touching the file or its mtime. - _restore_rearmed_spec(spec_path, spec_before, task, state) - raise RearmError( - f"cannot re-open story spec {spec_path} for the re-drive " - f"({e.__class__.__name__}: {e}) — fix or replace the file " - f"(it must be readable UTF-8), then re-run resolve" - ) from e - - # A previous restore latch is being replaced (or re-latched onto the same - # patch): the abandoned attempt applied that patch, so its NEW files sit - # untracked in the tree right now. The refresh below would capture them as - # "pre-existing" — after which every rollback preserves them and - # finalize_commit's `add -A` sweeps the abandoned attempt into the corrected - # story's commit. Subtract them instead (issue #90). - # - # Runs after the spec block for the same reason the refresh does (a cleared - # sentinel must not be snapshotted), and before it because it feeds it. - # Nothing is deleted here: the re-drive's reset (verify.safe_rollback) removes - # whatever the refreshed snapshot no longer blesses, at the right moment. - # The CODE tree, not `state.project`: every git read below (and every baseline - # the proof-of-work gate later measures against) must name the repository the - # dev writer stamps. - # - # That is `paths.repo_root` for every run this function can be reached from, but - # NOT because `paths.repo_root == workspace.root` universally — it does not. - # `Workspace.default` sets `root=paths.repo_root`, while the isolation constructor - # mounts `root=/worktrees/` and rebases a fresh `ProjectPaths` onto - # it, so under `isolation = "worktree"` the run-level `repo_root` is the main - # checkout and the baseline is stamped in the worktree. - # - # `bmadconfig.worktree_isolation_conflict` refuses worktree isolation beside a - # `repo_root:` OVERRIDE — a narrower fact than it looks. It forces - # `repo_root == project`; it says nothing about `repo_root` vs `workspace.root`. - # Under plain isolation with NO override those two still diverge and isolation is - # ON, so "wherever the roots could diverge, isolation is off" is false, and a rule - # built on it licenses treating `state.code_root` as the tree the dev writer - # stamped — which under isolation it is not. - # - # What is true, and the only claim to carry forward: `repo_root == project` in - # every reachable configuration, so reading HEAD here is right for the in-place - # case; and under isolation this value is deliberately SUPERSEDED rather than - # relied on — `engine._finish_inflight` discards the worktree and `_dev_phase` - # re-stamps `task.baseline_commit` from the fresh worktree's HEAD before any gate - # reads it. Do not carry an identity into new code; carry this argument. - # - # A pre-upgrade state.json with no recorded root degrades to `project` exactly as - # before. - repo = state.code_root - stale_residue = _stale_restore_residue(repo, journal, key, old_latch, old_baseline) - - # Advance the attempt baseline to the CODE TREE's current HEAD (`repo`, above) - # and refresh the untracked snapshot: whatever the human-driven resolve session left on the - # branch (a committed fixture, a corrected ledger, ...) is authorized input - # for the re-drive, not failed-attempt debris. Without this, the re-drive's - # reset-to-baseline in engine._rollback_or_pause parks the resolution - # commits on an attempt-preserve ref and rebuilds against a tree that - # contradicts the corrected spec — the re-driven dev session then hits the - # very gap the human just resolved. Best-effort: on a git failure the old - # baseline stands (the redrive rollback path tolerates a stale baseline; it - # just loses this protection). - # Runs AFTER the spec block so a just-cleared stories sentinel (an untracked - # file removed above) is not captured into baseline_untracked as a phantom - # pre-existing untracked file. The two locals are computed before either task - # field is assigned, so a failure on either git call can't advance - # baseline_commit while baseline_untracked stays stale, or vice versa. - advanced = False - try: - head = verify.rev_parse_head(repo) - untracked = sorted(verify.untracked_files(repo) - stale_residue) - except verify.GitError as e: - # `verify.GitError` is a TOTAL replacement for the `except Exception` that - # stood here, not a narrowing that leaks: both calls go through `_run_git`, - # which translates spawn (`GitSpawnError`), timeout (`GitTimeoutError`) and - # decode faults into this one taxonomy, and a non-zero rc into a plain - # `GitError`. Still swallowed rather than raised — a project that is not a - # git repo must not fail re-arm — but no longer SILENT: the degrade is the - # difference between "the re-drive starts from the resolution" and "it - # rebuilds against the tree the human just corrected away", and the - # re-stamp below now refuses to paper over it. - journal.append( - "rearm-baseline-advance-failed", - story_key=key, - repo=str(repo), - baseline=old_baseline or "", - error=f"{e.__class__.__name__}: {e}", - ) - else: - task.baseline_commit = head - task.baseline_untracked = untracked - advanced = True - - # Re-stamp the spec's own baseline to the advanced one, on BOTH re-drive legs. - # - # The patch-restore leg needs it because the in-review route skips step-03 — - # the only step that stamps `baseline_revision` — so without it the re-driven - # step-04 would build its review diff (and, on an intent-gap/bad-spec - # re-triage, revert) "since" the ORIGINAL pre-attempt sha, clawing back the - # very resolve-session commits the advance above just blessed as the re-drive's - # starting point. - # - # The from-scratch leg gets it too (#640a). Its step-03 re-stamps the key - # itself, so the write is redundant on the happy path — but only ON that path: - # until step-03 runs, the spec carries the escalated attempt's sha, and every - # gate that reads a claimed baseline before then reads a stale one. The cost is - # recorded rather than hidden: re-stamping removes the gate's INDEPENDENT - # signal on this leg (it then compares a value the orchestrator itself wrote), - # so a claim that genuinely diverged is journalled on the way out instead of - # being silently normalized. - # - # Gated on `advanced`, not on truthiness of `task.baseline_commit`: a failed - # advance leaves the OLD sha in that field, which passes a truthiness test - # identically to a freshly advanced one. Writing it would make spec and task - # agree on a stale value — the one state in which nothing downstream can tell - # that the advance never happened, and the re-drive rebuilds from the wrong - # point with no error anywhere. Skipping keeps the failure legible (the degrade - # is journalled above) and keeps re-arm non-fatal outside a repo. - # - # Loud on WRITE failure: a silently stale spec baseline is exactly the hazard - # being closed. - # - # Guarded on `is_file` FIRST, because a spec this process cannot reach is not a - # write failure here — it is a SILENT one. Both frontmatter writers answer such a - # path with `False` rather than an exception (`verify.set_frontmatter_status`, - # `verify.set_frontmatter_field`), so without a check the re-stamp no-ops with - # nothing on the record and the spec keeps the escalated attempt's sha. - # - # `task_spec_path` re-anchors the recorded path before we get here, which is what - # makes `is_file` mean what it says. Resolved raw it meant something else and worse: - # `spec_file` is persisted RELATIVE to the worktree for an isolated task, and the - # main checkout carries the same layout, so the check passed on the wrong file and - # the write landed there. The restore leg cannot reach any of this (its precondition - # rejects a truthy `task.worktree_path`); the from-scratch leg has no such guard, - # which is exactly why that precondition has to exist. - # - # `is_file` is necessary but not sufficient: a spec that EXISTS with no frontmatter - # block also returns `False` from both writers. That shape is caught by the flip's - # `flipped` check above and, here, by `overwritten` staying empty. - if task.spec_file: - spec_path = task_spec_path(task, state) - if not spec_path.is_file(): - # OUTSIDE the `advanced` gate on purpose. Nesting this record inside it - # made the two #640 legs shadow each other: on a project that is not a - # repo the advance fails, `advanced` is False, and an unreadable spec - # then produced NO record at all — the journal blamed git while the - # status flip above had silently no-opped for an entirely different - # reason. The two degrades compose; they do not substitute. + # Two shapes reach this arm and the transaction guard below covers both. + # On the flip's own read/decode fault nothing was written, so the rollback + # proves that and leaves the file and its mtime alone. A fault raised + # inside `strip_auto_run_result` is raised with the flip already PUBLISHED + # — an `atomic_write_bytes_confined` that cannot land (ENOSPC, EIO, a + # component swapped for a link under the `O_NOFOLLOW` walk), or a spec + # replaced under us between the two writes. Ordering the strip after the + # read-back check bought that check its byte-identical abort; it buys this + # one nothing, because the fault is IN the strip. + raise RearmError( + f"cannot re-open story spec {spec_path} for the re-drive " + f"({e.__class__.__name__}: {e}) — fix or replace the file " + f"(it must be readable UTF-8), then re-run resolve" + ) from e + + # A previous restore latch is being replaced (or re-latched onto the same + # patch): the abandoned attempt applied that patch, so its NEW files sit + # untracked in the tree right now. The refresh below would capture them as + # "pre-existing" — after which every rollback preserves them and + # finalize_commit's `add -A` sweeps the abandoned attempt into the corrected + # story's commit. Subtract them instead (issue #90). + # + # Runs after the spec block for the same reason the refresh does (a cleared + # sentinel must not be snapshotted), and before it because it feeds it. + # Nothing is deleted here: the re-drive's reset (verify.safe_rollback) removes + # whatever the refreshed snapshot no longer blesses, at the right moment. + # The CODE tree, not `state.project`: every git read below (and every baseline + # the proof-of-work gate later measures against) must name the repository the + # dev writer stamps. + # + # That is `paths.repo_root` for every run this function can be reached from, but + # NOT because `paths.repo_root == workspace.root` universally — it does not. + # `Workspace.default` sets `root=paths.repo_root`, while the isolation constructor + # mounts `root=/worktrees/` and rebases a fresh `ProjectPaths` onto + # it, so under `isolation = "worktree"` the run-level `repo_root` is the main + # checkout and the baseline is stamped in the worktree. + # + # `bmadconfig.worktree_isolation_conflict` refuses worktree isolation beside a + # `repo_root:` OVERRIDE — a narrower fact than it looks. It forces + # `repo_root == project`; it says nothing about `repo_root` vs `workspace.root`. + # Under plain isolation with NO override those two still diverge and isolation is + # ON, so "wherever the roots could diverge, isolation is off" is false, and a rule + # built on it licenses treating `state.code_root` as the tree the dev writer + # stamped — which under isolation it is not. + # + # What is true, and the only claim to carry forward: `repo_root == project` in + # every reachable configuration, so reading HEAD here is right for the in-place + # case; and under isolation this value is deliberately SUPERSEDED rather than + # relied on — `engine._finish_inflight` discards the worktree and `_dev_phase` + # re-stamps `task.baseline_commit` from the fresh worktree's HEAD before any gate + # reads it. Do not carry an identity into new code; carry this argument. + # + # A pre-upgrade state.json with no recorded root degrades to `project` exactly as + # before. + repo = state.code_root + stale_residue = _stale_restore_residue(repo, journal, key, old_latch, old_baseline) + + # Advance the attempt baseline to the CODE TREE's current HEAD (`repo`, above) + # and refresh the untracked snapshot: whatever the human-driven resolve session left on the + # branch (a committed fixture, a corrected ledger, ...) is authorized input + # for the re-drive, not failed-attempt debris. Without this, the re-drive's + # reset-to-baseline in engine._rollback_or_pause parks the resolution + # commits on an attempt-preserve ref and rebuilds against a tree that + # contradicts the corrected spec — the re-driven dev session then hits the + # very gap the human just resolved. Best-effort: on a git failure the old + # baseline stands (the redrive rollback path tolerates a stale baseline; it + # just loses this protection). + # Runs AFTER the spec block so a just-cleared stories sentinel (an untracked + # file removed above) is not captured into baseline_untracked as a phantom + # pre-existing untracked file. The two locals are computed before either task + # field is assigned, so a failure on either git call can't advance + # baseline_commit while baseline_untracked stays stale, or vice versa. + advanced = False + try: + head = verify.rev_parse_head(repo) + untracked = sorted(verify.untracked_files(repo) - stale_residue) + except verify.GitError as e: + # `verify.GitError` is a TOTAL replacement for the `except Exception` that + # stood here, not a narrowing that leaks: both calls go through `_run_git`, + # which translates spawn (`GitSpawnError`), timeout (`GitTimeoutError`) and + # decode faults into this one taxonomy, and a non-zero rc into a plain + # `GitError`. Still swallowed rather than raised — a project that is not a + # git repo must not fail re-arm — but no longer SILENT: the degrade is the + # difference between "the re-drive starts from the resolution" and "it + # rebuilds against the tree the human just corrected away", and the + # re-stamp below now refuses to paper over it. journal.append( - "rearm-baseline-restamp-skipped", + "rearm-baseline-advance-failed", story_key=key, - spec_file=str(spec_path), - baseline=task.baseline_commit or "", + repo=str(repo), + baseline=old_baseline or "", + error=f"{e.__class__.__name__}: {e}", ) - elif advanced and task.baseline_commit: - try: - # Read through the same reader both consumers of a claimed baseline use, - # so what gets journalled as "overwritten" is the value the gate would - # have judged — not whichever key happened to be inspected here (#716). - # - # INSIDE the try, with the write it describes. `read_frontmatter` opens - # the file itself, so an OSError here would otherwise escape as a - # traceback from the one block whose whole contract is to turn a spec - # this re-arm cannot move into an actionable `RearmError`. What it does - # NOT rescue: `read_frontmatter` DEGRADES an unparseable YAML block to - # `{}` rather than raising, so on such a spec `overwritten` is `""`, the - # guard below is falsy, and no divergence record is written even though - # the insert lands. That is the reader's deliberate observe-degrade - # contract, not something to defeat here — the value is unknowable, and - # inventing one would be worse than the silence. - overwritten = auto_dev_baseline_of(verify.read_frontmatter(spec_path)) - verify.set_frontmatter_field( - spec_path, - "baseline_revision", - task.baseline_commit, - confine_root=task_spec_root(task, state), - ) - except (OSError, UnicodeDecodeError, verify.FrontmatterWriteError) as e: - # FrontmatterWriteError joins the tuple rather than getting its own - # arm: the remedy is the same sentence ("fix the file"), and the - # exception already says which shape it could not move. What matters - # is that it aborts here — the stale-baseline hazard this block exists - # to close is exactly what a swallowed write would leave behind. - # - # ...and that the abort leaves the spec as this re-arm FOUND it. This is - # the LAST of the two refusals that can fire after a write has landed — - # the flip and the result strip are both behind us, `save_state` is not — - # so it carries the undo the sequenced refusals get for free (the other - # is the spec block's `(OSError, UnicodeDecodeError)` arm, which the - # strip raises through after the flip has published). Without - # it a spec with a movable `status:` beside an unmovable - # `baseline_revision:` came back flipped to the re-drive's status and - # stripped of the terminal result, while the run still called the story - # escalated. - _restore_rearmed_spec(spec_path, spec_before, task, state) - raise RearmError( - f"cannot re-stamp baseline_revision on {spec_path} " - f"({e.__class__.__name__}: {e}) — fix the file, then re-run resolve" - ) from e - if overwritten and overwritten != old_baseline: - # Compared against `old_baseline` — what the RUN recorded for the - # escalated attempt — NOT against `task.baseline_commit`, which the - # advance above has already moved to the new HEAD. Measuring against the - # advanced value made this fire on every ordinary from-scratch re-arm - # whose resolve session committed anything: the spec and the run agreed - # exactly, and the operator was still told they diverged. A record that - # fires on the routine case is the "trains the operator to scroll past - # the meaningful one" failure the `restore` split exists to prevent. - # - # What survives is the real signal, on BOTH legs: the spec claimed a - # baseline the run never recorded. That is the only trace left of a - # divergence the gate can no longer report, because the re-stamp is - # about to normalize it away. + else: + task.baseline_commit = head + task.baseline_untracked = untracked + advanced = True + + # Re-stamp the spec's own baseline to the advanced one, on BOTH re-drive legs. + # + # The patch-restore leg needs it because the in-review route skips step-03 — + # the only step that stamps `baseline_revision` — so without it the re-driven + # step-04 would build its review diff (and, on an intent-gap/bad-spec + # re-triage, revert) "since" the ORIGINAL pre-attempt sha, clawing back the + # very resolve-session commits the advance above just blessed as the re-drive's + # starting point. + # + # The from-scratch leg gets it too (#640a). Its step-03 re-stamps the key + # itself, so the write is redundant on the happy path — but only ON that path: + # until step-03 runs, the spec carries the escalated attempt's sha, and every + # gate that reads a claimed baseline before then reads a stale one. The cost is + # recorded rather than hidden: re-stamping removes the gate's INDEPENDENT + # signal on this leg (it then compares a value the orchestrator itself wrote), + # so a claim that genuinely diverged is journalled on the way out instead of + # being silently normalized. + # + # Gated on `advanced`, not on truthiness of `task.baseline_commit`: a failed + # advance leaves the OLD sha in that field, which passes a truthiness test + # identically to a freshly advanced one. Writing it would make spec and task + # agree on a stale value — the one state in which nothing downstream can tell + # that the advance never happened, and the re-drive rebuilds from the wrong + # point with no error anywhere. Skipping keeps the failure legible (the degrade + # is journalled above) and keeps re-arm non-fatal outside a repo. + # + # Loud on WRITE failure: a silently stale spec baseline is exactly the hazard + # being closed. + # + # Guarded on `is_file` FIRST, because a spec this process cannot reach is not a + # write failure here — it is a SILENT one. Both frontmatter writers answer such a + # path with `False` rather than an exception (`verify.set_frontmatter_status`, + # `verify.set_frontmatter_field`), so without a check the re-stamp no-ops with + # nothing on the record and the spec keeps the escalated attempt's sha. + # + # `task_spec_path` re-anchors the recorded path before we get here, which is what + # makes `is_file` mean what it says. Resolved raw it meant something else and worse: + # `spec_file` is persisted RELATIVE to the worktree for an isolated task, and the + # main checkout carries the same layout, so the check passed on the wrong file and + # the write landed there. The restore leg cannot reach any of this (its precondition + # rejects a truthy `task.worktree_path`); the from-scratch leg has no such guard, + # which is exactly why that precondition has to exist. + # + # `is_file` is necessary but not sufficient: a spec that EXISTS with no frontmatter + # block also returns `False` from both writers. That shape is caught by the flip's + # `flipped` check above and, here, by `overwritten` staying empty. + if task.spec_file: + spec_path = task_spec_path(task, state) + if not spec_path.is_file(): + # OUTSIDE the `advanced` gate on purpose. Nesting this record inside it + # made the two #640 legs shadow each other: on a project that is not a + # repo the advance fails, `advanced` is False, and an unreadable spec + # then produced NO record at all — the journal blamed git while the + # status flip above had silently no-opped for an entirely different + # reason. The two degrades compose; they do not substitute. journal.append( - "rearm-baseline-restamped", + "rearm-baseline-restamp-skipped", story_key=key, spec_file=str(spec_path), - overwritten=overwritten, - baseline=task.baseline_commit, - restore=bool(restore_patch), + baseline=task.baseline_commit or "", ) + elif advanced and task.baseline_commit: + try: + # Read through the same reader both consumers of a claimed baseline use, + # so what gets journalled as "overwritten" is the value the gate would + # have judged — not whichever key happened to be inspected here (#716). + # + # INSIDE the try, with the write it describes. `read_frontmatter` opens + # the file itself, so an OSError here would otherwise escape as a + # traceback from the one block whose whole contract is to turn a spec + # this re-arm cannot move into an actionable `RearmError`. What it does + # NOT rescue: `read_frontmatter` DEGRADES an unparseable YAML block to + # `{}` rather than raising, so on such a spec `overwritten` is `""`, the + # guard below is falsy, and no divergence record is written even though + # the insert lands. That is the reader's deliberate observe-degrade + # contract, not something to defeat here — the value is unknowable, and + # inventing one would be worse than the silence. + overwritten = auto_dev_baseline_of(verify.read_frontmatter(spec_path)) + verify.set_frontmatter_field( + spec_path, + "baseline_revision", + task.baseline_commit, + confine_root=task_spec_root(task, state), + ) + except (OSError, UnicodeDecodeError, verify.FrontmatterWriteError) as e: + # FrontmatterWriteError joins the tuple rather than getting its own + # arm: the remedy is the same sentence ("fix the file"), and the + # exception already says which shape it could not move. What matters + # is that it aborts here — the stale-baseline hazard this block exists + # to close is exactly what a swallowed write would leave behind. + # + # The abort still leaves the spec as this re-arm FOUND it, but no longer + # by an undo written here: the transaction guard around this whole window + # rolls the spec back on every fault that escapes it, so this arm only has + # to raise. Without that rollback a spec with a movable `status:` beside an + # unmovable `baseline_revision:` came back flipped to the re-drive's status + # and stripped of the terminal result, while the run still called the story + # escalated. + raise RearmError( + f"cannot re-stamp baseline_revision on {spec_path} " + f"({e.__class__.__name__}: {e}) — fix the file, then re-run resolve" + ) from e + if overwritten and overwritten != old_baseline: + # Compared against `old_baseline` — what the RUN recorded for the + # escalated attempt — NOT against `task.baseline_commit`, which the + # advance above has already moved to the new HEAD. Measuring against the + # advanced value made this fire on every ordinary from-scratch re-arm + # whose resolve session committed anything: the spec and the run agreed + # exactly, and the operator was still told they diverged. A record that + # fires on the routine case is the "trains the operator to scroll past + # the meaningful one" failure the `restore` split exists to prevent. + # + # What survives is the real signal, on BOTH legs: the spec claimed a + # baseline the run never recorded. That is the only trace left of a + # divergence the gate can no longer report, because the re-stamp is + # about to normalize it away. + journal.append( + "rearm-baseline-restamped", + story_key=key, + spec_file=str(spec_path), + overwritten=overwritten, + baseline=task.baseline_commit, + restore=bool(restore_patch), + ) - save_state(run_dir, state) + save_state(run_dir, state) + except BaseException as e: + # Roll the spec back to the bytes this re-arm found, record that it aborted, and + # re-raise the ORIGINAL fault. A failed rollback raises out of here instead — + # a part-written spec is the loudest thing this can be, and the original fault + # rides along in that `RearmError`'s `__context__` because it is still being + # handled at the moment the restore raises. + # + # `BaseException` and not `Exception`, and the breadth is load-bearing rather + # than defensive: `KeyboardInterrupt` and `SystemExit` derive from `BaseException` + # alone, and this window spends most of its time in blocking I/O an operator can + # interrupt — three git subprocesses (`rev_parse_head`, `untracked_files`, + # `commits_above`) plus `save_state`, all AFTER the status flip has published and + # BEFORE anything persists it. A Ctrl-C there under `except Exception` would exit + # by the one path that reproduces exactly the DW-79/DW-83 state this guard exists + # to end: a spec re-armed on disk against a task still recorded as ESCALATED. + # Narrowing this arm is a silent regression, so a test raises `KeyboardInterrupt` + # through the window on purpose. + _rollback_rearm(journal, key, spec_path, spec_before, task, state, e) + raise journal.append( "story-escalation-resolved", story_key=key, @@ -4593,6 +4704,64 @@ def rearm_event_notice( "that divergence", "", ) + if kind == "rearm-aborted": + # ONE kind, THREE renderings, told apart by `rollback` — a field the producer + # writes because this reader runs out of process and cannot look at the spec to + # see what is on disk. The split is by what the surface may CLAIM about the file, + # not by how the re-arm failed: + # + # * `restored` / `unchanged` — `_restore_rearmed_spec` either put a landed write + # back or READ the file and proved it byte-equal. Both license "left exactly as + # the re-arm found it", so they share a message. + # * `failed` — the restore could not write, so the spec may be half-written and + # no re-run of resolve can settle it. + # * anything else — `unknown`, an absent field, or a value this table does not + # recognize. Says what is TRUE regardless (nothing persisted, still escalated) + # and claims nothing about the file. The default is deliberately the + # non-reassuring branch: an unknown outcome rendered as the benign one is how a + # sentinel-clear abort came to describe a DELETED file as untouched, and a + # record written by a future producer must not inherit a reassurance by + # accident. + # + # Position-independent wording, because the same string renders as a `resolve` + # stderr line and as a TUI toast, and the TUI drops the `next_step`: the message + # alone has to carry everything an operator must act on. That is why `failed` + # names the restore-from-git remedy in the MESSAGE and keeps it in `next_step` + # too — this is the one re-arm kind whose imperative is not moot on the TUI, + # since an abort raises and that surface does not go on to resume. + # + # Neither surface may read this as a re-arm that half-succeeded — an abort raises, + # so `rearm_holds_the_resume` is deliberately NOT extended to this kind. There is + # no gesture left to hold. + spec = entry.get("spec_file", "") or "(none)" + error = entry.get("error", "?") + rollback = str(entry.get("rollback", "")) + if rollback == "failed": + # No enumeration of WHICH writes landed. A fault raised inside + # `strip_auto_run_result` reaches the guard with the flip published and the + # `## Auto Run Result` section still present, so the old sentence ("carrying + # this re-arm's status flip and missing its `## Auto Run Result` section") + # described a state this record cannot know it is in. + return ( + "warning", + f"the re-arm ABORTED ({error}) and putting the spec back FAILED — {spec} " + "may be left part-written, so restore it from git before re-running " + "resolve; nothing was persisted and the story is still escalated", + "Restore the spec from git, then re-run resolve", + ) + if rollback in ("restored", "unchanged"): + return ( + "warning", + f"the re-arm ABORTED ({error}) — nothing was persisted, the spec ({spec}) " + "was left exactly as the re-arm found it, and the story is still escalated", + "Fix the cause above, then re-run resolve", + ) + return ( + "warning", + f"the re-arm ABORTED ({error}) — nothing was persisted and the story is still " + f"escalated, but the re-arm could not confirm what it left on disk ({spec})", + "Check the recorded spec, then re-run resolve", + ) return None diff --git a/tests/test_cli.py b/tests/test_cli.py index ebba95a4..2c81aac5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2962,20 +2962,37 @@ def fake_rearm( def test_resolve_echoes_the_residue_even_when_the_rearm_aborts(tmp_path, monkeypatch, capsys): - """An abort is when the residue matters MOST, so the echo lives in a `finally`. - - `runs._stale_restore_residue` journals BEFORE the re-stamp block that raises - `RearmError`, so on that path the records are already on disk when the abort - happens — and an echo placed after an early `return 1` threw away records the - re-arm had genuinely written. The one it threw away is the one that cannot be - recovered from anywhere else: `stale-restore-commits` names commits now sitting - below a baseline the operator is looking at in a half-run re-arm, and nothing - but this line will tell them. The failure and the residue are both true, and the - operator needs both to decide what to do with the tree. + """An abort is when the residue matters MOST, so the echo lives in a `finally` — and + since the whole re-arm window became one transaction, the echo has to say the re-arm + ABORTED as well as what it left behind (DW-85). + + `runs._stale_restore_residue` journals BEFORE anything that can raise past it, so on + an abort those records are already on disk when the fault happens — and an echo + placed after an early `return 1` threw away records the re-arm had genuinely written. + The one it threw away is the one that cannot be recovered from anywhere else: + `stale-restore-commits` names commits now sitting below a baseline the operator is + looking at, and nothing but this line will tell them. + + But that line alone MISDESCRIBES the tree once the rollback exists. It says files + were "excluded from the re-drive baseline" and commits "sit below the re-drive's new + baseline" — a baseline `save_state` never persisted, because the transaction rolled + the whole window back. The residue records are true observations of what the re-arm + LOOKED at and false as a description of what it LEFT, so `rearm-aborted` is journalled + from the rollback and echoed beside them: the operator needs both, and needs to know + which one describes the disk. + + The fake journals the two records in the order the real path writes them (the residue + pass, then the abort record from `_rollback_rearm`'s `finally`) and then raises, which + is what makes the echo's one walk over the new entries the thing under test. Ablation (residue echo): move `_echo_rearm_events` out of the `finally` back under the `try` and the commits assertion reddens while the `error:` line still prints. + Ablation (abort line): drop the `rearm-aborted` arm from `runs.rearm_event_notice` + and the "still escalated" assertion reddens while the commits line still prints — + which is exactly the DW-85 state, a true residue notice with nothing saying it + describes a baseline that was never saved. + Ablation (success output): deleting the gate outright does NOT grade the last assertion. Drop `return 1` from `cmd_resolve`'s `except runs.RearmError` arm and the success line does leak to stdout, but `main` then answers 0 and the exit-code @@ -2996,6 +3013,14 @@ def fake_rearm( Journal(rd).append( "stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c1", "c2"] ) + # ...and then the rollback's own record, from `_rollback_rearm`'s `finally` + Journal(rd).append( + "rearm-aborted", + story_key=key, + spec_file="/p/specs/s1.md", + error="OSError: [Errno 28] No space left on device", + rollback="restored", + ) raise runs.RearmError("could not re-stamp the spec baseline") monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) @@ -3007,6 +3032,11 @@ def fake_rearm( out, err = capsys.readouterr() assert "error: could not re-stamp the spec baseline" in err # the abort still reports assert "2 commit(s) sit below the re-drive's new baseline (ffffffffffff..)" in err + # ...and the line that says the baseline those commits sit below was never persisted + assert "the re-arm ABORTED" in err + assert "nothing was persisted" in err + assert "still escalated" in err + assert "/p/specs/s1.md" in err assert "re-armed" not in out # ...and the failure is not dressed up as a success diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index fc869863..a82e2699 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -599,12 +599,19 @@ def test_rearm_records_leak_neither_the_code_root_nor_a_spec_name(): assert restamped["overwritten"] != restamped["baseline"] assert restamped["restore"] is False # a plain flag still ships - # The OTHER three kinds `runs.rearm_escalation` journals `spec_file` on. Routing is + # The OTHER four kinds the re-arm family journals `spec_file` on. Routing is # by field NAME, so these ride the same `_JOURNAL_ALIAS_FIELDS` entry as # `rearm-baseline-restamped` and are correct today for free — which is exactly why # they belong in the sweep: the canary is what catches a field added to one of - # these kinds later, and a sweep that covers two of four grades the routing of a + # these kinds later, and a sweep that covers two of five grades the routing of a # record shape nobody re-checks. + # + # `rearm-aborted` is the fifth and the one written by a DIFFERENT function + # (`runs._rollback_rearm`, from the transaction guard's error path) rather than by + # `rearm_escalation` itself — the divergence that made the routing entry's own + # producer note undercount. It carries two fields the others do not: `error`, which + # the free-text drop set reaches, and `rollback`, a literal enum string that is + # declared benign rather than routed and must therefore still ship VERBATIM. siblings = [ diagnostics._scrub_entry( {"ts": 3.0, "kind": kind, "story_key": STORY_KEY, "spec_file": SPEC_ABS, **extra}, @@ -616,11 +623,20 @@ def test_rearm_records_leak_neither_the_code_root_nor_a_spec_name(): ("rearm-spec-write-unreachable", {"target_branch": REARM_BRANCH}), ("rearm-spec-flip-skipped", {"status": "ready-for-dev"}), ("rearm-baseline-restamp-skipped", {"baseline": SHA}), + ( + "rearm-aborted", + {"error": f"OSError: cannot write {HOME_PATH}/spec.md", "rollback": "restored"}, + ), ) ] # every one of them aliases to the SAME alias as the restamped record above: one # spec, one alias, however many kinds carry it - assert [s["spec_file"] for s in siblings] == [alias, alias, alias] + assert [s["spec_file"] for s in siblings] == [alias, alias, alias, alias] + # the abort record's own two fields: the free-text one is dropped (it quotes a host + # path back), the enum one is deliberately NOT aliased — both surfaces read the + # record for `rollback`, so pseudonymizing it would destroy the field's whole point + assert "error" not in siblings[3] and siblings[3]["error_present"] is True + assert siblings[3]["rollback"] == "restored" assert [orig for ns, orig, _a in pseudo.entries() if ns == "spec"] == [SPEC_NAME] # `rearm-spec-write-unreachable` names the branch the re-drive cuts its replacement diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 4835e976..baf96f23 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -344,6 +344,12 @@ "restore", "returncode", "role", + # The outcome of an aborted re-arm's spec rollback (`rearm-aborted`), one of + # three literal enum strings the producer chooses. Benign rather than routed: + # it names no customer artifact and IS the field both operator surfaces read + # the record for, so an alias would destroy it (the failure + # `_JOURNAL_KIND_ALIAS_FIELDS`' `target` row documents in the other direction). + "rollback", "run_id", "run_type", "security_config_changed", diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 53f48e86..76052ee4 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -1680,10 +1680,15 @@ def test_rearm_restores_the_spec_when_the_baseline_restamp_aborts(tmp_path): to `ready-for-dev` and stripped of the `## Auto Run Result` section the next resolve session reads as its context — the one edit nothing else records. - Ablation: drop the `_restore_rearmed_spec(...)` call from the re-stamp's except arm - and this reddens on the byte comparison (the status flip and the strip both stand), - while the `RearmError` and the ESCALATED phase keep passing — which is exactly why - those two alone do not grade this. + The undo is no longer written into the re-stamp's own `except` arm: the whole window + from the first spec write to `save_state` is one transaction, and its guard rolls the + spec back for every fault that escapes — this one included. What the arm still owns is + the `RearmError` and its remedy. + + Ablation: delete the `except BaseException` arm from `rearm_escalation` and this + reddens on the byte comparison (the status flip and the strip both stand), while the + `RearmError` and the ESCALATED phase keep passing — which is exactly why those two + alone do not grade this. """ old_head = _resolve_repo(tmp_path) spec = tmp_path / "spec.md" @@ -1731,9 +1736,11 @@ def test_rearm_restores_the_spec_when_the_result_strip_faults(tmp_path, monkeypa reddens the flip first and leaves nothing to restore. The injection stands in for the faults above, which are real and are exactly what the atomic writers exist for. - Ablation: drop the `_restore_rearmed_spec(...)` call from that arm and this reddens on - the byte comparison alone — the `RearmError` and the ESCALATED phase both still pass, - since the flip landing is precisely what neither observes. Both of those assertions + Ablation: delete the `except BaseException` arm from `rearm_escalation` — the + transaction guard that now performs this undo, in place of the per-arm call this test + used to grade — and it reddens on the byte comparison alone. The `RearmError` and the + ESCALATED phase both still pass, since the flip landing is precisely what neither + observes. Both of those assertions are load-bearing for that claim, so both stay in THIS test: an isolated sibling row was once inserted between them and silently adopted the phase check, leaving this docstring citing an assertion the test no longer made. @@ -4093,6 +4100,78 @@ def test_rearm_event_notice_splits_the_flip_skip_on_the_refusal(): assert step == "" +def test_rearm_event_notice_splits_the_abort_three_ways_on_the_rollback(): + """One kind, THREE renderings, and the split is by what the surface may CLAIM about + the file — not by how the re-arm failed. + + Nothing else grades this. The CLI's abort-echo test asserts "the re-arm ABORTED", + "nothing was persisted", "still escalated" and the spec path, and every one of those + is true of the `failed` message too — so replacing the discriminator with `if False:` + was SILENT across the whole suite while an operator holding a part-written spec was + told it had been "left exactly as the re-arm found it". A row that reads the table + directly is the only place the three can be compared. + + The unrecognized-value leg is the load-bearing one. `unknown` is a real producer + answer (the sentinel-clear leg, and a spec the undo could not read), an absent field + is what a record from an older or future producer looks like, and neither may inherit + the reassuring branch by falling through to it. So the default is the branch that + claims nothing, and the assertions below say that in the strongest available form: + the "left exactly as the re-arm found it" sentence appears on `restored`/`unchanged` + and NOWHERE else. + + next_step is graded beside the message for `rearm-spec-flip-skipped`'s reason above — + it is the half that costs an operator time — and for one this kind adds: the TUI + drops next_step entirely, so `failed`'s restore-from-git remedy has to survive in the + MESSAGE as well. That is asserted on the message, not just on the step. + + Ablations, each run: replace the `rollback == "failed"` test with `if False:` and the + `failed` assertions redden; replace the `rollback in ("restored", "unchanged")` test + with `if True:` and the `unknown`/absent assertions redden; drop "restore it from git" + from the `failed` MESSAGE (keeping next_step) and the TUI-reachability assertion + reddens alone. + """ + entry = { + "kind": "rearm-aborted", + "spec_file": "/p/specs/s1.md", + "error": "OSError: [Errno 28] No space left on device", + } + left_as_found = "left exactly as the re-arm found it" + + _, failed_msg, failed_step = runs.rearm_event_notice({**entry, "rollback": "failed"}) + _, restored_msg, restored_step = runs.rearm_event_notice({**entry, "rollback": "restored"}) + _, unchanged_msg, _ = runs.rearm_event_notice({**entry, "rollback": "unchanged"}) + _, unknown_msg, unknown_step = runs.rearm_event_notice({**entry, "rollback": "unknown"}) + _, absent_msg, absent_step = runs.rearm_event_notice(entry) + _, future_msg, _ = runs.rearm_event_notice({**entry, "rollback": "something-new"}) + + # every rendering states the two facts that are true whatever happened + for msg in (failed_msg, restored_msg, unchanged_msg, unknown_msg, absent_msg, future_msg): + assert "the re-arm ABORTED" in msg + assert "nothing was persisted" in msg + assert "still escalated" in msg + + # ...and ONLY the two outcomes that proved it say the file is intact + assert left_as_found in restored_msg and left_as_found in unchanged_msg + assert left_as_found not in failed_msg + assert left_as_found not in unknown_msg + assert left_as_found not in absent_msg + assert left_as_found not in future_msg # an unrecognized value defaults to NOT reassuring + + # `failed` is the only one that can leave a part-written spec, and its remedy has to + # reach a TUI operator, which never sees next_step + assert "may be left part-written" in failed_msg + assert "restore it from git" in failed_msg + assert failed_step == "Restore the spec from git, then re-run resolve" + # ...and it does NOT enumerate which writes landed: a fault inside + # `strip_auto_run_result` reaches the guard with the flip published and the section + # still present, so an enumeration would describe a state this record cannot know + assert "## Auto Run Result" not in failed_msg + + # the three next_steps are distinct remedies, not one sentence reused + assert len({failed_step, restored_step, unknown_step}) == 3 + assert absent_step == unknown_step # an absent field IS the unknown outcome + + def test_rearm_holds_the_resume_only_on_the_record_that_proves_a_wedge(): """The hold is PROOF, not urgency — and it is asked of every kind the table knows. diff --git a/tests/test_runs.py b/tests/test_runs.py index b041b403..5900b3e8 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3066,12 +3066,30 @@ def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): def test_rearm_does_not_swallow_a_non_git_fault_from_the_commits_probe(monkeypatch, tmp_path): - """Only Git faults are warn-only; programming faults must escape. - - Ablation: widen the catch back to ``Exception`` and this fails with - ``DID NOT RAISE``, directly grading the narrowing rather than its old behavior. + """Only Git faults are warn-only; programming faults must escape — and the re-arm + they escape from leaves NOTHING behind. + + The propagation half graded the narrowing and stopped there, which made it silent on + the state the escape left: this probe runs after the status flip and the + `## Auto Run Result` strip have both published and before `save_state`, so the fault + used to exit with the spec re-armed on disk against a task the run still calls + ESCALATED — the one edit nothing else records (DW-79/DW-83). The window is one + transaction now, so the same fault also has to come back byte-identical. + + `generation` and `restore_patch` are read from the RELOADED task, not the object the + call mutated: `rearm_escalation` bumps both in memory long before the guard, and + `save_state` is the only thing that would have made them true. Asserting on the + in-memory task would pass with the whole transaction deleted. + + Ablations: widen the catch back to ``Exception`` and this fails with + ``DID NOT RAISE``, grading the narrowing; delete the `except BaseException` arm from + `rearm_escalation` and the spec-bytes assertion reddens while the raise still passes. """ - run_dir, _spec, _patch = _stale_restore_tree(tmp_path) + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + was = load_state(run_dir).tasks["1-1-a"] def boom(repo, baseline): raise MemoryError("not a git answer") @@ -3080,6 +3098,300 @@ def boom(repo, baseline): with pytest.raises(MemoryError, match="not a git answer"): runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + assert spec.read_bytes() == before # flip AND strip both undone + task = load_state(run_dir).tasks["1-1-a"] + assert task.phase == Phase.ESCALATED # nothing was persisted, so it is still armed + assert task.generation == was.generation + assert task.restore_patch == was.restore_patch + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "restored" # a published write really was put back + assert "MemoryError" in aborted["error"] + assert aborted["spec_file"] == str(spec) + + +def test_rearm_rolls_back_when_save_state_itself_fails(monkeypatch, tmp_path): + """`save_state` IS the commit point, so a fault raised BY it is the sharpest case + the transaction exists for: every spec write has landed and the one thing that would + make them true has not. + + It was also outside every undo the function used to carry — those sat in two `except` + arms further up — so an ENOSPC here left the spec flipped and stripped while + `state.json` still described an escalated story, with nothing on the record at all. + + The state file is compared BYTE-for-byte rather than by reloading and checking the + phase: a phase check passes for every reason a write could be absent, while the bytes + also grade the `generation` bump and the cleared `defer_reason` riding in the same + object. + + Ablation: delete the `except BaseException` arm and the spec bytes and the abort + record both redden; the OSError still propagates, which is why it alone is no oracle. + """ + from bmad_loop.journal import STATE_FILE + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + state_before = (run_dir / STATE_FILE).read_bytes() + + def boom(run_dir_, state_): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(runs, "save_state", boom) + with pytest.raises(OSError, match="No space left on device"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == before + assert (run_dir / STATE_FILE).read_bytes() == state_before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "restored" + assert "OSError" in aborted["error"] + + +def test_rearm_rolls_back_when_the_window_is_interrupted(monkeypatch, tmp_path): + """The guard catches `BaseException`, and the breadth is load-bearing rather than + defensive — nothing else in the suite grades it. + + `KeyboardInterrupt` and `SystemExit` derive from `BaseException` alone, so narrowing + the arm to `except Exception` passes every other test while reopening the exact + DW-79/DW-83 state on the most ordinary operator gesture there is. The window is + mostly blocking I/O: three git subprocesses (`rev_parse_head`, `untracked_files`, + `commits_above`) and then `save_state`, all AFTER the status flip has published and + BEFORE anything persists it. A Ctrl-C in there under a narrowed arm exits with the + spec re-armed on disk against a task still recorded as ESCALATED. + + Raised from `save_state` because that is the last statement inside the guard, so the + interrupt lands at the widest point of the exposure — every spec write behind it and + the commit point not yet reached. + + Ablation (run): narrow the arm to `except Exception` and this reddens on the + spec-bytes assertion, which is simply the first of the four to run — remove the three + tree/state assertions and the abort record reddens behind them with + `ValueError: not enough values to unpack`, because with the arm narrowed no + `rearm-aborted` entry is written at all. The `KeyboardInterrupt` still propagates + either way, which is exactly why the raise alone is no oracle. + """ + from bmad_loop.journal import STATE_FILE + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + state_before = (run_dir / STATE_FILE).read_bytes() + + def interrupted(run_dir_, state_): + raise KeyboardInterrupt + + monkeypatch.setattr(runs, "save_state", interrupted) + with pytest.raises(KeyboardInterrupt): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == before + assert (run_dir / STATE_FILE).read_bytes() == state_before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "restored" + assert "KeyboardInterrupt" in aborted["error"] + + +def test_rearm_abort_on_the_sentinel_leg_claims_nothing_about_the_file(monkeypatch, tmp_path): + """The sentinel clear is the one in-window tree change the transaction does NOT undo, + so the abort record must not describe the tree as untouched. + + That leg deletes the sentinel rather than writing spec bytes, and re-creating it would + fight a gesture that is already safe to repeat — `_clear_sentinel` preserves a copy + under `{run_dir}/sentinels/` and a retried resolve re-clears it idempotently. What was + wrong was never the deletion; it was the CLAIM. `spec_before` is `None` on this leg, + and folding that into `unchanged` made the notice name a file this re-arm had just + DELETED as proof nothing moved. + + So the outcome is `unknown`, and the rendering is graded here rather than only the + field: the field is what the producer wrote, the sentence is what the operator reads. + + Ablation (run): make `_restore_rearmed_spec` answer `"unchanged"` for + `original is None` and this reddens on the recorded `rollback` first; drop that one + assertion and the RENDERED message reddens behind it, on a notice that now reads + "(…1-1-a-unresolved.md) was left exactly as the re-arm found it" about a file this + re-arm deleted. Both halves are asserted because the field and the sentence are + different claims. The deletion assertions keep passing throughout, which is why they + alone do not grade this. + """ + from bmad_loop.journal import STATE_FILE + from bmad_loop.model import Phase + + sentinel = tmp_path / "1-1-a-unresolved.md" + sentinel.write_text("---\nstatus: blocked\n---\n\nplanning halted\n", encoding="utf-8") + run = escalated_run( + tmp_path, + "r1", + story_key="1-1-a", + source="stories", + sentinel_kind="unresolved", + spec_file=str(sentinel), + git_project=True, + ) + run_dir = run.run_dir + state_before = (run_dir / STATE_FILE).read_bytes() + + def boom(run_dir_, state_): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(runs, "save_state", boom) + with pytest.raises(OSError, match="No space left on device"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + # the deletion STANDS — it is deliberately outside the transaction — and the + # preserved copy is why that is safe + assert not sentinel.exists() + assert (run_dir / "sentinels" / sentinel.name).is_file() + # ...while everything the transaction DOES cover was rolled back + assert (run_dir / STATE_FILE).read_bytes() == state_before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "unknown" + _severity, message, _next_step = runs.rearm_event_notice(aborted) + assert "the re-arm ABORTED" in message + assert "still escalated" in message + # the whole point: no claim about a file that is no longer there + assert "left exactly as the re-arm found it" not in message + + +def test_rearm_rolls_back_when_a_mid_window_journal_append_fails(monkeypatch, tmp_path): + """A `journal.append` inside the residue pass is an ordinary file write and can fail + like one — and it is the fault source furthest from anything that looks like a spec + write, which is exactly why no per-arm undo ever covered it. + + Only the residue kind is made to fail, so the abort record itself can still land: the + point being graded is that a fault from a helper that writes no spec still rolls the + spec back and still says so. + + Ablation: delete the `except BaseException` arm and the spec-bytes assertion reddens. + """ + from bmad_loop.journal import Journal + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + real_append = Journal.append + + def flaky(self, kind, **fields): + if kind == "stale-restore-excluded": + raise OSError(5, "Input/output error") + return real_append(self, kind, **fields) + + monkeypatch.setattr(Journal, "append", flaky) + with pytest.raises(OSError, match="Input/output error"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "restored" + + +def test_rearm_records_unchanged_when_the_sequenced_refusal_fires(tmp_path): + """`rollback: "unchanged"` is a DIFFERENT fact from `"restored"`, and the surfaces + render it differently, so the flip's read-back refusal has to produce it. + + That refusal is sequenced ahead of every write — `set_frontmatter_status` decides it + cannot move a spec with no top-level `status:` before it writes anything, and the + `## Auto Run Result` strip is deliberately ordered after the check — so the guard + finds the spec exactly as `spec_before` captured it and rewrites nothing. Recording + that as `restored` would tell an operator a write had landed and been undone on the + one path where nothing was ever written. + + Ablation: make `_restore_rearmed_spec`'s "bytes equal to `original`" arm return True + — the arm this path actually takes — and this reddens on the `rollback` value alone, + while every other assertion still passes. Its `original is None` arm does NOT grade + this row: the spec here is readable, so `spec_before` is set and that arm never runs. + """ + from bmad_loop.model import Phase + + run_dir, spec = _escalated_run( + tmp_path, "---\ntitle: t\n---\n\n## Intent\n\nbody\n\n## Auto Run Result\n\nx\n" + ) + before = spec.read_bytes() + + with pytest.raises(runs.RearmError, match="no frontmatter `status:`"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "unchanged" + assert "RearmError" in aborted["error"] + + +def test_rearm_reports_a_rollback_that_itself_failed_and_keeps_the_original_fault( + monkeypatch, tmp_path +): + """A restore that cannot write leaves a HALF-WRITTEN spec, which is the loudest thing + this can be — so it raises through the guard rather than degrading, and the abort + record says `failed` so both surfaces print the "restore it from git" remedy instead + of "the spec was left as the re-arm found it". + + The chain is the other half of the claim. `_restore_rearmed_spec` raises WHILE the + original fault is being handled, so that fault rides in `__context__` and the + operator sees both causes rather than a `RearmError` that has erased the reason the + re-arm aborted in the first place. + + Only the restore's writer is broken: the flip and the strip go through `verify` and + `devcontract`, so this injection cannot pre-empt the writes it is meant to fail to + undo. + + Ablation: move the abort record out of `_rollback_rearm`'s `finally` into its success + path and the `failed` row disappears entirely — the raise still propagates, which is + why the record and not the exception is what grades this. + """ + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + + def no_space(*_a, **_kw): + raise OSError(28, "No space left on device") + + def probe_boom(repo, baseline): + raise MemoryError("not a git answer") + + monkeypatch.setattr(runs.verify, "commits_above", probe_boom) + monkeypatch.setattr(runs, "atomic_write_bytes_confined", no_space) + with pytest.raises(runs.RearmError, match="cannot restore") as excinfo: + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert str(spec) in str(excinfo.value) + chain = [] + exc: BaseException | None = excinfo.value + while exc is not None: + chain.append(exc) + exc = exc.__cause__ or exc.__context__ + assert any(isinstance(e, MemoryError) for e in chain) # the original fault survives + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "failed" + # the record names the fault the re-arm ABORTED on, not the one the rollback hit — + # the second is in the exception the operator already has + assert "MemoryError" in aborted["error"] + + +def test_ordinary_rearm_writes_no_abort_record(tmp_path): + """The negative side of the transaction: a re-arm that reaches `save_state` must + leave no trace of a rollback that never happened. + + An abort record on a successful re-arm would print "nothing was persisted, the story + is still escalated" beside `re-armed ` on the very same stderr — the + contradiction that trains an operator to stop reading the warnings. + + Ablation: move the `_rollback_rearm(...)` call out of the `except` arm into a + `finally` and this reddens. + """ + from bmad_loop.model import Phase + + run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert not _kinds(run_dir, "rearm-aborted") + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.PENDING + assert "## Auto Run Result" not in spec.read_text(encoding="utf-8") + assert "status: ready-for-dev" in spec.read_text(encoding="utf-8") + def test_archive_run(tmp_path): run_dir = _make_state_run(tmp_path, "20260611-100000-aaaa") diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 3d0754bc..f25a3035 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4998,7 +4998,10 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk already queued behind this toast. Ablation: make `runs.rearm_event_notice` return None for any one of these kinds - and this reddens on that kind's message alone. + and this reddens on that kind's message alone. Drop "restore it from git" from the + `rearm-aborted` `failed` MESSAGE while keeping it in that arm's `next_step` and only + the remedy assertion reddens — which is the point of grading it here rather than on + the CLI, where the dropped half is still printed. """ from bmad_loop import resolve, runs from bmad_loop.journal import Journal @@ -5030,6 +5033,22 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): spec_file="wt/specs/s1.md", status="ready-for-dev", ) + # `rearm-aborted` is journalled by `runs._rollback_rearm` from the transaction + # guard's error path. It rides this walk for its ROUTING, which is what this test + # grades — the rendering path is real on this surface either way, since a genuine + # abort reaches `_do_rearm`'s `finally` (and so this echo) BEFORE its + # `except RearmError` arm returns. The `failed` outcome is the one chosen on + # purpose: it is the single re-arm kind whose imperative is NOT moot here, because + # this path does not go on to resume, and this surface drops `next_step` — so the + # restore-from-git remedy has to survive in the MESSAGE or a TUI operator never + # gets it at all. + journal.append( + "rearm-aborted", + story_key=sk, + spec_file="wt/specs/s1.md", + error="OSError: [Errno 28] No space left on device", + rollback="failed", + ) return "ready-for-dev" monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) @@ -5071,6 +5090,11 @@ def severity_of(fragment: str) -> str: assert severity_of("could not be re-opened to `ready-for-dev`") == "warning" # `note` maps onto Textual's own channel name, not through unchanged assert severity_of("excluded the abandoned restore's new files") == "information" + # the abort record, and specifically the half that only the MESSAGE can carry on a + # surface with no `next_step`: without it a TUI operator is told the spec may be + # part-written and given no remedy for it + assert severity_of("may be left part-written") == "warning" + assert any("restore it from git" in n[0] for n in notes), notes # the CLI's trailing imperative is omitted here: the resume is already queued assert not any("before resuming" in n[0] for n in notes), notes assert any("re-armed 1" in n[0] for n in notes) # the ordinary notice still fires From 368302da8cffca128ba7b5651321f4b55f73ab8a Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 01:12:52 -0700 Subject: [PATCH 16/45] Close the re-arm transaction's remaining escapes from the spec flip window Re-drive of DW-79/DW-83/DW-85 against the corrected intent contract. The guard landed in 63ddb1f9; these are the paths that still left the spec published against an escalated task. - The abort record's own journal append absorbs any ordinary Exception, not just OSError, so a TypeError from it cannot replace the fault the record exists to report. KeyboardInterrupt/SystemExit still leave. - _restore_rearmed_spec selects its writer by the same lexical rule as the three writers it undoes. Calling the confined helper unconditionally refused exactly the out-of-root specs the flip and strip could still break. - The preimage capture refuses to publish a flip whose bytes it could not read from a file that is there AND that the re-drive reads, gated on the same pair as the flip's own refusal. - A save_state that demonstrably committed is no longer rolled back underneath. _rearm_commit_landed asks the disk, the only witness of a rename, and degrades to rolling back on any fault of its own. - The failed remedy names git or the operator's own copy; an untracked or out-of-checkout spec has no committed version to recover. Adds 12 rows across the abort paths, including the interrupt the abort append must not swallow and the failed outcome on the plain writer arm. --- CHANGELOG.md | 15 +- docs/FEATURES.md | 14 +- src/bmad_loop/frontmatter.py | 7 +- src/bmad_loop/runs.py | 221 ++++++++++++++--- tests/test_portability_guard.py | 3 +- tests/test_resolve.py | 207 ++++++++++++++-- tests/test_runs.py | 368 ++++++++++++++++++++++++++++- tests/test_sprintstatus_advance.py | 2 +- tests/test_tui_app.py | 14 +- 9 files changed, 785 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 486173fb..19b7bc9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,8 +241,19 @@ breaking changes may land in a minor release. residue notices they echo from a `finally` can no longer describe files as excluded from a baseline that was never saved, and an outcome the undo could not confirm (the cleared sentinel among them) reports the abort without claiming the file is intact. A rollback that - cannot write still raises, naming the possibly part-written spec, with the original fault - kept in the exception chain. + cannot write still raises, naming the possibly part-written spec to restore from git _or_ + your own copy — an untracked or out-of-checkout spec has no committed version to recover — + with the original fault kept in the exception chain. The undo picks its writer the same + lexical way the three spec writers it undoes do, so a spec in an artifacts folder configured + outside the checkout is restored rather than refused; it declines to write at all when the + spec's bytes could not be CAPTURED from a file that is there and that the re-drive will + actually read, since a transient read fault followed by successful writes would leave a + published flip with nothing to put back; and it leaves a re-arm whose `save_state` + demonstrably committed alone, because that rename can be interrupted on the way out and + undoing the spec beneath it mirrors the same defect. An ORDINARY failure of the abort + record's OWN journal write is suppressed whatever its type, so an observation that cannot + be made never replaces the fault the operator is being told about — an interrupt still + leaves, since by then the rollback has already run and the operator asked to stop. - Stop an LLM-authored preference escalation from aborting the review leg. `_review_and_commit` splats a review session's own `result.json` escalation entries into `journal.append`, so a diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d8212406..2eacea73 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -142,12 +142,22 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w back), `unchanged` (the file was read and PROVED byte-identical — a refusal sequenced ahead of every write), `failed` (the restore itself could not write, so the spec may be part-written — that one raises rather than degrading, keeps the original fault in the exception chain, and - names the file to restore from git in both the message and its next step), or `unknown`. The - last covers everything the undo could not confirm, the cleared sentinel among them, and the + names the file to restore — from git _or_ your own copy, since the bytes it failed to write + are gone with the process and a spec is not necessarily tracked — in both the message and its + next step), or `unknown`. The last covers everything the undo could not confirm: the cleared + sentinel, a re-arm that resolved no spec path at all (the record then carries an empty + locator and the notice says `(none)`), and a spec that is gone or unreadable by the time the + undo looks, which it declines to re-create rather than fight whatever removed it. The surfaces then report that nothing was persisted and the story is still escalated WITHOUT claiming the file on disk is intact — an unconfirmed outcome must never render as the reassuring one. Without this record the surfaces described the residue of a re-arm that had been rolled back — files "excluded from the re-drive baseline" for a baseline never saved. + Two boundaries keep the undo honest. It refuses to write at all when it could not first + CAPTURE the spec's bytes from a file that is there — a transient read fault followed by + writes that succeed would otherwise leave a published flip with nothing to put back — and it + does NOT undo a re-arm whose `save_state` demonstrably committed, since that state write is + a single atomic rename whose call can still be interrupted on its way out, and rolling the + spec back underneath it would build the same defect mirrored. All of these warnings reach the TUI's re-arm as well as `resolve`'s — both route every kind through one shared table, so neither surface can silently learn a kind the other drops, though each still owns where it calls the echo from and the TUI drops the trailing "before diff --git a/src/bmad_loop/frontmatter.py b/src/bmad_loop/frontmatter.py index cb4e3efd..fdbf888d 100644 --- a/src/bmad_loop/frontmatter.py +++ b/src/bmad_loop/frontmatter.py @@ -455,8 +455,11 @@ def set_frontmatter_status(path: Path, status: str, *, confine_root: Path) -> bo 46-byte spec to 12. The write is CONFINED, and this is the canonical statement of the rule the - three spec writers share (`verify.set_frontmatter_field` and - `devcontract._atomic_write_spec` restate it by reference): + FOUR writers of a spec's bytes share (`verify.set_frontmatter_field` and + `devcontract._atomic_write_spec` restate it by reference; `runs._restore_rearmed_spec` + — the re-arm transaction's UNDO — implements it so it can put back exactly what the + other three published, and a spec they can write but it refuses is the transaction's + write set going unhonoured): * A spec path under ``confine_root`` goes through `platform_util.atomic_write_bytes_confined`, which walks the components diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 678d195b..36a03b3a 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -35,6 +35,7 @@ UnconfinedWriteError, _mkstemp_beside, atomic_replace, + atomic_write_bytes, atomic_write_bytes_confined, atomic_write_text_confined, create_exclusive_confined, @@ -3040,20 +3041,20 @@ def task_spec_root(task: StoryTask, state: RunState) -> Path: shape: `model._serialized_worktree_path` keeps a path verbatim exactly when `relative_to(worktree_path)` raises, so the two spellings did not share a prefix. Returning the worktree there would name a root that can never contain the path - `task_spec_path` passes through — the three `_atomic_write_spec` writers would - silently take the plain no-follow arm (losing #593's O_NOFOLLOW walk) and - `_restore_rearmed_spec`, which calls the confined writer directly, would RAISE. - - The project can often confine it. Where nothing can, the THREE `_atomic_write_spec` - writers land on the arm they already took — they select lexically, so an out-of-root - path simply takes the plain no-follow write as before. That is not true of every - writer: `_restore_rearmed_spec` calls `atomic_write_bytes_confined` DIRECTLY with no - lexical arm, so for a spec outside both the mount and the project — the shared + `task_spec_path` passes through — all FOUR writers of these bytes would silently take + the plain no-follow arm and lose #593's O_NOFOLLOW walk. + + The project can often confine it. Where nothing can, every writer of these bytes + lands on the arm it already took — they all select LEXICALLY, so an out-of-root path + simply takes the plain no-follow write as before. That parity is load-bearing and was + once broken: `_restore_rearmed_spec` called `atomic_write_bytes_confined` DIRECTLY + with no lexical arm, so for a spec outside both the mount and the project — the shared artifact dir `_spec_is_shared_with_the_redrive` treats as first-class and reachable — - it raises `UnconfinedWriteError` and the re-arm's undo is lost with the spec already - flipped and stripped. That asymmetry PRE-DATES this anchor (the previous body - returned the worktree there, which equally cannot confine the path) and is tracked - separately; it is named here so the paragraph is not read as covering it. + the flip, the strip and the re-stamp all LANDED while the re-arm's undo alone raised + `UnconfinedWriteError`, losing the rollback on precisely the specs it could still + break. A writer that refuses where its siblings write does not add safety here; it + subtracts the transaction. Do not re-introduce the asymmetry by "hardening" one of + the four in isolation. The arm is not unconditionally an improvement either, and that exception is graded by `test_task_spec_root_refuses_a_spec_the_project_cannot_reach`: `_atomic_write_spec` @@ -3519,13 +3520,27 @@ def _restore_rearmed_spec( Folding those into one "nothing had to be put back" answer is what made the notice overclaim, so the distinction lives in the return type rather than in a comment. - Byte-verbatim and CONFINED, matching the writes it undoes: `atomic_write_text_confined` - would re-encode and translate newlines, so a CRLF spec would come back subtly - different from the file this re-arm found, and an unconfined write would drop the - `O_NOFOLLOW` walk of the parent components (#593) that every other write to this path - takes. A restore that itself fails RAISES rather than degrading — the spec is then + Byte-verbatim, never the text writer: `atomic_write_text_confined` would re-encode and + translate newlines, so a CRLF spec would come back subtly different from the file this + re-arm found. + + And it picks its arm the SAME LEXICAL WAY the three writers it undoes do + (`frontmatter.set_frontmatter_status` states the rule; `verify.set_frontmatter_field` + and `devcontract._atomic_write_spec` restate it): under `confine_root`, through the + component-walking confined helper (#593); outside it, the plain `follow_symlinks=False` + write. Calling the confined helper unconditionally looked stricter and was strictly + worse — an artifacts folder configured OUTSIDE both the mount and the project is + supported configuration (`bmadconfig` resolves one, `verify.spec_within_roots` trusts + it, `_spec_is_shared_with_the_redrive` treats it as first-class), and there the flip, + the strip and the re-stamp all LAND while this undo alone raised + `UnconfinedWriteError`. The undo then reported `failed` on exactly the specs it was + able to break, which is the asymmetry `task_spec_root`'s docstring used to name as + out of scope. A restore that refuses where the writes succeeded is not extra safety; + it is the transaction's write set going unhonoured. + + A restore that itself fails RAISES rather than degrading — the spec is then half-written and only the operator can settle it, which is the loudest thing this can - be. `UnconfinedWriteError` is an `OSError`, so the one arm covers both. + be. `UnconfinedWriteError` is an `OSError`, so the one arm still covers both. """ if original is None: return "unknown" @@ -3534,14 +3549,23 @@ def _restore_rearmed_spec( return "unchanged" except OSError: return "unknown" + confine_root = task_spec_root(task, state) try: - atomic_write_bytes_confined(spec_path, original, confine_root=task_spec_root(task, state)) + if spec_path.is_relative_to(confine_root): + atomic_write_bytes_confined( + spec_path, original, confine_root=confine_root, require_writable_target=True + ) + else: + atomic_write_bytes( + spec_path, original, follow_symlinks=False, require_writable_target=True + ) except OSError as e: raise RearmError( f"cannot restore {spec_path} after a failed re-arm " f"({e.__class__.__name__}: {e}) — the spec may carry this re-arm's status " "flip and may have lost its `## Auto Run Result` section, while the story is " - "still escalated; restore the spec from git, then re-run resolve" + "still escalated; restore the spec from git or from your own copy, then " + "re-run resolve" ) from e return "restored" @@ -3579,10 +3603,21 @@ def _rollback_rearm( transaction covers is the spec's BYTES from the first spec write onward; the deletion is outside that, so the record must not claim the tree is as the re-arm found it. - The record is journalled through a `finally` and only its OSError is suppressed. - Recording an abort is an OBSERVATION, and an observation that cannot be made must - not replace the fault the operator is being told about — while a restore failure is - a repair write, and repair writes raise. + The record is journalled through a `finally` and every `Exception` from that append + is suppressed. Recording an abort is an OBSERVATION, and an observation that cannot + be made must not replace the fault the operator is being told about — while a restore + failure is a repair write, and repair writes raise. + + `Exception` and not `OSError`, which is the ONE place in this transaction where the + breadth is deliberately NARROWER than the guard's own `BaseException` and, at the + same time, wider than a filesystem taxonomy. Wider, because `Journal.append` + serializes caller-supplied values and opens a file: a `TypeError` or `ValueError` out + of `json.dumps`, or anything else this append can raise, would otherwise REPLACE the + fault the whole record exists to report — the `Always:` re-raise invariant the two + pinned `MemoryError` tests depend on. Narrower, because `KeyboardInterrupt` and + `SystemExit` must still leave: by the time this `finally` runs the rollback has + already completed, so an interrupt here cannot reproduce DW-79/DW-83, and discarding + the operator's Ctrl-C to keep a breadcrumb would be the worse trade. """ # `unknown` is the floor, not `unchanged`: a re-arm that resolved no spec path made # no claim about any file, and the surfaces must not manufacture one for it. @@ -3606,10 +3641,75 @@ def _rollback_rearm( error=f"{error.__class__.__name__}: {error}", rollback=rollback, ) - except OSError: + except Exception: # nosec B110 - the OBSERVATION must not replace the fault + # See the docstring: the ORIGINAL re-arm fault wins over ANY ordinary + # failure of this append, not just a filesystem one. `KeyboardInterrupt` + # and `SystemExit` are not `Exception` and still propagate. pass +def _rearm_commit_landed(run_dir: Path, story_key: str, task: StoryTask) -> bool: + """Did `save_state` already COMMIT this re-arm, despite the fault now unwinding? + + `journal.save_state` ends in `atomic_replace`, so the commit is a single rename that + either happened or did not — but the CALL can still fail after it: a `KeyboardInterrupt` + delivered between that rename and the return unwinds through the transaction guard + with `state.json` already describing a PENDING, re-armed task. Rolling the spec back + there does not restore the pre-re-arm world; it MANUFACTURES the mirror image of + DW-79/DW-83 — persisted state re-armed against a spec that is not — and then reports + it as "nothing was persisted, the story is still escalated", which is simply false. + + The guard cannot know this from control flow (no assignment after `save_state` runs + on that path), so it ASKS THE DISK, which is the only witness of a rename. Both the + bumped `generation` and the reset `phase` must match the object `save_state` was + handed: `generation` alone would be satisfied by a state file this call never wrote + only if some other writer had minted the same bump, and `phase` alone moves for + reasons a re-arm does not own. + + Degrades to `False` — roll back, the pre-existing behavior — on ANY failure to read + or parse the state file. This is observation feeding a repair decision, and the safe + default is the one that leaves the spec as the re-arm found it: a re-arm that did + NOT commit and is wrongly believed to have is the DW-79/DW-83 defect itself, while + the converse leaves a rolled-back spec beside committed state that the next resume + re-drives from a spec still carrying the escalated status — recoverable, and loud. + + ANY failure means `BaseException`, and that breadth is the whole reason this probe is + safe to call where it is called. Its ONE call site sits inside the transaction guard's + `except BaseException` arm and runs BEFORE `_rollback_rearm`, so a fault escaping this + function escapes the guard too and the rollback never happens — leaving exactly the + spec-flipped-against-an-ESCALATED-task state the guard exists to end, now reached by + the code added to prevent its mirror image. `load_state` reads and parses a file, so a + `KeyboardInterrupt` or `SystemExit` delivered anywhere in it is not hypothetical, and + under `except Exception` it took precisely that path. + + Swallowing an interrupt here is therefore the correct trade, and it is not a lost + Ctrl-C: the rollback is a REPAIR WRITE that must not be skipped, and the guard's own + `raise` still propagates the original re-arm fault immediately afterwards, so the + process still exits loudly — one spec-sized write later. This is the reverse of the + trade `_rollback_rearm`'s abort-record append makes, and the two are consistent + because the acts differ: recording is an observation and must never displace a fault, + while repairing is a write whose omission IS the defect. The abort record's append + remains the ONE place in this transaction whose breadth is narrower than the guard's. + + No `rearm-aborted` record is written on the committed path either (the caller skips + the whole rollback). Every rendering of that kind asserts that nothing was persisted; + there is no value of `rollback` that is true here, and inventing one would put a + false sentence on both operator surfaces rather than leave the fault to speak. + """ + try: + persisted = load_state(run_dir).tasks.get(story_key) + except BaseException: + # See the docstring: a fault escaping this probe escapes the guard arm that + # calls it and skips the rollback entirely, so an interrupt is absorbed here + # and the original fault still propagates from the guard's `raise` below. + return False + return ( + persisted is not None + and persisted.generation == task.generation + and persisted.phase == task.phase + ) + + def _redrive_spec_status(state: RunState, task: StoryTask, *, isolated_redrive: bool) -> str: """The spec's status AS THE RE-DRIVE WILL READ IT, or ``""`` when unprovable. @@ -4085,12 +4185,47 @@ def rearm_escalation( redrive="isolated" if isolated_redrive else "in-place", ) # Captured immediately before the FIRST write, so an abort further down can - # put the spec back exactly as found. Unreadable degrades to `None`: the - # writes below answer such a path with `False` rather than an exception, so - # there would be nothing to undo either. + # put the spec back exactly as found. + # + # A path that is NOT a file here degrades to `None`, and that degrade is + # sound for the reason it always was: every writer below answers such a + # path with `False` rather than an exception, so there is nothing to undo. + # A missing spec, a dangling link and a directory all land there. + # + # A path that IS a file whose bytes could not be read is the opposite + # case, and it must FAIL BEFORE WRITING. The read below is one syscall + # among many against a file three later writers open independently, so a + # transient fault (EIO on a network mount, a momentary EACCES, ENFILE + # under load) can be followed by writes that all succeed — and the abort + # that follows would then find `spec_before is None`, record `unknown`, + # and re-raise with the flip PUBLISHED and nothing put back. That is + # DW-79/DW-83 reached through the transaction's own preimage. Refusing + # keeps the escalation armed for a retry. + # + # Gated on the SAME PAIR as the flip's refusal one screen below + # (`spec_path.is_file() and write_reaches_the_redrive`), because it is the + # same abort-vs-warn decision about the same file and the two must not + # disagree. `is_file` alone is not enough: under isolation the readable + # file is the worktree copy the re-drive DESTROYS before reading anything, + # so an abort there demands a repair to a file nothing opens — its remedy + # cannot change what the re-drive reads, and it costs the operator the + # interactive resolve session over a spec whose real reachability record + # (`rearm-spec-write-unreachable`) has already been written above. On that + # shape the unreadable preimage is an OBSERVATION, and observations + # degrade: `spec_before` stays `None`, the writes below no-op or land on a + # doomed copy, and any later abort records `unknown` rather than claiming + # a file it never captured. try: spec_before = spec_path.read_bytes() - except OSError: + except OSError as e: + if spec_path.is_file() and write_reaches_the_redrive: + raise RearmError( + f"cannot read story spec {spec_path} before re-opening it for " + f"the re-drive ({e.__class__.__name__}: {e}) — the re-arm " + "refuses to write a spec it could not capture first, since a " + "later abort would have nothing to put back; fix or replace " + "the file, then re-run resolve" + ) from e spec_before = None try: flipped = verify.set_frontmatter_status( @@ -4465,7 +4600,16 @@ def rearm_escalation( # to end: a spec re-armed on disk against a task still recorded as ESCALATED. # Narrowing this arm is a silent regression, so a test raises `KeyboardInterrupt` # through the window on purpose. - _rollback_rearm(journal, key, spec_path, spec_before, task, state, e) + # + # That breadth is also what makes the commit point AMBIGUOUS on exactly one path, + # and the check below is the price of it: `save_state` commits by `atomic_replace` + # and can still be interrupted between that rename and its return, so a fault + # arriving here does NOT prove the transaction failed. `_rearm_commit_landed` asks + # the disk — the only witness of a rename — and a committed re-arm is left alone: + # undoing the spec then would build the mirror image of the defect this guard + # closes, persisted state re-armed against a spec that is not. + if not _rearm_commit_landed(run_dir, key, task): + _rollback_rearm(journal, key, spec_path, spec_before, task, state, e) raise journal.append( "story-escalation-resolved", @@ -4742,12 +4886,21 @@ def rearm_event_notice( # `## Auto Run Result` section still present, so the old sentence ("carrying # this re-arm's status flip and missing its `## Auto Run Result` section") # described a state this record cannot know it is in. + # + # Nor does it promise GIT alone. The bytes the undo failed to write lived only + # in this process and are gone with it, and a spec is not necessarily tracked: + # an untracked artifact, or one in an artifacts folder configured outside the + # checkout entirely (supported configuration — `bmadconfig` resolves one), has + # no committed copy to check out. Naming git as THE remedy sent that operator + # to a command with nothing to give them; naming it as ONE of two keeps the + # common case one word away without asserting a recovery that may not exist. return ( "warning", f"the re-arm ABORTED ({error}) and putting the spec back FAILED — {spec} " - "may be left part-written, so restore it from git before re-running " - "resolve; nothing was persisted and the story is still escalated", - "Restore the spec from git, then re-run resolve", + "may be left part-written, so restore it from git or from your own copy " + "before re-running resolve; nothing was persisted and the story is still " + "escalated", + "Restore the spec from git or your own copy, then re-run resolve", ) if rollback in ("restored", "unchanged"): return ( diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index baf96f23..3964584f 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -345,7 +345,8 @@ "returncode", "role", # The outcome of an aborted re-arm's spec rollback (`rearm-aborted`), one of - # three literal enum strings the producer chooses. Benign rather than routed: + # FOUR literal enum strings the producer chooses (`restored`, `unchanged`, + # `unknown`, `failed`). Benign rather than routed: # it names no customer artifact and IS the field both operator surfaces read # the record for, so an alias would destroy it (the failure # `_JOURNAL_KIND_ALIAS_FIELDS`' `target` row documents in the other direction). diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 76052ee4..c6e11d8b 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -782,6 +782,55 @@ def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive assert next_step +def test_rearm_completes_on_an_unreachable_spec_it_could_not_capture(tmp_path, monkeypatch): + """The preimage refusal is gated on the SAME pair as the flip's refusal, so a spec the + re-drive does not read keeps warn-and-continue. + + This is the isolated shape the row above builds: `task_spec_path` anchors the writes on + the mount, and a re-armed task's mount is discarded before the re-drive reads anything, + so the readable file is the copy that is destroyed. `rearm-spec-write-unreachable` has + already recorded that fact by the time the preimage is captured. + + Add one transient `EIO` on the first `read_bytes` of that spec and, gated on + `is_file()` ALONE, the re-arm aborted — demanding that the operator repair a file the + re-drive never opens, over a remedy that cannot change what it reads, at the cost of + the interactive resolve session. The unreadable preimage is an OBSERVATION on this + shape, and observations degrade: `spec_before` stays `None` and the re-arm completes. + + Its sibling `tests/test_runs.py::test_rearm_refuses_a_spec_whose_bytes_it_could_not_capture` + holds the other half — on a REACHABLE spec the same fault still refuses, because there + the write it is about to publish is the one the re-drive will read. + + Ablation: drop the `write_reaches_the_redrive` conjunct and this reddens with the + `RearmError` the reachable row expects, while that row stays green. + """ + _resolve_repo(tmp_path) + spec = tmp_path / "spec.md" + spec.write_text(SPEC, encoding="utf-8") + run_dir, _, _ = _escalated_run( + tmp_path, spec_file=str(spec), worktree_path=str(tmp_path / "wt" / "u1") + ) + real_read_bytes = Path.read_bytes + failed_once = [] + + def flaky(self): + if self == spec and not failed_once: + failed_once.append(1) + raise OSError(5, "Input/output error") + return real_read_bytes(self) + + monkeypatch.setattr(Path, "read_bytes", flaky) + + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + + assert failed_once # the fault really did land on the capture + assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.PENDING + # ...and the record that DOES describe this shape is still the one written + (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] + assert rec["spec_file"] == str(spec) + assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-aborted"] == [] + + def test_rearm_does_not_warn_about_unreachable_writes_without_a_worktree(tmp_path): """The control for the row above: the in-place case is where those writes DO land, so a record there would fire on every ordinary re-arm.""" @@ -1775,20 +1824,21 @@ def test_rearm_restores_an_isolated_tasks_spec_that_sits_outside_the_worktree( An absolute `spec_file` beside a set `worktree_path` means the spec is lexically OUTSIDE the mount (`model._serialized_worktree_path` keeps a path verbatim exactly when `relative_to(worktree_path)` raises) — the shape a shared artifact directory - produces. `_restore_rearmed_spec` calls `atomic_write_bytes_confined` DIRECTLY, so - a `confine_root` naming the worktree does not merely degrade the write the way the - three `_atomic_write_spec` writers do: it raises `UnconfinedWriteError`, which the - arm re-raises as "cannot restore ...". The operator was then left with the exact - state the undo exists to prevent — a spec carrying this re-arm's status flip and - stripped of its `## Auto Run Result`, on a story the run still calls ESCALATED — - plus a second error masking the first. - - `task_spec_root` now answers the project for that shape, which CAN confine the - spec, so the restore lands and the original fault is the one that surfaces. - - Ablation: revert `task_spec_root` to `Path(task.worktree_path or state.project)` - and this reddens twice — the `match=` fails on "cannot restore ... UnconfinedWrite - Error", and the byte comparison fails behind it. + produces. `task_spec_root` answers the PROJECT there rather than the mount, which CAN + confine this spec, so the undo takes its confined arm and lands, and the original + fault is the one that surfaces — instead of an `UnconfinedWriteError` re-raised as + "cannot restore ..." over a spec left carrying this re-arm's status flip and stripped + of its `## Auto Run Result`, on a story the run still calls ESCALATED. + + Ablation: revert `task_spec_root` to `Path(task.worktree_path or state.project)` AND + make `_restore_rearmed_spec` take `atomic_write_bytes_confined` unconditionally; this + then reddens twice, on the `match=` and on the byte comparison behind it. Both halves + are needed because either one alone now rescues the write, and that redundancy is + deliberate — the root moved for this shape (graded directly by + `test_task_spec_root_yields_the_project_when_the_worktree_cannot_confine_the_spec`) + and the undo later gained the same lexical arm its three sibling writers have, which + is what carries a spec outside BOTH roots + (`test_rearm_restores_a_spec_outside_every_root_it_could_be_confined_to`). """ _resolve_repo(tmp_path) wt = tmp_path / ".bmad-loop" / "runs" / "wt-mount" # the mount, which holds no spec @@ -1813,6 +1863,130 @@ def boom(spec_path, *, confine_root): assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED +def test_rearm_restores_a_spec_outside_every_root_it_could_be_confined_to(tmp_path, monkeypatch): + """The undo has to reach the spec wherever its three sibling writers reached it. + + An artifacts folder configured OUTSIDE the checkout is supported configuration — + `bmadconfig` resolves one, `verify.spec_within_roots` trusts it, and + `_spec_is_shared_with_the_redrive` treats a spec that lands there as first-class and + reachable by the re-drive. On that shape neither candidate root can confine the path: + the mount cannot, and neither can the project, so `task_spec_root`'s fallback names a + root the spec is lexically outside of. + + `frontmatter.set_frontmatter_status`, `verify.set_frontmatter_field` and + `devcontract._atomic_write_spec` all select their writer on that same lexical test and + simply take the plain no-follow arm, so the flip, the strip and the re-stamp LAND. + `_restore_rearmed_spec` called `atomic_write_bytes_confined` unconditionally, so the + undo alone raised `UnconfinedWriteError` — the transaction's write set going + unhonoured on exactly the specs it was still able to break, and the operator left with + a flipped, stripped spec on a story the run still called ESCALATED plus a second error + masking the first. A writer that refuses where its siblings write is not extra safety. + + The project deliberately sits UNDER `tmp_path` here so the spec can be a sibling of + it: that is the only way to build a path outside both roots without leaving the + fixture's tree. + + The fault is raised from `save_state` rather than from a git probe because it must be + reached unconditionally: `_stale_restore_residue` returns before touching git when the + task carries no restore latch, so a `commits_above` injection would never fire here. + + Ablation: make `_restore_rearmed_spec` call `atomic_write_bytes_confined` + unconditionally again and this reddens on the `match=` — the raise becomes + "cannot restore ... UnconfinedWriteError" instead of the fault the re-arm aborted on + — with the byte comparison reddening behind it. + """ + project = tmp_path / "proj" + project.mkdir() + _resolve_repo(project) + spec = tmp_path / "artifacts" / "spec.md" # outside the project, and outside any mount + spec.parent.mkdir(parents=True, exist_ok=True) + spec.write_text( + "---\nstatus: blocked\n---\n\n## Intent\n\nx\n\n## Auto Run Result\n\nterminal\n", + encoding="utf-8", + ) + before = spec.read_bytes() + run_dir, _, _ = _escalated_run(project, spec_file=str(spec)) + + def boom(run_dir_, state_): + raise MemoryError("nothing to do with the spec") + + monkeypatch.setattr(runs, "save_state", boom) + + # the flip and the strip both LAND on this path (their writers degrade to the plain + # arm), so there is a real published write for the undo to put back + with pytest.raises(MemoryError, match="nothing to do with the spec"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == before + assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED + (aborted,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-aborted"] + assert aborted["rollback"] == "restored" + + +def test_rearm_reports_a_failed_rollback_through_the_plain_arm(tmp_path, monkeypatch): + """The undo's `failed` outcome has to be reachable through BOTH of its writers. + + `tests/test_runs.py::test_rearm_reports_a_rollback_that_itself_failed_and_keeps_the_original_fault` + injects at `runs.atomic_write_bytes_confined`, and its fixture always puts the spec + under the project, so it only ever grades the CONFINED arm. The plain + `atomic_write_bytes` arm added for the out-of-every-root shape had no `failed` + coverage at all — the sibling row above grades that arm's `"restored"` outcome only, + so a plain arm that raised the wrong type, or swallowed instead of raising, was + invisible. + + Same three claims as the confined row, on the other writer: the `RearmError` names the + spec, the record says `failed`, and the ORIGINAL fault rides in the exception chain + because the restore raises WHILE that fault is being handled. + + Ablation: make `_restore_rearmed_spec` take `atomic_write_bytes_confined` + unconditionally and this reddens on the INJECTED-fault assertion. That ablation is + the one that matters and the one the three claims above cannot catch on their own: + the confined writer refuses this out-of-root path with `UnconfinedWriteError`, which + IS an `OSError`, so it produces the same `RearmError`, the same `failed` record and + the same chained `MemoryError` — every claim stays true while the plain arm this row + exists for is never reached. Naming the injected error is what tells the two apart. + """ + project = tmp_path / "proj" + project.mkdir() + _resolve_repo(project) + spec = tmp_path / "artifacts" / "spec.md" # outside the project, and outside any mount + spec.parent.mkdir(parents=True, exist_ok=True) + spec.write_text( + "---\nstatus: blocked\n---\n\n## Intent\n\nx\n\n## Auto Run Result\n\nterminal\n", + encoding="utf-8", + ) + run_dir, _, _ = _escalated_run(project, spec_file=str(spec)) + + def boom(run_dir_, state_): + raise MemoryError("nothing to do with the spec") + + def no_space(*_a, **_kw): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(runs, "save_state", boom) + # ONLY the undo's out-of-root writer: the flip and the strip reach this path through + # `verify` and `devcontract`, so this cannot pre-empt the writes it is meant to fail + # to undo + monkeypatch.setattr(runs, "atomic_write_bytes", no_space) + + with pytest.raises(runs.RearmError, match="cannot restore") as excinfo: + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert str(spec) in str(excinfo.value) + # the fault the operator is shown is the one the PLAIN arm raised. Without this the + # row cannot tell its own writer apart from the confined one refusing the same path + assert "No space left on device" in str(excinfo.value) + chain = [] + exc: BaseException | None = excinfo.value + while exc is not None: + chain.append(exc) + exc = exc.__cause__ or exc.__context__ + assert any(isinstance(e, MemoryError) for e in chain) # the original fault survives + (aborted,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-aborted"] + assert aborted["rollback"] == "failed" + assert "MemoryError" in aborted["error"] + + def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): """A claim the re-stamp normalizes away is the only trace of a divergence the gate can no longer report, so it lands in the journal on the way out — read @@ -4161,7 +4335,10 @@ def test_rearm_event_notice_splits_the_abort_three_ways_on_the_rollback(): # reach a TUI operator, which never sees next_step assert "may be left part-written" in failed_msg assert "restore it from git" in failed_msg - assert failed_step == "Restore the spec from git, then re-run resolve" + # ...and it names a SECOND source, because the bytes the undo failed to write are gone + # with the process and an untracked or out-of-checkout spec has no committed copy + assert "or from your own copy" in failed_msg + assert failed_step == "Restore the spec from git or your own copy, then re-run resolve" # ...and it does NOT enumerate which writes landed: a fault inside # `strip_auto_run_result` reaches the guard with the flip published and the section # still present, so an enumeration would describe a state this record cannot know diff --git a/tests/test_runs.py b/tests/test_runs.py index 5900b3e8..8d6d2222 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3370,6 +3370,363 @@ def probe_boom(repo, baseline): assert "MemoryError" in aborted["error"] +@pytest.mark.parametrize("append_fault", [TypeError, OSError]) +def test_rearm_keeps_the_original_fault_when_the_abort_record_cannot_be_written( + monkeypatch, tmp_path, append_fault +): + """Writing the abort record is an OBSERVATION, and an observation that cannot be made + must not REPLACE the fault the operator is being told about. + + `_rollback_rearm` journals `rearm-aborted` from a `finally` that runs while the + original fault is unwinding, so anything that append raises escapes in its place — + and the whole re-raise invariant this transaction is built on (the two pinned + `MemoryError` rows) dies quietly with it. The rollback itself has already completed + by then, so nothing about DW-79/DW-83 is at stake in that suppression; only the + breadcrumb is lost, and the operator still receives the fault that explains why. + + BOTH rows matter and they grade different halves. `OSError` is the obvious shape (an + unwritable journal) and passes under either breadth. `TypeError` is the one that + grades the WIDTH: `Journal.append` serializes caller-supplied values and opens a + file, so `json.dumps` and the open can raise outside the filesystem taxonomy + entirely. + + Ablation: narrow the catch back to `except OSError` and the `TypeError` row reddens + with `TypeError` where the `MemoryError` should be, while the `OSError` row stays + green — which is exactly why one row alone is no oracle. + """ + from bmad_loop.journal import Journal + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + + def probe_boom(repo, baseline): + raise MemoryError("not a git answer") + + real_append = Journal.append + + def flaky(self, kind, **fields): + if kind == "rearm-aborted": + raise append_fault("the abort record could not be written") + return real_append(self, kind, **fields) + + monkeypatch.setattr(runs.verify, "commits_above", probe_boom) + monkeypatch.setattr(Journal, "append", flaky) + with pytest.raises(MemoryError, match="not a git answer"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + # the rollback ran BEFORE the record was attempted, so the transaction still held + assert spec.read_bytes() == before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + # ...and the suppressed append left no entry, which the acceptance criterion is + # deliberately conditioned on rather than promising one here + assert not _kinds(run_dir, "rearm-aborted") + + +def test_rearm_lets_an_interrupt_from_the_abort_append_leave(monkeypatch, tmp_path): + """The abort record's append suppresses `Exception` and deliberately NOT + `KeyboardInterrupt` — the ONE place in this transaction where the breadth is narrower + than the guard's own `BaseException`, and the asymmetry has to be graded from the + side the sibling rows cannot reach. + + It is sound only because of WHERE this `finally` runs: the rollback has already + completed by the time the record is attempted, so the spec is back to the bytes the + re-arm found and an interrupt escaping here cannot reproduce DW-79/DW-83. What IS at + stake is the operator's Ctrl-C. Swallowing it to keep a breadcrumb would answer a + stop they issued themselves with a `MemoryError` traceback, and would leave the + process running past the point they asked it to stop. + + Ablation: broaden that catch to `except BaseException` and this reddens with the + `MemoryError` arriving in the interrupt's place. Neither row of + `test_rearm_keeps_the_original_fault_when_the_abort_record_cannot_be_written` + reddens there, because `TypeError` and `OSError` are both already `Exception`. + """ + from bmad_loop.journal import Journal + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + + def probe_boom(repo, baseline): + raise MemoryError("not a git answer") + + real_append = Journal.append + + def interrupted(self, kind, **fields): + if kind == "rearm-aborted": + raise KeyboardInterrupt + return real_append(self, kind, **fields) + + monkeypatch.setattr(runs.verify, "commits_above", probe_boom) + monkeypatch.setattr(Journal, "append", interrupted) + # the INTERRUPT is what leaves, not the fault the re-arm aborted on + with pytest.raises(KeyboardInterrupt): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + # ...and letting it through costs nothing: the rollback ran first, so the spec is as + # the re-arm found it and the story is still armed for a retry + assert spec.read_bytes() == before + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + assert not _kinds(run_dir, "rearm-aborted") + + +def test_rearm_records_unknown_when_the_rollback_cannot_read_the_spec(monkeypatch, tmp_path): + """`unchanged` is earned ONLY by reading the file and proving it byte-equal, so an + undo that could not even LOOK must answer `unknown`. + + This is the read-failure arm, and it is a different arm from the one the sentinel row + grades: there `original` is `None` and `_restore_rearmed_spec` returns before touching + the disk, so that row cannot reach this code at all. Here the preimage was captured + normally and the file is gone by the time the undo runs — another actor removed it + mid-window — so `read_bytes` raises, the undo declines to re-create a file it did not + delete, and it says so. + + Folding that into `unchanged` asserted a byte-equality the producer never checked, and + the surfaces then told the operator the spec "was left exactly as the re-arm found + it" about a file that is not there. + + Ablation: make the `except OSError` arm in `_restore_rearmed_spec` return + `"unchanged"` and this reddens on the recorded `rollback` first; drop that assertion + and the rendered message reddens behind it. + """ + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + + def vanishes(repo, baseline): + spec.unlink() # a concurrent actor removes it AFTER the flip published + raise MemoryError("not a git answer") + + monkeypatch.setattr(runs.verify, "commits_above", vanishes) + with pytest.raises(MemoryError, match="not a git answer"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert not spec.exists() # the undo does NOT fight the actor that removed it + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "unknown" + _severity, message, _next_step = runs.rearm_event_notice(aborted) + assert "could not confirm what it left on disk" in message + assert "left exactly as the re-arm found it" not in message + + +def test_rearm_abort_without_a_spec_records_an_empty_locator(monkeypatch, tmp_path): + """A re-arm that never resolved a spec path writes `""` there, NOT the story key. + + `spec_file` is the SPEC's locator on all five `rearm-*` kinds and + `diagnostics._JOURNAL_ALIAS_FIELDS` routes it by that field NAME into the `spec` + namespace. Falling back to the story key would push an identifier through the wrong + namespace — rendered as a spec that does not exist — and the notice would name it as + a file. The empty string routes nowhere and renders as `(none)`. + + Ablation: replace the `""` fallback with `story_key` and both halves redden — the + field carries the key, and the notice names it where `(none)` belongs. + """ + from bmad_loop.model import Phase + + run = escalated_run(tmp_path, "r1", story_key="1-1-a", git_project=True) + run_dir = run.run_dir + + def boom(run_dir_, state_): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(runs, "save_state", boom) + with pytest.raises(OSError, match="No space left on device"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["spec_file"] == "" + assert aborted["rollback"] == "unknown" # no spec path ⇒ no claim about any file + _severity, message, _next_step = runs.rearm_event_notice(aborted) + assert "(none)" in message + assert "1-1-a" not in message + + +def test_rearm_records_failed_when_the_rollback_write_is_interrupted(monkeypatch, tmp_path): + """An interrupt during the restore WRITE leaves the spec in exactly the state `failed` + describes — flipped and not put back — so that is what the record must say. + + `_rollback_rearm`'s inner arm catches `BaseException` for this reason, and the breadth + is as load-bearing as the guard's own: the restore is a file write, and a Ctrl-C + landing in it is the ordinary way for one to be abandoned half-done. Under a narrowed + `except Exception` the interrupt skips the arm, `rollback` keeps its `unknown` floor, + and the `finally` then records "could not confirm what it left on disk" for a spec + the run knows perfectly well it left part-written — the one outcome whose remedy is + not moot. + + Ablation: narrow that inner arm to `except Exception` and this reddens on the + recorded `rollback` (`unknown` for `failed`). The `KeyboardInterrupt` still + propagates either way, which is why the raise alone is no oracle. + """ + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + + def probe_boom(repo, baseline): + raise MemoryError("not a git answer") + + def interrupted(*_a, **_kw): + raise KeyboardInterrupt + + monkeypatch.setattr(runs.verify, "commits_above", probe_boom) + monkeypatch.setattr(runs, "atomic_write_bytes_confined", interrupted) + with pytest.raises(KeyboardInterrupt): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "failed" + # the record still names the fault the RE-ARM aborted on, not the interrupt + assert "MemoryError" in aborted["error"] + _severity, message, next_step = runs.rearm_event_notice(aborted) + assert "may be left part-written" in message + assert next_step.startswith("Restore the spec") + + +def test_rearm_does_not_roll_back_a_commit_that_already_landed(monkeypatch, tmp_path): + """`save_state` commits by ATOMIC REPLACE, so a fault escaping the call does not prove + the transaction failed — and rolling the spec back after a commit that DID land builds + the mirror image of the defect this guard closes. + + The rename is a single instant; the call around it is not. An interrupt delivered + between the replace and the return unwinds through the guard with `state.json` already + describing a PENDING, re-armed task. Undoing the spec there leaves persisted state + re-armed against a spec that is not, and reports it as "nothing was persisted, the + story is still escalated" — a false sentence on both operator surfaces, and a re-drive + that reads the escalated attempt's terminal status on its first save. + + Control flow cannot see this (there is no statement after `save_state` on that path), + so the guard asks the DISK, the only witness of a rename. No `rearm-aborted` record is + written on this leg either: every rendering of that kind asserts nothing was + persisted, and there is no value of `rollback` that is true here. + + Ablation: delete the `_rearm_commit_landed` check from the guard and this reddens on + the flipped-status assertion — the spec is rolled back underneath committed state — + with the abort-record assertion reddening behind it. + """ + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + real_save_state = runs.save_state + + def commits_then_dies(run_dir_, state_): + real_save_state(run_dir_, state_) # the atomic replace LANDS... + raise KeyboardInterrupt # ...and the call is interrupted on its way out + + monkeypatch.setattr(runs, "save_state", commits_then_dies) + with pytest.raises(KeyboardInterrupt): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + text = spec.read_text(encoding="utf-8") + assert "status: ready-for-dev" in text # the flip STANDS beside the commit + assert "## Auto Run Result" not in text + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.PENDING + assert not _kinds(run_dir, "rearm-aborted") + + +@pytest.mark.parametrize("probe_fault", [KeyboardInterrupt, OSError]) +def test_rearm_rolls_back_when_the_commit_probe_itself_fails(monkeypatch, tmp_path, probe_fault): + """`_rearm_commit_landed` is asked a question ON THE ERROR PATH, so a fault raised + ANSWERING it must not cost the rollback. + + Its one call site sits inside the transaction guard's `except BaseException` arm and + runs BEFORE `_rollback_rearm`, so anything escaping the probe escapes the guard too + and the undo never happens — the spec left flipped to the re-drive's status and + stripped of its `## Auto Run Result`, against a task the run still calls ESCALATED, + with no `rearm-aborted` record. That is DW-79/DW-83 reached through the very code + added to prevent its mirror image, which is why the probe degrades to "not committed" + — roll back — on ANY fault rather than only on an `Exception`. + + The probe reads and PARSES a file, so `KeyboardInterrupt` there is an ordinary + outcome, not a contrivance: `load_state` is a `read_text` plus a `json.loads` plus a + `RunState.from_dict`, and an operator's Ctrl-C lands wherever it lands. + + Swallowing that interrupt costs nothing the operator asked for. The rollback is a + repair write whose omission IS the defect, and the guard's `raise` still propagates + the original `MemoryError` a spec-sized write later — which the last assertion pins. + + BOTH rows matter and they grade different halves. `OSError` (a corrupt or unreadable + state file) passes under either breadth. `KeyboardInterrupt` is the one that grades + the WIDTH. + + Ablation: narrow the probe's catch back to `except Exception` and the + `KeyboardInterrupt` row reddens — the spec comes back flipped and no abort record + exists — while the `OSError` row stays green, which is exactly why one row alone is + no oracle. + """ + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + before = spec.read_bytes() + + def probe_boom(repo, baseline): + raise MemoryError("not a git answer") + + real_load_state = runs.load_state + calls = [] + + def unreadable(run_dir_): + # `rearm_escalation` opens with its OWN `load_state`; only the probe's call, + # made from inside the guard arm, is the one under test + calls.append(1) + if len(calls) > 1: + raise probe_fault("the commit probe could not read the state file") + return real_load_state(run_dir_) + + monkeypatch.setattr(runs.verify, "commits_above", probe_boom) + monkeypatch.setattr(runs, "load_state", unreadable) + with pytest.raises(MemoryError, match="not a git answer"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == before # the rollback ran despite the probe failing + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "restored" + assert "MemoryError" in aborted["error"] + + +def test_rearm_refuses_a_spec_whose_bytes_it_could_not_capture(monkeypatch, tmp_path): + """The preimage read is the transaction's own precondition, so a spec that IS a file + and whose bytes could not be captured must FAIL BEFORE the first write. + + That read is one syscall among many against a file three later writers open + independently, so a TRANSIENT fault (EIO on a network mount, a momentary EACCES, + ENFILE under load) can be followed by writes that all succeed. `spec_before` is then + `None`, the abort further down records `unknown` and puts nothing back, and the + re-arm exits with the flip published against a task still ESCALATED — DW-79/DW-83 + reached through the guard's own preimage. + + The fake fails only the FIRST read of this spec, which is what makes that reachable: + every later read succeeds, so without the refusal the re-arm runs to completion. + + A path that is NOT a file keeps degrading to `None` — a missing spec, a dangling link + and a directory all answer `False` from every writer below, so there is genuinely + nothing to undo, and those shapes stay warn-and-continue. + + Ablation: drop the `is_file()` refusal and this reddens with `DID NOT RAISE` — the + re-arm completes, the spec ends up flipped and stripped, and nothing records it. + """ + from bmad_loop.model import Phase + + run_dir, spec, _patch = _stale_restore_tree(tmp_path) + real_read_bytes = Path.read_bytes + before = real_read_bytes(spec) + failed_once = [] + + def flaky(self): + if self == spec and not failed_once: + failed_once.append(1) + raise OSError(5, "Input/output error") + return real_read_bytes(self) + + monkeypatch.setattr(Path, "read_bytes", flaky) + with pytest.raises(runs.RearmError, match="refuses to write a spec it could not capture"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert real_read_bytes(spec) == before # nothing was written, so nothing to undo + assert load_state(run_dir).tasks["1-1-a"].phase == Phase.ESCALATED + (aborted,) = _kinds(run_dir, "rearm-aborted") + assert aborted["rollback"] == "unknown" # no preimage ⇒ no claim about the file + + def test_ordinary_rearm_writes_no_abort_record(tmp_path): """The negative side of the transaction: a re-arm that reaches `save_state` must leave no trace of a rollback that never happened. @@ -4285,10 +4642,13 @@ def test_task_spec_root_yields_the_project_when_the_worktree_cannot_confine_the_ `relative_to(worktree_path)` raises, so this pair means the spec is lexically outside the mount. `task_spec_path` passes an absolute path through untouched, so answering the worktree here names a root that can NEVER contain the anchored path: - `devcontract._atomic_write_spec` gates on the same lexical `is_relative_to` and - would silently take the plain no-follow arm — losing #593's O_NOFOLLOW walk — while - `_restore_rearmed_spec`, which calls `atomic_write_bytes_confined` directly, would - raise `UnconfinedWriteError` and turn a recoverable re-arm abort into a lost undo. + `devcontract._atomic_write_spec` gates on the same lexical `is_relative_to` and would + silently take the plain no-follow arm, losing #593's O_NOFOLLOW walk — and so would + `_restore_rearmed_spec`, the re-arm's undo, which now selects its writer the same + lexical way rather than calling `atomic_write_bytes_confined` directly. All four + degrade together, which is the point: the undo that once RAISED `UnconfinedWriteError` + here, turning a recoverable re-arm abort into a lost one, is the asymmetry that + parity removed. The project is not guaranteed to contain it either; where nothing does, the write lands on the arm it already took. That is not unconditional, and the exception is diff --git a/tests/test_sprintstatus_advance.py b/tests/test_sprintstatus_advance.py index cce63b8d..62245e64 100644 --- a/tests/test_sprintstatus_advance.py +++ b/tests/test_sprintstatus_advance.py @@ -460,7 +460,7 @@ def boom(path, data: bytes, *, follow_symlinks=True, require_writable_target=Fal @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_advance_writes_through_a_symlinked_board(tmp_path): """The row that grades this SITE's `follow_symlinks` argument — the DEFAULT - here, unlike the three spec writers, which pass False to match the + here, unlike the spec writers, which pass False to match the name-replacing `atomic_replace` they already had. The default is what preserves behaviour: `write_text` opened through a link, so diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index f25a3035..cd978957 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4998,10 +4998,11 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk already queued behind this toast. Ablation: make `runs.rearm_event_notice` return None for any one of these kinds - and this reddens on that kind's message alone. Drop "restore it from git" from the - `rearm-aborted` `failed` MESSAGE while keeping it in that arm's `next_step` and only - the remedy assertion reddens — which is the point of grading it here rather than on - the CLI, where the dropped half is still printed. + and this reddens on that kind's message alone. Drop the remedy + ("restore it from git or from your own copy") from the `rearm-aborted` `failed` + MESSAGE while keeping it in that arm's `next_step` and only the remedy assertion + reddens — which is the point of grading it here rather than on the CLI, where the + dropped half is still printed. """ from bmad_loop import resolve, runs from bmad_loop.journal import Journal @@ -5094,7 +5095,10 @@ def severity_of(fragment: str) -> str: # surface with no `next_step`: without it a TUI operator is told the spec may be # part-written and given no remedy for it assert severity_of("may be left part-written") == "warning" - assert any("restore it from git" in n[0] for n in notes), notes + # the WHOLE remedy, not its first three words: the message names a second source + # because an untracked or out-of-checkout spec has no committed copy, and asserting + # only the "from git" prefix passes for a message that never gained the rest + assert any("restore it from git or from your own copy" in n[0] for n in notes), notes # the CLI's trailing imperative is omitted here: the resume is already queued assert not any("before resuming" in n[0] for n in notes), notes assert any("re-armed 1" in n[0] for n in notes) # the ordinary notice still fires From a66900bee7545c995c4c860c5fc10edefaca179d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 10:36:44 -0700 Subject: [PATCH 17/45] sweep dw2-rearm-transaction-window: DW-79, DW-83, DW-85 via bmad-loop --- src/bmad_loop/runs.py | 36 ++++ tests/test_portability_guard.py | 333 +++++++++++++++++++++++++++++++- tests/test_resolve.py | 77 ++++++++ tests/test_tui_app.py | 28 +++ 4 files changed, 469 insertions(+), 5 deletions(-) diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 36a03b3a..74d6c3a2 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3666,6 +3666,42 @@ def _rearm_commit_landed(run_dir: Path, story_key: str, task: StoryTask) -> bool only if some other writer had minted the same bump, and `phase` alone moves for reasons a re-arm does not own. + Those two conjuncts are a sufficient identity ONLY because `rearm_escalation` runs as + the SOLE writer of this run's `state.json`, and that model is the probe's premise + rather than an assumption left implicit. Exactly TWO call sites reach this + transaction — `cli.cmd_resolve` and `tui.TuiApp._do_rearm` — and each consults + liveness before any side effect: :func:`engine_liveness` in the CLI, its pid-file + sibling :func:`liveness` in the TUI (`probe_liveness` is the shared body). A third + control command, `cli.cmd_resume`, never re-arms but DOES write this run's + `state.json` (through `_resume_paused_run`), which is why the sole-writer claim has + to account for it as well as for the two callers. + `tests/test_portability_guard.py::test_rearm_escalation_called_only_behind_a_liveness_gate` + holds that enumeration, which is otherwise prose a third call site could falsify + silently. + + Those gates establish that no engine is PROVABLY ALIVE — not that one is proven + dead — and the premise rests on the difference, so it is stated rather than rounded + off. `"alive"` is refused outright at all three. `"unknown"` is not: `cmd_resolve` + proceeds on it under `--force`, `cmd_resume` warns and proceeds by design (it is the + recovery path that rewrites engine.pid), and the TUI counts it as blocking only for a + pid-backed run. So the model this probe leans on is the engine stopped AND the + operator driving one control command at a time. Under it only THIS caller can have + moved either field, which is exactly what the exact-phase predicate reports — the + predicate is correct for the reason it is narrow. + + Two overlapping control commands are OUTSIDE that model rather than handled by it, + and deliberately so. `journal.save_state` stages through a FIXED `state.json.tmp` + sibling before its `atomic_replace` — the collision `_write_stop_request` documents + under #379, which names the stop-request file as the ONE control file with genuinely + *concurrent* writers — so two overlapping re-arms lose a `save_state` to + `FileNotFoundError` long before this probe's identity could matter. Answering them + here was weighed and declined: a lock taken by only `rearm_escalation` excludes + nobody (the honest fix is a run-level one shared with `_resume_paused_run` and the + engine's own `save_state`), and a durable per-re-arm token stamped on `StoryTask` + would buy this probe a precision the `save_state` writer beneath it cannot honour, at + the cost of a new persisted model field. Tracked as DW-93; the probe stays two + conjuncts over the reloaded task. + Degrades to `False` — roll back, the pre-existing behavior — on ANY failure to read or parse the state file. This is observation feeding a repair decision, and the safe default is the one that leaves the spec as the re-arm found it: a re-arm that did diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 3964584f..cd3cc13d 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -27,6 +27,8 @@ ``test_journal_kinds_are_literal_or_the_position_is_declared`` holding the kind half readable and ``test_journal_append_writes_only_accounted_fields`` covering the two names ``Journal.append`` mints itself, which no call site spells. +* ``runs.rearm_escalation`` is called from exactly two places, each of which consults + liveness first — ``test_rearm_escalation_called_only_behind_a_liveness_gate``. If this test flags something unexpected, fix the source (route it through the seam / a platform helper) rather than widening an allowlist. @@ -36,6 +38,7 @@ import ast import json +from collections import Counter from pathlib import Path import pytest @@ -181,6 +184,43 @@ # a hand-rolled fourth mint would omit (#705). SESSION_TASK_ID_CHOKEPOINT = {"engine.py": "_session_task_id"} +# The complete set of ``runs.rearm_escalation`` call sites, as +# ``(file, enclosing function)``. The re-arm transaction's own commit probe +# (``runs._rearm_commit_landed``) proves "did MY save_state land?" with nothing but +# ``(generation, phase)`` over the reloaded task, and that is a sufficient IDENTITY +# only under a sole-writer model: no engine advancing the task underneath, and one +# control command at a time. Its docstring argues that model from this enumeration. +# +# Prose cannot hold it. A third call site — or either existing gate deleted — leaves +# every test in the repo green while the probe's premise quietly becomes false, and +# the failure it opens is DW-79/DW-83's own shape: a spec left re-armed against a task +# the run still calls ESCALATED. So the enumeration is scanned instead of asserted. +# +# Deliberately NOT a lock and not a durable per-re-arm token: the spec's ``Never`` +# forbids both (a lock only ``rearm_escalation`` takes excludes nobody; a token buys a +# precision ``save_state`` cannot honour). It forbids no guard, and this is the cheap +# half — it does not make overlapping callers safe, it makes the day someone adds one +# impossible to miss. Overlapping control commands stay out of the model, as DW-93. +REARM_ESCALATION_CALLERS = { + ("cli.py", "cmd_resolve"), + ("tui/app.py", "_do_rearm"), +} + +# What counts as consulting liveness, matched as a substring of the callee's name +# because the two sites legitimately spell it differently and neither spelling is more +# correct: the CLI calls ``runs.engine_liveness`` directly, the TUI goes through +# ``self._resolve_blocked_by_liveness`` (which reaches ``runs.liveness``, the pid-file +# sibling sharing ``probe_liveness``). Pinning either exact name would redden on a +# rename that changes nothing, while the substring still reddens on the deletion this +# guard exists for. +# +# What the gate establishes is that the engine is not PROVABLY alive, not that it is +# proven dead — ``"unknown"`` proceeds under ``--force`` in ``cmd_resolve``, and the +# TUI counts it as blocking only for a pid-backed run. This guard therefore grades +# that the result controls a terminating branch before the call; the caller-level +# tests pin the exact alive/unknown policy on the two real surfaces. +LIVENESS_GATE_MARK = "liveness" + # The journal field names ``diagnostics`` routes BY NAME, read off the live module # rather than copied, so the guard cannot drift from the tables it grades: add a row # there and the corresponding producer stops being an offender with no edit here. @@ -541,7 +581,7 @@ # ⚠️ STATED BOUND: a LOCALLY ALIASED handle is invisible. `j = self.journal` followed # by `j.append(kind, customer_email=x)` produces no finding (verified by running it # through `_scan_source`). No such site exists in the tree today, and resolving the -# binding would be `_verify_call_aliases`' shape rather than a new idea — but the +# binding would be `_call_aliases`' shape rather than a new idea — but the # guard does not do it, and a reader must not assume it does. JOURNAL_RECEIVERS = {"journal", "_journal"} @@ -892,8 +932,8 @@ def _called_name(func: ast.expr) -> str | None: return None -def _verify_call_aliases(tree: ast.AST, target: str) -> frozenset[str]: - """Bare names statically bound to one guarded verify-call target. +def _call_aliases(tree: ast.AST, target: str) -> frozenset[str]: + """Bare names statically bound to one guarded call target. The call-site spelling alone misses the ordinary Python aliases a future caller may use: rename-on-import and a local assignment from either the @@ -1179,6 +1219,70 @@ def descend(node: ast.AST, fn: ast.AST | None) -> None: return nodes +def _names_rearm_escalation(func: ast.expr, aliases: frozenset[str] = frozenset()) -> bool: + """True when ``func`` spells the re-arm transaction's entry point. + + Qualified and bare spellings are direct matches; ``aliases`` adds ordinary + rename-on-import and assignment bindings. Matching an attribute without checking + its value means an unrelated ``x.rearm_escalation(...)`` also registers — that + false positive is a review prompt naming a real call to a function of that name, + which is the trade every sibling detector in this file makes. + """ + return _names_guarded_verify_call(func, "rearm_escalation", aliases) + + +def _block_exits(body: list[ast.stmt]) -> bool: + """Whether this simple guard body cannot fall through to the re-arm below it.""" + return bool(body) and isinstance(body[-1], (ast.Return, ast.Raise)) + + +def _liveness_call(node: ast.AST) -> bool: + return isinstance(node, ast.Call) and LIVENESS_GATE_MARK in (_called_name(node.func) or "") + + +def _top_level_liveness_bindings(fn: ast.AST, lineno: int) -> set[str]: + """Names bound by an earlier top-level liveness probe in ``fn``.""" + bindings: set[str] = set() + assert isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + for stmt in fn.body: + if stmt.lineno >= lineno or not isinstance(stmt, (ast.Assign, ast.AnnAssign)): + continue + value = stmt.value + if value is None or not _liveness_call(value): + continue + targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target] + bindings.update(target.id for target in targets if isinstance(target, ast.Name)) + return bindings + + +def _test_uses_liveness(test: ast.expr, bindings: set[str]) -> bool: + return any( + _liveness_call(node) or (isinstance(node, ast.Name) and node.id in bindings) + for node in ast.walk(test) + ) + + +def _consults_liveness_before(fn: ast.AST | None, lineno: int) -> bool: + """True when a preceding liveness decision blocks fall-through to the re-arm. + + The two real callers keep the gate in their top-level statement sequence: the TUI + calls its boolean helper directly in an ``if`` and the CLI binds ``engine_liveness`` + before testing that result. Requiring a terminating guard body deliberately rejects + an ignored probe, a probe hidden in an uncalled nested function, and one conditional + on an unrelated outer branch. A more deeply factored gate is a review prompt rather + than a silent pass. + """ + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + return False + bindings = _top_level_liveness_bindings(fn, lineno) + for stmt in fn.body: + if stmt.lineno >= lineno or not isinstance(stmt, ast.If): + continue + if _block_exits(stmt.body) and _test_uses_liveness(stmt.test, bindings): + return True + return False + + def _scan(): """Single pass over the tree → list of (kind, rel, lineno, line_text).""" findings = [] @@ -1202,8 +1306,9 @@ def _scan_source(src: str, rel: str): tree = ast.parse(src, filename=rel) docs = _docstring_node_ids(tree) env_aliases = _env_name_aliases(tree) - verify_command_aliases = _verify_call_aliases(tree, "verify_commands_outcome") - verify_classifier_aliases = _verify_call_aliases(tree, "verify_command_results_outcome") + verify_command_aliases = _call_aliases(tree, "verify_commands_outcome") + verify_classifier_aliases = _call_aliases(tree, "verify_command_results_outcome") + rearm_aliases = _call_aliases(tree, "rearm_escalation") # First positional args of `_run_git(...)` calls — the one position where a # git argv literal feeds the chokepoint instead of bypassing it. Collected up @@ -1687,6 +1792,24 @@ def record_mint(value: ast.expr, *, bare_at_depth: bool) -> None: ) ) + # Every `rearm_escalation` CALL, carrying `(enclosing function, gated)` — the two + # facts `REARM_ESCALATION_CALLERS` is an enumeration of. The `def` in `runs.py` is + # not a Call and needs no exemption. + for node in ast.walk(tree): + if isinstance(node, ast.Call) and _names_rearm_escalation(node.func, rearm_aliases): + findings.append( + ( + "rearmcall", + rel, + node.lineno, + line_at(node.lineno), + ( + enclosing_names.get(id(node)), + _consults_liveness_before(enclosing_nodes.get(id(node)), node.lineno), + ), + ) + ) + return findings @@ -1974,6 +2097,67 @@ def test_session_task_id_composed_only_at_the_chokepoint(): ) +def test_rearm_escalation_called_only_behind_a_liveness_gate(): + """``runs.rearm_escalation`` is reached from exactly two places, and each consults + liveness before it. + + ``runs._rearm_commit_landed`` decides whether the re-arm transaction COMMITTED — + and therefore whether to roll the spec back — from ``(generation, phase)`` over the + reloaded task, nothing more. Those two conjuncts are a sufficient identity only + while ``rearm_escalation`` is the sole writer of that run's ``state.json``, and that + model is argued from this enumeration: two callers, each behind a liveness + consultation, with no engine running. A third caller, or either gate deleted, makes + the premise false — and the defect it reopens is DW-79/DW-83's own: a spec left + flipped against a task the run still calls ESCALATED. + + Note what the gate does and does not establish. It proves the engine is not + PROVABLY alive, not that it is dead: ``"alive"`` is refused outright, while + ``"unknown"`` proceeds under ``--force`` in ``cmd_resolve`` and counts as blocking + in the TUI only for a pid-backed run. So this grades the falsifiable half — that + an earlier liveness decision BLOCKS fall-through before the call. The rest of the + model (one control command at a time) is out of scope here and tracked as DW-93. + + ``cli.cmd_resume`` is deliberately absent: it writes this run's ``state.json`` + through ``_resume_paused_run``, so the sole-writer claim must account for it, but it + never re-arms and so is not a call site. Listing it here would make the enumeration + unfalsifiable in the direction that matters. + + ⚠️ What this assertion grades, precisely — the two halves differ, and the + difference is the reason the probe rows below exist: + + * the ENUMERATION, yes, in both directions and with multiplicity. The count is + non-empty on today's tree, so deleting the ``rearmcall`` emit reddens it — unlike the + sibling repo-wide "nothing is flagged" guards, which go green when their detector + dies. Adding a third call, even inside an existing caller, reddens it too. + * the GATE, no. Both sites are gated today, so ``ungated == []`` would survive a + ``_consults_liveness_before`` that always answered ``True`` — including one that + had lost its line-position check, which is the half a late gate would exploit. + ``REARM_CALL_PROBES`` is where that is caught, and the two are not + interchangeable. + + Ablations to run against this row: drop the ``if + self._resolve_blocked_by_liveness(...)`` block from ``tui.TuiApp._do_rearm`` and the + gate half must redden naming ``tui/app.py``; add a call in a third function and the + count comparison must redden.""" + findings = _of("rearmcall") + sites = _rearm_callsite_counts(findings) + declared = Counter(REARM_ESCALATION_CALLERS) + assert sites == declared, ( + "the count of runs.rearm_escalation call sites moved. That enumeration is what " + "runs._rearm_commit_landed's (generation, phase) commit probe argues its " + "sole-writer premise from — a new caller needs that docstring revisited (and " + "DW-93 consulted), not this constant widened:\n" + f" scanned: {sorted(sites.elements())}\n" + f" declared: {sorted(declared.elements())}" + ) + ungated = [(rel, ln, txt) for _, rel, ln, txt, (_, gated) in findings if not gated] + assert ungated == [], ( + "runs.rearm_escalation called without a preceding liveness refusal — the re-arm " + "mutates persisted state for a run it must know is not being driven:\n" + + "\n".join(f" {rel}:{ln}: {txt.strip()}" for rel, ln, txt in ungated) + ) + + def _journal_field_offenders(findings) -> list[tuple[str, int, str, str]]: """The routing invariant as a filter, in the two directions a finding can fail: a field name that neither ``diagnostics`` nor the benign inventory accounts for, @@ -3345,6 +3529,145 @@ def test_session_task_id_exemption_is_scoped_to_the_chokepoint(label, rel, sourc ) +# The re-arm caller detector's probe matrix. Today's tree has exactly two `rearmcall` +# findings and BOTH are gated, so the tree-wide guard's `ungated == []` half would stay +# green with the gate logic deleted, or with its line-position check dropped — only +# these rows redden. Each is driven through the real `_scan_source`. +REARM_CALL_PROBES = [ + # (label, source, expected enclosing function, expected `gated`) + ( + "qualified-call-behind-the-gate", + "def cmd_resolve(args):\n" + " live = runs.engine_liveness(run_dir)\n" + ' if live == "alive":\n' + " return\n" + " runs.rearm_escalation(run_dir, story_key)\n", + "cmd_resolve", + True, + ), + # The TUI's spelling, which reaches `runs.liveness` rather than `engine_liveness`. + # This is the row that makes the substring match load-bearing rather than lax. + ( + "tui-spelling-of-the-gate", + "def _do_rearm(self, run_id, run_dir):\n" + " if self._resolve_blocked_by_liveness(run_id, run_dir):\n" + " return\n" + " runs.rearm_escalation(run_dir, story_key)\n", + "_do_rearm", + True, + ), + # A rename-on-import third caller — the alias resolver's first ordinary shape. + ( + "renamed-call-from-import", + "from .runs import rearm_escalation as rearm\n" + "def cmd_something(args):\n" + " if runs.engine_liveness(run_dir):\n" + " return\n" + " rearm(run_dir, story_key)\n", + "cmd_something", + True, + ), + # Assignment aliases are just as callable as import aliases. + ( + "assigned-call-alias", + "handler = runs.rearm_escalation\n" + "def cmd_something(args):\n" + " if runs.engine_liveness(run_dir):\n" + " return\n" + " handler(run_dir, story_key)\n", + "cmd_something", + True, + ), + # Merely reading liveness is not a gate when the result is ignored. + ( + "ignored-liveness-result", + "def cmd_something(args):\n" + " live = runs.engine_liveness(run_dir)\n" + " runs.rearm_escalation(run_dir, story_key)\n", + "cmd_something", + False, + ), + # Nor is a guard hidden in a closure that the caller never invokes. + ( + "uninvoked-nested-guard", + "def cmd_something(args):\n" + " def guard():\n" + " if runs.engine_liveness(run_dir):\n" + " return\n" + " runs.rearm_escalation(run_dir, story_key)\n", + "cmd_something", + False, + ), + # An ungated third caller: the defect this guard exists for. + ( + "no-gate-at-all", + "def cmd_something(args):\n runs.rearm_escalation(run_dir, story_key)\n", + "cmd_something", + False, + ), + # The gate present but BELOW the call, which is not a gate. Without the line + # comparison in `_consults_liveness_before` this row reads as `True` and the whole + # position rule is unheld. + ( + "gate-below-the-call", + "def cmd_something(args):\n" + " runs.rearm_escalation(run_dir, story_key)\n" + " live = runs.engine_liveness(run_dir)\n", + "cmd_something", + False, + ), +] +REARM_CALL_NON_PROBES = [ + # The definition is not a call and needs no exemption. + ("the-definition", "def rearm_escalation(run_dir, story_key=None):\n return None\n"), + # A different function whose name merely starts the same way. + ("similar-name", "def f():\n runs.rearm_escalation_notice(run_dir)\n"), + # A mere mention as a value, not a call. + ("reference-not-a-call", "def f():\n handler = runs.rearm_escalation\n"), +] + + +@pytest.mark.parametrize( + "label,source,fn,gated", REARM_CALL_PROBES, ids=[p[0] for p in REARM_CALL_PROBES] +) +def test_rearm_call_detector_reports_the_site_and_its_gate(label, source, fn, gated): + """Each call shape is found, attributed to its enclosing function, and graded on + whether an earlier liveness guard blocks fall-through. `cli.py` is passed because + nothing in this detector is file-scoped — the enumeration lives in the tree-wide + assertion, not here.""" + found = [f for f in _scan_source(source, "cli.py") if f[0] == "rearmcall"] + assert len(found) == 1, f"the {label!r} shape produced {len(found)} findings:\n{source}" + assert found[0][4] == (fn, gated), f"the {label!r} shape graded as {found[0][4]}" + + +def _rearm_callsite_counts(findings) -> Counter: + """Call-site multiplicity, not just distinct enclosing functions.""" + return Counter((rel, fn) for _, rel, _, _, (fn, _) in findings) + + +def test_rearm_callsite_count_does_not_hide_a_second_call_in_one_function(): + source = ( + "def cmd_resolve(args):\n" + " if runs.engine_liveness(run_dir):\n" + " return\n" + " runs.rearm_escalation(run_dir, first)\n" + " runs.rearm_escalation(run_dir, second)\n" + ) + found = [f for f in _scan_source(source, "cli.py") if f[0] == "rearmcall"] + assert _rearm_callsite_counts(found) == Counter({("cli.py", "cmd_resolve"): 2}) + + +@pytest.mark.parametrize( + "label,source", REARM_CALL_NON_PROBES, ids=[p[0] for p in REARM_CALL_NON_PROBES] +) +def test_rearm_call_detector_stays_silent_on_non_calls(label, source): + """A definition, a reference and a similarly-named neighbour are not call sites. A + detector that flagged these would push noise into the tree-wide enumeration, which + is an equality assertion and so fails on a false positive as loudly as on a miss.""" + found = [f for f in _scan_source(source, "cli.py") if f[0] == "rearmcall"] + assert not found, f"the {label!r} shape produced a `rearmcall` finding:\n{source}" + + # The journal detector's probe matrix, as `(label, source, expected)` where # `expected` is the exact set of field names the scan must extract — `None` standing # for an unresolvable splat. Asserting the SET rather than "something was found" is diff --git a/tests/test_resolve.py b/tests/test_resolve.py index c6e11d8b..87094532 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -1987,6 +1987,83 @@ def no_space(*_a, **_kw): assert "MemoryError" in aborted["error"] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_rearm_rollback_replaces_a_link_planted_at_the_spec_rather_than_writing_through_it( + tmp_path, monkeypatch +): + """The out-of-root undo replaces the NAME, so a link planted at it cannot aim the + captured bytes into whatever it points at — on the shape this row drives, where that + file's bytes DIFFER from the preimage. + + That scope is the short-circuit's, not a hedge. `_restore_rearmed_spec` answers + `"unchanged"` and writes NOTHING when `spec_path.read_bytes()` already equals the + preimage, and that read follows the link — so a link aimed at a byte-equal file is + never replaced and there is nothing left for `follow_symlinks` to decide. Reaching + that shape needs a second actor mutating the spec's name mid-window, which this + story's triage log has repeatedly found unreachable while the run is paused and the + resolve session that wrote the spec has terminated. It is therefore left ungraded + rather than pinned by a row built on an actor that does not exist. + + `_restore_rearmed_spec`'s plain arm passes `follow_symlinks=False`, matching the + three writers it undoes (`frontmatter.set_frontmatter_status` states the rule). + That argument was the one thing on this path with no caller-level coverage: the + sibling rows above drive the arm over a plain regular file, where following or not + following resolves to the same inode, so dropping the argument left them green while + the undo silently gained the default's `path.resolve()` — and with it a window in + which the last thing that touches the spec's name decides which file this re-arm's + preimage lands in. + + The window is the widened transaction's own: the flip and the strip publish to the + real file, then the guard's whole residue/advance/`save_state` tail runs before the + undo looks at the name again. This row plants the link at the last moment inside that + tail — from the injected `save_state`, so the redirection is in place before the + rollback and after every write it exists to put back. + + The `restored` record is the third claim rather than a redundant one: the undo has to + read the link (seeing the OTHER file's bytes, which do not match the preimage), take + its writer, and land — the same three steps a silent write-through also takes, which + is why the byte assertions and not the record are what tell the two apart. + + Ablation: drop `follow_symlinks=False` from `_restore_rearmed_spec`'s plain + `atomic_write_bytes` call and this reddens on the FIRST assertion — the preimage + lands in the unrelated file — with `not spec.is_symlink()` reddening behind it. The + final byte comparison stays green through that ablation (it reads THROUGH the link), + so it cannot carry this row on its own. + """ + project = tmp_path / "proj" + project.mkdir() + _resolve_repo(project) + spec = tmp_path / "artifacts" / "spec.md" # outside the project, and outside any mount + spec.parent.mkdir(parents=True, exist_ok=True) + spec.write_text( + "---\nstatus: blocked\n---\n\n## Intent\n\nx\n\n## Auto Run Result\n\nterminal\n", + encoding="utf-8", + ) + before = spec.read_bytes() + bystander = tmp_path / "artifacts" / "someone-elses-notes.md" + bystander.write_bytes(b"not this re-arm's file\n") + bystander_before = bystander.read_bytes() + run_dir, _, _ = _escalated_run(project, spec_file=str(spec)) + + def boom(run_dir_, state_): + # the flip and the strip have already LANDED on the real file; the name is + # redirected here, inside the window, before the undo looks at it again + spec.unlink() + spec.symlink_to(bystander) + raise MemoryError("nothing to do with the spec") + + monkeypatch.setattr(runs, "save_state", boom) + + with pytest.raises(MemoryError, match="nothing to do with the spec"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert bystander.read_bytes() == bystander_before # the preimage did NOT go through + assert not spec.is_symlink() # the name was replaced, whatever it pointed at + assert spec.read_bytes() == before + (aborted,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-aborted"] + assert aborted["rollback"] == "restored" + + def test_rearm_journals_the_spec_baseline_it_overwrote(tmp_path): """A claim the re-stamp normalizes away is the only trace of a divergence the gate can no longer report, so it lands in the journal on the way out — read diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index cd978957..a82f4eb6 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4517,6 +4517,34 @@ async def test_story_checkpoint_card_surfaces_real_review_cycles(project, monkey assert "verification passed" not in line +def test_tui_rearm_refuses_an_alive_run_before_any_mutation(project, monkeypatch): + """The liveness helper's result must control the re-arm, not merely be observed.""" + from bmad_loop import runs + + notes: list[str] = [] + rearms: list[str] = [] + run_id = "20260611-100000-aaaa" + run_dir = project.project / RUNS_DIR / run_id + app = BmadLoopApp(project.project) + + def fail_if_rearm_continues(_path): + raise AssertionError("continued past liveness gate") + + monkeypatch.setattr(data, "liveness", lambda _run_dir: "alive") + monkeypatch.setattr(app, "notify", lambda message, **_kwargs: notes.append(message)) + monkeypatch.setattr(policy_mod, "load", fail_if_rearm_continues) + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda _run_dir, story_key, **_kwargs: rearms.append(story_key), + ) + + app._do_rearm(run_id, run_dir, "1") + + assert rearms == [] + assert notes == [f"run {run_id} may still be live — stop it first"] + + async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypatch): from bmad_loop import resolve, runs From c8dd4d86b4bc8cc03a8930f17586fe50878eae70 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 12:09:25 -0700 Subject: [PATCH 18/45] sweep dw2-rearm-commits-probe-record: DW-81 via bmad-loop --- CHANGELOG.md | 7 +++ docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 19 +++++--- src/bmad_loop/diagnostics.py | 24 +++++++++ src/bmad_loop/runs.py | 80 ++++++++++++++++++++++++------ src/bmad_loop/tui/app.py | 3 +- tests/test_cli.py | 49 +++++++++++++++++++ tests/test_diagnostics.py | 65 +++++++++++++++++++++++++ tests/test_portability_guard.py | 14 +++++- tests/test_resolve.py | 48 ++++++++++++++++++ tests/test_runs.py | 86 ++++++++++++++++++++++++++++++--- tests/test_tui_app.py | 22 ++++++++- 12 files changed, 389 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b7bc9a..0b5dca83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ breaking changes may land in a minor release. ### Added +- **A failed re-arm commits probe now journals `rearm-commits-probe-failed`** (DW-81). + The warn-only probe that lists the commits an abandoned attempt left below the re-drive's + new baseline used to swallow its `GitError` and write nothing — byte-identical to finding + no commits at all. It now records the baseline and the typed error, and both operator + surfaces render it through `runs.rearm_event_notice`. Advisory: it does not hold the + resume. + - **Review-gate verify commands are journalled** (#656, partial). The three review gates (`verify_review`, `verify_review_stories`, `verify_review_bundle`) now emit one `verify-command-result` per command, `verification_stage: "review"`, sharing the story's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 2eacea73..0ceb1e84 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -75,7 +75,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - CRITICAL resolution: `bmad-loop resolve ` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself. The re-arm advances the story's baseline in the **code tree** and is honest when it cannot: a failed advance is narrowed to typed git errors, journalled, echoed to stderr, and explicitly NOT followed by a re-stamp it did not earn, so - spec and task never silently agree on a stale sha (#640). A re-stamp that does overwrite a differing + spec and task never silently agree on a stale sha (#640). The re-arm's other warn-only git probe — the one that lists the commits an abandoned attempt left below the re-drive's new baseline — is honest the same way (DW-81): its Git failures journal `rearm-commits-probe-failed` and echo to the same surfaces, because that probe's silence is otherwise indistinguishable from a clean answer, and the absent warning is the operator's only sign that those commits are now a permanent starting point nothing will revisit. A re-stamp that does overwrite a differing claim records what it replaced, and warns on either leg: the record fires only when the spec claimed a baseline the run never recorded, which is the only remaining trace of a divergence the gate can no longer report. `spec_file` is persisted relative to a worktree for an isolated task, so every out-of-process reader diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 6a8a285a..39d091d7 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2965,10 +2965,16 @@ def _resolve_restore_patch( def _echo_rearm_events(run_dir: Path, before: list[dict[str, Any]] | None) -> bool: - """Surface the events a just-completed re-arm journaled: the `stale-restore-*` - residue of the restore attempt it abandoned (runs._stale_restore_residue), and the - `rearm-*` records the status flip, the advance and the re-stamp write. The commits - variant is the one the human must act on — nothing else will. + """Surface the events a just-completed re-arm journaled: the residue of the restore + attempt it abandoned — the `stale-restore-*` records AND `rearm-commits-probe-failed`, + all written by `runs._stale_restore_residue` — and the `rearm-*` records the status + flip, the advance and the re-stamp write. Split by PRODUCER, not on the prefix, + because the prefix does not partition them: the commits probe's failure record is + spelled `rearm-*` for the re-arm it degrades, not for the abandoned restore it + measures. The commits pair is what the human must act on — nothing else will tell + them — and it takes both, because `stale-restore-commits` is written only when the + probe ANSWERED: without its twin, that record's absence reads as "clean" whether or + not anyone could tell. Named for the re-arm, not for the stale restore: it began as a `stale-restore-*` echo and now carries the baseline family too, so a name from the narrower era @@ -3266,8 +3272,9 @@ def cmd_resolve(args: argparse.Namespace) -> int: # In the `finally`, not after the `try`: `_stale_restore_residue` journals # BEFORE the re-stamp block that raises `RearmError`, so on that path the # records were already written and returning early threw them away — including - # `stale-restore-commits`, the one record whose whole point is that nothing - # else will tell the human. An abort is when that residue matters most: the + # the commits PAIR (`stale-restore-commits` when the probe answered, + # `rearm-commits-probe-failed` when it could not), whose whole point is that + # nothing else will tell the human. An abort is when that residue matters most: the # re-arm half-ran and the operator has to decide what to do with the tree. hold_resume = _echo_rearm_events(run_dir, before_entries) print( diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 87b943d3..99c72ee6 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -133,6 +133,30 @@ # single record: alias one and leave the other and a dump pseudonymizes half a # comparison, which is worse than either doing both or doing neither. "overwritten": "commit", + # A THIRD spelling of a baseline sha, journalled by `runs._stale_restore_residue` + # on BOTH of its commit records: `stale-restore-commits` (the probe answered, and + # these shas sit above it) and `rearm-commits-probe-failed` (the probe could not + # answer). Routing is by NAME, so this one entry gives that one value ONE alias + # across both kinds — which is also why the producer was not respelled to the + # already-routed `baseline`: that would give one sha two spellings and two + # aliases in a single dump. + # + # Routed for CORRELATION, not to stop a leak — this table's header states that + # purpose ("pseudonymized, not dropped, so events stay correlatable") and it is + # the whole reason this entry exists. Left unrouted a real sha does NOT ship + # verbatim: `_scrub_str` applies `looks_like_secret` AFTER `looks_like_identifier`, + # and a real 40-hex sha clears the length+entropy bar, so the fallback renders + # `` — USUALLY, and the exception is the point. Real shas + # straddle that check's bar: measured over this repo's own history (1790 commits, + # 2026-08-31), about one sha in twenty-five is NOT caught and ships verbatim. So + # routing closes a real, intermittent leak. On the ~96% the fallback does catch it + # buys the other thing this table exists for: `` is safe and + # useless — an operator can no longer tell that the probe-failure record and the + # commits record name the SAME baseline, which is the one comparison those two + # kinds exist to support. That the name ALSO had to leave the routing guard's + # benign inventory follows from that inventory's own rule: a name carrying a sha + # belongs in a `diagnostics` table. + "old_baseline": "commit", # A spec name IS the customer's feature name — `Pseudonymizer`'s own docstring # has always listed "spec filenames" among what it exists to alias, so the # omission here was a routing gap, not a policy. A producer that journals a diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 74d6c3a2..09a6a298 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -4750,6 +4750,34 @@ def rearm_event_notice( "abandoned attempt rather than your resolve, revert them now", "", ) + if kind == "rearm-commits-probe-failed": + # The sibling row above is written only when the probe ANSWERED, so its + # absence used to mean either "no commits from the abandoned attempt" or + # "the probe could not tell" — and nothing downstream could separate them. + # The range is named in BOTH halves on purpose: `resolve` prints the + # next_step and the TUI drops it, so the message has to stand alone there. + baseline = str(entry.get("old_baseline", "?")) + # A first-party value is a full rev-parse hex result. Refuse to turn a + # malformed persisted value into a copy/paste command, and collapse control + # characters from git's detail before either operator surface renders it. + base = baseline[:12] if re.fullmatch(r"[0-9a-fA-F]{12,}", baseline) else "unknown" + detail = str(entry.get("error", "?")) + if baseline: + # `commits_above` repeats the complete baseline in its GitError. The + # display label and command deliberately use the short form, so leaving + # the full value in the detail defeated that truncation on both surfaces. + detail = detail.replace(baseline, base) + detail = re.sub(r"[\x00-\x1f\x7f\ud800-\udfff]+", " ", detail) + if len(detail) > 4096: + detail = detail[:4093] + "..." + return ( + "warning", + f"could not list the commits above the abandoned attempt's baseline " + f"({base}..) — {detail}; this silence proves nothing, so fix the Git " + f"failure, then run `git log {base}..HEAD` yourself and revert anything " + "that did not come from your resolve", + f"Fix the Git failure, then check `git log {base}..HEAD` before resuming", + ) if kind == "rearm-baseline-advance-failed": return ( "warning", @@ -4980,11 +5008,15 @@ def rearm_holds_the_resume(entry: dict[str, Any]) -> bool: but futile: the re-drive re-plans from a tree that never saw the correction and mints the same sentinel again. - The other warnings stay advisory and do NOT hold. `stale-restore-commits`, - `stale-restore-unparseable` and `rearm-baseline-advance-failed` each report - something an operator may need to act on, but none of them PROVES the re-drive - cannot route, and holding on a maybe would turn the ordinary degrade path into a - two-command gesture for an outcome nothing decided. + The other warnings stay advisory and do NOT hold — `stale-restore-commits`, + `stale-restore-unparseable`, `rearm-commits-probe-failed` and + `rearm-baseline-advance-failed` among them: each reports something an operator may + need to act on, but none of them PROVES the re-drive cannot route, and holding on a + maybe would turn the ordinary degrade path into a two-command gesture for an + outcome nothing decided. `rearm-commits-probe-failed` is the newest and the least + tempting to promote: it says the commits probe could not answer, which is strictly + LESS than the answer — it proves nothing about whether the re-drive can route, only + that one advisory could not be computed. Not folded into `rearm_event_notice`'s tuple, because they are different questions asked of the same entry: that table answers "what do I tell the operator", this @@ -5021,8 +5053,11 @@ def _stale_restore_residue( human is the classifier. `bmad-loop resolve` echoes these to stderr. Best-effort throughout: a deleted or unreadable patch, a non-repo project, a - bad old baseline — none may wedge a resolve. A patch parse failure journals - its degrade; a commits-probe Git failure deliberately degrades silently. + bad old baseline — none may wedge a resolve. Both degrades journal a record of + their own: a patch parse failure writes `stale-restore-unparseable`, and a + commits-probe Git failure writes `rearm-commits-probe-failed` (DW-81). Neither + is silent, because the sibling record's ABSENCE is what an operator reads as + "clean" — and a probe that could not answer is not the same claim. """ if not old_latch: return set() @@ -5053,17 +5088,34 @@ def _stale_restore_residue( if old_baseline: try: shas = verify.commits_above(repo, old_baseline) - except verify.GitError: - # Follow rearm_escalation's baseline-advance taxonomy boundary; - # this warn-only probe remains silent. - shas = [] - if shas: + except verify.GitError as e: + # Follows rearm_escalation's baseline-advance taxonomy boundary, and the + # catch stays EXACTLY `verify.GitError`: every fault `_run_git` can + # translate (spawn, timeout, decode, non-zero rc) lands here, and a + # non-git fault still escapes to the re-arm's transaction guard. + # + # Warn-only, but no longer silent. This arm used to set `shas = []` and + # fall through to the `if shas:` gate below, which made a probe that + # FAILED byte-identical — on every downstream surface — to a probe that + # found no commits above the old baseline. The operator was told nothing + # either way, and the absent record is the only warning they get that the + # abandoned attempt's commits may still be sitting under the re-drive's + # new baseline. The `else:` is what retires the sentinel rather than + # leaving it inert: no `shas` exists on this leg to gate on. journal.append( - "stale-restore-commits", + "rearm-commits-probe-failed", story_key=story_key, old_baseline=old_baseline, - commits=shas, + error=f"{e.__class__.__name__}: {e}", ) + else: + if shas: + journal.append( + "stale-restore-commits", + story_key=story_key, + old_baseline=old_baseline, + commits=shas, + ) return residue diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 6c96f528..2b59c5a8 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -989,7 +989,8 @@ def _do_rearm( # In the `finally`, matching `cli.cmd_resolve`. `_stale_restore_residue` # journals BEFORE the re-stamp block that raises `RearmError`, so on that # path the records were already written and returning early threw them - # away — including `stale-restore-commits`, the one record whose whole + # away — including the commits PAIR (`stale-restore-commits` when the probe + # answered, `rearm-commits-probe-failed` when it could not), whose whole # point is that nothing else will tell the human. This surface used to # `return` there while the CLI echoed, so the two DID drift on the abort # path even after they were unified on routing — and an abort is when the diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c81aac5..9c3b4c8a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3175,6 +3175,55 @@ def fake_rearm( assert commits.endswith("revert them now") +def test_resolve_echoes_the_commits_probe_failure(tmp_path, monkeypatch, capsys): + """The failed commits probe reaches stderr, imperative and all (DW-81). + + Before it had a record, a probe that FAILED and a probe that found nothing were + byte-identical here: neither journalled, so neither printed, and the operator was + never told that the abandoned attempt's commits might be sitting under the + re-drive's new baseline unreverted. Graded on this surface specifically because + it is the one that appends `next_step` — the TUI drops it — and because the + record is warn-only by contract, so an echo is the only place it can ever appear. + + Ablation: delete the `rearm-commits-probe-failed` row from + `runs.rearm_event_notice` and the line is not printed at all, so `next(...)` + raises `StopIteration`; drop `tail` from `_echo_rearm_events`' f-string and only + the `endswith` reddens. + """ + from bmad_loop import runs + from bmad_loop.journal import Journal + + _escalated_run(tmp_path, "r1") + baseline = "b" * 40 + + def fake_rearm( + rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + ): + Journal(rd).append( + "rearm-commits-probe-failed", + story_key=key, + old_baseline=baseline, + error=f"GitError: git rev-list {baseline}..HEAD failed in /code: " + "not a git repository", + ) + return key + + monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) + monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) + assert ( + cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--resume"]) == 0 + ) + + lines = capsys.readouterr().err.splitlines() + probe = next(ln for ln in lines if "could not list the commits above" in ln) + assert probe.startswith("warning: ") # advisory severity, rendered as the prefix + assert "bbbbbbbbbbbb.." in probe and "b" * 40 not in probe # truncated, not raw + assert "not a git repository" in probe # the typed cause survives the echo + assert probe.endswith( + "; Fix the Git failure, then check `git log bbbbbbbbbbbb..HEAD` before resuming" + ) + + def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): from bmad_loop import resolve from bmad_loop.journal import load_state diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a82e2699..01be5c00 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -660,6 +660,71 @@ def test_rearm_records_leak_neither_the_code_root_nor_a_spec_name(): assert canary not in rendered, f"LEAK: {canary!r}" +def test_the_two_commit_probe_records_alias_one_baseline_to_one_name(): + """`old_baseline` is a 40-hex sha on BOTH of `_stale_restore_residue`'s records, + and routing is by field NAME, so one entry has to cover both kinds (DW-81). + + It was declared benign in `tests/test_portability_guard.py`'s inventory, which is + the misfiling that inventory's own warning describes — "a name carrying a story + key, a branch, a sha, a spec filename, a path, or free text belongs in a + `diagnostics` table instead" — and the second producer is what forced it. + + The two kinds are graded together rather than one standing in for the other, + because the value's whole use is a comparison an operator makes across them: the + probe-failure record says "I could not tell you what sits above this sha" and the + commits record says "these do". Aliasing one spelling and not the other would + destroy that correlation. That is also why the producer was not respelled to the + already-routed `baseline` — same sha, two spellings, two aliases in one dump. + + Ablation: drop `"old_baseline"` from `_JOURNAL_ALIAS_FIELDS` and the test dies at + the `next(...)` alias lookup with `StopIteration`. The canary sweep is not the + grade: depending on its entropy, the fallback may redact a sha as a secret rather + than preserving the correlatable alias this table promises. + + `commits` remains deliberately outside this test and outside DW-81's routing + change. It is a list on `stale-restore-commits` but an integer count on + `rollback-manual-required`; routing it requires a separate, kind-scoped policy. + """ + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + probe_failed = diagnostics._scrub_entry( + { + "ts": 1.0, + "kind": "rearm-commits-probe-failed", + "story_key": STORY_KEY, + "old_baseline": SHA, + "error": f"GitError: git rev-list {SHA}..HEAD failed in {HOME_PATH}", + }, + pseudo, + {}, + 1.0, + ) + commits = diagnostics._scrub_entry( + { + "ts": 2.0, + "kind": "stale-restore-commits", + "story_key": STORY_KEY, + "old_baseline": SHA, + "commits": ["c" * 40], + }, + pseudo, + {}, + 1.0, + ) + + alias = next(a for ns, orig, a in pseudo.entries() if ns == "commit" and orig == SHA) + # aliased, not dropped — the key stays and only the VALUE is replaced + assert probe_failed["old_baseline"] == commits["old_baseline"] == alias != SHA + # one legend entry for the shared baseline, not one per record spelling + assert {orig for ns, orig, _a in pseudo.entries() if ns == "commit"} == {SHA} + # the free-text sibling on the probe record quotes both the sha and a host path + # back, and is reached by the drop set rather than aliased + assert "error" not in probe_failed and probe_failed["error_present"] is True + + rendered = json.dumps([probe_failed, commits]) + for canary in (SHA, PROPRIETARY, HOME_PATH, *CANARIES): + assert canary not in rendered, f"LEAK: {canary!r}" + + def test_sentinel_upstream_record_drops_the_stories_root_it_names(): """`rearm-upstream-write-unreachable` carries an absolute host path naming the folder a sentinel's upstream correction has to land in. diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index cd3cc13d..9e525f57 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -351,7 +351,19 @@ "next", "normalized", "ok", - "old_baseline", + # `old_baseline` is NOT here any more: it moved to `_JOURNAL_ALIAS_FIELDS` + # (the `commit` namespace) once a second producer — + # `rearm-commits-probe-failed` — forced the decision this set's own warning + # describes, and on the same footing as the `question` note above: it was a + # live leak, just an intermittent one. Unrouted, a real 40-hex sha usually + # collapses to `` at `_scrub_str`'s secret check — but only + # usually. Real shas straddle that bar, and about one in twenty-five sampled + # from this repo's own history ships VERBATIM. Routing also restores the + # correlation the alias table exists to preserve: even on the shas the + # fallback does catch, `` left the two records naming one + # baseline unable to be seen as naming the same one. Left as a note rather + # than a silent deletion, because a name leaving this set is the guard working + # — a benign declaration that turned out to be wrong. "open", "open_now", "original", diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 87094532..fbfaf171 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -4426,6 +4426,51 @@ def test_rearm_event_notice_splits_the_abort_three_ways_on_the_rollback(): assert absent_step == unknown_step # an absent field IS the unknown outcome +def test_rearm_event_notice_renders_the_commits_probe_failure(): + """The row that stops a FAILED commits probe reading as a clean one (DW-81). + + `stale-restore-commits` is written only when the probe answered, so its absence + used to carry two opposite meanings — "nothing from the abandoned attempt" and + "nobody could tell" — and neither operator surface could separate them. This row + is the separation, so it is graded on all three returned fields: + + * the truncated baseline, because that is the ref the operator has to diff from + and the record is read out of process from the journal line alone; + * the typed error, because a bad baseline and a non-repo code tree are different + things to go fix; + * the range, in the MESSAGE as well as the next_step — the TUI drops `next_step` + and resumes in the same gesture, so a message that only said "something went + wrong" would leave that surface's operator with no action at all. + + Ablation: return None for this kind and every assertion here reddens; drop the + `git log` range from the message while keeping it in the next_step and only the + message assertion does — which is the half the TUI would have lost. + """ + baseline = "abc123def456" + "0" * 28 + rec = { + "kind": "rearm-commits-probe-failed", + "story_key": "1-1-a", + "old_baseline": baseline, + "error": f"GitError: git rev-list {baseline}..HEAD failed in /code:\n" + + "fatal " + + "x" * 5000, + } + severity, message, next_step = runs.rearm_event_notice(rec) + assert severity == "warning" + assert "abc123def456.." in message # truncated to 12, as the sibling row does + assert "0" * 28 not in message # ...and NOT the whole sha + assert "GitError" in message and "rev-list" in message # the typed cause + assert "\n" not in message and len(message) < 4500 # terminal-safe and bounded + assert "proves nothing" in message # the silence is not evidence of "clean" + assert "fix the Git failure" in message # do not blindly repeat the failed probe + assert "git log abc123def456..HEAD" in message # actionable on the TUI alone + assert next_step == ( + "Fix the Git failure, then check `git log abc123def456..HEAD` before resuming" + ) + # the imperative lives ONLY in next_step: the TUI drops it and resumes here + assert "before resuming" not in message + + def test_rearm_holds_the_resume_only_on_the_record_that_proves_a_wedge(): """The hold is PROOF, not urgency — and it is asked of every kind the table knows. @@ -4451,6 +4496,9 @@ def test_rearm_holds_the_resume_only_on_the_record_that_proves_a_wedge(): "stale-restore-commits", "stale-restore-unparseable", "stale-restore-excluded", + # the probe that could NOT answer proves strictly less than the answer, so if + # `stale-restore-commits` does not hold, neither can this + "rearm-commits-probe-failed", "rearm-baseline-advance-failed", "rearm-baseline-restamp-skipped", "rearm-baseline-restamped", diff --git a/tests/test_runs.py b/tests/test_runs.py index 8d6d2222..d64121c6 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2912,7 +2912,18 @@ def _kinds(run_dir, prefix="stale-restore-"): def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): """The abandoned attempt's applied new files must NOT be blessed as pre-existing, or finalize_commit's `add -A` sweeps them into the corrected - story's commit. The resolve session's own untracked file still is.""" + story's commit. The resolve session's own untracked file still is. + + Also the commits probe's ORDINARY answer: nothing was committed above the old + baseline here, so `verify.commits_above` returns `[]` and BOTH commit records + stay away. That silence is the one an operator is entitled to read as "clean", + which is exactly why the failed probe now writes `rearm-commits-probe-failed` + instead of reproducing it (DW-81). + + Ablation: relax the producer's `if shas:` gate to `if shas is not None:` and the + `stale-restore-commits` assertion reddens; journal the probe failure outside its + `except` arm and the `rearm-commits-probe-failed` one does. + """ run_dir, _spec, patch = _stale_restore_tree(tmp_path) runs.rearm_escalation( @@ -2927,6 +2938,9 @@ def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): assert len(excluded) == 1 assert excluded[0]["files"] == ["newfile.txt"] assert excluded[0]["patch"] == str(patch) + # the probe ran and answered "none" — neither commit record may appear + assert not _kinds(run_dir, "stale-restore-commits") + assert not _kinds(run_dir, "rearm-commits-probe-failed") def test_rearm_re_latching_the_same_patch_still_excludes_its_residue(tmp_path): @@ -3008,10 +3022,19 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_path): """A bad old baseline is warn-only, and the persisted reset proves re-arm - reached its save rather than returning early. + reached its save rather than returning early — and it now leaves a RECORD. + + The probe failing used to be byte-identical to the probe finding nothing: both + wrote no journal entry, so `assert not _kinds(run_dir, "stale-restore-commits")` + below is true for two opposite reasons and cannot tell them apart. The + `rearm-commits-probe-failed` assertions are what separate them (DW-81) — without + them this test passes on a re-arm that silently swallowed the fault. Ablation: catch a type outside ``verify.GitError`` and the real rev-list - failure escapes before any of these completion assertions can run. + failure escapes before any of these completion assertions can run. Delete the + new ``journal.append("rearm-commits-probe-failed", ...)`` and the length + assertion below reddens while every pre-existing assertion here stays green — + which is the gap it was added to close. """ from bmad_loop.model import Phase @@ -3030,17 +3053,28 @@ def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_p assert task.generation == initial_generation + 1 assert task.restore_patch is None assert task.baseline_commit == git(tmp_path, "rev-parse", "HEAD") - assert not _kinds(run_dir, "stale-restore-commits") + assert not _kinds(run_dir, "stale-restore-commits") # the probe never answered... + probe = _kinds(run_dir, "rearm-commits-probe-failed") # ...and now says so + assert len(probe) == 1 + assert probe[0]["old_baseline"] == "0" * 39 + "1" # the baseline it could not read + assert probe[0]["story_key"] == "1-1-a" + # the typed error, spelled the way the sibling `rearm-baseline-advance-failed` + # spells it — `GitError: ...`, not a bare repr + assert probe[0]["error"].startswith("GitError: ") + assert "rev-list" in probe[0]["error"] excluded = _kinds(run_dir, "stale-restore-excluded") assert len(excluded) == 1 assert excluded[0]["files"] == ["newfile.txt"] def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): - """A non-repository code tree reaches the same typed, silent degrade. + """A non-repository code tree reaches the same typed, warn-only degrade — and + the same record, because the operator's exposure is identical either way. Ablation: catch a type outside ``verify.GitError`` and the pinned probe fault - escapes, so the persisted generation and latch reset never appear. + escapes, so the persisted generation and latch reset never appear. Delete the + new ``journal.append("rearm-commits-probe-failed", ...)`` and only the record + assertions redden. """ from bmad_loop.model import Phase @@ -3062,6 +3096,41 @@ def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): assert task.restore_patch is None assert task.baseline_commit == "0" * 39 + "1" assert not _kinds(run_dir, "stale-restore-commits") + probe = _kinds(run_dir, "rearm-commits-probe-failed") + assert len(probe) == 1 + assert probe[0]["old_baseline"] == "0" * 39 + "1" + assert probe[0]["error"].startswith("GitError: ") + assert len(_kinds(run_dir, "stale-restore-unparseable")) == 1 + + +def test_rearm_skips_the_commits_probe_entirely_without_a_recorded_baseline(tmp_path): + """No recorded baseline means no range to ask about, so the probe never runs — + and a probe that never ran must not journal that it FAILED. + + The `if old_baseline:` guard is what separates "there was nothing to measure + against" from "the measurement broke", and `rearm-commits-probe-failed` claims + the second. Telling an operator to go diff a range that was never established + would be the mirror of the silence DW-81 closed: a warning with no referent, + trained straight into the scroll-past habit the `restore` split exists to prevent. + + The sibling `stale-restore-unparseable` is asserted PRESENT on purpose: without + it this test is green for the uninteresting reason that + `_stale_restore_residue` returned early on a missing latch and journalled + nothing at all. It proves the function ran and only the commits block was + skipped. + + Ablation: delete the `if old_baseline:` guard and `commits_above` is handed a + `None` baseline, git fails on the `None..HEAD` range, and the record this test + denies appears. + """ + run_dir, _spec = _escalated_run(tmp_path, _SPEC_WITH_ARR, restore_patch_stale="old.patch") + assert load_state(run_dir).tasks["1-1-a"].baseline_commit is None # pin the premise + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert not _kinds(run_dir, "rearm-commits-probe-failed") + assert not _kinds(run_dir, "stale-restore-commits") + # ...while the residue pass itself did run: the latched patch is missing assert len(_kinds(run_dir, "stale-restore-unparseable")) == 1 @@ -3107,6 +3176,11 @@ def boom(repo, baseline): assert aborted["rollback"] == "restored" # a published write really was put back assert "MemoryError" in aborted["error"] assert aborted["spec_file"] == str(spec) + # ...and the degrade record is NOT one of the things it leaves behind: this fault + # is not a git answer, so the warn-only arm never runs and the abort is the whole + # story. Graded by the same narrowing as the raise above — widen the catch to + # `Exception` and the fault is swallowed into a record instead of propagating. + assert not _kinds(run_dir, "rearm-commits-probe-failed") def test_rearm_rolls_back_when_save_state_itself_fails(monkeypatch, tmp_path): diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index a82f4eb6..1ec73aa5 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -5010,7 +5010,9 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk TUI's own copy of the chain happened to handle. That copy carried `rearm-baseline-*` only and silently dropped the whole - `stale-restore-*` family, including `stale-restore-commits` — the record + `stale-restore-*` family (and would have dropped `rearm-commits-probe-failed`, + the record that says the commits probe could not answer at all), including + `stale-restore-commits` — the record `cli._echo_rearm_events`' docstring calls the one a human must act on, and the one whose whole point is that nothing else will tell them. All of it is warn-only by contract, so a toast is the only place this path can ever show it, @@ -5050,6 +5052,16 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): commits=["c1", "c2"], ) journal.append("stale-restore-excluded", story_key=sk, patch="a.patch", files=["new.txt"]) + # The commits record's TWIN: the probe that could not answer at all. It rides + # this walk because the pair is the whole point — the record above is written + # only when the probe answered, so without this one its absence reads as + # "clean" on the surface that resumes in the same gesture (DW-81). + journal.append( + "rearm-commits-probe-failed", + story_key=sk, + old_baseline="e" * 40, + error=f"GitError: git rev-list {'e' * 40}..HEAD failed in /code: fatal", + ) journal.append( "rearm-baseline-restamp-skipped", story_key=sk, @@ -5115,6 +5127,14 @@ def severity_of(fragment: str) -> str: assert severity_of("2 commit(s) sit below the re-drive's new baseline (ffffffffffff..)") == ( "warning" ) + # ...and its twin, the probe that could not answer — advisory, so a toast is the + # only place this surface can ever show it + assert severity_of("could not list the commits above the abandoned attempt's baseline") == ( + "warning" + ) + # the range is carried in the MESSAGE, because this surface drops `next_step` + assert any("git log eeeeeeeeeeee..HEAD" in n[0] for n in notes), notes + assert not any("e" * 40 in n[0] for n in notes), notes assert severity_of("is not a readable file from here") == "warning" assert severity_of("could not be re-opened to `ready-for-dev`") == "warning" # `note` maps onto Textual's own channel name, not through unchanged From c9ff25a50c6c426897f2e10c2843fcd50d317ab7 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 13:35:41 -0700 Subject: [PATCH 19/45] fix(loop): journal spent review budgets, dedupe harvest sightings, clamp at word boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bmad-loop notes from the build-review-economics work: - devcontract._flatten clamps at a word boundary: a mid-word cut backs up to the last join space, a cut ending exactly at a word end keeps the full cut, and a single unbroken token keeps the hard clamp (the DW-91-style mid-word `reason:` cut). - The process-exhaust ticket class (DW-55/64/90) is retired: a finalized, verify-green story whose review still recommends a follow-up journals the spent budget (review-followup-damped / review-budget-committed, the `refiled` field dropped, `re_review_capped` and the notify rules kept) and files no ledger entry. The refiled_followups record, its model field, and the #425 _carry_review_budget_followups carry are removed (three post-merge carries remain); DW-90 is closed in the ledger. - Harvest dedupes cross-spec sightings (DW-88 vs DW-65): a real finding that is not this spec's own replay but matches an OPEN entry — identical fingerprinted origin harvested from another spec, or a `DW-:`-prefixed summary — stamps a `seen-again:` line via the new deferredwork.mark_seen_again_many instead of filing a duplicate, and never enters harvested_deferrals, so the isolated carry cannot re-file it. Done entries never match; recurrence after a close files fresh. The spec-deferrals-harvested event gains an additive `seen_again` field. DW-88 is closed as a duplicate of DW-65. Gate: full pytest 7730 passed / 51 skipped, pyright 0 errors, trunk fmt/check clean. --- src/bmad_loop/deferredwork.py | 50 +++++ src/bmad_loop/devcontract.py | 18 +- src/bmad_loop/engine.py | 294 ++++++++++---------------- src/bmad_loop/model.py | 5 - src/bmad_loop/worktree_flow.py | 8 +- tests/test_deferredwork.py | 56 +++++ tests/test_devcontract.py | 12 ++ tests/test_engine.py | 193 ++++++++++++++--- tests/test_engine_worktree.py | 355 +++----------------------------- tests/test_model.py | 18 +- tests/test_portability_guard.py | 1 + tests/test_sweep.py | 28 +-- 12 files changed, 458 insertions(+), 580 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 36323cab..97c0793c 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -1018,6 +1018,56 @@ def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: return bool(mark_done_many(path, [dw_id], date, note)) +def mark_seen_again_many( + path: Path, dw_ids: Sequence[str], date: str, note: str +) -> tuple[list[bool], str | None]: + """Stamp `seen-again: ()` under each entry's status line, in ONE + read and ONE atomic write. Returns one applied flag per id, plus the text it + published — None when it wrote nothing (every id missing, or already carrying + this exact line). + + The writer half of the format doc's dedupe rule (deferred-work-format.md): a + finding that matches an existing entry is recorded as a sighting on that + entry, never as a duplicate entry. Idempotent per (id, date, note): a replay + stamping the same line is skipped, so a flag reads "this call inserted it", + not "the line is there". A missing id is False rather than an error — the + caller matched against a snapshot, and a rival may have archived the entry + since. A missing ledger applies nothing and takes no lock, like + :func:`mark_done_many`'s absent-file arm. + + ONE locked read->edit->write (#286/#469): each insert lands on the text the + previous one produced (:func:`_find_entry` re-parses the evolving text, so + spans stay honest), and the published text is handed back from inside the + hold for the same ``post_engine_ledger_digest`` reasons as + :func:`append_entries_published`. `date` is orchestrator-owned and raises + when malformed; `note` is sanitized to one line (#305). + """ + _require_iso_date(date) + line = f"seen-again: {date} ({_one_line(note)})" + if not dw_ids: + return [], None + if not path.is_file(): + return [False for _ in dw_ids], None + with ledger_lock(path): + if not path.is_file(): + return [False for _ in dw_ids], None + text = path.read_text(encoding="utf-8") + applied: list[bool] = [] + for dw_id in dw_ids: + entry = _find_entry(text, dw_id) + if entry is None or line in entry.body: + applied.append(False) + continue + text = _insert_after_status(text, entry, line) + applied.append(True) + if not any(applied): + return applied, None + atomic_write_text(path, text) + # Returned from INSIDE the hold: this is the published text by + # construction, not a read-back that a rival could have moved. + return applied, text + + _MARK_DONE_TAIL_RE = re.compile( r"\nresolution:[ \t]*(.*)" r"\nresolution-undo:[ \t]*([0-9a-f]{64})[ \t]+" diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 0c5ff0dc..19f7aa87 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -198,15 +198,23 @@ def harvest_fingerprint(*parts: str) -> str: def _flatten(value: Any, limit: int) -> str: - """Collapse a YAML scalar to one clamped line. + """Collapse a YAML scalar to one line clamped at a word boundary. - Strip after clamping because the cut can land on a join space. Keeping that - cleanup here makes the value written to the ledger and the value used in its - fingerprint identical. + A cut landing mid-word backs up to the last join space so the ledger never + records a truncated half-token; a cut ending exactly at a word end keeps the + full cut, and a single unbroken token longer than the limit keeps the hard + clamp. Fingerprints (``harvest_fingerprint(summary, location)``) therefore + drift only for over-limit multi-word values -- accepted. """ if value is None: return "" - return " ".join(str(value).split())[:limit].strip() + text = " ".join(str(value).split()) + if len(text) <= limit: + return text + if text[limit] == " ": + return text[:limit] + head, sep, _ = text[:limit].rpartition(" ") + return head if sep else text[:limit] def _is_yaml_scalar(value: Any) -> bool: diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 8ea90b1b..a81fd926 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1594,7 +1594,6 @@ def _replay_unlatched_ledger_carries(self) -> None: if not task.worktree_path or not ( task.harvested_deferrals or task.bundle_closes_intended - or task.refiled_followups or task.story_closes_intended or task.board_advance_intended ): @@ -2334,24 +2333,6 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # later isolated carry. Crash replay never enters this branch. if feedback is None: task.harvested_deferrals = [] - # Same rule, and the abandoned attempt's ledger row is already - # gone: an escalation leaves the unit worktree mounted and - # unmerged, and the re-drive that reaches here discarded it - # (`_finish_inflight`'s resume-restart arm), taking the only - # copy of that row. Carrying the record anyway files a - # follow-up against the attempt that COMMITTED, whose review - # recommended none — and `append_entry` has nothing to dedupe - # it against, so a tracked ledger commits the wrong row rather - # than absorbing it (#457). `rearm_escalation` already - # voids the history behind the record by resetting - # `followup_reviews_spent` for a fresh damping budget. - # - # The fixable-retry exemption above is inherited, not reasoned: - # this field's one producer fires on a finalized, verify-green - # story immediately before `_commit`, and no path leads from - # there back into a `feedback is not None` iteration, so such an - # iteration can never hold a record to preserve. - task.refiled_followups = [] # 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 @@ -2920,11 +2901,9 @@ def _review_and_commit( outcome = self._verify_review(task) if outcome.ok: if damped: - # refile BEFORE break so the ledger edit squashes into the - # same story commit (mirrors the exhaustion rescue ordering). # Verify-green here is the same authority as the converged / # rescue paths — never ships uncompleted work. - self._record_review_budget_followup(task, damped=True) + self._journal_review_budget_spent(task, damped=True) clean = True break self.journal.append( @@ -2974,9 +2953,9 @@ def _review_and_commit( # (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 re-file the lingering follow-up as a - # fresh deferred-work entry instead of rolling everything back (the - # failure mode that silently threw away review-passing work). + # 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 @@ -2986,14 +2965,14 @@ def _review_and_commit( # 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 - # re-file a follow-up the last pass did not actually recommend. Only for + # record a follow-up the last pass did not actually recommend. Only for # the non-isolated path: in worktree isolation a defer already keeps the # unit's worktree + patch (no work is lost), so there is nothing to # rescue and committing into the main repo would be wrong. if refileable_followup and not self._isolated: rescue = self._verify_review(task) if rescue.ok: - self._record_review_budget_followup(task) + self._journal_review_budget_spent(task) self._commit(task) return if rescue.contradiction: @@ -3127,9 +3106,9 @@ def _salvage_review_timeout(self, task: StoryTask, result: SessionResult) -> boo refiled: str | None = None if task.followup_review_recommended: # Refile BEFORE _commit so the ledger edit squashes into the story - # commit (mirrors _record_review_budget_followup's ordering). A new - # origin string: the review-budget-followup origin's wording and - # re-review cap are load-bearing for that path and must not blur. + # 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. refiled = deferredwork.append_entry( self.workspace.paths.deferred_work, title=( @@ -4114,6 +4093,15 @@ def _harvest_spec_deferrals( 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. """ if not self._generic_dev(): return @@ -4207,6 +4195,52 @@ def _harvest_spec_deferrals( return spec_name = spec_path.name + # 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 + text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + 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. + seen_again_ids: list[str] = [] + harvestable: list[devcontract.DeferredFinding] = [] + for finding in findings: + origin = f"{HARVEST_ORIGIN} {finding.fingerprint}" + if any( + deferredwork.field_line_present(entry.body, "origin", origin) + and deferredwork.field_line_present(entry.body, "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.body, "origin", origin) + or finding.summary.startswith(f"{entry.id}:") + ) + ), + None, + ) + if match is None: + harvestable.append(finding) + elif 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. @@ -4218,7 +4252,7 @@ def _harvest_spec_deferrals( finding.location or None, finding.severity or None, ) - for finding in findings + for finding in harvestable ] if malformed: self.journal.append( @@ -4275,9 +4309,28 @@ def _harvest_spec_deferrals( # already latched and its separate pre-write save will be skipped. self._save() - ledger = self.workspace.paths.deferred_work - text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" - seen = deferredwork.parse_ledger(text) + 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() + marked_published = deferredwork.mark_seen_again_many( + ledger, + seen_again_ids, + self._today(), + f"spec-deferral harvest of {spec_name}", + )[1] + if marked_published is not None: + # 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). + task.post_engine_ledger_digest = _digest_of(marked_published) + self._save() + specs: list[deferredwork.EntrySpec] = [] deduped = 0 for origin, title, reason, location, severity in pending: @@ -4347,6 +4400,7 @@ def _harvest_spec_deferrals( story_key=task.story_key, spec=spec_name, dw_ids=filed, + seen_again=seen_again_ids, deduped=deduped, malformed=len(malformed), ) @@ -4468,9 +4522,9 @@ def _close_declared_deferred( # 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 `_dev_phase` clear like - # `refiled_followups`' would be a second branch saying the same thing, which - # no test could redden). The live read is authoritative: a resolve session + # 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 = [] @@ -6649,12 +6703,13 @@ def _fix_phase(self, task: StoryTask, reason: str) -> Decision: # is right only when the repair actually ran (#489). return Decision(Action.DEFER, session_failure) - def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) -> None: - """A *finalized, verify-green* story that the review pass kept recommending - a follow-up for is being committed (not rolled back); preserve the lingering - recommendation as a new open deferred-work entry so a later, deliberate - review can pick it up. Called immediately before ``_commit`` so the ledger - edit is squashed into the same commit. + 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`` @@ -6666,31 +6721,28 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) 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 such an entry (a - sweep bundle closing a ``review-budget-followup`` id), don't re-file again - — commit + notify only, so a second non-convergence reaches a human - instead of slowly looping across sweeps. The loud re-review notice fires on - both paths (a capped story that still won't converge must reach a human even - under damping).""" + 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 - spec = Path(task.spec_file).name if task.spec_file else task.story_key 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}; this entry " - f"preserves the lingering recommendation for a deliberate later review." + 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}; this entry preserves the lingering follow-up " - f"recommendation for a deliberate later review." + f"{self.state.run_id}; the spent budget is journaled and no ledger entry " + f"is filed." ) re_review = False if task.dw_ids and ledger.is_file(): @@ -6704,56 +6756,12 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) ) for i in task.dw_ids ) - refiled: str | None = None - if not re_review: - tail = "the damping cap was spent" if damped else "the review budget was exhausted" - title = f"Follow-up review still recommended for {task.story_key} after {tail}" - entry = { - "title": title, - "origin": "review-budget-followup", # verbatim: re-review cap + replay dedupe key - "source_spec": spec, - "reason": reason, - "severity": "low", - } - # Persist the intent BEFORE the write, never after it succeeds. This - # writes the ACTIVE workspace's ledger, so under isolation the row is - # inside a unit worktree that `close_unit_workspace` deletes, and a - # gitignored one is skipped by `finalize_commit`'s `git add -A` in - # silence — the run journals `refiled: DW-n` having filed nothing a - # later sweep can reach (#425). The DONE-leg carry is the delivery - # path, and it reads only PERSISTED records. - # - # Recording after the append instead loses the row outright on a hard - # host loss: nothing saves between here and `_commit`'s COMMITTING - # save, and that window spans every blocking `pre_commit_gate` - # workflow (the shipped TEA plugin binds three, each a live session). - # The resumed run replays this same review result in the same - # worktree, `append_entry` dedupes the already-open row to None, the - # record is never made, and the carry finds an empty payload. - # - # Keyed dedupe, not an `if refiled:` gate, for the same reason - # `_harvest_spec_deferrals` keeps a stable union: a replay must not - # append a second copy, but it must not need a NEW id to record - # authorship either. Safe to pre-latch — `refiled_followups` is a - # record, not a suppression bit: both consumers (replay eligibility, - # the carry) only ever make the engine do more, and `append_entry` - # dedupes an already-open row, so recording an append that then fails - # costs at most a carry that files the row the operator was owed. - known = { - (str(item.get("origin", "")), str(item.get("source_spec", ""))) - for item in task.refiled_followups - } - if (entry["origin"], entry["source_spec"]) not in known: - task.refiled_followups.append(entry) - self._save() - refiled = deferredwork.append_entry(ledger, **entry) if damped: self.journal.append( "review-followup-damped", story_key=task.story_key, cycle=task.review_cycle, cap=cap, - refiled=refiled, re_review_capped=re_review, ) else: @@ -6761,7 +6769,6 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) "review-budget-committed", story_key=task.story_key, cycles=cycles, - refiled=refiled, re_review_capped=re_review, ) note = reason @@ -7095,25 +7102,19 @@ def _carry_isolated_ledger_writes(self, task: StoryTask) -> None: 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 two appends lead, why the story close - below trails them, and why ``SweepEngine``'s override runs its bundle + 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 collision is reachable, not theoretical: a story may declare - ``closes_deferred:`` on the very ``review-budget-followup`` row that - ``_carry_review_budget_followups`` is about to dedupe against. Close it - first and the follow-up is re-filed as a second entry. - 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 all three and is ordered freely: it writes + ``_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) - self._carry_review_budget_followups(task) self._carry_story_deferred_closes(task) self._carry_board_advance(task) @@ -7206,82 +7207,10 @@ def _carry_harvested_deferrals(self, task: StoryTask) -> None: self._save() self.journal.append("harvest-carried", story_key=task.story_key, dw_ids=carried) - def _carry_review_budget_followups(self, task: StoryTask) -> None: - """Re-file an isolated unit's review-budget follow-ups into the main ledger. - - The third producer in the ``git add -A`` family (#425). - ``_record_review_budget_followup`` runs on a finalized, verify-green story - the review pass would not stop recommending a follow-up for; under - isolation that write is correct but is silently dropped when the main - ledger is gitignored. Hence a carry rather than a guard, which would - suppress a legitimate entry on every isolated run. - - No ``_isolated`` predicate: ``refiled_followups`` is populated by that one - producer and this hook is reached only from the isolated DONE leg and its - replay, so the record IS the guard. - - Unconditional and idempotent — ``append_entry`` dedupes an OPEN row with - the same ``origin:`` + ``source_spec:``, while an already-CLOSED row with - that provenance earns a fresh entry, exactly as a recurrence does in - place. That is what lets the producer record its intent BEFORE its own - append, which durability requires, so ``review-followup-carried`` with - ``dw_ids == []`` is an ordinary outcome and not a carry that ran on - nothing. - - A TRACKED ledger is safe unconditionally: no exclude or ignore rule masks a - tracked file's MODIFICATION, so the unit's own write always rides the merge - and its row arrives already deduped — yielding an empty ``carried`` and no - commit. - - The commit is best effort — unlike ``_carry_harvested_deferrals``, whose - re-raise is backed by ``harvest_carry_commit_pending`` — because it can - only ever FAIL: the sole ledger shape that reaches it is a gitignored one, - and ``git add`` refuses an ignored path with rc 1 every time, so a - commit-pending latch would just retry a refusal. Nor can it leave the tree - dirty, the shape it writes being the one git does not see. The row on disk - is the value; the commit is bookkeeping. - - Every record must belong to the attempt now being committed — a premise - ``_dev_phase`` enforces, not this frame. A record left over from an - ABANDONED attempt died with its discarded worktree, so it has nothing - upstream to dedupe against and the carry would append AND commit a row - about work that never landed; the fresh-attempt clear beside - ``harvested_deferrals`` is what prevents that (#457). - """ - if not task.refiled_followups: - return - ledger = self.paths.deferred_work - specs = [ - deferredwork.EntrySpec( - title=str(item["title"]), - origin=str(item["origin"]), - source_spec=str(item["source_spec"]), - reason=str(item["reason"]), - severity=str(item["severity"]) if item.get("severity") else None, - ) - for item in task.refiled_followups - ] - carried = [dw_id for dw_id in deferredwork.append_entries(ledger, specs) if dw_id] - if carried: - try: - verify.commit_paths( - self.paths.repo_root, - f"chore(deferred-work): carry {task.story_key}'s review follow-up", - [ledger], - ) - except verify.GitError as e: - self.journal.append( - "review-followup-carry-uncommitted", - story_key=task.story_key, - dw_ids=carried, - error=str(e), - ) - self.journal.append("review-followup-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 fourth and last producer in the ``git add -A`` family. + 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 @@ -7309,8 +7238,7 @@ def _carry_story_deferred_closes(self, task: StoryTask) -> None: with ``dw_ids == []`` is an ordinary outcome. Best effort, like ``SweepEngine``'s close and unlike - ``_carry_harvested_deferrals`` — and, as with - ``_carry_review_budget_followups``, because the commit can only ever FAIL + ``_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 diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index d6b17f56..16bb3af0 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -256,9 +256,6 @@ class StoryTask: # JSON-native containers only; callers persist these through state.json. harvested_deferrals: list[dict[str, Any]] = field(default_factory=list) bundle_closes_intended: list[str] = field(default_factory=list) - # `append_entry` kwargs for review-budget follow-ups this task filed into the - # ACTIVE workspace's ledger, which under isolation is the unit worktree's. - refiled_followups: list[dict[str, Any]] = field(default_factory=list) # Deferred-work ids a story DECLARED it closes (`closes_deferred:`), recorded at # the commit boundary. Same ledger, same isolation problem: a gitignored path # never merges out of the unit worktree, so the flip has to be re-applied. @@ -455,7 +452,6 @@ def to_dict(self) -> dict[str, Any]: "ledger_changed_before_harvest": self.ledger_changed_before_harvest, "harvested_deferrals": self.harvested_deferrals, "bundle_closes_intended": self.bundle_closes_intended, - "refiled_followups": self.refiled_followups, "story_closes_intended": self.story_closes_intended, "board_advance_intended": self.board_advance_intended, "accepted_dev_session_index": self.accepted_dev_session_index, @@ -640,7 +636,6 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": ledger_changed_before_harvest=bool(d.get("ledger_changed_before_harvest", False)), harvested_deferrals=[deepcopy(dict(item)) for item in d.get("harvested_deferrals", [])], bundle_closes_intended=[str(i) for i in d.get("bundle_closes_intended", [])], - refiled_followups=[deepcopy(dict(item)) for item in d.get("refiled_followups", [])], story_closes_intended=[str(i) for i in d.get("story_closes_intended", [])], board_advance_intended=( str(d["board_advance_intended"]) diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index ba2fa42f..96644a77 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1783,10 +1783,10 @@ def _carried_artifact_rels(self, repo: Path) -> tuple[str, ...]: ``clean_incoming_collisions``' ``protected`` operand (#618). The sprint board and the deferred-work ledger, because those are the two - files the four post-merge carries name: ``_carry_harvested_deferrals``, - ``_carry_review_budget_followups`` and ``_carry_story_deferred_closes`` pass - ``paths.deferred_work`` and ``_carry_board_advance`` passes - ``paths.sprint_status``, all four to ``verify.commit_paths`` against this same + files the three post-merge carries name: ``_carry_harvested_deferrals`` + and ``_carry_story_deferred_closes`` pass ``paths.deferred_work`` and + ``_carry_board_advance`` passes + ``paths.sprint_status``, all three to ``verify.commit_paths`` against this same ``repo``. That call stages by PATHSPEC — `git add -- :(literal)` — so whatever the working tree holds at that path is committed no matter who wrote it, and a merge that walked past an operator's edit there hands the run its diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 64668b08..6297805d 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -29,6 +29,7 @@ mark_done_many_reopenable, mark_open, mark_open_many, + mark_seen_again_many, next_seq, open_ids, parse_declaration, @@ -147,6 +148,61 @@ def test_mark_done_missing_entry(tmp_path): assert path.read_text(encoding="utf-8") == snapshot +def test_mark_seen_again_many_inserts_after_status(tmp_path): + path = write_ledger(tmp_path) + applied, published = mark_seen_again_many( + path, ["DW-1"], "2026-08-31", "spec-deferral harvest of spec-2-2-b.md" + ) + assert applied == [True] + text = path.read_text(encoding="utf-8") + assert published == text + assert "status: open\nseen-again: 2026-08-31 (spec-deferral harvest of spec-2-2-b.md)" in text + entries = {e.id: e for e in parse_ledger(text)} + assert entries["DW-1"].open # the line does not disturb status parsing + assert "seen-again: 2026-08-31" not in entries["DW-3"].body # only the target + + +def test_mark_seen_again_many_is_idempotent_on_replay(tmp_path): + path = write_ledger(tmp_path) + assert mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x")[0] == [True] + snapshot = path.read_text(encoding="utf-8") + applied, published = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x") + assert applied == [False] and published is None + assert path.read_text(encoding="utf-8") == snapshot + # a different sighting (new date) is a new line, not a dupe to skip + assert mark_seen_again_many(path, ["DW-1"], "2026-09-01", "harvest of x")[0] == [True] + assert path.read_text(encoding="utf-8").count("seen-again:") == 3 # DW-3 owns one + + +def test_mark_seen_again_many_missing_id_is_false(tmp_path): + path = write_ledger(tmp_path) + applied, published = mark_seen_again_many(path, ["DW-99", "DW-1"], "2026-08-31", "harvest of x") + assert applied == [False, True] and published is not None + assert "seen-again: 2026-08-31 (harvest of x)" in path.read_text(encoding="utf-8") + # a missing ledger applies nothing and creates nothing + missing = tmp_path / "absent" / "deferred-work.md" + assert mark_seen_again_many(missing, ["DW-1"], "2026-08-31", "x") == ([False], None) + assert not missing.exists() + + +def test_mark_seen_again_many_sanitizes_the_note_to_one_line(tmp_path): + path = write_ledger(tmp_path) + applied, _ = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest\nof spec-x.md") + assert applied == [True] + text = path.read_text(encoding="utf-8") + assert "seen-again: 2026-08-31 (harvest of spec-x.md)" in text + # the break never minted a phantom entry or truncated DW-1's span + assert [e.id for e in parse_ledger(text)] == ["DW-1", "DW-2", "DW-3"] + + +def test_mark_seen_again_many_raises_on_a_bad_date_without_writing(tmp_path): + path = write_ledger(tmp_path) + snapshot = path.read_text(encoding="utf-8") + with pytest.raises(ValueError): + mark_seen_again_many(path, ["DW-1"], "08/31/2026", "x") + assert path.read_text(encoding="utf-8") == snapshot + + def test_mark_open_round_trips_one_reopenable_close_character_for_character(tmp_path): path = write_ledger(tmp_path) before = path.read_text(encoding="utf-8") diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index aa46c375..a91440b2 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -1934,6 +1934,18 @@ def test_flatten_strips_a_join_space_at_the_clamp_boundary(tmp_path): assert finding.fingerprint == devcontract.harvest_fingerprint("s", finding.location) +@pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param("alpha betas gamma", "alpha", id="mid-word cut backs up to the boundary"), + pytest.param("alpha beta gamma", "alpha beta", id="cut at a word end keeps the full cut"), + pytest.param("a" * 30, "a" * 10, id="single unbroken token keeps the hard clamp"), + ], +) +def test_flatten_clamps_at_a_word_boundary(raw, expected): + assert devcontract._flatten(raw, 10) == expected + + def test_deferred_fingerprint_ignores_evidence_but_tracks_location(tmp_path): def fingerprint(path: Path, evidence: str, location: str) -> str: findings, _ = devcontract.parse_deferred_findings( diff --git a/tests/test_engine.py b/tests/test_engine.py index 6e4ba95f..7ef3e4cd 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7002,8 +7002,9 @@ def review_cycle2(spec): def test_budget_exhausted_finalized_work_commits(project): """A finalized story (status: done, sprint done, verify green) whose review pass keeps recommending an independent follow-up is COMMITTED when the review - budget is exhausted — not rolled back. The lingering recommendation is - re-filed as a fresh open deferred-work entry, and the run records the event.""" + budget is exhausted — not rolled back. The spent budget is journaled and the + run notifies; no deferred-work entry is filed (the process-exhaust ticket + class is retired).""" from bmad_loop import deferredwork write_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -7030,12 +7031,16 @@ def test_budget_exhausted_finalized_work_commits(project): # the finalized work is committed, not reverted assert "change for 1-1-a" in (project.project / "src.txt").read_text() kinds = [e["kind"] for e in engine.journal.entries()] - assert "review-budget-committed" in kinds and "story-deferred" not in kinds - # the lingering follow-up is preserved as a new open deferred-work entry - open_entries = [ - e for e in deferredwork.parse_ledger(project.deferred_work.read_text()) if e.open - ] - assert any("origin: review-budget-followup" in e.body for e in open_entries) + assert "story-deferred" not in kinds + committed = [e for e in engine.journal.entries() if e["kind"] == "review-budget-committed"] + assert len(committed) == 1 and committed[0]["re_review_capped"] is False + assert "refiled" not in committed[0] + # the process-exhaust ticket class is retired: no ledger entry is filed + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) def test_budget_exhausted_unfinalized_defers(project): @@ -7491,7 +7496,7 @@ def test_followup_damping_converges_at_cap(project): """Default damping cap (1): a finalized story whose review keeps recommending an independent follow-up converges after honoring exactly ONE self-recommended follow-up. Round 1 spends the grant; round 2 (still recommending) is damped → - verify, refile, commit. The 3rd scripted review never runs, and — being the + verify, journal, commit. The 3rd scripted review never runs, and — being the expected steady state — the damped converge stays quiet (no ATTENTION).""" from bmad_loop import deferredwork @@ -7514,13 +7519,15 @@ def test_followup_damping_converges_at_cap(project): assert "review-followup-damped" in kinds assert "review-budget-committed" not in kinds # not the exhaustion path assert "story-deferred" not in kinds - # the lingering follow-up is preserved as exactly one open DW entry - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text()) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 + # the process-exhaust ticket class is retired: no ledger entry is filed + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) + damped = [e for e in engine.journal.entries() if e["kind"] == "review-followup-damped"] + assert len(damped) == 1 and damped[0]["re_review_capped"] is False + assert "refiled" not in damped[0] # damped convergence is the steady state — no review-budget ATTENTION notice # (the always-on run-finished notice is the only thing in the file). attention = engine.run_dir / "ATTENTION" @@ -7532,7 +7539,7 @@ def test_followup_damping_converges_at_cap(project): def test_followup_damping_cap_zero_converges_immediately(project): """Cap 0: the orchestrator never honors a pass's own follow-up. The first finalized round that still recommends one is damped immediately — verify, - refile, commit — after a single review round, with nothing spent.""" + journal, commit — after a single review round, with nothing spent.""" from bmad_loop import deferredwork write_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -7550,12 +7557,11 @@ def test_followup_damping_cap_zero_converges_immediately(project): assert task.followup_reviews_spent == 0 # cap 0 grants nothing to spend kinds = [e["kind"] for e in engine.journal.entries()] assert "review-followup-damped" in kinds - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text()) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) def test_nonterminal_rounds_do_not_spend_damping_cap(project): @@ -7628,8 +7634,8 @@ def test_followup_damping_resume_replay_does_not_double_count(project): """A host death in the post-session window of the grant-spending review round must not double-count the damping spend on resume. The recorded round-1 result replays (re-deriving the spend), then round 2 damps and converges: the story - reaches DONE with followup_reviews_spent == 1 (not 2) and exactly one refiled - entry — append_entry's open-dedupe keeps a replayed refile from duplicating. + reaches DONE with followup_reviews_spent == 1 (not 2) and no ledger entry — + the spent budget is journal-only. Modeled on test_resume_final_review_cycle_replays_clean_result.""" from bmad_loop import deferredwork @@ -7680,12 +7686,11 @@ def crashing_emit(stage, *args, **kwargs): assert final.review_cycle == 2 assert final.followup_reviews_spent == 1 # re-derived once, never double-counted assert len(adapter.sessions) == 1 # only round 2 re-ran; round 1 was replayed - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text()) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 # exactly one, even across the crash/replay + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) # journal-only, even across the crash/replay def _tail_death_review_effect(paths, story_key, *, followup: bool): @@ -14242,6 +14247,132 @@ def test_review_pass_deferrals_harvested_and_deduped_across_both_sites(project): assert events[-1]["deduped"] == 1 +def _seeded_ledger(project, *, origin: str, source_spec: str, status: str = "open") -> None: + """One hand-written open entry, shaped like a harvest from ANOTHER spec.""" + project.deferred_work.write_text( + "# Deferred Work\n\n" + f"### DW-1: {HARVEST_A['summary']}\n\n" + f"origin: {origin}\n" + f"location: {HARVEST_A['location']}\n" + f"source_spec: `{source_spec}`\n" + "severity: medium\n" + f"reason: {HARVEST_A['evidence']}\n" + f"status: {status}\n", + encoding="utf-8", + ) + + +def test_harvest_marks_a_cross_spec_duplicate_seen_again(project): + """DW-88 vs DW-65: an identical finding (same fingerprint) already harvested + from ANOTHER spec is a sighting, not new work — no entry is filed, the open + match gains a `seen-again:` line, the journal names it, and the finding never + enters `harvested_deferrals` (so the isolated carry cannot re-file it).""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + assert [e.id for e in entries] == ["DW-1"] # no new entry + assert "seen-again: " in entries[0].body + assert "(spec-deferral harvest of spec-1-1-a.md)" in entries[0].body + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == [] and event["seen_again"] == ["DW-1"] + assert event["deduped"] == 0 + assert engine.state.tasks["1-1-a"].harvested_deferrals == [] + + +def test_harvest_marks_a_dw_prefixed_summary_seen_again(project): + """The DW-88 shape itself: a finding whose summary begins with an open + entry's own `DW-:` id is that entry re-reported, whatever its + fingerprint or origin says.""" + _seeded_ledger( + project, + origin="code review of spec-9-9-z.md, 2026-06-01", + source_spec="spec-9-9-z.md", + ) + finding = { + "summary": "DW-1: the retry ceiling is still missing", + "evidence": "re-reported by a later review", + "location": "src/retry.py:90", + } + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[finding])], + policy=_harvest_policy(), + ) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + assert [e.id for e in entries] == ["DW-1"] + assert "seen-again: " in entries[0].body + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == [] and event["seen_again"] == ["DW-1"] + assert engine.state.tasks["1-1-a"].harvested_deferrals == [] + + +def test_harvest_seen_again_replay_adds_one_line_only(project): + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + assert engine.run().done == 1 + + task = engine.state.tasks["1-1-a"] + engine._harvest_spec_deferrals(task, {"spec_file": str(spec_path(project, "1-1-a"))}) + + text = project.deferred_work.read_text(encoding="utf-8") + assert text.count("seen-again: ") == 1 # the replay skipped its identical line + events = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert [e["seen_again"] for e in events] == [["DW-1"], ["DW-1"]] + assert [e["dw_ids"] for e in events] == [[], []] + + +def test_harvest_files_fresh_when_the_match_is_done(project): + """A closed entry never absorbs a sighting: recurrence after a close files a + fresh entry, consistent with `_apply_append`'s open-only dedupe scan.""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger( + project, + origin=f"spec-deferred {fp}", + source_spec="spec-9-9-z.md", + status="done 2026-06-05", + ) + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + assert [e.id for e in entries] == ["DW-1", "DW-2"] + assert entries[1].open and entries[1].title == HARVEST_A["summary"] + assert "seen-again: " not in entries[0].body + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == ["DW-2"] and event["seen_again"] == [] + + def test_ledger_digest_collapses_absent_and_empty_only(): assert _digest_of(None) == _digest_of("") assert _digest_of("# Deferred Work\n") != _digest_of(None) diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index ec0d9412..7a04cf4f 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1784,10 +1784,10 @@ def test_branch_per_run_kept_failure_detaches_so_next_unit_runs(project): def test_worktree_followup_damped_commits_and_integrates(project): """Damping fires the same in worktree isolation (default cap 1, no _isolated guard): a finalized unit whose review keeps recommending a follow-up converges - after one honored round, the work MERGES into the main repo, and the refiled - follow-up lands in the MAIN repo's ledger — not stranded inside the discarded - unit worktree. Exempting isolation would leave isolated runs non-convergent AND - deferred (strictly worse), which this locks out.""" + after one honored round and the work MERGES into the main repo. No ledger + entry is filed anywhere — the spent budget is journal-only. Exempting + isolation would leave isolated runs non-convergent AND deferred (strictly + worse), which this locks out.""" from bmad_loop import deferredwork commit_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -1806,330 +1806,40 @@ def test_worktree_followup_damped_commits_and_integrates(project): kinds = journal_kinds(engine) assert "review-followup-damped" in kinds and "unit-merged" in kinds assert "story-deferred" not in kinds - # the refiled follow-up is in the MAIN repo ledger, integrated from the worktree - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8")) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 - - -# --------------------------------------------- review-budget follow-up carry (#425) -# -# The third producer in the `git add -A` family. Every row here GITIGNORES the -# ledger: with a tracked one the entry rides the unit commit and the merge -# delivers it, which is why `test_worktree_followup_damped_commits_and_integrates` -# passed all along while the reported defect was live. - - -def _refiled_followups(project): - """Open `review-budget-followup` rows in the MAIN checkout's ledger.""" - if not project.deferred_work.is_file(): - return [] - return [ - entry - for entry in deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8")) - if entry.open and "origin: review-budget-followup" in entry.body - ] - + # no refiled follow-up anywhere — the spent budget is journal-only + ledger = ( + project.deferred_work.read_text(encoding="utf-8") if project.deferred_work.exists() else "" + ) + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) -def _damping_script(project, story_key="1-1-a"): - """Dev, then three passes that keep recommending — the default cap 1 damps.""" - return [wt_dev_effect(project, story_key)] + [ - wt_review_effect(project, story_key, clean=False) for _ in range(3) - ] +# The review-budget follow-up carry (#425) is retired with the ticket class it +# delivered: a spent review budget is journaled, never filed as a ledger entry, +# so an isolated merge has nothing to strand. One row locks that in on the +# gitignored shape — the one whose row NEEDED the retired carry. -def test_gitignored_damped_followup_reaches_the_main_ledger(project): - """#425: `_record_review_budget_followup` writes `self.workspace.paths`, which - under isolation is the unit worktree's ledger. `finalize_commit`'s `git add -A` - skips a gitignored path in silence, the merge brings nothing over, and - `close_unit_workspace(success=True)` then deletes the worktree — the DONE leg - takes no `capture_diff`, so not even a `changes.patch` survives. Without the - carry the run journals `refiled: DW-1` while the main checkout has no ledger at - all. - `check-ignore` is the oracle, not the presence of the rule: a row that reads - the tracked-ledger shape by accident is the vacuity this whole block exists to - avoid.""" +def test_a_damped_isolated_story_journals_without_writing_any_ledger(project): + """Gitignored ledger — the shape whose row needed the retired #425 carry. A + damped force-converge journals the spent budget and writes NO ledger entry: + not in the unit worktree, not in the main checkout, nothing to carry.""" ignore_before_commit(project, "deferred-work.md") commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - - summary = engine.run() - - assert summary.done == 1 and summary.deferred == 0 and not summary.paused - rel = project.deferred_work.relative_to(project.project).as_posix() - assert git(project.project, "check-ignore", rel).strip() == rel - assert not verify.path_tracked(project.project, rel) - damped = [e for e in engine.journal.entries() if e["kind"] == "review-followup-damped"] - assert len(damped) == 1 and damped[0]["refiled"] - assert [e.title for e in _refiled_followups(project)] == [ - "Follow-up review still recommended for 1-1-a after the damping cap was spent" - ] - carried = [e for e in engine.journal.entries() if e["kind"] == "review-followup-carried"] - assert len(carried) == 1 and carried[0]["dw_ids"] == [_refiled_followups(project)[0].id] - # `git add -- ` refuses with rc 1: the row lands, the commit - # cannot, and that is recorded rather than raised. - uncommitted = [ - e for e in engine.journal.entries() if e["kind"] == "review-followup-carry-uncommitted" + script = [wt_dev_effect(project, "1-1-a")] + [ + wt_review_effect(project, "1-1-a", clean=False) for _ in range(3) ] - assert len(uncommitted) == 1 - - -def test_tracked_damped_followup_is_not_refiled_twice_by_the_carry(project): - """A tracked ledger delivers the row through the merge, so the carry re-reads - its own provenance and appends nothing. `append_entry` dedupes on `origin:` + - `source_spec:` against OPEN entries, which is what makes running the carry - unconditionally safe rather than needing a tracked/ignored predicate.""" - write_ledger(project, {}) - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - - summary = engine.run() - - assert summary.done == 1 and not summary.paused - assert verify.path_tracked( - project.project, project.deferred_work.relative_to(project.project).as_posix() - ) - assert len(_refiled_followups(project)) == 1 - carried = [e for e in engine.journal.entries() if e["kind"] == "review-followup-carried"] - assert len(carried) == 1 and carried[0]["dw_ids"] == [] - - -def test_a_deduped_followup_is_still_recorded_for_the_carry(project): - """The record is the INTENT, not a receipt for a new id. A story whose - follow-up was already open appends nothing, and the producer still records it, - because the record has to be durable before the append that may dedupe it — - otherwise a replay after a host loss can never reconstruct authorship. The - carry then dedupes in turn and journals an empty `dw_ids`, which the tracked - path already produces routinely.""" - write_ledger(project, {}) - deferredwork.append_entry( - project.deferred_work, - title="already recommended", - origin="review-budget-followup", - source_spec="spec-1-1-a.md", - reason="filed by an earlier run", - severity="low", - ) - git(project.project, "add", "-A") - git(project.project, "commit", "-q", "-m", "prior follow-up") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - + engine, _ = make_engine(project, script) # default wt_policy() → cap 1 summary = engine.run() - assert summary.done == 1 and not summary.paused + assert summary.done == 1 and summary.deferred == 0 and not summary.paused damped = [e for e in engine.journal.entries() if e["kind"] == "review-followup-damped"] - assert len(damped) == 1 and damped[0]["refiled"] is None - assert len(engine.state.tasks["1-1-a"].refiled_followups) == 1 - carried = [e for e in engine.journal.entries() if e["kind"] == "review-followup-carried"] - assert len(carried) == 1 and carried[0]["dw_ids"] == [] - # no duplicate: the pre-existing open row is the only one - assert len(_refiled_followups(project)) == 1 - - -def _host_loss_before_the_commit_save(engine, snap): - """A host loss inside `_commit`, before its `advance(COMMITTING)` + `_save()`. - - `snap` captures the bytes that were DURABLE at that instant. Restoring them - over whatever `run()`'s unwind-`finally` wrote is what makes this a SIGKILL - rather than a SIGINT: the engine's own teardown save would otherwise persist - the very record whose durability is under test, and the row would arrive for - a reason the fix has nothing to do with. - """ - - def commit_with_host_loss(_task): - snap["state"] = (engine.run_dir / "state.json").read_bytes() - raise RuntimeError("host died between the ledger write and the COMMITTING save") - - engine._commit = commit_with_host_loss - - -def test_host_loss_before_the_commit_save_still_carries_the_followup(project): - """The producer's record must be persisted BEFORE its ledger append. - - Nothing saves between `_record_review_budget_followup` and `_commit`'s - COMMITTING save, and that window spans every blocking `pre_commit_gate` - workflow — the shipped TEA plugin binds three, each a live session. Recording - after a successful append loses the row for good: the resumed run replays the - same review result in the same worktree, `append_entry` dedupes the row it - already wrote there to None, so the record is never made, the carry finds an - empty payload, and `close_unit_workspace` deletes the worktree holding the - only copy. - """ - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - snap: dict = {} - _host_loss_before_the_commit_save(engine, snap) - - assert engine.run().crashed - - worktree = Path(engine.state.tasks["1-1-a"].worktree_path) - # the row exists, but only inside the unit worktree's gitignored ledger - assert [e.id for e in _refiled_followups(project.rebased(worktree))] == ["DW-1"] - assert not project.deferred_work.exists() - - (engine.run_dir / "state.json").write_bytes(snap["state"]) - durable = load_state(engine.run_dir).tasks["1-1-a"] - assert durable.phase == Phase.REVIEW_VERIFY - assert durable.refiled_followups - - state = load_state(engine.run_dir) - state.clear_pause() - adapter = MockAdapter([]) - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=adapter, - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.crashed and not summary.paused - assert not worktree.is_dir() - assert [e.title for e in _refiled_followups(project)] == [ - "Follow-up review still recommended for 1-1-a after the damping cap was spent" - ] - - -def test_a_replayed_review_result_records_the_followup_once(project): - """The record is keyed on `origin:` + `source_spec:`, so the replay that - re-enters the damped path with the record already persisted appends no second - copy — and files no second ledger row.""" - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - _host_loss_before_the_commit_save(engine, {}) - - assert engine.run().crashed - # the unwind-finally persisted the record, so the replay re-enters the damped - # path with it already in hand — the case the keyed dedupe exists for - assert len(load_state(engine.run_dir).tasks["1-1-a"].refiled_followups) == 1 - - state = load_state(engine.run_dir) - state.clear_pause() - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=MockAdapter([]), - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.crashed - assert len(resumed.state.tasks["1-1-a"].refiled_followups) == 1 - assert len(_refiled_followups(project)) == 1 - - -def test_crashed_post_merge_followup_carry_replays_from_its_record(project): - """A story whose ONLY ledger write is a damped follow-up has both other carry - payloads empty, so the resume pass has to name this one to reach it: crash in - the merge-to-latch window and the row is otherwise stranded in a worktree that - is already gone.""" - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - crash_at_merge_back(engine, after="merge") - - assert engine.run().crashed - - crashed = load_state(engine.run_dir).tasks["1-1-a"] - assert crashed.phase == Phase.DONE and not crashed.isolated_ledger_carried - assert crashed.refiled_followups and not crashed.harvested_deferrals - assert not project.deferred_work.exists() - - state = load_state(engine.run_dir) - state.clear_pause() - adapter = MockAdapter([]) - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=adapter, - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.crashed and not summary.paused - assert adapter.sessions == [] - assert "resume-ledger-carry" in journal_kinds(resumed) - assert len(_refiled_followups(project)) == 1 - assert load_state(resumed.run_dir).tasks["1-1-a"].isolated_ledger_carried - - -def test_a_re_armed_story_does_not_carry_the_abandoned_attempt_s_followup(project, monkeypatch): - """The record is scoped to the attempt that made it, and `_dev_phase`'s - fresh-attempt clear is what enforces that. - - An escalation between the damped record and the commit leaves the unit - worktree mounted and unmerged; the re-drive then DISCARDS it, taking the only - copy of the row with it. Without the clear the record outlives its attempt, - and the next attempt's DONE leg files a follow-up against the commit that - actually landed — one whose review recommended nothing. `rearm_escalation` - already resets `followup_reviews_spent` for a fresh damping budget, so keeping - the record contradicts the re-arm on its own terms. - - Nothing upstream absorbs it either: the abandoned attempt's ledger write never - reached the main checkout, so `append_entry` has no open row to dedupe against - and a TRACKED ledger would commit the stale row rather than swallow it. - """ - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - - real_finalize = verify.finalize_commit - - def commit_fails(*_a, **_k): - raise verify.GitError("simulated commit failure") - - monkeypatch.setattr(verify, "finalize_commit", commit_fails) - - assert engine.run().paused - - escalated = load_state(engine.run_dir).tasks["1-1-a"] - assert escalated.phase == Phase.ESCALATED - assert escalated.refiled_followups # persisted by the producer, pre-append - assert not project.deferred_work.exists() # the row is only in the doomed worktree - - monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert ( - runs.rearm_escalation( - engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True - ) - == "1-1-a" - ) - - state = load_state(engine.run_dir) - state.clear_pause() - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=MockAdapter( - [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)] - ), - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.paused and not summary.crashed - # the re-drive converged on its own review: only the ABANDONED attempt damped - assert journal_kinds(resumed).count("review-followup-damped") == 1 - # the harm, asserted before its cause: no follow-up row for the commit that - # landed, and no carry claiming to have filed one - assert [e.title for e in _refiled_followups(project)] == [] - assert "review-followup-carried" not in journal_kinds(resumed) - assert not resumed.state.tasks["1-1-a"].refiled_followups + assert len(damped) == 1 and damped[0]["re_review_capped"] is False + assert "refiled" not in damped[0] + assert not project.deferred_work.exists() # no main-checkout row + assert "review-followup-carried" not in journal_kinds(engine) # ----------------------------------------------------------------- configured target @@ -5216,7 +4926,7 @@ def test_crashed_post_merge_board_advance_replays_from_its_record(project): assert crashed.phase == Phase.DONE and not crashed.isolated_ledger_carried # durable, and the ONLY payload that can reach the carry for this story assert crashed.board_advance_intended == "done" - assert not crashed.harvested_deferrals and not crashed.refiled_followups + assert not crashed.harvested_deferrals assert not crashed.story_closes_intended and not crashed.bundle_closes_intended assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "ready-for-dev" @@ -5691,8 +5401,7 @@ def test_a_gitignored_board_story_finished_by_one_run_is_not_re_picked_by_the_ne def test_crashed_post_merge_story_close_replays_from_its_record(project): """A story whose ONLY ledger write is a declared close has every other carry - payload empty, so the resume pass has to name this one to reach it — the same - reachability the damped follow-up needed, for the fourth producer.""" + payload empty, so the resume pass has to name this one to reach it.""" ignore_before_commit(project, "deferred-work.md") write_ledger(project, {"DW-1": "open"}) commit_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -5708,7 +5417,7 @@ def test_crashed_post_merge_story_close_replays_from_its_record(project): assert crashed.phase == Phase.DONE and not crashed.isolated_ledger_carried # only the new disjunct can reach the carry assert crashed.story_closes_intended == ["DW-1"] - assert not crashed.harvested_deferrals and not crashed.refiled_followups + assert not crashed.harvested_deferrals assert _ledger_entry(project, "DW-1").open state = load_state(engine.run_dir) @@ -5738,7 +5447,7 @@ def test_a_re_armed_story_does_not_carry_a_withdrawn_declaration(project, monkey `_close_declared_deferred` reads `closes_deferred:` LIVE and reassigns `story_closes_intended` before its own early return, so it needs no - `_dev_phase` clear the way `refiled_followups` does: DONE is reachable only + `_dev_phase` clear: DONE is reachable only through `_finalize_commit_phase`, which always re-enters the producer. A human who resolves an escalation by WITHDRAWING the declaration must not have the abandoned attempt's ids closed on their behalf — the exact stale-snapshot case diff --git a/tests/test_model.py b/tests/test_model.py index 50fd5ac0..3ced6da2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -474,7 +474,6 @@ def test_board_advance_intended_keeps_none_distinct_from_a_status(): "ledger_changed_before_harvest", "harvested_deferrals", "bundle_closes_intended", - "refiled_followups", "story_closes_intended", "accepted_dev_session_index", "harvest_carry_commit_pending", @@ -483,7 +482,7 @@ def test_board_advance_intended_keeps_none_distinct_from_a_status(): def test_deferred_work_state_fields_round_trip_through_json(): - """All twelve fields are hand-enumerated in both serializers. Non-default + """All eleven fields are hand-enumerated in both serializers. Non-default values make a missing line on either side observable, while the JSON leg pins the on-disk container shape rather than only an in-memory dataclass copy.""" task = StoryTask( @@ -496,7 +495,6 @@ def test_deferred_work_state_fields_round_trip_through_json(): ledger_changed_before_harvest=True, harvested_deferrals=[{"origin": "spec-deferred abc", "title": "finding"}], bundle_closes_intended=["DW-3", "DW-7"], - refiled_followups=[{"origin": "review-budget-followup", "title": "follow-up"}], story_closes_intended=["DW-4"], accepted_dev_session_index=3, harvest_carry_commit_pending=True, @@ -512,9 +510,6 @@ def test_deferred_work_state_fields_round_trip_through_json(): assert restored.ledger_changed_before_harvest is True assert restored.harvested_deferrals == [{"origin": "spec-deferred abc", "title": "finding"}] assert restored.bundle_closes_intended == ["DW-3", "DW-7"] - assert restored.refiled_followups == [ - {"origin": "review-budget-followup", "title": "follow-up"} - ] assert restored.story_closes_intended == ["DW-4"] assert restored.accepted_dev_session_index == 3 assert restored.harvest_carry_commit_pending is True @@ -522,7 +517,7 @@ def test_deferred_work_state_fields_round_trip_through_json(): def test_deferred_work_state_fields_default_for_one_old_state_dict(): - """A state.json written before this package has none of the twelve keys. + """A state.json written before this package has none of the eleven keys. Every load must use ``d.get`` so resume reaches the old behavior instead of raising KeyError; one shared old document prevents testing only a subset.""" doc = StoryTask(story_key="1-1-a", epic=1).to_dict() @@ -537,7 +532,6 @@ def test_deferred_work_state_fields_default_for_one_old_state_dict(): assert restored.ledger_changed_before_harvest is False assert restored.harvested_deferrals == [] assert restored.bundle_closes_intended == [] - assert restored.refiled_followups == [] assert restored.story_closes_intended == [] assert restored.accepted_dev_session_index is None assert restored.harvest_carry_commit_pending is False @@ -563,21 +557,15 @@ def test_deferred_work_state_containers_do_not_alias_the_persisted_doc(): {"title": "original", "metadata": {"labels": ["review"]}}, ], bundle_closes_intended=["DW-1"], - refiled_followups=[{"title": "followup", "metadata": {"labels": ["review"]}}], ).to_dict() restored = StoryTask.from_dict(doc) restored.harvested_deferrals[0]["title"] = "mutated" restored.harvested_deferrals[0]["metadata"]["labels"].append("follow-up") restored.bundle_closes_intended.append("DW-2") - restored.refiled_followups[0]["title"] = "mutated" - restored.refiled_followups[0]["metadata"]["labels"].append("follow-up") assert doc["harvested_deferrals"] == [ {"title": "original", "metadata": {"labels": ["review"]}}, ] assert doc["bundle_closes_intended"] == ["DW-1"] - assert doc["refiled_followups"] == [ - {"title": "followup", "metadata": {"labels": ["review"]}}, - ] def test_deferred_work_state_container_defaults_are_not_shared(): @@ -585,10 +573,8 @@ def test_deferred_work_state_container_defaults_are_not_shared(): other = StoryTask(story_key="1-2-b", epic=1) one.harvested_deferrals.append({"title": "one"}) one.bundle_closes_intended.append("DW-1") - one.refiled_followups.append({"title": "one"}) assert other.harvested_deferrals == [] assert other.bundle_closes_intended == [] - assert other.refiled_followups == [] def test_restore_patch_round_trips(): diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 9e525f57..defffd6f 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -406,6 +406,7 @@ "run_id", "run_type", "security_config_changed", + "seen_again", "sentinel", "sentinel_kind", "session_status", diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 93cf106c..97ae64ab 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -4489,11 +4489,11 @@ def test_migration_duplicate_refusal_clears_a_baseline_it_arrived_holding(projec # ------------------------------------------ review-budget commit-instead-of-rollback -def test_sweep_bundle_budget_exhausted_commits_and_refiles(project): +def test_sweep_bundle_budget_exhausted_commits_and_journals(project): """A bundle whose review keeps recommending a follow-up but is finalized (spec done, owned dw ids closed, verify green) is COMMITTED when the review - budget is exhausted — not rolled back. The lingering follow-up is re-filed as - a fresh open deferred-work entry.""" + budget is exhausted — not rolled back. The spent budget is journaled; no + deferred-work entry is filed.""" write_ledger(project, {"DW-1": "open"}) plan = triage_result( ["DW-1"], @@ -4519,15 +4519,16 @@ def test_sweep_bundle_budget_exhausted_commits_and_refiles(project): entries = ledger_entries(project) assert entries["DW-1"].status.startswith("done") # the worked item closed refiled = [e for e in entries.values() if e.open and "origin: review-budget-followup" in e.body] - assert len(refiled) == 1 + assert refiled == [] # journal-only: no follow-up entry filed kinds = {e["kind"] for e in engine.journal.entries()} assert "review-budget-committed" in kinds and "story-deferred" not in kinds def test_sweep_bundle_budget_followup_not_refiled_twice(project): """Re-review cap: when a bundle itself closes a `review-budget-followup` entry - and still won't converge, the work is committed but NOT re-filed again — a - second non-convergence should reach a human, not loop across sweeps.""" + (legacy and hand-filed rows still exist) and still won't converge, the work is + committed and the journal flags the repeat — a second non-convergence should + reach a human, not loop across sweeps.""" ledger = ( "# Deferred Work\n\n" "### DW-1: follow-up still recommended for dw-prior\n" @@ -4568,11 +4569,11 @@ def test_sweep_bundle_budget_followup_not_refiled_twice(project): assert len(capped) == 1 and capped[0]["re_review_capped"] is True -def test_sweep_bundle_followup_damped_commits_and_refiles(project): +def test_sweep_bundle_followup_damped_commits_and_journals(project): """Default damping cap (1): a bundle whose review keeps recommending a follow-up converges after ONE honored round instead of burning the whole review budget. - The lingering follow-up is re-filed once, the work is committed, and — the - steady state — the damped converge stays quiet (no review-budget ATTENTION).""" + The spent budget is journaled (no ledger entry), the work is committed, and — + the steady state — the damped converge stays quiet (no review-budget ATTENTION).""" write_ledger(project, {"DW-1": "open"}) plan = triage_result( ["DW-1"], @@ -4593,7 +4594,7 @@ def test_sweep_bundle_followup_damped_commits_and_refiles(project): entries = ledger_entries(project) assert entries["DW-1"].status.startswith("done") # the worked item closed refiled = [e for e in entries.values() if e.open and "origin: review-budget-followup" in e.body] - assert len(refiled) == 1 + assert refiled == [] # journal-only: no follow-up entry filed kinds = {e["kind"] for e in engine.journal.entries()} assert "review-followup-damped" in kinds assert "review-budget-committed" not in kinds and "story-deferred" not in kinds @@ -4603,9 +4604,10 @@ def test_sweep_bundle_followup_damped_commits_and_refiles(project): def test_sweep_bundle_damped_re_review_capped_notifies_not_refiles(project): """Re-review cap survives damping: when a bundle itself closes a - `review-budget-followup` entry and still won't converge, the damped force- - converge commits but does NOT re-file again — and, unlike an ordinary quiet - damped converge, it raises an ATTENTION notice so a human sees the repeat.""" + `review-budget-followup` entry (legacy and hand-filed rows still exist) and + still won't converge, the damped force-converge commits — and, unlike an + ordinary quiet damped converge, it raises an ATTENTION notice so a human sees + the repeat.""" ledger = ( "# Deferred Work\n\n" "### DW-1: follow-up still recommended for dw-prior\n" From 96c3afbb5d702b26aee8102bd25ec69b375801dc Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 17:35:04 -0700 Subject: [PATCH 20/45] sweep dw-resolve-session-root-context: DW-14, DW-35 via bmad-loop --- CHANGELOG.md | 9 + src/bmad_loop/bmadconfig.py | 2 + src/bmad_loop/cli.py | 33 ++- .../data/skills/bmad-loop-resolve/SKILL.md | 16 +- src/bmad_loop/resolve.py | 131 +++++++--- tests/test_bmadconfig.py | 14 ++ tests/test_cli.py | 175 +++++++++++++ tests/test_resolve.py | 235 ++++++++++++++++-- tests/test_resolve_skill_contract.py | 30 +++ 9 files changed, 595 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5dca83..382af6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ breaking changes may land in a minor release. ### Added +- **Interactive resolve context names both the BMAD project root and the run's code + root.** `bmad-loop resolve` warns before a divergent-root session launches, while + keeping the session project-rooted and directing code fixes and commits to the code + root. + - **A failed re-arm commits probe now journals `rearm-commits-probe-failed`** (DW-81). The warn-only probe that lists the commits an abandoned attempt left below the re-drive's new baseline used to swallow its `GitError` and write nothing — byte-identical to finding @@ -77,6 +82,10 @@ breaking changes may land in a minor release. ### Changed +- **Resolve context builds only the mode-specific details its consumer uses.** + Non-stories runs skip stories-root lookup, and stories sentinels report null frozen-spec + reachability without probing a spec they do not edit. + - **`bmad-loop diagnose --json` reports `schema_version: 3`.** Replacing a journal-entry value with a presence key is a payload break under the additive-only rule, and the redaction fixes above make two: a consumer reading `entry["question"]` on `decision-pending`, or an diff --git a/src/bmad_loop/bmadconfig.py b/src/bmad_loop/bmadconfig.py index 94fce0ce..a37f9567 100644 --- a/src/bmad_loop/bmadconfig.py +++ b/src/bmad_loop/bmadconfig.py @@ -199,6 +199,8 @@ def load_paths(project: Path) -> ProjectPaths: doc = yaml.safe_load(raw) or {} except yaml.YAMLError as e: raise BmadConfigError(f"invalid YAML in {config_path}: {e}") from e + if not isinstance(doc, dict): + raise BmadConfigError(f"{config_path} must contain a top-level mapping") impl = doc.get("implementation_artifacts") plan = doc.get("planning_artifacts") diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 39d091d7..03467834 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3116,9 +3116,31 @@ def cmd_resolve(args: argparse.Namespace) -> int: if args.interactive: adapters = _make_adapters(project, run_dir, pol) model = pol.adapter.resolved("dev").model + # The interactive session uses the CURRENT CLI project as cwd. Its code root + # must come from the CURRENT config too: both can have moved since state.json + # was written. This is best-effort observation only; the mandatory config + # re-read after the human conversation remains the authority for re-arm. + try: + pre_session_paths = bmadconfig.load_paths(project) + except (bmadconfig.BmadConfigError, OSError): + pre_session_code_root = state.code_root + else: + pre_session_code_root = pre_session_paths.repo_root _ctx_path, withheld = resolve.build_context( - state, run_dir, story_key, isolation=pol.scm.isolation + state, + run_dir, + story_key, + isolation=pol.scm.isolation, + project_root=project, + code_root=pre_session_code_root, ) + if pre_session_code_root != project: + print( + f"warning: resolve session stays project-rooted at {project.as_posix()!r}; " + "code fixes and commits belong in the run's code root at " + f"{pre_session_code_root.as_posix()!r}", + file=sys.stderr, + ) print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…") try: produced = resolve.run_session( @@ -3227,6 +3249,15 @@ def cmd_resolve(args: argparse.Namespace) -> int: file=sys.stderr, ) else: + if args.interactive and paths.repo_root != pre_session_code_root: + print( + "error: the code root changed during the resolve session from " + f"{pre_session_code_root.as_posix()!r} to {paths.repo_root.as_posix()!r}; " + "the agent's guidance no longer names the tree the re-drive would use. " + "No re-arm was performed; reconcile the code change, then run resolve again.", + file=sys.stderr, + ) + return 1 # The SAME refusal `_resume_paused_run` makes, hoisted ahead of both writes # below — because aiming the mirror at the tree config.yaml names is only # correct for a configuration the orchestrator will actually run, and this is diff --git a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md index f6e5353f..095b4d50 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -31,6 +31,8 @@ These environment variables are set: { "story_key": "6-4-cli-list-command", "run_id": "20260613-111429-6a14", + "project_root": "/abs/path/to/bmad-project", + "code_root": "/abs/path/to/code-repository", "spec_file": "/abs/path/to/_bmad-output/implementation-artifacts/spec-.md", "spec_reaches_the_redrive": true, "redrive_base_ref": "", @@ -47,13 +49,23 @@ These environment variables are set: } ``` +The interactive session's working directory is always `project_root`. That tree holds +the BMAD artifacts and specs you inspect or clarify. `code_root` is the tree where the +run's code and git work belong; it may be different. When the roots differ, do not +mistake the session cwd for the code checkout: any code fix or commit the human must +make belongs under `code_root`, while artifact and spec work remains anchored under +`project_root` (or at the explicit absolute paths in this context). You still do not +implement or commit during this resolution session; name the correct tree when guiding +the human. + **`spec_reaches_the_redrive` says whether your edit has a future.** The re-drive reads one tree; `spec_file` may name another. Under worktree isolation the run's mount is discarded before the re-drive reads anything, so a spec inside that mount is destroyed with it. When this field is `false`, every write to `spec_file` still SUCCEEDS and is then thrown away — worse than not editing at all, because the session -looks resolved. `null` means the task has no spec on record: there is nothing to edit -and step 4 does not apply. +looks resolved. `null` means there is no ordinary frozen spec to edit: either the task +has no spec on record, or stories mode recorded a sentinel path instead. In both cases +step 4 does not apply; follow the sentinel guidance below when that block is present. **`redrive_base_ref` tells you which of the two remedies applies.** Read it before you tell the human anything: a branch name and `HEAD` mean opposite things. diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index addc4d56..a3db1e2c 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -24,7 +24,7 @@ from .engine import _session_task_id from .escalation import critical_escalations from .journal import TASK_CYCLE_ARTIFACTS -from .model import RunState +from .model import RunState, StoryTask from .platform_util import safe_segment from .runs import ( redrive_base_ref, @@ -45,6 +45,38 @@ def context_path(run_dir: Path, story_key: str) -> Path: return _story_dir(run_dir, story_key) / "context.json" +def _rebase_recorded_project_path(path: Path, state: RunState, project_root: Path) -> Path: + """Move a project-owned persisted path with a renamed project, lexically. + + Run state intentionally keeps the launch-time project string, while the resolve + session runs from the live CLI project. Paths outside the recorded project are + shared/external and remain untouched. This is spelling arithmetic only: do not + introduce filesystem canonicalization at the context serialization boundary. + """ + recorded_project = Path(state.project) + if project_root == recorded_project: + return path + try: + relative = path.relative_to(recorded_project) + except ValueError: + return path + return project_root / relative + + +def _context_stories_root(task: StoryTask | None, state: RunState, project_root: Path) -> Path: + """Resolve the stories tree against the live project after a project move.""" + if task is not None and task.worktree_path: + recorded_mount = Path(task.worktree_path) + live_mount = _rebase_recorded_project_path(recorded_mount, state, project_root) + if live_mount != recorded_mount: + try: + if live_mount.is_dir(): + return live_mount + except OSError: + pass + return _rebase_recorded_project_path(task_stories_root(task, state), state, project_root) + + def resolution_path(run_dir: Path, story_key: str) -> Path: return _story_dir(run_dir, story_key) / "resolution.json" @@ -179,7 +211,13 @@ def _gather_escalations( def build_context( - state: RunState, run_dir: Path, story_key: str, *, isolation: str + state: RunState, + run_dir: Path, + story_key: str, + *, + isolation: str, + project_root: Path | None = None, + code_root: Path | None = None, ) -> tuple[Path, int]: """Write resolve//context.json for the resolve skill to read, and return it beside the number of already-answered escalations withheld from it. @@ -201,6 +239,13 @@ def build_context( seam that reports it.""" task = state.tasks.get(story_key) isolated_redrive = isolation == "worktree" + current_project_root = project_root if project_root is not None else Path(state.project) + current_code_root = code_root if code_root is not None else state.code_root + context_spec_path = ( + _rebase_recorded_project_path(task_spec_path(task, state), state, current_project_root) + if task and task.spec_file + else None + ) # Patch-restore availability (#2564): the shared `validate_restore_latch` # verdict, not a local copy of one leg. Any of them — worktree isolation (the # re-drive discards and re-mounts the unit's worktree), a spec-less escalation, @@ -210,13 +255,19 @@ def build_context( restore_supported = task is not None and ( validate_restore_latch(state, task, story_key, worktree_isolation=isolated_redrive) is None ) - # Which tree holds this run's STORY MANIFEST — the workspace root, answered by - # `task_stories_root` rather than by `task_spec_root`. The latter answers a - # write-confinement question about `spec_file` and falls back to the project for an - # out-of-mount spec; borrowing it here pointed the sentinel and the stories block at - # the main checkout while `stories_engine._stories_folder` was still the mount, so - # one `context.json` could name two trees. - stories_root = task_stories_root(task, state) + stories_ctx: dict[str, Any] | None = None + if state.source == "stories": + # Which tree holds this run's STORY MANIFEST — the workspace root, answered by + # `task_stories_root` rather than by `task_spec_root`. Sprint mode consumes no + # stories data, so it must not probe for a stories root at all. + stories_root = _context_stories_root(task, state, current_project_root) + stories_ctx = _stories_context( + state, story_key, stories_root, task, context_spec_path=context_spec_path + ) + # The persisted detection verdict is authoritative, matching re-arm. A real spec + # may legally have a sentinel-shaped basename, while a recorded sentinel remains a + # sentinel even when its file is unreadable or has disappeared. + sentinel = state.source == "stories" and task is not None and bool(task.sentinel_kind) # DW-11: hide what an earlier resolve cycle already answered. `start` is the task's # own watermark — 0 for a task never resolved, and for every pre-upgrade # `state.json`, which is the unfiltered pre-DW-11 walk. @@ -226,13 +277,20 @@ def build_context( context = { "story_key": story_key, "run_id": state.run_id, + # The interactive session deliberately stays rooted at the BMAD project, while + # code and git may live in the run's separately configured repository root. + # Serialize the caller's live snapshot without another canonicalization step: + # these are stable POSIX-form context strings, not a new path-resolution seam. + "project_root": current_project_root.as_posix(), + "code_root": current_code_root.as_posix(), # Absolute, matching the shape `bmad-loop-resolve/SKILL.md` documents: an # isolated unit's `spec_file` is persisted RELATIVE to the mounted worktree # (`model.StoryTask._serialized_worktree_path`) and the agent session runs # from the project root, where the main checkout carries the same # `_bmad-output/specs/...` layout — the raw value would name the wrong - # tree's copy. `task_spec_path` is the same re-anchor `rearm_escalation` - # writes through, so the agent edits the file the re-arm will flip. + # tree's copy. `task_spec_path` provides the persisted anchor; when the whole + # project moved, `_rebase_recorded_project_path` carries that project-owned + # spelling onto the live session root without resolving it through the OS. # as_posix() for the same reason `resolution_path` below uses it — the # context contract is one string on every OS — and because the value this # replaces was ALREADY posix under isolation: `_serialized_worktree_path` @@ -240,7 +298,7 @@ def build_context( # NON-isolated absolute case, which was previously emitted verbatim: on Windows # that changes `C:\\...\\spec.md` to `C:/.../spec.md`. Deliberate — one # spelling for every reader — and consumed by an agent, which accepts '/'. - "spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None), + "spec_file": context_spec_path.as_posix() if context_spec_path is not None else None, "baseline_commit": task.baseline_commit if task else None, "paused_reason": state.paused_reason, "escalations": escalations, @@ -254,13 +312,13 @@ def build_context( # every write succeed. `rearm_escalation` already journals # `rearm-spec-write-unreachable` on this same verdict; naming it here is what # lets the session act on it instead of learning it afterwards. - # Guarded on `task.spec_file` exactly as `spec_file` above is: a verdict about - # whether an edit SURVIVES is meaningless beside a `"spec_file": null`, and - # emitting one invited the session to act on a reachability answer for a file - # the same document says does not exist. Both fields are one claim. + # Guarded on `task.spec_file` as `spec_file` above is, and additionally on the + # stories sentinel the context just discovered: a verdict about whether a + # frozen-spec edit SURVIVES is meaningless when the file is absent, and a + # sentinel flow explicitly edits upstream intent rather than its sentinel. "spec_reaches_the_redrive": ( spec_reaches_the_redrive(task, state, isolated_redrive=isolated_redrive) - if task and task.spec_file + if task and task.spec_file and not sentinel else None ), # WHERE a correction has to land to be read. Emitted beside the verdict @@ -289,22 +347,27 @@ def build_context( # sentinel indicator, so it sees WHAT the story is meant to do and WHETHER the # frozen spec even exists yet (a sentinel has no plan to edit — resolve the # underlying ambiguity instead). Sprint mode leaves the context unchanged. - if state.source == "stories": - stories_ctx = _stories_context(state, story_key, stories_root) - if stories_ctx: - context["stories"] = stories_ctx + if stories_ctx: + context["stories"] = stories_ctx path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(context, indent=2), encoding="utf-8") return path, withheld -def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]: +def _stories_context( + state: RunState, + story_key: str, + root: Path, + task: StoryTask | None, + *, + context_spec_path: Path | None, +) -> dict[str, Any]: """The stories-mode extension of the resolve context: the spec folder, the manifest entry for the story (title/description/checkpoint flags/invoke_dev_with), - and — when the escalated spec is a fixed-slug pre-planning-halt sentinel — a - sentinel indicator with its kind and recorded blocking condition. Best-effort: - an unreadable manifest just yields the folder (resolve still runs).""" + and — when the task persisted a pre-planning-halt sentinel verdict — a sentinel + indicator using that recorded kind/path plus the best-effort blocking condition. + An unreadable manifest or sentinel never changes the recorded mode.""" from . import stories # `root`, not `Path(state.project)`: the caller resolved it with @@ -328,18 +391,20 @@ def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, A "done_checkpoint": entry.done_checkpoint, "invoke_dev_with": entry.invoke_dev_with, } - try: - st = stories.resolve_story_spec(folder, story_key) - except (OSError, UnicodeDecodeError): - st = None - if st is not None and st.kind == stories.KIND_SENTINEL and st.path is not None: + if task is not None and task.sentinel_kind: + sentinel_kind = task.sentinel_kind + sentinel_path = context_spec_path try: - condition = stories.recorded_blocking_condition(st.path.read_text(encoding="utf-8")) + condition = ( + stories.recorded_blocking_condition(sentinel_path.read_text(encoding="utf-8")) + if sentinel_path is not None + else "" + ) except (OSError, UnicodeDecodeError): condition = "" ctx["sentinel"] = { - "kind": st.sentinel_kind, - "path": st.path.as_posix(), + "kind": sentinel_kind, + "path": sentinel_path.as_posix() if sentinel_path is not None else None, "blocking_condition": condition, } return ctx diff --git a/tests/test_bmadconfig.py b/tests/test_bmadconfig.py index 99aa01d2..1da880d0 100644 --- a/tests/test_bmadconfig.py +++ b/tests/test_bmadconfig.py @@ -62,6 +62,20 @@ def test_load_paths_non_utf8_config_raises_bmad_config_error(project) -> None: bmadconfig.load_paths(project.project) +def test_load_paths_non_mapping_config_raises_bmad_config_error(project) -> None: + """A syntactically valid YAML sequence is still an invalid BMAD config. + + The typed boundary matters to best-effort observers such as interactive resolve: + they catch ``BmadConfigError`` and can fall back to the run's recorded roots. + """ + install_bmad_config(project) + cfg = project.project / "_bmad" / "bmm" / "config.yaml" + cfg.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + with pytest.raises(bmadconfig.BmadConfigError, match="top-level mapping"): + bmadconfig.load_paths(project.project) + + def test_rebased_reroots_project_and_artifacts(tmp_path: Path) -> None: src = tmp_path / "main" paths = ProjectPaths( diff --git a/tests/test_cli.py b/tests/test_cli.py index 9c3b4c8a..9158c986 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3246,6 +3246,181 @@ def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): assert load_state(run_dir).tasks["s1"].phase == Phase.PENDING +def test_resolve_warns_about_divergent_roots_before_the_session(tmp_path, monkeypatch, capsys): + """The advisory names both trees before the interactive boundary while the + session still receives the project as its cwd root. + + Ablation: move the warning below `run_session` and the in-session assertion fails; + switch the launch root to `state.code_root` and the project assertion fails. + """ + from bmad_loop import resolve + from bmad_loop.journal import load_state, save_state + + run_dir = _escalated_run(tmp_path, "r1") + code_root = tmp_path / "code\nroot\x1b[31m" + state = load_state(run_dir) + state.repo_root = str(code_root) + save_state(run_dir, state) + context_roots: dict[str, Path] = {} + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + + def fake_context(*args, **kwargs): + context_roots["project"] = kwargs["project_root"] + context_roots["code"] = kwargs["code_root"] + return None, 0 + + monkeypatch.setattr(resolve, "build_context", fake_context) + + def fake_session(adapter, project, rd, story_key, **kwargs): + err = capsys.readouterr().err + assert "resolve session stays project-rooted" in err + assert repr(tmp_path.as_posix()) in err + assert repr(code_root.as_posix()) in err + assert "\x1b" not in err # terminal escape is rendered as the literal "\\x1b" + assert len(err.splitlines()) == 1 # embedded newline did not inject another line + assert "code fixes and commits belong" in err + assert project == tmp_path + return True + + monkeypatch.setattr(resolve, "run_session", fake_session) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + assert context_roots == {"project": tmp_path, "code": code_root} + + +def test_resolve_context_uses_live_project_after_project_rename(tmp_path, monkeypatch, capsys): + """The current CLI project is both session cwd and context root even when the + persisted launch-time project path names the pre-rename location.""" + from bmad_loop import resolve, runs + from bmad_loop.journal import load_state, save_state + + _write_bmad_config(tmp_path) + run_dir = _escalated_run(tmp_path, "r1") + old_project = tmp_path.parent / "project-before-rename" + state = load_state(run_dir) + state.project = str(old_project) + state.repo_root = str(old_project) + save_state(run_dir, state) + seen: dict[str, Path] = {} + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + + def fake_context(*args, **kwargs): + seen["project"] = kwargs["project_root"] + seen["code"] = kwargs["code_root"] + return None, 0 + + monkeypatch.setattr(resolve, "build_context", fake_context) + monkeypatch.setattr( + resolve, + "run_session", + lambda adapter, project, *a, **k: seen.setdefault("cwd", project) or True, + ) + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: key) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + assert seen == {"project": tmp_path, "code": tmp_path, "cwd": tmp_path} + assert str(old_project) not in capsys.readouterr().err + + +def test_resolve_context_uses_live_configured_code_root(project, monkeypatch, capsys): + """A paused run may record an old code root, but the pre-session context and + warning must name the config root the mandatory post-session re-stamp will use.""" + from bmad_loop import resolve, runs + + _run_dir, moved, recorded = _resolve_run_with_a_moved_code_root(project, monkeypatch) + seen: dict[str, Path] = {} + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + + def fake_context(*args, **kwargs): + seen["project"] = kwargs["project_root"] + seen["code"] = kwargs["code_root"] + return None, 0 + + monkeypatch.setattr(resolve, "build_context", fake_context) + + def fake_session(*args, **kwargs): + err = capsys.readouterr().err + assert repr(project.project.as_posix()) in err + assert repr(moved.as_posix()) in err + assert recorded.as_posix() not in err + return True + + monkeypatch.setattr(resolve, "run_session", fake_session) + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: key) + + argv = ["resolve", "--project", str(project.project), "r1", "--no-resume"] + assert cli.main(argv) == 0 + assert seen == {"project": project.project, "code": moved.resolve()} + + +def test_resolve_refuses_to_rearm_when_code_root_changes_during_session( + project, monkeypatch, capsys +): + """The context snapshot and the re-drive must name the same code tree. + + A config edit while the interactive conversation is open otherwise lets the + human follow the supplied guidance in the old tree before the command silently + re-arms and resumes in the new one. + """ + from bmad_loop import resolve, runs + from bmad_loop.journal import load_state + from bmad_loop.model import Phase + + install_bmad_config(project) + run_dir = _escalated_run(project.project, "r1") + moved = project.project / "code-after-session" + moved.mkdir() + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + + def move_code_root_during_session(*args, **kwargs): + _configure_repo_root(project, moved) + return True + + monkeypatch.setattr(resolve, "run_session", move_code_root_during_session) + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda *a, **k: pytest.fail("re-armed after the context's code root went stale"), + ) + + argv = ["resolve", "--project", str(project.project), "r1", "--no-resume"] + assert cli.main(argv) == 1 + + state = load_state(run_dir) + assert state.tasks["s1"].phase == Phase.ESCALATED + err = capsys.readouterr().err + assert "code root changed during the resolve session" in err + assert project.project.as_posix() in err + assert moved.resolve().as_posix() in err + assert "No re-arm was performed" in err + + +def test_resolve_same_root_launches_without_a_divergence_warning(tmp_path, monkeypatch, capsys): + """Legacy and ordinary same-root runs stay quiet. The session call is the + positive control that the absent warning did not result from an early return. + + Ablation: make the warning unconditional and this fails on stderr. + """ + from bmad_loop import resolve + + _escalated_run(tmp_path, "r1") # empty repo_root: legacy fallback to project + launched: list[Path] = [] + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr( + resolve, + "run_session", + lambda adapter, project, *a, **k: launched.append(project) or True, + ) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + assert launched == [tmp_path] + assert "resolve session stays project-rooted" not in capsys.readouterr().err + + def test_resolve_passes_the_tasks_own_generation_to_the_session(tmp_path, monkeypatch): """`cmd_resolve` hands the resolve session the generation it read off the task — a real value, not a constant. The row seeds a NON-zero generation deliberately: diff --git a/tests/test_resolve.py b/tests/test_resolve.py index fbfaf171..c4639ddb 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -548,6 +548,8 @@ def test_build_context_gathers_critical_escalations(tmp_path): path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == "6-4-cli-list-command" + assert ctx["project_root"] == tmp_path.as_posix() + assert ctx["code_root"] == tmp_path.as_posix() assert ctx["spec_file"] == spec.as_posix() assert ctx["baseline_commit"] == "abc123" details = [e["detail"] for e in ctx["escalations"]] @@ -559,6 +561,90 @@ def test_build_context_gathers_critical_escalations(tmp_path): assert "\\" not in ctx["resolution_path"] +def test_build_context_names_a_divergent_recorded_code_root(tmp_path): + """The session cwd remains the project, but the agent contract separately names + the persisted tree where this run's code and git work belong. Neither spelling is + canonicalized; only the stable POSIX serialization is applied. + + The same-root legacy fallback is covered by the preceding test, whose state has an + empty `repo_root` and therefore emits the project for both fields. + """ + code_root = tmp_path / "code" / ".." / "recorded-code" + run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md", repo_root=code_root) + + ctx = json.loads( + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + ) + assert ctx["project_root"] == tmp_path.as_posix() + assert ctx["code_root"] == code_root.as_posix() + assert ctx["project_root"] != ctx["code_root"] + + +def test_build_context_prefers_supplied_live_roots_over_recorded_launch_roots(tmp_path): + """A project rename and a paused-run config edit can move both live roots while + state.json still names launch-time locations. The CLI-supplied snapshot wins in + the payload; recorded roots remain only the observation-failure fallback.""" + recorded_project = tmp_path / "recorded-project" + recorded_code = tmp_path / "recorded-code" + live_project = tmp_path / "live-project" + live_code = tmp_path / "live-code" + run_dir, state, _ = _escalated_run( + recorded_project, + spec_file="/abs/spec.md", + repo_root=recorded_code, + ) + + path, _withheld = resolve.build_context( + state, + run_dir, + "6-4-cli-list-command", + isolation="", + project_root=live_project, + code_root=live_code, + ) + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["project_root"] == live_project.as_posix() + assert ctx["code_root"] == live_code.as_posix() + assert recorded_project.as_posix() not in {ctx["project_root"], ctx["code_root"]} + assert recorded_code.as_posix() not in {ctx["project_root"], ctx["code_root"]} + + +def test_build_context_rebases_project_owned_artifacts_after_project_rename(tmp_path): + """The live project root and every project-owned path in the payload move + together; otherwise the resolver is told its cwd is the renamed project while + its story manifest and frozen spec still point into the vanished old spelling. + """ + key = "6-4-cli-list-command" + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + folder = live_project / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "Live title", "description": "d"}]) + live_spec = folder / "stories" / f"{key}-live-title.md" + live_spec.parent.mkdir(parents=True, exist_ok=True) + live_spec.write_text("---\nstatus: in-review\n---\n", encoding="utf-8") + recorded_spec = recorded_project / live_spec.relative_to(live_project) + run_dir, state, _ = _escalated_run( + recorded_project, + spec_file=str(recorded_spec), + source="stories", + spec_folder="epic-1", + ) + + path, _withheld = resolve.build_context( + state, + run_dir, + key, + isolation="", + project_root=live_project, + code_root=live_project, + ) + + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["project_root"] == live_project.as_posix() + assert ctx["spec_file"] == live_spec.as_posix() + assert ctx["stories"]["story"]["title"] == "Live title" + + def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_path, monkeypatch): """`context.json` names the spec in the tree the RUN owns, absolute. @@ -2192,9 +2278,8 @@ def _rearmable(run_dir): def test_build_context_tolerates_non_utf8_present_spec(tmp_path): - """A non-UTF-8 PRESENT story spec makes resolve_story_spec's frontmatter read - raise UnicodeDecodeError; build_context must degrade to best-effort (folder-only) - stories context, not crash the resolve command.""" + """A non-UTF-8 ordinary story spec cannot turn into sentinel guidance merely + because its name or bytes are observed; persisted task state remains authoritative.""" key = "6-4-cli-list-command" stories_dir = tmp_path / "stories" stories_dir.mkdir(parents=True) @@ -2214,7 +2299,13 @@ def test_build_context_tolerates_non_utf8_sentinel(tmp_path): stories_dir = tmp_path / "stories" stories_dir.mkdir(parents=True) (stories_dir / f"{key}-unresolved.md").write_bytes(_BAD_UTF8) # undecodable sentinel - run_dir, state, _ = _escalated_run(tmp_path, source="stories", sentinel_kind="unresolved") + sentinel = stories_dir / f"{key}-unresolved.md" + run_dir, state, _ = _escalated_run( + tmp_path, + source="stories", + spec_file=str(sentinel), + sentinel_kind="unresolved", + ) path = _context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) @@ -3059,6 +3150,8 @@ def test_build_context_keeps_the_withheld_count_out_of_the_payload(tmp_path): assert set(ctx) == { "story_key", "run_id", + "project_root", + "code_root", "spec_file", "baseline_commit", "paused_reason", @@ -3089,6 +3182,7 @@ def test_run_session_detects_resolution(tmp_path, monkeypatch): _context(state, run_dir, "6-4-cli-list-command", isolation="") def fake_subprocess_run(argv, cwd, env): + assert cwd == str(tmp_path) # supplied project is the process/session boundary cwd # simulate the agent writing the resolution marker resolve.resolution_path(run_dir, "6-4-cli-list-command").write_text("{}", encoding="utf-8") @@ -3251,13 +3345,46 @@ def test_build_context_stories_sentinel_indicator(tmp_path): "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", encoding="utf-8", ) - run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + ) state.spec_folder = "epic-1" ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) sent = ctx["stories"]["sentinel"] assert sent["kind"] == "unresolved" assert "intent too vague" in sent["blocking_condition"] + assert ctx["spec_reaches_the_redrive"] is None + + +def test_build_context_keeps_recorded_sentinel_mode_after_its_file_disappears(tmp_path): + """The persisted detection verdict survives an absent sentinel file. + + The missing file only removes the best-effort blocking-condition text; it must + not turn the next resolve session into an ordinary frozen-spec flow. + """ + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + sentinel = folder / "stories" / f"{key}-unresolved.md" + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + ) + state.spec_folder = "epic-1" + + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + assert ctx["stories"]["sentinel"] == { + "kind": "unresolved", + "path": sentinel.as_posix(), + "blocking_condition": "", + } + assert ctx["spec_reaches_the_redrive"] is None def test_build_context_sprint_mode_has_no_stories_block(tmp_path): @@ -3269,6 +3396,82 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): assert "stories" not in ctx +def test_build_context_sprint_mode_does_not_resolve_a_stories_root(tmp_path, monkeypatch): + """A sprint context has no consumer for stories-root data, so it performs no + stories-only filesystem lookup. + + Ablation: move `task_stories_root` back above the source gate and this fails at + the planted seam rather than passing from an absent `stories` payload alone. + """ + run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") + monkeypatch.setattr( + resolve, + "task_stories_root", + lambda *_a, **_k: pytest.fail("stories root resolved for sprint context"), + ) + + ctx = json.loads( + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + ) + assert "stories" not in ctx + + +@pytest.mark.parametrize("sentinel_kind", ["unresolved", "ambiguous"]) +def test_build_context_sentinel_does_not_probe_frozen_spec_reachability( + tmp_path, monkeypatch, sentinel_kind +): + """A stories sentinel is explicitly not a frozen spec, so reachability is null + without invoking the helper that answers whether a frozen-spec edit survives. + + Ablation: compute reachability before discovering the sentinel and the planted + helper fails; merely overwriting the result with null afterwards is insufficient. + """ + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + sentinel = folder / "stories" / f"{key}-{sentinel_kind}.md" + sentinel.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind=sentinel_kind, + ) + state.spec_folder = "epic-1" + monkeypatch.setattr( + resolve, + "spec_reaches_the_redrive", + lambda *_a, **_k: pytest.fail("sentinel spec reachability was probed"), + ) + + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + assert "sentinel" in ctx["stories"] + assert ctx["stories"]["sentinel"]["kind"] == sentinel_kind + assert ctx["spec_reaches_the_redrive"] is None + + +def test_build_context_sentinel_shaped_ordinary_spec_keeps_reachability(tmp_path): + """A real stories spec may legally use a sentinel-shaped basename. Only the + persisted detection verdict selects sentinel mode, matching re-arm; the basename + alone must not erase ordinary frozen-spec reachability or add sentinel guidance.""" + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + spec = folder / "stories" / f"{key}-unresolved.md" + spec.write_text("---\nstatus: in-review\n---\n", encoding="utf-8") + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(spec), + source="stories", + sentinel_kind="", + ) + state.spec_folder = "epic-1" + + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + assert "sentinel" not in ctx["stories"] + assert ctx["spec_reaches_the_redrive"] is True + + def test_build_context_leaves_an_out_of_mount_spec_unchanged(tmp_path): """Matrix row 5 graded at the layer that PUBLISHES the path to a human. @@ -3324,7 +3527,12 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): rel = f"epic-1/stories/{key}-unresolved.md" run_dir, state, _ = _escalated_run( - tmp_path, run_id, spec_file=rel, source="stories", worktree_path=str(wt) + tmp_path, + run_id, + spec_file=rel, + source="stories", + sentinel_kind="unresolved", + worktree_path=str(wt), ) state.spec_folder = "epic-1" @@ -3351,17 +3559,18 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec There `task_spec_root` answers the PROJECT — a write-confinement decision — while the story manifest still lives in the mount, exactly where `stories_engine._stories_folder` - looks for it. + looks for it. Sentinel identity is no longer inferred from the decoy filenames; the + distinct manifest titles grade the stories-root choice instead. Ablation: revert `_stories_context`'s root to `task_spec_root(task, state)` and this - reddens on the blocking condition — it reports the decoy twin's.""" + reddens on the story title — it reports the decoy twin's.""" key = "6-4-cli-list-command" run_id = "20260613-111429-6a14" wt = tmp_path / ".bmad-loop" / "runs" / run_id / "worktrees" / "1" - for root, condition in ((wt, "the mount's real halt"), (tmp_path, "the decoy twin")): + for root, condition in ((wt, "the mount's real intent"), (tmp_path, "the decoy twin")): folder = root / "epic-1" - _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + _stories_manifest(folder, [{"id": key, "title": condition, "description": "d"}]) (folder / "stories" / f"{key}-unresolved.md").write_text( f"---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\n{condition}\n", encoding="utf-8", @@ -3381,10 +3590,8 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == outside.as_posix() # unchanged: absolute passes through - sent = ctx["stories"]["sentinel"] - assert "the mount's real halt" in sent["blocking_condition"] - assert "decoy" not in sent["blocking_condition"] - assert Path(sent["path"]).is_relative_to(wt) # the mount, NOT task_spec_root's project + assert ctx["stories"]["story"]["title"] == "the mount's real intent" + assert "sentinel" not in ctx["stories"] # task never recorded a sentinel verdict def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index 396fee47..c17b0f82 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -84,6 +84,25 @@ def test_every_emitted_context_key_is_documented(skill_md): ) +def test_skill_routes_project_artifacts_and_code_work_to_their_distinct_roots(skill_md): + """Mentioning both keys is inert unless the skill explains the operational split. + + Each assertion grades a separate instruction the divergent-root resolution needs: + where the session starts, where BMAD artifact/spec work stays, and where code/git + work belongs. Removing the substantive guidance while leaving the schema keys + documented therefore fails this contract rather than passing the key census above. + """ + normalized = " ".join(skill_md.split()) + + assert "session's working directory is always `project_root`" in normalized + assert ( + "artifact and spec work remains anchored under `project_root` (or at the explicit " + "absolute paths in this context)" + ) in normalized + assert "When the roots differ" in normalized + assert "any code fix or commit the human must make belongs under `code_root`" in normalized + + def test_skill_branches_on_the_in_place_remedy(skill_md): """`spec_reaches_the_redrive: false` has TWO remedies, and the wrong one is lost work in the other direction. @@ -153,3 +172,14 @@ def test_skill_branches_on_spec_reachability(skill_md): assert "cut fresh from `redrive_base_ref`" in normalized # and the prohibition names whose job the landing is, rather than just refusing it assert "is the HUMAN's step" in normalized + + +def test_skill_explains_null_reachability_for_stories_sentinels(skill_md): + """A sentinel retains a path but deliberately has no frozen-spec verdict, so + the general null definition must not tell the agent that no path was recorded. + """ + normalized = " ".join(skill_md.split()) + + assert "no ordinary frozen spec to edit" in normalized + assert "stories mode recorded a sentinel path instead" in normalized + assert "follow the sentinel guidance below" in normalized From b5b9c35c88da17363b8f2894c33a8a788dc09f2b Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 17:43:53 -0700 Subject: [PATCH 21/45] sweep dw-remove-dead-artifact-relpaths: DW-15 via bmad-loop --- CHANGELOG.md | 10 +++++--- src/bmad_loop/verify.py | 44 +++++-------------------------- tests/test_verify.py | 57 +++++------------------------------------ 3 files changed, 20 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 382af6f4..25dff866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ breaking changes may land in a minor release. ### Changed +- **Remove the unused whole-artifact-folder exclusion helper** (DW-15). Proof-of-work + exclusions remain file-granular and rollback protection keeps its workspace-rooted + path derivation. + - **Resolve context builds only the mode-specific details its consumer uses.** Non-stories runs skip stories-root lookup, and stories sentinels report null frozen-spec reachability without probing a spec they do not edit. @@ -451,9 +455,9 @@ resolve` manufactures exactly that dual-key spec, inserting `baseline_revision` gate probe — the commit-identity lookup, both ancestry checks and `has_changes_since` — asked the BMAD project directory about it, so a marker only the project tree held satisfied proof of work and a correct attempt was refused forever. The four probes now share the git root, and so do the - three exclude sources that feed them. `artifact_relpaths` is deliberately untouched: it has no - production caller, and rollback protection builds its own list against the workspace root. No - effect where the two roots coincide, which is every other configuration. + three file-granular exclude sources that feed them. Rollback protection independently builds its + own list against the workspace root. No effect where the two roots coincide, which is every other + configuration. - **`bmad-loop resolve` advances the re-arm baseline in the code tree, and says so when it cannot** (#640). The advance read HEAD of the BMAD project directory rather than the git root, and diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 74554ef3..b35f77c2 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3164,36 +3164,6 @@ def set_frontmatter_field(path: Path, key: str, value: str, *, confine_root: Pat return True -def artifact_relpaths(paths: ProjectPaths) -> tuple[str, ...]: - """Repo-relative posix prefixes of the orchestrator-owned BMAD artifact - folders (the output root and the implementation/planning artifact dirs), - relative to ``paths.project``. Folders configured outside the project tree - are skipped — nothing to exclude there. - - NO PRODUCTION CALLER. Both consumers it was written for have moved: the - dev/bundle proof-of-work gate now composes its excludes file-granularly through - ``verify_dev_exclude_relpaths``, rooted on ``paths.repo_root`` where git runs - (#716), and rollback protection builds its own list against the workspace root in - ``RecoveryFlow.protected_relpaths``. Its ``paths.project`` anchor is therefore - inert rather than correct — do not cite it as evidence that project-rooting is - right for anything, and re-derive the root if a caller is ever added.""" - out: list[str] = [] - for folder in ( - paths.output_folder, - paths.implementation_artifacts, - paths.planning_artifacts, - ): - try: - rel = folder.relative_to(paths.project).as_posix() - except ValueError: - continue # configured outside the project tree; nothing to exclude here - # A folder == project root yields ".", which as an exclude prefix would - # disable change detection for the whole tree — drop it. - if rel and rel != ".": - out.append(rel) - return tuple(out) - - def verify_dev_exclude_relpaths( paths: ProjectPaths, spec_path: Path, @@ -3203,12 +3173,10 @@ def verify_dev_exclude_relpaths( ) -> tuple[str, ...]: """Repo-relative posix paths the dev/bundle proof-of-work gate excludes from its probe (`_changes_since`, via `_verify_shared_gates.proof_of_work_probe`) — - file-granularity, unlike `artifact_relpaths`' whole-folder - exclusion. `artifact_relpaths` has NO production caller left: rollback - protection builds its own list in `recovery_flow.protected_relpaths` against - `workspace.root`, and `Engine._protected_relpaths` merely delegates there. Do - not adopt it as a shortcut — it is still anchored on `paths.project`, which is - #716's root cause. Deliberately does NOT exclude `output_folder`: + deliberately file-granular. Rollback protection is a separate concern: it + builds its own list in `RecoveryFlow.protected_relpaths` against + `workspace.root`, and `Engine._protected_relpaths` merely delegates there. + Deliberately does NOT exclude `output_folder`: in the standard layout it is the parent directory of `implementation_artifacts`/ `planning_artifacts`, so excluding it as a directory prefix would swallow those two folders' content right back out of view via the same git-pathspec prefix @@ -3991,8 +3959,8 @@ def verify_dev_stories( # A plan-halt leg produced only its own spec (the plan), which proof-of-work # already excludes; skip it (extra_exclude=None) and record the plan spec. # Otherwise stories mode adds the spec folder's stories/ subdir + stories.yaml - # on top of the gate's own file-granular exclude — NOT the whole-folder - # artifact_relpaths, so a story whose entire authorized scope is ledger/spec + # on top of the gate's own file-granular exclude — NOT a whole-folder artifact + # exclusion, so a story whose entire authorized scope is ledger/spec # reconciliation doesn't register as a false "no changes". Engine-written # paths compose only on that live-gate leg; ``None`` must remain ``None`` for # plan halt rather than being combined with a tuple. diff --git a/tests/test_verify.py b/tests/test_verify.py index 7af370bc..e65d2594 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -3056,10 +3056,10 @@ def test_verify_dev_stories_ledger_only_counts_as_real_work(project): """T3 regression: a stories-mode story whose entire authorized diff is ledger/spec reconciliation under implementation_artifacts (e.g. deferred-work.md) must pass proof-of-work, not false-negative "no changes". Guards the file-granular - exclude port off #79 — the old whole-folder `artifact_relpaths` exclusion - swallowed the ledger, re-introducing KNOWN-BUG-ledger-only-story-false-no- - changes.md in stories mode (verify_dev_exclude_relpaths excludes only the - session's own spec + sprint-status, so sibling ledger content counts).""" + exclude port off #79 — the old whole-folder artifact exclusion swallowed the + ledger, re-introducing KNOWN-BUG-ledger-only-story-false-no-changes.md in stories + mode (verify_dev_exclude_relpaths excludes only the session's own spec + + sprint-status, so sibling ledger content counts).""" spec_folder = project.planning_artifacts / "epic-a" task = make_stories_task(project, "1") sp = write_story(spec_folder, "1", "x", "done", task.baseline_commit) @@ -6286,48 +6286,6 @@ def test_verify_dev_stories_refuses_bookkeeping_only_changes_under_the_monorepo_ ).ok -def test_artifact_relpaths_returns_in_repo_folders(project): - """The orchestrator-owned artifact folders, repo-relative posix.""" - rels = verify.artifact_relpaths(project) - assert "_bmad-output/implementation-artifacts" in rels - assert "_bmad-output/planning-artifacts" in rels - assert all(r and r != "." for r in rels) - - -def test_artifact_relpaths_drops_dot_when_folder_is_project_root(project): - """A folder configured == project root yields "."; it must be dropped so it - can't become a whole-tree exclude that disables the proof-of-work gate.""" - paths = dataclasses.replace(project, output_folder=project.project) - rels = verify.artifact_relpaths(paths) - assert "." not in rels and "" not in rels - # the real sub-dirs are still excluded; only the root-collapsing "." is dropped - assert "_bmad-output/implementation-artifacts" in rels - - -def test_has_changes_since_excludes_artifact_only_edit(project): - """A change confined to the artifact folders is not proof of dev work.""" - baseline = verify.rev_parse_head(project.project) - # root-level _bmad-output edit (bundle/ledger) + nested impl-artifact edit: - # both must be excluded, proving artifact_relpaths covers output_folder too. - (project.output_folder / "ledger.json").write_text("bookkeeping\n") - (project.implementation_artifacts / "spec-x.md").write_text("bookkeeping\n") - assert verify.has_changes_since(project.project, baseline) is True # unscoped - assert ( - verify.has_changes_since( - project.project, baseline, exclude=verify.artifact_relpaths(project) - ) - is False - ) - # a real source edit still counts - (project.project / "src.txt").write_text("real\n") - assert ( - verify.has_changes_since( - project.project, baseline, exclude=verify.artifact_relpaths(project) - ) - is True - ) - - def test_changes_since_reports_a_git_refusal_and_has_changes_since_collapses_it(project): """The two-function split, at its own layer: `_changes_since` answers the tri-state and `has_changes_since` is its fail-open collapse. @@ -6393,10 +6351,9 @@ def test_has_changes_since_subtracts_baseline_untracked(project): def test_verify_dev_exclude_relpaths_is_file_granular(project): - """Unlike artifact_relpaths (whole-folder), this excludes only the - sprint-status ledger and the session's own claimed spec file — sibling - artifact-folder content (deferred-work.md, other stories' specs) is left - un-excluded so it can register as real work.""" + """Excludes only the sprint-status ledger and the session's own claimed spec + file — sibling artifact-folder content (deferred-work.md, other stories' specs) + is left un-excluded so it can register as real work.""" sp = spec_path(project, "1-1-a") rels = verify.verify_dev_exclude_relpaths(project, sp, root=project.repo_root) assert "_bmad-output/implementation-artifacts/sprint-status.yaml" in rels From 8ae69575d26c66b485e1ec00bee2ac509fd86a5a Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 18:13:31 -0700 Subject: [PATCH 22/45] sweep dw-peel-task-generation-suffix: DW-16 via bmad-loop --- src/bmad_loop/tui/data.py | 18 ++++++++---------- tests/test_tui_data.py | 30 +++++++++++++----------------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index 14f2ccf5..789156de 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -607,18 +607,16 @@ def _story_key_from_task_id(task_id: str, role: str) -> str: predates story-key stamping (#153 phase 1). The id is ``safe_segment(f"{story_key}-{part}-{seq}{gen}")`` where ``part`` is the role, or a workflow label for labeled plugin sessions, and ``gen`` is a ``-g`` re-arm - generation suffix emitted only above zero (#705). This parser handles the - unsuffixed shape ONLY: a ``-g1`` tail fails the ``seq.isdigit()`` test below and - returns the whole id. That is unreachable rather than latent-correct — every - session-start has carried ``story_key`` since #153 phase 1, so the entries this - fallback sees are exactly the ones that predate generations — but widen the - fallback and this is the assumption that breaks. So peel the trailing - ``-{part}-{seq}``: drop the numeric seq, then the recorded role when it - matches (the common case), else one more ``-`` group (best-effort, since a - label is not recoverable from the entry).""" + generation suffix emitted only above zero (#705). Peel one optional terminal + generation suffix, then peel the trailing ``-{part}-{seq}``: drop the numeric + seq, then the recorded role when it matches (the common case), else one more + ``-`` group (best-effort, since a label is not recoverable from the entry). + Malformed shapes return the original task id unchanged.""" + original = task_id + task_id = re.sub(r"-g[1-9][0-9]*\Z", "", task_id) head, sep, seq = task_id.rpartition("-") if not sep or not seq.isdigit(): - return task_id # not the expected shape — best we can do + return original # not the expected shape — best we can do if role and head.endswith(f"-{role}"): return head[: -(len(role) + 1)] parent = head.rpartition("-")[0] diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 65f528a1..f5ea159e 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -1133,20 +1133,9 @@ def test_story_key_from_task_id_grammar_including_the_generation_suffix(): `_session_task_id` composes `safe_segment(f"{story_key}-{part}-{seq}{gen}")`, and #705 added `gen` — a `-g` suffix emitted only above generation zero. That - changed the grammar this parser documents, and nothing recorded either half of it. - - The `-g1` row pins a DOCUMENTED LIMITATION, not a desired outcome: the suffix - fails `seq.isdigit()` and the whole id comes back as the story key. It is - unreachable today because every `session-start` has carried `story_key` since #153 - phase 1, so the entries this fallback actually sees predate generations entirely. - It is pinned precisely because that reasoning is an assumption about the CALLER: - widen the fallback to entries that can carry `-gN` and this row is where the - breakage surfaces, instead of a bogus `1-1-a-dev-1-g1` row appearing in the - active-agent view. - - Ablation: drop the `seq.isdigit()` term and the `-g1` row changes answer (it then - peels `g1` as if it were a sequence); change the emitted suffix shape in - `engine._session_task_id` and the last row reddens. + changed the grammar this parser documents. Only a final numeric generation + component is peeled; generation-like malformed or nonterminal components retain + the existing best-effort fallback behavior. """ # the ordinary unsuffixed shape: the recorded role is peeled with its seq assert data._story_key_from_task_id("1-1-a-dev-1", "dev") == "1-1-a" @@ -1156,6 +1145,13 @@ def test_story_key_from_task_id_grammar_including_the_generation_suffix(): # not the expected shape at all — returned verbatim assert data._story_key_from_task_id("nonsense", "dev") == "nonsense" - # generation-suffixed (#705): NOT parsed, returned whole. Unreachable today. - assert data._story_key_from_task_id("1-1-a-dev-1-g1", "dev") == "1-1-a-dev-1-g1" - assert data._story_key_from_task_id("1-1-a-dev-1-g12", "dev") == "1-1-a-dev-1-g12" + # generation-suffixed (#705): one terminal numeric generation is peeled + assert data._story_key_from_task_id("1-1-a-dev-1-g1", "dev") == "1-1-a" + assert data._story_key_from_task_id("1-1-a-dev-1-g12", "dev") == "1-1-a" + # malformed and nonterminal generation-like components are not suffixes + assert data._story_key_from_task_id("1-1-a-dev-1-g", "dev") == "1-1-a-dev-1-g" + assert data._story_key_from_task_id("1-1-a-dev-1-gx", "dev") == "1-1-a-dev-1-gx" + assert data._story_key_from_task_id("1-1-a-dev-1-g0", "dev") == "1-1-a-dev-1-g0" + assert data._story_key_from_task_id("1-1-a-dev-1-g01", "dev") == "1-1-a-dev-1-g01" + assert data._story_key_from_task_id("1-1-a-dev-1-g١", "dev") == "1-1-a-dev-1-g١" + assert data._story_key_from_task_id("1-1-a-dev-1-g1-extra", "dev") == "1-1-a-dev-1-g1-extra" From 550f11cfb30b4ed2ebb8b409575d4e9d962f6154 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 18:25:59 -0700 Subject: [PATCH 23/45] sweep dw-document-spec-path-resolvers: DW-17, DW-18, DW-36 via bmad-loop --- CHANGELOG.md | 13 ++++++++++--- src/bmad_loop/recovery_flow.py | 11 ++++++++++- src/bmad_loop/resolve.py | 2 +- src/bmad_loop/runs.py | 30 ++++++++++++++++++++++++++---- src/bmad_loop/tui/app.py | 4 ++-- src/bmad_loop/verify.py | 15 ++++++++++++--- tests/test_portability_guard.py | 6 +++--- tests/test_resolve.py | 8 ++++---- 8 files changed, 68 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25dff866..fdc8c83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,9 +164,10 @@ breaking changes may land in a minor release. - **Re-arm writes the spec the run actually used, and reports every write it could not make** (#640). `StoryTask` persists `spec_file` relative to the worktree and re-arm resolved it against - the process cwd, where the main checkout carries the same layout — so the status flip and the - baseline re-stamp landed on the WRONG file while the worktree's real spec kept the escalated - attempt's sha. The recorded path is now re-anchored on the worktree before either write. + the process cwd, where the main checkout carries the same implementation-artifacts-relative + path — so the status flip and the baseline re-stamp landed on the WRONG file while the + worktree's real spec kept the escalated attempt's sha. The recorded path is now re-anchored on + the worktree before either write. Separately, both frontmatter writers answer a spec they cannot move with `False` rather than an exception and those returns were discarded; they now journal `rearm-baseline-restamp-skipped` and `rearm-spec-flip-skipped`, on genuine failure only — re-arm reads the status back, so an ordinary @@ -245,6 +246,12 @@ breaking changes may land in a minor release. ### Fixed +- **Document the three story-spec path resolvers and their distinct ownership + contracts** (DW-17, DW-18, DW-36). Persisted-task anchoring, session-reported + candidate probing, and exact attempt recovery binding now cross-reference one + another and spell out their different bare-basename results; production prose + also names the real implementation-artifacts-relative layout. + - **An aborted re-arm no longer leaves the spec re-armed against an escalated task** (DW-79, DW-83, DW-85). `runs.rearm_escalation` published the status flip and stripped the stale `## Auto Run Result` about 250 lines before `save_state`, and only two of the aborts diff --git a/src/bmad_loop/recovery_flow.py b/src/bmad_loop/recovery_flow.py index 5b1aae21..a167788f 100644 --- a/src/bmad_loop/recovery_flow.py +++ b/src/bmad_loop/recovery_flow.py @@ -114,7 +114,16 @@ def protected_relpaths(self) -> tuple[str, ...]: return tuple(out) def _attempt_owned_spec(self, task: StoryTask) -> tuple[Path, str | None] | None: - """Resolve this attempt's bound spec and its exact Git exclusion. + """Bind recovery restoration to this attempt's spec and exact Git exclusion. + + This is the restore authority for the dispatched attempt, not an ordinary + path lookup. Operations on the tree recorded by a task use + ``runs.task_spec_path``, which anchors a bare basename directly on that tree. + Binding a reported or persisted spelling inside the current active + ``ProjectPaths`` uses ``verify.resolve_spec_path``, which chooses an existing + project candidate or falls back under implementation artifacts. Here a bare + basename probes both locations and is accepted only when exactly one trusted + regular-file candidate exists. Relative persisted paths may name either a project-relative file or a basename under the configured implementation-artifacts directory. The diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index a3db1e2c..04246fc3 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -287,7 +287,7 @@ def build_context( # isolated unit's `spec_file` is persisted RELATIVE to the mounted worktree # (`model.StoryTask._serialized_worktree_path`) and the agent session runs # from the project root, where the main checkout carries the same - # `_bmad-output/specs/...` layout — the raw value would name the wrong + # implementation-artifacts-relative path — the raw value would name the wrong # tree's copy. `task_spec_path` provides the persisted anchor; when the whole # project moved, `_rebase_recorded_project_path` carries that project-owned # spelling onto the live session root without resolving it through the OS. diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 09a6a298..73a16b45 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -1,4 +1,16 @@ -"""Run-directory discovery and helpers shared by the CLI and the TUI.""" +"""Run-directory discovery and helpers shared by the CLI and the TUI. + +The public story-spec ownership and confinement seam consists of four helpers: + +* :func:`task_spec_path` anchors a persisted ``StoryTask.spec_file`` on the tree + that owned the task when it was recorded. +* :func:`task_spec_root` names the matching root used to confine writes to that + anchored spec. +* :func:`task_stories_root` locates the workspace tree from which the run reads + its stories folder; it is a read locator, not a spec-write confinement root. +* :func:`spec_reaches_the_redrive` reports whether an edit at the persisted-task + anchor will survive to the workspace used by the next attempt. +""" from __future__ import annotations @@ -2990,14 +3002,24 @@ def validate_restore_latch( def task_spec_path(task: StoryTask, state: RunState) -> Path: - """The recorded spec path, re-anchored on the tree it was persisted relative to. + """The persisted-task spec anchor, re-based on the tree it was recorded relative to. + + Use this when an operation must address the tree recorded by the task. A bare + basename is joined directly to :func:`task_spec_root`; it is NOT probed against the + project and has no implementation-artifacts fallback. To bind either a + session-reported path or a persisted spelling inside the current active + ``ProjectPaths``, use :func:`verify.resolve_spec_path`, which probes project-first + and then falls back under the implementation-artifacts directory. Recovery uses + ``recovery_flow.RecoveryFlow._attempt_owned_spec`` instead: for a bare basename it + probes both locations and accepts the binding only when exactly one trusted regular + file exists. `StoryTask._serialized_worktree_path` (`model.py`) persists a worktree-local spec RELATIVE to the mounted worktree root, and `from_dict` reads it back raw. Resolving that against the process cwd is not merely unreachable — it is actively wrong: `bmad-loop resolve` runs from the project root, where the MAIN CHECKOUT carries the - same `_bmad-output/specs/...` layout, so a bare `Path(task.spec_file)` names the main - checkout's copy of the story spec. `is_file()` then answers True, `confine_root` + same implementation-artifacts-relative path, so a bare `Path(task.spec_file)` names + the main checkout's copy of the story spec. `is_file()` then answers True, `confine_root` accepts it (it genuinely is under `project`), and the status flip and the baseline re-stamp both land on a file the run never used while the worktree's real spec is left on the escalated attempt's sha. diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 2b59c5a8..f4cfe1c9 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1071,8 +1071,8 @@ def _paused_spec(self, state: RunState) -> tuple[Path | None, str, bool]: raw value: `model.StoryTask._serialized_worktree_path` persists an isolated unit's spec RELATIVE to the mounted worktree root and `from_dict` reads it back raw, so a bare `Path(task.spec_file)` resolves against the TUI process cwd — - where the main checkout carries the very same `_bmad-output/specs/...` layout - and answers with the WRONG tree's copy of the story spec.""" + where the main checkout carries the same implementation-artifacts-relative + path and answers with the WRONG tree's copy of the story spec.""" task = self._paused_task(state) if task is None or not task.spec_file: return None, "", True diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index b35f77c2..e0f2fdde 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3257,9 +3257,18 @@ def spec_within_roots(spec_path: Path, paths: ProjectPaths) -> bool: def resolve_spec_path(spec_file: str, paths: ProjectPaths) -> Path: - """A session-reported ``spec_file`` as a concrete path: an absolute value passes - through untouched, a relative one is probed against ``paths.project`` and falls - back to ``paths.implementation_artifacts``. + """Probe a session-reported ``spec_file`` candidate into a concrete path. + + This lookup binds a reported or persisted spelling inside the current active + ``ProjectPaths``. The spelling may come directly from a disposable session or be + read from a task and rebound for a current or fresh workspace. An absolute value + passes through untouched. A relative value — including a bare basename — is probed + against ``paths.project`` first and falls back under + ``paths.implementation_artifacts``. When an operation must instead address the tree + recorded by the task, use :func:`runs.task_spec_path`, which anchors a bare basename + directly on that tree without this fallback. Recovery uses + ``recovery_flow.RecoveryFlow._attempt_owned_spec`` to bind restoration to exactly + one trusted regular-file candidate after probing both locations. Neither branch promises the result exists — the fallback is returned unprobed when the project candidate is not a file — so every caller re-tests diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index defffd6f..ca8afb04 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -131,7 +131,7 @@ # Everywhere else the field arrives from `load_state`, and # `_serialized_worktree_path` persists an isolated unit's spec RELATIVE to the mount. # A bare `Path(...)` there resolves against the READER's cwd — the main checkout, -# which carries the same `_bmad-output/specs/...` layout and answers with the wrong +# which carries the same implementation-artifacts-relative path and answers with the wrong # tree's copy. That defect shipped in `tui/app.py::_paused_spec`, where it reached a # destructive write, and was then re-found one surface at a time in `resolve.py`, # `sweep.py`, `stories_engine.py` and `worktree_flow.py` across four review rounds. @@ -1955,8 +1955,8 @@ def test_spec_path_resolved_only_through_the_anchor(): loads state from disk must say WHICH tree the value is relative to. The four allowlisted files run inside that tree already; everything else — the TUI, the resolve-context builder, the sweep and stories engines, the read-model - projections — does not, and the main checkout carries an identical - ``_bmad-output/specs/...`` layout that answers a bare ``Path(...)`` with the wrong + projections — does not, and the main checkout carries the same + implementation-artifacts-relative path that answers a bare ``Path(...)`` with the wrong copy. That is not a hypothetical: it shipped in ``tui/app.py::_paused_spec``, where ``_do_replan`` then WROTE to the main checkout's file and the operator's replan silently did not happen. diff --git a/tests/test_resolve.py b/tests/test_resolve.py index c4639ddb..e6c7f55b 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -651,8 +651,8 @@ def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_ `StoryTask._serialized_worktree_path` persists an isolated unit's `spec_file` RELATIVE to the mounted worktree and `from_dict` reads it back raw, so the raw value handed to the agent was a bare relpath. The `bmad-loop-resolve` session runs - from the PROJECT root, where the main checkout carries the very same - `_bmad-output/specs/...` layout — so that relpath resolved, silently, onto the + from the PROJECT root, where the main checkout carries the same + implementation-artifacts-relative path — so that relpath resolved, silently, onto the main checkout's twin, and the human and the agent edited a spec the run never used while `rearm_escalation` (which re-anchors through `task_spec_path`) flipped the worktree's. `build_context` now emits the same re-anchor the re-arm writes @@ -1318,8 +1318,8 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, relative to the mounted worktree root — no worktree prefix — and `from_dict` reads it back raw, so a bare `Path(task.spec_file)` resolves against the process cwd. That is not merely unreachable, it is actively WRONG: `bmad-loop resolve` runs from - the project root, and the main checkout carries the very same - `_bmad-output/specs/...` layout. `is_file()` answered True on the wrong file, + the project root, and the main checkout carries the same + implementation-artifacts-relative path. `is_file()` answered True on the wrong file, `confine_root` accepted it (it genuinely is under `project`), and both the status flip AND the baseline re-stamp landed on a spec the run never used, while the worktree's real spec kept the escalated attempt's sha and the re-drive re-wedged. From 1de78e4bd2fed858f8762b8ae1341d06bf0a8508 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 19:28:00 -0700 Subject: [PATCH 24/45] sweep dw-atomic-tui-replan: DW-33 via bmad-loop --- CHANGELOG.md | 3 + src/bmad_loop/devcontract.py | 35 ++++++++++ src/bmad_loop/tui/app.py | 3 +- tests/test_devcontract.py | 98 ++++++++++++++++++++++++++ tests/test_tui_app.py | 131 ++++++++++++++++++++++++++++++++++- 5 files changed, 267 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc8c83d..5210d21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,9 @@ breaking changes may land in a minor release. ### Fixed +- **Restore the original spec when a TUI replan cannot strip its stale result** + (DW-33), keeping the status reset and result removal atomic before resume. + - **Document the three story-spec path resolvers and their distinct ownership contracts** (DW-17, DW-18, DW-36). Persisted-task anchoring, session-reported candidate probing, and exact attempt recovery binding now cross-reference one diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 19f7aa87..e3819221 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -733,6 +733,41 @@ def strip_auto_run_result(spec_path: Path, *, confine_root: Path) -> bool: return True +def reset_spec_for_replan(spec_path: Path, *, confine_root: Path) -> bool: + """Reset a spec to ``draft`` and strip its stale result transactionally. + + The TUI exposes those two writes as one operator action. Capture the exact + preimage before the status write and restore it through the same confined + atomic writer if either stage faults after changing (or removing) the file, + so a failed replan never publishes only its first half. A reset that makes no + change is a refusal, not permission to strip the result independently. + + Rollback deliberately catches ``BaseException`` so an interrupt between the + two writes cannot leave a partial replan. A fault that leaves the preimage + untouched does not rewrite it. If the restore itself fails, that failure + escapes; otherwise the original stage failure is re-raised. + """ + original = spec_path.read_bytes() + try: + reset = reset_spec_status(spec_path, "draft", confine_root=confine_root) + if not reset: + if not spec_path.is_file(): + raise FileNotFoundError(f"replan spec vanished during status reset: {spec_path}") + return False + stripped = strip_auto_run_result(spec_path, confine_root=confine_root) + if not stripped and not spec_path.is_file(): + raise FileNotFoundError(f"replan spec vanished during result strip: {spec_path}") + except BaseException: + try: + unchanged = spec_path.read_bytes() == original + except OSError: + unchanged = False + if not unchanged: + _atomic_write_spec(spec_path, original.decode("utf-8"), confine_root=confine_root) + raise + return True + + # Provenance stamped into a synthesized `## Auto Run Result` section so a human # (or a later re-derivation) can tell an orchestrator-repaired marker from one # the skill wrote itself. Single line (no internal newlines) so the writer's diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index f4cfe1c9..c5277f64 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -839,8 +839,7 @@ def _do_replan(self, run_id: str, spec_path: Path, confine_root: Path) -> None: self.notify(f"replan: no spec at {spec_path} — not resuming", severity="error") return try: - reset = devcontract.reset_spec_status(spec_path, "draft", confine_root=confine_root) - devcontract.strip_auto_run_result(spec_path, confine_root=confine_root) + reset = devcontract.reset_spec_for_replan(spec_path, confine_root=confine_root) except (OSError, UnicodeDecodeError, verify.FrontmatterWriteError) as e: # FrontmatterWriteError is not an OSError: a spec whose `status:` is a # block scalar or a flow mapping reads fine and fails the WRITE. It diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index a91440b2..9816ead2 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -1062,6 +1062,104 @@ def test_strip_auto_run_result_skips_fenced_boundary_lines(tmp_path): assert "## Intent\n\nbody\n" in text +# --------------------------------------------------------- reset_spec_for_replan + + +def test_reset_spec_for_replan_does_not_rewrite_unchanged_reset_refusal(tmp_path, monkeypatch): + """A pre-write frontmatter refusal propagates without an unnecessary undo.""" + sp = tmp_path / "spec.md" + original = b"---\nstatus: |\n ready-for-dev\n---\n\nbody\n" + sp.write_bytes(original) + writes: list[tuple] = [] + monkeypatch.setattr( + devcontract, + "_atomic_write_spec", + lambda *args, **kwargs: writes.append((args, kwargs)), + ) + + with pytest.raises(verify.FrontmatterWriteError): + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert sp.read_bytes() == original + assert writes == [] + + +def test_reset_spec_for_replan_restores_when_reset_commits_then_interrupts(tmp_path, monkeypatch): + """An interrupt after reset's atomic replace still rolls the transaction back.""" + sp = tmp_path / "spec.md" + original = b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n## Auto Run Result\r\n" + sp.write_bytes(original) + real_reset = devcontract.reset_spec_status + interrupt = KeyboardInterrupt("interrupt after reset commit") + + def reset_then_interrupt(*args, **kwargs): + assert real_reset(*args, **kwargs) is True + raise interrupt + + monkeypatch.setattr(devcontract, "reset_spec_status", reset_then_interrupt) + + with pytest.raises(KeyboardInterrupt) as caught: + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert caught.value is interrupt + assert sp.read_bytes() == original + + +def test_reset_spec_for_replan_restores_when_reset_finds_spec_missing(tmp_path, monkeypatch): + """A vanished spec during reset is restored rather than treated as a refusal.""" + sp = tmp_path / "spec.md" + original = b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n## Auto Run Result\r\n" + sp.write_bytes(original) + + def vanish_during_reset(*args, **kwargs): + sp.unlink() + return False + + monkeypatch.setattr(devcontract, "reset_spec_status", vanish_during_reset) + + with pytest.raises(FileNotFoundError, match="vanished during status reset"): + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert sp.read_bytes() == original + + +def test_reset_spec_for_replan_restores_on_baseexception_from_strip(tmp_path, monkeypatch): + """The strip-stage guard is intentionally broader than ``Exception``.""" + + class StripAbort(BaseException): + pass + + sp = tmp_path / "spec.md" + original = b"---\nstatus: ready-for-dev\n---\n\n## Auto Run Result\n" + sp.write_bytes(original) + abort = StripAbort("abort result strip") + + def abort_strip(*args, **kwargs): + raise abort + + monkeypatch.setattr(devcontract, "strip_auto_run_result", abort_strip) + + with pytest.raises(StripAbort) as caught: + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert caught.value is abort + assert sp.read_bytes() == original + + +def test_reset_spec_for_replan_restores_when_strip_finds_spec_missing(tmp_path, monkeypatch): + """A vanished spec is a failed transaction, not a successful no-section strip.""" + sp = tmp_path / "spec.md" + original = b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n## Auto Run Result\r\n" + sp.write_bytes(original) + real_strip = devcontract.strip_auto_run_result + + def vanish_then_strip(*args, **kwargs): + sp.unlink() + return real_strip(*args, **kwargs) + + monkeypatch.setattr(devcontract, "strip_auto_run_result", vanish_then_strip) + + with pytest.raises(FileNotFoundError, match="vanished during result strip"): + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert sp.read_bytes() == original + + def test_strip_auto_run_result_ignores_heading_in_longer_outer_fence(tmp_path): """Destructive-op guard: a `## Auto Run Result` fenced inside a 4-backtick block that contains a lone inner ``` line must be preserved (no-op). Line diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 1ec73aa5..54e0da6d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3561,6 +3561,131 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): assert strips == [(spec, project.project)] +async def test_plan_checkpoint_replan_restores_preimage_when_result_strip_fails( + project, monkeypatch +): + """The reset and result strip are one TUI transaction, including confinement. + + The real status reset commits first; the injected second-stage fault then forces + the helper to restore the byte-for-byte preimage. Using an isolated worktree also + pins that both the forward status write and rollback carry the caller's owning + root into the confined atomic writer. + + Ablation: delete the rollback write in ``reset_spec_for_replan`` and this reddens + on byte identity because the spec remains at ``status: draft``. + """ + from bmad_loop import devcontract + + calls: list[str] = [] + roots: list[Path] = [] + real_atomic_write = devcontract._atomic_write_spec + + writes = 0 + + def fail_second_atomic_write(p, text, **kw): + nonlocal writes + writes += 1 + roots.append(kw["confine_root"]) + if writes == 2: + raise OSError("injected result-strip write failure") + return real_atomic_write(p, text, **kw) + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr(devcontract, "_atomic_write_spec", fail_second_atomic_write) + wt = _unit_worktree(project.project) + _run_dir, spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + worktree_path=str(wt), + blocked_result="stale terminal result", + ) + original = ( + b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n# worktree plan for 1\r\n" + b"\r\n## Auto Run Result\r\n\r\n- Status: blocked\r\n\r\nstale terminal result\r\n" + ) + spec.write_bytes(original) + monkeypatch.chdir(project.project) + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until( + pilot, + lambda: any("injected result-strip write failure" in m for m in notifications(app)), + ) + assert app.is_running + assert spec.read_bytes() == original + assert calls == [] + assert roots == [wt, wt, wt] + + +async def test_plan_checkpoint_replan_does_not_strip_when_reset_refuses(project, monkeypatch): + """An unchanged reset must not independently commit the result-strip half.""" + calls: list[str] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _run_dir, spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + spec_status="draft", + blocked_result="stale terminal result", + ) + original = spec.read_bytes() + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until(pilot, lambda: any("could not reset" in m for m in notifications(app))) + assert spec.read_bytes() == original + assert calls == [] + + +async def test_plan_checkpoint_replan_rollback_failure_stays_loud(project, monkeypatch): + """A failed undo escapes to the TUI error path and still cannot resume.""" + from bmad_loop import devcontract + + calls: list[str] = [] + writes = 0 + real_atomic_write = devcontract._atomic_write_spec + + def fail_rollback(p, text, **kw): + nonlocal writes + writes += 1 + if writes == 2: + raise OSError("injected rollback failure") + return real_atomic_write(p, text, **kw) + + def fail_strip(p, **kw): + raise OSError("injected result-strip failure") + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr(devcontract, "_atomic_write_spec", fail_rollback) + monkeypatch.setattr(devcontract, "strip_auto_run_result", fail_strip) + _run_dir, _spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + blocked_result="stale terminal result", + ) + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until( + pilot, + lambda: any("injected rollback failure" in m for m in notifications(app)), + ) + assert app.is_running + assert calls == [] + + def _unit_worktree(root: Path, run_id: str = "20260611-100000-aaaa", unit: str = "1") -> Path: """The UNRESOLVED spelling of where `workspace.open_unit_workspace` mounts a unit. @@ -3625,7 +3750,10 @@ def spy_strip(p, **kw): monkeypatch.setattr(devcontract, "strip_auto_run_result", spy_strip) wt = _unit_worktree(project.project) _run_dir, spec = _stories_paused_run( - project.project, stage="plan-checkpoint", worktree_path=str(wt) + project.project, + stage="plan-checkpoint", + worktree_path=str(wt), + blocked_result="stale terminal result", ) twin = project.project / spec.relative_to(wt) untouched = twin.read_bytes() @@ -3637,6 +3765,7 @@ def spy_strip(p, **kw): await pilot.click(await ready(pilot, "#act-replan")) await until(pilot, lambda: calls == ["20260611-100000-aaaa"]) assert verify.read_frontmatter(spec)["status"] == "draft" + assert "## Auto Run Result" not in spec.read_text(encoding="utf-8") assert twin.read_bytes() == untouched assert roots == [wt, wt] From 2468c0ddc1f5a1a2422b2703957c28829f547332 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 22:39:06 -0700 Subject: [PATCH 25/45] sweep dw2-isolation-flip-mount-state: DW-41, DW-42, DW-45 via bmad-loop --- src/bmad_loop/engine.py | 88 ++-- src/bmad_loop/model.py | 24 ++ src/bmad_loop/recovery_flow.py | 19 +- src/bmad_loop/sweep.py | 34 +- src/bmad_loop/verify.py | 148 +++++-- src/bmad_loop/workspace.py | 64 ++- src/bmad_loop/worktree_flow.py | 85 +++- tests/test_engine_worktree.py | 733 +++++++++++++++++++++++++++++++-- tests/test_model.py | 119 ++++++ tests/test_recovery_flow.py | 33 +- tests/test_sweep.py | 186 ++++++++- tests/test_verify.py | 71 ++++ tests/test_verify_worktree.py | 12 + tests/test_worktree_flow.py | 39 +- 14 files changed, 1484 insertions(+), 171 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index a81fd926..7f98f7df 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1434,54 +1434,21 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: `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, task.branch, run_dir=self.run_dir - ) + discard_worktree(self.paths.repo_root, task.worktree_path, "", run_dir=self.run_dir) # before the clears below: the relativization is measured against this field task.release_mount_owned_state() task.worktree_path = "" task.branch = "" def _release_orphaned_mount(self, task: StoryTask) -> None: - """Give up a mount live policy has stopped treating as isolated, and say so. - - Reached when `isolation` flipped `worktree -> none` across a resume: policy is - re-read every resume and a change is journaled, never refused, so a task can - arrive still recording the previous attempt's mount while execution happens in - the MAIN workspace. `_finish_inflight` re-anchors `spec_file` INTO that mount - first and unconditionally — the anchor must precede the `isolated` gate, - because the relative spelling resolves against the main checkout, which carries - the identical layout, and `recovery_flow` would restore over the operator's own - copy. Every non-isolated leg that then proceeds has to UNDO that anchor, or it - consumes a `spec_file` absolutized into a tree this run will not enter again: - `_dispatched_spec_for_attempt` resolves it `strict=True`, raises, and leaves the - attempt unbound, and an explicit-spec prompt meets the snapshot gate with - nothing bound. - - FOUR call sites, not one. The restart arm carried this first, but the three - continuation arms — the spec-approval `DEV_VERIFY` leg, the recorded-result - `_resumable_session` leg and the `COMMITTING` finalizer — each finish their work - and `return` without ever reaching it, so they were left consuming the anchored - path. A helper rather than a hoist above the arm dispatch: the restart arm asks - `_refuse_gated_story` FIRST and that can raise `RunPaused`, and the anchored - spelling is load-bearing until an arm commits to acting. Releasing above the - dispatch would undo it for a task that never proceeds. - - The BASELINE goes with the spec: `baseline_commit`/`baseline_untracked` were - measured inside the mount, and handing them to `_rollback_or_pause` against the - main checkout makes a unit's empty untracked snapshot read every untracked file - in the operator's own checkout as this attempt's debris. The CLAIM goes too — - `worktree_path` is how `runs` answers which tree owns the state this task has - already persisted (`task_spec_root`, `task_stories_root`), so keeping it set - anchors those readers on a tree the run has left. Clearing it is not deleting - the tree: the directory stays where it is and the journal names it, and - `workspace.open_unit_workspace` reclaims it if a later flip back to `worktree` - needs its deterministic path. - - Does NOT fix `redrive_base_ref` / `spec_reaches_the_redrive`, and never could: - those describe the re-drive rather than the attempt, and `bmad-loop resolve` - asks them in a SEPARATE process before this resume runs. They take the live - isolation mode as a parameter instead — see `runs.redrive_base_ref`. + """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 @@ -1638,28 +1605,19 @@ def _finish_inflight(self) -> None: if task.terminal: continue if task.worktree_path: - # Re-anchor BEFORE the `isolated` gate, because that gate is live - # policy (`self._isolated`) while the relative spelling is persisted - # state: `model._serialized_worktree_path` relativizes whenever - # `worktree_path` is set, and `from_dict` reads it back raw. Two arms - # below then act on a task whose paths `reopen_unit` never - # re-absolutized — an `isolation` flip across a resume (policy is - # re-read and only journaled, never refused), and the restart arm, - # which discards the mount and clears `worktree_path` before it saves. - # Either way the raw value resolves against the MAIN checkout, which - # carries the same layout, so `recovery_flow._attempt_owned_spec` finds - # exactly one candidate, `spec_within_roots` accepts it, and the - # snapshot restore rewrites the operator's own copy. Anchoring here - # names the tree that actually owned the attempt; when that tree is - # gone the binding is unresolvable and recovery refuses it loudly. + # Portable spec paths are persisted relative to their recorded mount. + # 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(Path(task.worktree_path)) - isolated = self._isolated and task.worktree_path - if isolated and task.defer_reason is not None: + 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 isolated task is that interrupted + # 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) @@ -1674,7 +1632,7 @@ def _finish_inflight(self) -> None: # 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 isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -1705,7 +1663,7 @@ def _finish_inflight(self) -> None: continuation = functools.partial( self._review_and_commit, task, resume_result=result ) - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -1727,7 +1685,7 @@ def _finish_inflight(self) -> None: # and finalize_commit tolerates both the pre- and post-squash # crash states (#115). self.journal.append("resume-commit", story_key=task.story_key) - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -1767,7 +1725,7 @@ def _finish_inflight(self) -> None: self.journal.append( "resume-restart", story_key=task.story_key, phase=str(task.phase) ) - if isolated: + if restart_isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) else: @@ -1777,7 +1735,7 @@ def _finish_inflight(self) -> None: # recovery refuse loudly instead of rewriting the main checkout. self._release_orphaned_mount(task) - if not isolated and task.baseline_commit: + 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 @@ -2056,7 +2014,7 @@ def _run_story(self, task: StoryTask) -> None: ctx = self._emit("pre_story", task) if self._vetoed(ctx, task): return - if self._isolated: + 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 diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 16bb3af0..1cf99d53 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -587,6 +587,30 @@ def rebase_spec_paths_on(self, root: Path) -> None: self.spec_file = _rebased_on(self.spec_file, root) self.dispatched_spec_file = _rebased_on(self.dispatched_spec_file, root) + def relativize_project_local_accepted_spec(self, project: Path) -> None: + """Make a canonical project-local accepted spec portable to a worktree. + + Only the accepted ``spec_file`` is normalized. Prior-attempt dispatch path + and snapshot fields remain byte-for-byte untouched until fresh attempt + binding replaces them after the mount opens. Relative values already carry + the intended authority, while external, missing, unresolvable, and + symlink-external absolute values keep their original spelling. Resolving + both sides before containment prevents a lexical in-project path through an + outward symlink from being redirected into a replacement checkout. + """ + raw = self.spec_file + if not raw or not Path(raw).is_absolute(): + return + try: + project_root = project.resolve(strict=True) + target = Path(raw).resolve(strict=True) + relative = target.relative_to(project_root) + except (OSError, RuntimeError, ValueError): + return + if not target.is_file(): + return + self.spec_file = relative.as_posix() + @classmethod def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot = d.get("dispatched_spec_snapshot") diff --git a/src/bmad_loop/recovery_flow.py b/src/bmad_loop/recovery_flow.py index a167788f..8579fa19 100644 --- a/src/bmad_loop/recovery_flow.py +++ b/src/bmad_loop/recovery_flow.py @@ -41,6 +41,11 @@ PRESERVE_REF_PROBE_LIMIT = 100 +def attempt_preserve_ref_name(run_id: str, tip: str) -> str: + """Canonical commits-only recovery branch for one run and pinned tip.""" + return f"attempt-preserve/{safe_ref_segment(run_id)}-{tip[:8]}" + + class _OwnedSpecAuthorityError(RuntimeError): """A previously canonical owned-spec name became unsafe to restore.""" @@ -1194,14 +1199,14 @@ def preserve_attempt_commits(self, task: StoryTask, *, allow_pause: bool) -> Non # blind). These two calls carried no guard at all, so until #343 a plain # git *timeout* — which `_run_git` does translate, and which every sibling # here already treats as routine — crashed the rollback outright; OSError - # joins it because the translation stops at timeouts. The `not commits` - # return sits inside the try to keep the original call order: HEAD is still - # only read once there is something to park. + # joins it because the translation stops at timeouts. Pin HEAD before + # enumerating so the range and the recovery ref describe the same observed + # tip even if the checkout moves between those operations. try: - commits = verify.commits_above(workspace.root, baseline) + head = verify.rev_parse_head(workspace.root) + commits = verify.commits_above(workspace.root, baseline, head) if not commits: return - head = verify.rev_parse_head(workspace.root) # the tip the ref parks at except (verify.GitError, OSError) as exc: self.journal.append( "attempt-preserve-enumerate-failed", story_key=task.story_key, error=str(exc) @@ -1216,13 +1221,13 @@ def preserve_attempt_commits(self, task: StoryTask, *, allow_pause: bool) -> Non # an exotic/overlong id can't blow the ref-name limit, fail `git branch`, and # drop the recovery ref (which on a re-drive would then reset past the work # anyway). - slug = safe_ref_segment(self.state.run_id) try: ref = verify.preserve_commits( workspace.root, baseline, - f"attempt-preserve/{slug}-{head[:8]}", + attempt_preserve_ref_name(self.state.run_id, head), commits=commits, + revision=head, ) except (verify.GitError, OSError): ref = None # branch creation failed — treat as a preservation failure diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index bc66cb07..a2a2a924 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -837,29 +837,19 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: COMMITTING window IS recovered, though — same as the base engine's resume-commit arm (#115).""" if task.worktree_path: - # The same re-anchor `Engine._finish_inflight` makes, for the same reason - # and in the same position — ABOVE the `isolated` gate. Sweep does not - # inherit it: `SweepEngine` replaces `_loop` wholesale and `Engine._loop` - # is the only caller of `_finish_inflight`, so nothing on this path had - # re-absolutized the persisted spelling. Both legs below need it. The - # restart arm discards the mount and clears `worktree_path` before the - # caller saves, which would strand the mount-RELATIVE value beside an - # empty `worktree_path` (`_serialized_worktree_path` only relativizes - # while that field is set); and the gate is live policy, so an - # `isolation` flip across a resume drops the `elif task.baseline_commit` - # and the two non-isolated arms onto never-re-anchored paths. Either way - # the raw value resolves against the MAIN checkout — same layout, so - # `recovery_flow._attempt_owned_spec` finds one candidate, - # `spec_within_roots` accepts it, and the snapshot restore rewrites the - # operator's own copy. + # Sweep replaces Engine._loop, so it performs Engine._finish_inflight's + # mount-relative re-anchor itself. Accepted receipts reopen this mount + # regardless of live policy; restart is the only path allowed to release + # or discard its ownership before future work begins. task.rebase_spec_paths_on(Path(task.worktree_path)) - isolated = self._isolated and task.worktree_path + mounted = bool(task.worktree_path) + restart_isolated = self._isolated and mounted if task.phase == Phase.COMMITTING: # the gate+advance save landed pre-death; finish the commit # instead of rolling verified bundle work back (see # Engine._finalize_commit_phase for the re-drive contract). self.journal.append("resume-commit", story_key=task.story_key) - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -878,7 +868,7 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: and self._accepted_dev_session_matches(task) ): self._save() - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -890,10 +880,14 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: else: self._resume_after_dev_verify(task) return True - if isolated: + if restart_isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) - elif task.baseline_commit: + elif mounted: + # Live in-place policy applies to the replacement attempt, not to an + # incomplete attempt's mount-owned baselines, paths, and claims. + self._release_orphaned_mount(task) + if not restart_isolated and task.baseline_commit: # latch resolved_redrive so the corrected spec + restored diff stay # protected through every reset of this re-drive, not just this # first one; cause="resolved" keeps a human-initiated re-arm diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index e0f2fdde..554400fc 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -583,6 +583,19 @@ def rev_parse_head(repo: Path) -> str: return out +def rev_parse_revision(repo: Path, revision: str) -> str: + """Resolve ``revision`` to one pinned commit sha. + + Callers that will mutate refs must not carry a moving branch name across the + mutation boundary. ``^{commit}`` also refuses non-commit objects instead of + handing a later worktree/reset operation an object with different semantics. + """ + rc, out, detail = _git_out(repo, "rev-parse", "--verify", f"{revision}^{{commit}}") + if rc != 0: + raise GitError(f"git rev-parse --verify {revision} failed in {repo}: {detail}") + return out + + def last_commit_for(repo: Path, path: Path) -> str: """Sha of the most recent commit touching ``path``, or ``""`` when no commit does (an untracked or deleted-without-history file) or the path lies outside @@ -1467,34 +1480,39 @@ def path_ignored(repo: Path, path: Path) -> bool: return proc.returncode == 0 -def commits_above(repo: Path, baseline: str) -> list[str]: - """Commit shas reachable from HEAD but not from ``baseline`` — the commits an - attempt added on top of its pre-attempt baseline, in ``git rev-list`` order (do - not assume a strict newest-first / HEAD-first ordering across merges or clock - skew; callers that need the tip should read HEAD directly). Empty when HEAD is - at or behind baseline. Raises GitError on a git failure (a bad baseline is a +def commits_above(repo: Path, baseline: str, revision: str = "HEAD") -> list[str]: + """Commit shas reachable from ``revision`` but not from ``baseline`` — the + commits an attempt added on top of its pre-attempt baseline, in ``git rev-list`` + order (do not assume a strict newest-first ordering across merges or clock skew; + callers that need the tip should resolve it directly). Empty when the revision + is at or behind baseline. Raises GitError on a git failure (a bad baseline is a real error, never quietly "no commits"). Reads stdout ALONE (`_git_out`): git exits 0 while still warning on stderr, and against the merged stream that warning is a phantom sha handed to - :func:`preserve_commits` — "Empty when HEAD is at or behind baseline" stops + :func:`preserve_commits` — "empty when the revision is at/below baseline" stops holding on any host whose git config warns (#442).""" - rc, out, detail = _git_out(repo, "rev-list", f"{baseline}..HEAD") + rc, out, detail = _git_out(repo, "rev-list", f"{baseline}..{revision}") if rc != 0: - raise GitError(f"git rev-list {baseline}..HEAD failed in {repo}: {detail}") + raise GitError(f"git rev-list {baseline}..{revision} failed in {repo}: {detail}") return [line for line in out.splitlines() if line] def preserve_commits( - repo: Path, baseline: str, ref_name: str, commits: list[str] | None = None + repo: Path, + baseline: str, + ref_name: str, + commits: list[str] | None = None, + *, + revision: str = "HEAD", ) -> str | None: - """Park the commits an attempt made above ``baseline`` under a branch at HEAD + """Park the commits an attempt made above ``baseline`` under a branch at ``revision`` so a following ``git reset --hard baseline`` cannot orphan them — they survive `git gc` and are recoverable by name, not just via the reflog. Returns - ``ref_name`` on success; ``None`` when there is nothing to preserve (HEAD at/ - below baseline) or the branch could not be created (the caller must then refuse - to reset rather than silently destroy committed work). ``-f`` because a retry - within the same run may re-preserve the same head under the same name. + ``ref_name`` on success; ``None`` when there is nothing to preserve (the + revision is at/below baseline). Creation failures raise, so the caller must + refuse to reset rather than silently destroy committed work. ``-f`` because a + retry within the same run may re-preserve the same tip under the same name. ``commits`` lets a caller that already ran :func:`commits_above` pass the result in to skip a second ``git rev-list`` subprocess; ``None`` self-fetches (keeps the @@ -1505,12 +1523,12 @@ def preserve_commits( of this module), so a caller can never mistake a preservation failure for a harmless no-op and reset past committed work.""" if commits is None: - commits = commits_above(repo, baseline) + commits = commits_above(repo, baseline, revision) if not commits: return None - rc, out = _git(repo, "branch", "-f", ref_name, "HEAD") + rc, out = _git(repo, "branch", "-f", ref_name, revision) if rc != 0: - raise GitError(f"git branch -f {ref_name} HEAD failed in {repo}: {out}") + raise GitError(f"git branch -f {ref_name} {revision} failed in {repo}: {out}") return ref_name @@ -2132,6 +2150,23 @@ def delete_branch(repo: Path, name: str, force: bool = False) -> None: raise GitError(f"git branch -d {name} failed in {repo}: {out}") +def reset_branch_if_tip(repo: Path, name: str, revision: str, expected_tip: str) -> None: + """Move a branch to a pinned revision only while its tip is unchanged. + + ``git update-ref `` is the compare-and-swap primitive: a + concurrently advanced branch makes the command fail rather than losing the + rival commit. The caller resolves both shas before destructive follow-up. + """ + ref = f"refs/heads/{name}" + # A refs/heads name can itself be symbolic. The default update-ref behavior + # dereferences it, which could reset the target branch (including main) instead + # of the attempt-local story ref. --no-deref replaces that name itself while + # preserving the expected-old CAS for ordinary and symbolic refs. + rc, out = _git(repo, "update-ref", "--no-deref", ref, revision, expected_tip) + if rc != 0: + raise GitError(f"git update-ref {ref} {revision} {expected_tip} failed in {repo}: {out}") + + def worktree_add( repo: Path, path: Path, branch: str, base: str | None = None, *, create: bool = True ) -> None: @@ -2202,22 +2237,85 @@ def worktree_prune(repo: Path) -> None: def worktree_list(repo: Path) -> list[Path]: """Paths of every worktree attached to `repo` (the main checkout first). - Reads stdout ALONE (`_git_out`) so the record parse does not depend on no - stderr line ever starting with ``"worktree "``. The advisories measured for + Reads stdout alone through NUL-delimited porcelain so paths may contain + newlines and the record parse does not depend on no stderr line ever starting + with ``"worktree "``. The advisories measured for #442 — an unknown `core.fsyncMethod` value and its family — do NOT start that way, so the `startswith` filter screens them out and this parse was correct by accident rather than by construction; the filter stays as a second, independent screen.""" - rc, out, detail = _git_out(repo, "worktree", "list", "--porcelain") - if rc != 0: + proc = _run_git( + ["git", "-C", str(repo), "worktree", "list", "--porcelain", "-z"], + repo, + ) + if proc.returncode != 0: + detail = (proc.stdout + proc.stderr).strip() raise GitError(f"git worktree list failed in {repo}: {detail}") paths = [] - for line in out.splitlines(): - if line.startswith("worktree "): - paths.append(Path(line[len("worktree ") :])) + for field in proc.stdout.split("\0"): + if field.startswith("worktree "): + paths.append(Path(field[len("worktree ") :])) return paths +def worktree_is_registered(repo: Path, path: Path) -> bool: + """Whether ``path`` is this repository's exact live linked worktree. + + Directory existence is insufficient for recovery: a deleted ``.git`` marker + below the main checkout makes git silently discover the parent repository, + while a replacement repository at the same path can have its own valid + toplevel. Require all three identities to agree: the persisted path is not a + symlink, the main repository still lists it, and git invoked there reports + both that exact toplevel and the main repository's common git directory. + + Ordinary git refusal reads as ``False`` so the recovery caller can escalate + with its recorded-mount message. Spawn/timeout faults raised by ``_git_out`` + remain typed and fail loud. + """ + if path.is_symlink(): + return False + try: + candidate = path.resolve(strict=True) + except (OSError, RuntimeError): + return False + registered = False + for listed in worktree_list(repo): + try: + if listed.resolve(strict=True) == candidate: + registered = True + break + except (OSError, RuntimeError): + continue + if not registered: + return False + + def git_path(root: Path, raw: str) -> Path: + value = Path(raw) + return (value if value.is_absolute() else root / value).resolve(strict=True) + + def path_out(root: Path, *args: str) -> tuple[int, str]: + proc = _run_git(["git", "-C", str(root), *args], root) + # Git terminates this scalar with one newline. Removing exactly that + # delimiter preserves whitespace/newlines that belong to the path itself. + return proc.returncode, proc.stdout.removesuffix("\n") + + rc, top = path_out(candidate, "rev-parse", "--show-toplevel") + if rc != 0: + return False + rc, mounted_common = path_out(candidate, "rev-parse", "--git-common-dir") + if rc != 0: + return False + rc, repo_common = path_out(repo, "rev-parse", "--git-common-dir") + if rc != 0: + return False + try: + return Path(top).resolve(strict=True) == candidate and git_path( + candidate, mounted_common + ) == git_path(repo, repo_common) + except (OSError, RuntimeError): + return False + + def dirty_paths(repo: Path) -> dict[str, str]: """Repo-relative posix path -> two-char porcelain XY status for every dirty entry in `repo`'s working tree. Excludes the orchestrator's own working dir diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index 338868bf..d5af41bf 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -24,6 +24,7 @@ from . import verify from .bmadconfig import ProjectPaths from .platform_util import safe_ref_segment, safe_segment +from .recovery_flow import attempt_preserve_ref_name # Per-unit worktrees live under the run dir (.bmad-loop/runs//worktrees/), # which `bmad-loop init` already gitignores — so unit checkouts never show up as @@ -112,10 +113,11 @@ def open_unit_workspace( """Mount a fresh worktree for `unit_key` and return its rebased workspace. The worktree is mounted under the run dir (see unit_worktrees_dir), not under - .git/. The unit branch is cut from `base` (the target branch's HEAD). When the - branch already exists (branch_per=run re-mounting the shared run branch - across serial units) it is re-checked-out from its own HEAD instead, so it - keeps the commits earlier units already landed on it. + .git/. A new unit branch is cut from a pinned resolution of ``base``. Existing + run-scoped branches reattach at their own pinned tip so earlier landed units + remain reachable. Existing story-scoped branches are abandoned-attempt state: + commits unique to their named tip are parked under ``attempt-preserve/*``, then + the story branch is compare-and-swap reset to the pinned base before remount. """ branch = unit_branch_name(run_id, unit_key, branch_per) unresolved_wt = unit_worktrees_dir(run_dir) / safe_segment(unit_key) @@ -125,6 +127,26 @@ def open_unit_workspace( raise verify.GitError( f"cannot resolve worktree mount path for {unit_key} ({unresolved_wt}): {e}" ) from e + # Pin every moving input before the first mutation. A story branch is an + # attempt-local name: reclaim preserves any commits unique to its old tip, + # then resets it to the requested base with compare-and-swap semantics. A run + # branch is cumulative and deliberately keeps its own tip across remounts. + pinned_base = verify.rev_parse_revision(repo_root, base) + branch_tip: str | None = None + if verify.branch_exists(repo_root, branch): + branch_tip = verify.rev_parse_revision(repo_root, f"refs/heads/{branch}") + if branch_tip is not None and branch_per == "story": + commits = verify.commits_above(repo_root, pinned_base, branch_tip) + if commits: + preserve_ref = attempt_preserve_ref_name(run_id, branch_tip) + verify.preserve_commits( + repo_root, + pinned_base, + preserve_ref, + commits=commits, + revision=branch_tip, + ) + verify.reset_branch_if_tip(repo_root, branch, pinned_base, branch_tip) wt.parent.mkdir(parents=True, exist_ok=True) # Reclaim whatever still occupies this unit's mount point before adding. # `wt` and `branch` are both DETERMINISTIC in (run_id, unit_key, run_dir), so a @@ -139,17 +161,35 @@ def open_unit_workspace( # keeps that preservation intact for the in-place run and spends the orphan only # when a mount actually needs its path. # - # The BRANCH is deliberately not passed: `discard_worktree` would force-delete it, - # and under `branch_per=run` this name is the SHARED run branch carrying commits - # earlier units already landed. Dropping only the worktree frees the checkout that - # blocks `worktree_add` while leaving those commits reachable, so the - # `branch_exists` fork below still re-mounts the branch from its own HEAD. + # The BRANCH is deliberately not passed: `discard_worktree` would force-delete it. + # A run-scoped name carries commits earlier units landed; a story-scoped name has + # already been safely reset above. Dropping only the worktree frees the checkout + # that blocks `worktree_add` without introducing a second ref mutation here. discard_worktree(repo_root, str(wt), "", run_dir=run_dir) - if verify.branch_exists(repo_root, branch): + if branch_tip is not None: verify.worktree_add(repo_root, wt, branch, create=False) + if branch_per == "story": + try: + mounted_tip = verify.rev_parse_head(wt) + current_tip = verify.rev_parse_revision(repo_root, f"refs/heads/{branch}") + if mounted_tip != pinned_base or current_tip != pinned_base: + raise verify.GitError( + f"story branch {branch} moved after reclaim reset: " + f"expected {pinned_base}, mounted {mounted_tip}, current {current_tip}" + ) + except (verify.GitError, OSError): + # The branch may have moved after the reset CAS but before checkout. + # Drop only the mount we just created; the rival ref is evidence and + # must not be reset or deleted by this failure cleanup. + discard_worktree(repo_root, str(wt), "", run_dir=run_dir) + raise else: - verify.worktree_add(repo_root, wt, branch, base=base, create=True) - baseline = verify.rev_parse_head(wt) + verify.worktree_add(repo_root, wt, branch, base=pinned_base, create=True) + # A story checkout was already verified against the pinned base above. Do + # not re-read its symbolic HEAD after that boundary: a rival ref move in this + # final window would record unverified history as the attempt baseline even + # though the mounted index and files still represent ``pinned_base``. + baseline = pinned_base if branch_per == "story" else verify.rev_parse_head(wt) return UnitWorkspace( workspace=Workspace(root=wt, paths=paths.rebased(wt)), repo_root=repo_root, diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 96644a77..14dc2a48 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1482,11 +1482,57 @@ def _board_seed(self, worktree: Path) -> tuple[str, ...]: return () return (rel,) + def _accepted_spec_seed( + self, + task: StoryTask, + worktree: Path, + *, + project_relative_only: bool = False, + ) -> tuple[str, ...]: + """Copy an accepted project-local spec a tracked checkout did not deliver. + + The isolation-flip normalizer gives a main-checkout absolute spec a + project-relative spelling before this point. If the accepted file is + ignored or untracked, ``git worktree add`` cannot carry it, so binding the + new attempt would silently fall back to the bare story key. Seed only a + canonical regular file inside the project, only when the mounted checkout + lacks its corresponding path. Absolute/external spellings pass through; + prior-attempt binding fields remain authoritative until fresh binding + replaces them after the mount is provisioned. + """ + raw = task.spec_file + if not raw or Path(raw).is_absolute(): + return () + try: + project = self.paths.project.resolve(strict=True) + source = ( + self.paths.project / raw + if project_relative_only + else verify.resolve_spec_path(raw, self.paths) + ).resolve(strict=True) + relative = source.relative_to(project) + destination = (worktree / relative).resolve(strict=False) + mounted_root = worktree.resolve(strict=True) + destination.relative_to(mounted_root) + except (OSError, RuntimeError, ValueError): + return () + if not source.is_file() or destination.is_file(): + return () + return (relative.as_posix(),) + def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> None: """Run one unit's `drive` body in a fresh per-unit worktree, then merge it back into the target branch. `drive` either returns (DONE/DEFERRED → integrate) or raises RunPaused (spec-approval gate / escalation → leave the worktree mounted for resume/inspection, integration skipped).""" + # A sprint run can accept an absolute spec in the main checkout before + # isolation is enabled. Convert only that canonical project-local accepted + # artifact to the portable spelling the new mount can own. This must happen + # before worktree creation and must not disturb an existing attempt binding, + # whose path/snapshot authority belongs to its prior workspace. + accepted_spec_before = task.spec_file + task.relativize_project_local_accepted_spec(self.paths.project) + accepted_spec_relocated = task.spec_file != accepted_spec_before try: unit = self._open_unit_workspace( self.paths.repo_root, @@ -1565,6 +1611,13 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N # behind — each decides its own exclusions; see the methods. seeds.extend(self._ledger_seed(unit.path)) seeds.extend(self._board_seed(unit.path)) + seeds.extend( + self._accepted_spec_seed( + task, + unit.path, + project_relative_only=accepted_spec_relocated, + ) + ) # plugins (e.g. the Unity engine) may prime an isolated checkout with # gitignored paths they need — e.g. an MCP-generated skill tree + client # config so the worktree's Editor MCP is reachable. Aggregate every loaded @@ -1615,6 +1668,13 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N "worktree-seed-dropped", story_key=task.story_key, entries=undelivered_seeds ) + if accepted_spec_relocated and not _is_file(unit.path / str(task.spec_file)): + self.escalate_unit( + task, + f"accepted spec for {task.story_key} disappeared before it could be " + "delivered to the replacement worktree", + ) + trees = [p.skill_tree for p in profiles] # The wheel's own bundled skills, journal-only like worktree-seed-dropped but # under their own kind so a user seed that spells a skill rel can neither forge @@ -2280,10 +2340,31 @@ def reopen_unit(self, task: StoryTask) -> UnitWorkspace: be mounted — if it was pruned out from under us we cannot safely reuse it, so escalate rather than run a session in a missing directory.""" wt = Path(task.worktree_path) - if not wt.is_dir(): + try: + mounted = wt.is_dir() and verify.worktree_is_registered(self.paths.repo_root, wt) + except verify.GitError as exc: + self.escalate_unit( + task, + f"cannot verify recorded worktree for {task.story_key} ({wt}): {exc}", + ) + if not mounted: + self.escalate_unit( + task, + f"worktree for {task.story_key} is gone or unopenable ({wt}); " + "cannot resume in place", + ) + try: + mounted_branch = verify.current_branch(wt) + except verify.GitError as exc: + self.escalate_unit( + task, + f"cannot verify recorded worktree branch for {task.story_key} ({wt}): {exc}", + ) + if mounted_branch != task.branch: self.escalate_unit( task, - f"worktree for {task.story_key} is gone ({wt}); cannot resume in place", + f"worktree for {task.story_key} is on {mounted_branch!r}, not recorded " + f"branch {task.branch!r}; cannot resume in place", ) # Spec paths are persisted relative to the worktree (model.to_dict) so # state stays portable; re-absolutize both accepted/result ownership and diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 7a04cf4f..4a591048 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -237,6 +237,119 @@ def test_worktree_happy_path_merges_to_target(project): assert "worktree-teardown-degraded" not in kinds +def test_local_absolute_ignored_accepted_spec_is_seeded_and_bound_in_mount(project): + """Relativizing an accepted spec also delivers it to a tracked-only checkout.""" + rel = "_bmad-output/implementation-artifacts/accepted-untracked.md" + ignore_before_commit(project, rel) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + accepted = project.project / rel + accepted.parent.mkdir(parents=True, exist_ok=True) + accepted.write_bytes(b"accepted operator bytes\n") + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + task = StoryTask("1-1-a", 1, spec_file=str(accepted)) + engine.state.tasks[task.story_key] = task + observed: dict[str, object] = {} + + def bind_then_defer(current): + engine._bind_dispatched_spec_for_attempt(current) + observed["path"] = current.dispatched_spec_file + observed["snapshot"] = current.dispatched_spec_snapshot + observed["root"] = engine.workspace.root + current.phase = Phase.DEFERRED + current.defer_reason = "test complete" + + engine._run_isolated(task, bind_then_defer) + + mounted_root = observed["root"] + assert isinstance(mounted_root, Path) + assert observed["path"] == str(mounted_root / rel) + assert observed["snapshot"] == b"accepted operator bytes\n" + + +def test_prior_dispatch_does_not_block_fresh_mounted_spec_binding(project): + """A none-to-worktree re-drive relocates accepted input, then replaces old authority.""" + rel = "_bmad-output/implementation-artifacts/accepted-rearm.md" + ignore_before_commit(project, rel) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + accepted = project.project / rel + accepted.parent.mkdir(parents=True, exist_ok=True) + accepted.write_bytes(b"ignored accepted bytes\n") + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + old_dispatch = str(project.project / ".bmad-loop" / "runs" / "old" / "spec.md") + old_snapshot = b"prior dispatch bytes\x00" + task = StoryTask( + "1-1-a", + 1, + spec_file=str(accepted), + dispatched_spec_file=old_dispatch, + dispatched_spec_snapshot=old_snapshot, + ) + engine.state.tasks[task.story_key] = task + observed: dict[str, object] = {} + + def bind_fresh_attempt(current): + observed["prior_path"] = current.dispatched_spec_file + observed["prior_snapshot"] = current.dispatched_spec_snapshot + engine._bind_dispatched_spec_for_attempt(current) + observed["accepted"] = current.spec_file + observed["bound"] = current.dispatched_spec_file + observed["snapshot"] = current.dispatched_spec_snapshot + observed["root"] = engine.workspace.root + current.phase = Phase.DEFERRED + current.defer_reason = "test complete" + + engine._run_isolated(task, bind_fresh_attempt) + + mounted_root = observed["root"] + assert isinstance(mounted_root, Path) + assert observed["prior_path"] == old_dispatch + assert observed["prior_snapshot"] == old_snapshot + assert observed["accepted"] == rel + assert observed["bound"] == str(mounted_root / rel) + assert observed["snapshot"] == b"ignored accepted bytes\n" + + +def test_relocated_accepted_spec_disappearance_does_not_bind_fallback(project): + """A normalized absolute spec keeps its exact project-path authority. + + Ablation: resolve the seed through the ordinary relative fallback and omit + the mounted-file gate; the nested fallback is bound after the accepted source + disappears between normalization and seeding. + """ + from bmad_loop.engine import RunPaused + + rel = "_bmad-output/implementation-artifacts/accepted-race.md" + ignore_before_commit(project, rel) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + accepted = project.project / rel + accepted.parent.mkdir(parents=True, exist_ok=True) + accepted.write_bytes(b"accepted operator bytes\n") + fallback = project.implementation_artifacts / rel + fallback.parent.mkdir(parents=True, exist_ok=True) + fallback.write_bytes(b"unrelated fallback bytes\n") + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + task = StoryTask("1-1-a", 1, spec_file=str(accepted)) + engine.state.tasks[task.story_key] = task + real_open = engine._worktree_flow._open_unit_workspace + + def open_then_remove_source(*args, **kwargs): + unit = real_open(*args, **kwargs) + accepted.unlink() + return unit + + engine._worktree_flow._open_unit_workspace = open_then_remove_source + drove: list[bool] = [] + + with pytest.raises(RunPaused, match="accepted spec.*disappeared"): + engine._run_isolated(task, lambda _task: drove.append(True)) + + assert drove == [] + assert task.phase == Phase.ESCALATED + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_missing_upstream_skill_seed_escalates_before_dispatch_and_records_mount(project, tmp_path): """A shared install outside the repo passes main's through-link resolution but @@ -1317,15 +1430,19 @@ def test_started_merge_replay_failure_does_not_carry_harvest(project, monkeypatc commit_sprint(project, {"1-1-a": "ready-for-dev"}) engine, _ = make_engine(project, []) engine.state.target_branch = "main" - worktree = engine.run_dir / "worktrees" / "1-1-a" - worktree.mkdir(parents=True) - source = rev_parse_head(project.project) + from bmad_loop.workspace import open_unit_workspace + + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + source = rev_parse_head(unit.path) task = StoryTask( story_key="1-1-a", epic=1, phase=Phase.DONE, - worktree_path=str(worktree), - branch="bmad-loop/test-run/1-1-a", + worktree_path=str(unit.path), + branch=unit.branch, + baseline_commit=unit.baseline, commit_sha=source, harvested_deferrals=[_harvest_record()], ) @@ -1974,6 +2091,151 @@ def test_worktree_reopen_reabsolutizes_both_spec_ownership_paths(project, tmp_pa assert task.dispatched_spec_file == outside_dispatched +def test_reopen_rejects_existing_directory_that_is_not_the_recorded_worktree(project, monkeypatch): + """A plain directory below main must not make git fall back to main on resume.""" + from bmad_loop.engine import RunPaused + + engine, _ = make_engine(project, [], policy=wt_policy()) + fake = engine.run_dir / "worktrees" / "plain-directory" + fake.mkdir(parents=True) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(fake), + branch="bmad-loop/test-run/1-1-a", + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="gone or unopenable"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + assert engine.workspace.root == project.project + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_reopen_rejects_symlink_alias_to_recorded_worktree(project, monkeypatch): + """A retargetable alias is not the exact persisted mount ownership claim.""" + from bmad_loop.engine import RunPaused + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=wt_policy()) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + alias = unit.path.parent / "recorded-alias" + alias.symlink_to(unit.path, target_is_directory=True) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(alias), + branch=unit.branch, + baseline_commit=unit.baseline, + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="gone or unopenable"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + + +@pytest.mark.parametrize("checkout", ["wrong-branch", "detached"]) +def test_reopen_rejects_registered_mount_on_wrong_recorded_branch(project, monkeypatch, checkout): + """Registration is not ownership when the linked checkout changed branch.""" + from bmad_loop.engine import RunPaused + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=wt_policy()) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + if checkout == "wrong-branch": + git(unit.path, "checkout", "-q", "-b", "operator-branch") + else: + git(unit.path, "checkout", "-q", "--detach") + assert verify.worktree_is_registered(project.project, unit.path) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(unit.path), + branch=unit.branch, + baseline_commit=unit.baseline, + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="not recorded branch"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + + +@pytest.mark.parametrize("damage", ["missing-marker", "foreign-repository"]) +def test_reopen_rejects_corrupted_but_registered_mount(project, monkeypatch, damage): + """Toplevel and common-dir identity fail closed before any continuation.""" + from bmad_loop.engine import RunPaused + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=wt_policy()) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + if damage == "missing-marker": + (unit.path / ".git").unlink() + else: + shutil.rmtree(unit.path) + unit.path.mkdir() + git(unit.path, "init", "-q") + git(unit.path, "config", "user.email", "test@example.com") + git(unit.path, "config", "user.name", "Test User") + git(unit.path, "commit", "--allow-empty", "-q", "-m", "foreign root") + assert unit.path.resolve() in [path.resolve() for path in worktree_list(project.project)] + assert not verify.worktree_is_registered(project.project, unit.path) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(unit.path), + branch=unit.branch, + baseline_commit=unit.baseline, + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="gone or unopenable"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + + def test_restart_arm_anchors_spec_ownership_before_it_discards_the_mount(project, monkeypatch): """The restart arm destroys the only tree that can resolve the persisted spelling. @@ -2304,25 +2566,16 @@ def _stop(*_a, **_k): assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) -def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, monkeypatch): - """Every non-isolated leg undoes the re-anchor, not just the restart arm. +def test_isolation_flip_keeps_the_mount_for_an_accepted_continuation(project, monkeypatch): + """Recorded ownership wins over live policy until accepted work is integrated. - `_finish_inflight` re-anchors `spec_file` INTO the recorded mount unconditionally, - above the `isolated` gate. Three arms below then reach the MAIN workspace on their - non-isolated legs and `return` without ever reaching the restart arm that first - carried the release — the spec-approval `DEV_VERIFY` continuation graded here, the - recorded-result `_resumable_session` continuation and the `COMMITTING` finalizer. - Left unreleased, each continues with `spec_file` absolutized into a mount the run - has already left: `_dispatched_spec_for_attempt` resolves that `strict=True`, - raises, and leaves the attempt unbound, and an explicit-spec prompt meets the - snapshot gate with nothing bound. + The worktree path is persisted state while ``self._isolated`` is live policy for + the next attempt. A verified DEV_VERIFY continuation must therefore reopen the + exact mount after ``worktree -> none`` instead of releasing its accepted spec and + running in main. - Graded at the moment the continuation RUNS, not on the saved state — the defect is - what the arm consumes, and a later save could launder it. That is also why this - cannot be folded into the restart-arm row above: that one never enters an arm. - - Ablation: drop `self._release_orphaned_mount(task)` from the `DEV_VERIFY` else-leg - and `seen["spec_file"]` becomes the absolute path into the mount. + Ablation: gate the DEV_VERIFY reopen on ``self._isolated`` and the observed paths + are released to main (and ``isolation-flip-orphaned-worktree`` is journaled). """ commit_sprint(project, {"1-1-a": "ready-for-dev"}) in_place = Policy( @@ -2333,17 +2586,23 @@ def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, engine, _ = make_engine(project, [], policy=in_place) assert not engine._isolated # the premise: live policy says in-place - mount = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1-1-a" + from bmad_loop.workspace import open_unit_workspace + + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + mount = unit.path rel = "_bmad-output/accepted.md" (mount / rel).parent.mkdir(parents=True, exist_ok=True) (mount / rel).write_text("# spec\n", encoding="utf-8") task = StoryTask("1-1-a", 1, phase=Phase.DEV_VERIFY) - task.worktree_path = str(mount) # the persisted mount the live policy ignores + task.worktree_path = str(mount) # persisted ownership survives the policy flip + task.branch = unit.branch task.spec_file = rel # persisted RELATIVE, as `_serialized_worktree_path` writes it task.dispatched_spec_file = rel task.dispatched_spec_snapshot = b"pre-launch bytes" - task.baseline_commit = rev_parse_head(project.project) + task.baseline_commit = unit.baseline task.baseline_untracked = [] engine.state.tasks["1-1-a"] = task @@ -2362,14 +2621,11 @@ def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, engine._finish_inflight() assert seen, "the DEV_VERIFY continuation arm never ran" - # the arm acts on the spelling the MAIN workspace re-probes, not the orphan's - assert seen["spec_file"] == rel - assert seen["spec_file"] != str(mount / rel) - assert seen["dispatched"] is None # the attempt died with its tree - assert seen["worktree_path"] == "" # the claim is dropped BEFORE the arm acts - assert seen["baseline_commit"] is None # unit operands never reach the main checkout - assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) - assert mount.is_dir() # released, not deleted + assert seen["spec_file"] == str(mount / rel) + assert seen["dispatched"] == str(mount / rel) + assert seen["worktree_path"] == str(mount) + assert seen["baseline_commit"] == rev_parse_head(project.project) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(engine) def test_open_unit_workspace_reclaims_the_orphan_holding_its_mount_path(project): @@ -2414,6 +2670,265 @@ def test_open_unit_workspace_reclaims_the_orphan_holding_its_mount_path(project) assert (second.path / "landed.txt").read_text(encoding="utf-8") == "earlier unit\n" +def test_story_remount_preserves_named_tip_and_restarts_from_pinned_base(project): + """Story branches are attempt-local while the preserve ref keeps abandoned work.""" + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + (first.path / "abandoned.txt").write_text("old attempt\n", encoding="utf-8") + git(first.path, "add", "abandoned.txt") + git(first.path, "commit", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + + (project.project / "advanced.txt").write_text("new base\n", encoding="utf-8") + git(project.project, "add", "advanced.txt") + git(project.project, "commit", "-q", "-m", "advance requested base") + pinned_base = rev_parse_head(project.project) + + second = open_unit_workspace(*args) + + preserve_ref = f"attempt-preserve/test-run-{old_tip[:8]}" + assert rev_parse_head(second.path) == pinned_base + assert not (second.path / "abandoned.txt").exists() + assert (second.path / "advanced.txt").read_text(encoding="utf-8") == "new base\n" + assert git(project.project, "rev-parse", preserve_ref) == old_tip + + +def test_story_remount_without_unique_commits_still_moves_to_advanced_base(project): + """Reset is unconditional for story scope, not coupled to preservation work. + + Ablation: nest ``reset_branch_if_tip`` under ``if commits`` and this leaves the + replacement at the original base because there is no abandoned commit to park. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + old_base = rev_parse_head(first.path) + + (project.project / "advanced-without-abandoned.txt").write_text("new base\n") + git(project.project, "add", "advanced-without-abandoned.txt") + git(project.project, "commit", "-q", "-m", "advance requested base") + advanced = rev_parse_head(project.project) + + second = open_unit_workspace(*args) + + assert old_base != advanced + assert rev_parse_head(second.path) == advanced + assert (second.path / "advanced-without-abandoned.txt").read_text() == "new base\n" + + +def test_new_story_workspace_uses_base_sha_pinned_before_ref_moves(project, monkeypatch): + """A moving requested branch cannot change the operation's selected snapshot.""" + from bmad_loop.workspace import open_unit_workspace + + real_resolve = verify.rev_parse_revision + observed: dict[str, str] = {} + + def resolve_then_advance(repo, revision): + pinned = real_resolve(repo, revision) + if revision == "main" and "pinned" not in observed: + observed["pinned"] = pinned + (project.project / "late-base-move.txt").write_text("too late\n") + git(project.project, "add", "late-base-move.txt") + git(project.project, "commit", "-q", "-m", "concurrent base move") + observed["advanced"] = rev_parse_head(project.project) + return pinned + + monkeypatch.setattr(verify, "rev_parse_revision", resolve_then_advance) + unit = open_unit_workspace( + project.project, + project, + "test-run", + "1-1-pinned", + "main", + "story", + project.project / ".bmad-loop" / "runs" / "test-run", + ) + + assert observed["pinned"] != observed["advanced"] + assert rev_parse_head(unit.path) == observed["pinned"] + assert not (unit.path / "late-base-move.txt").exists() + + +def test_story_remount_preservation_failure_keeps_old_tip_and_mount(project, monkeypatch): + """Preservation must complete before either the story ref or mount is discarded. + + INVERSE ablation: catch the preservation error and continue to reset/discard; + then the old branch tip and mounted directory assertions fail. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + monkeypatch.setattr( + verify, + "preserve_commits", + lambda *_a, **_k: (_ for _ in ()).throw(verify.GitError("preserve refused")), + ) + + with pytest.raises(verify.GitError, match="preserve refused"): + open_unit_workspace(*args) + + assert git(project.project, "rev-parse", first.branch) == old_tip + assert first.path.is_dir() + + +def test_story_remount_cas_failure_keeps_concurrent_tip_and_mount(project, monkeypatch): + """A rival branch move after preservation is never overwritten by reclaim. + + INVERSE ablation: remove the expected-old operand from ``update-ref`` and the + concurrently created tip is reset to main instead of surviving. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + real_reset = verify.reset_branch_if_tip + rival: dict[str, str] = {} + + def move_then_reset(repo, name, revision, expected_tip): + git(first.path, "commit", "--allow-empty", "-q", "-m", "concurrent story move") + rival["tip"] = rev_parse_head(first.path) + real_reset(repo, name, revision, expected_tip) + + monkeypatch.setattr(verify, "reset_branch_if_tip", move_then_reset) + + with pytest.raises(verify.GitError, match="update-ref"): + open_unit_workspace(*args) + + assert git(project.project, "rev-parse", first.branch) == rival["tip"] + assert git(project.project, "rev-parse", f"attempt-preserve/test-run-{old_tip[:8]}") == old_tip + assert first.path.is_dir() + + +def test_story_remount_rejects_branch_move_after_reset_before_checkout(project, monkeypatch): + """The second race window cannot smuggle a rival tip into the replacement.""" + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + pinned = rev_parse_head(project.project) + real_add = verify.worktree_add + + def move_then_add(repo, path, branch, base=None, *, create=True): + if not create: + git(repo, "update-ref", f"refs/heads/{branch}", old_tip, pinned) + real_add(repo, path, branch, base, create=create) + + monkeypatch.setattr(verify, "worktree_add", move_then_add) + + with pytest.raises(verify.GitError, match="moved after reclaim reset"): + open_unit_workspace(*args) + + assert git(project.project, "rev-parse", first.branch) == old_tip + assert not first.path.exists() + + +def test_story_remount_does_not_reread_head_after_tip_validation(project, monkeypatch): + """A final moving HEAD read cannot replace the already-validated baseline. + + Ablation: restore ``baseline = rev_parse_head(wt)`` and the injected rival + move lands between validation and that read, returning the rival as baseline. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + pinned = rev_parse_head(project.project) + real_head = verify.rev_parse_head + reads = 0 + + def move_on_redundant_head_read(repo): + nonlocal reads + if Path(repo).resolve() == first.path.resolve(): + reads += 1 + if reads == 2: + git(project.project, "update-ref", f"refs/heads/{first.branch}", old_tip, pinned) + return real_head(repo) + + monkeypatch.setattr(verify, "rev_parse_head", move_on_redundant_head_read) + + second = open_unit_workspace(*args) + + assert reads == 1 + assert second.baseline == pinned + + +def test_restart_discard_retains_shared_run_branch_history(project): + """Restart teardown frees the mount without deleting a cumulative run ref.""" + from bmad_loop.workspace import open_unit_workspace + + engine, _ = make_engine(project, [], policy=wt_policy(branch_per="run")) + args = ( + project.project, + project, + "test-run", + "1-1-a", + "main", + "run", + engine.run_dir, + ) + first = open_unit_workspace(*args) + (first.path / "landed-before-restart.txt").write_text("retained\n") + git(first.path, "add", "landed-before-restart.txt") + git(first.path, "commit", "-q", "-m", "landed before restart") + landed_tip = rev_parse_head(first.path) + task = StoryTask( + "1-1-a", + 1, + worktree_path=str(first.path), + branch=first.branch, + baseline_commit=first.baseline, + ) + + engine._discard_unit_for_restart(task) + second = open_unit_workspace(*args) + + assert rev_parse_head(second.path) == landed_tip + assert (second.path / "landed-before-restart.txt").read_text() == "retained\n" + + +def test_story_remount_bad_base_fails_before_discard(project): + """An unresolvable requested base leaves the existing story workspace intact.""" + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + first = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", run_dir + ) + tip = rev_parse_head(first.path) + + with pytest.raises(verify.GitError, match="missing-base"): + open_unit_workspace( + project.project, + project, + "test-run", + "1-1-a", + "missing-base", + "story", + run_dir, + ) + + assert rev_parse_head(first.path) == tip + assert first.path.is_dir() + + def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) gated = Policy( @@ -2435,9 +2950,14 @@ def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): state = load_state(engine.run_dir) state.clear_pause() adapter = MockAdapter([wt_review_effect(project, "1-1-a", clean=True)]) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) resumed = Engine( paths=project, - policy=gated, + policy=in_place, adapter=adapter, run_dir=engine.run_dir, journal=engine.journal, @@ -2452,6 +2972,144 @@ def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): assert worktree_clean(project.project) +@pytest.mark.parametrize("role", ["dev", "review"]) +def test_isolation_flip_replays_recorded_session_in_mount_and_lands_it(project, monkeypatch, role): + """Completed dev/review results retain their recorded workspace through merge. + + Ablation: gate the recorded-result reopen on live isolation and each row writes + the continuation marker in main directly, leaving no unit merge evidence. + """ + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) + engine, _ = make_engine(project, [], policy=in_place) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + phase = Phase.DEV_RUNNING if role == "dev" else Phase.REVIEW_RUNNING + task = StoryTask( + "1-1-a", + 1, + phase=phase, + attempt=1, + review_cycle=1, + baseline_commit=unit.baseline, + worktree_path=str(unit.path), + branch=unit.branch, + ) + task.record_session( + SessionRecord( + task_id=f"1-1-a-{role}-1", + role=role, + status="completed", + result_json={"workflow": f"recorded-{role}"}, + ) + ) + engine.state.tasks[task.story_key] = task + observed: list[Path] = [] + + def continue_in_recorded_workspace(*_args, **_kwargs): + observed.append(engine.workspace.root) + marker = engine.workspace.root / f"continued-{role}.txt" + marker.write_text(f"{role}\n", encoding="utf-8") + git(engine.workspace.root, "add", marker.name) + git(engine.workspace.root, "commit", "-q", "-m", f"continue {role}") + task.phase = Phase.DONE + + target = "_drive_story" if role == "dev" else "_review_and_commit" + monkeypatch.setattr(engine, target, continue_in_recorded_workspace) + + engine._finish_inflight() + + assert observed == [unit.path] + assert (project.project / f"continued-{role}.txt").read_text(encoding="utf-8") == f"{role}\n" + assert "unit-merged" in journal_kinds(engine) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(engine) + assert not unit.path.exists() + + +def test_isolation_flip_finishes_mounted_defer_before_teardown(project): + """A persisted defer decision is completed where its rejected work lives.""" + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none", keep_failed=False), + ) + engine, _ = make_engine(project, [], policy=in_place) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.DEV_VERIFY, + baseline_commit=unit.baseline, + worktree_path=str(unit.path), + branch=unit.branch, + defer_reason="accepted rejection", + ) + engine.state.tasks[task.story_key] = task + rejected = unit.path / "rejected-work.txt" + rejected.write_text("preserve in failed patch\n", encoding="utf-8") + + engine._finish_inflight() + + assert task.phase == Phase.DEFERRED + assert "resume-defer" in journal_kinds(engine) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(engine) + assert not unit.path.exists() + patch = engine.run_dir / "failed" / "1-1-a" / "changes.patch" + assert "rejected-work.txt" in patch.read_text(encoding="utf-8") + assert "preserve in failed patch" in patch.read_text(encoding="utf-8") + + +def test_isolation_flip_missing_mount_escalates_before_continuation(project, monkeypatch): + """Missing recorded ownership never falls back to a continuation in main. + + Ablation: select reopening from live isolation and the finalizer spy runs in the + main checkout instead of the task escalating. + """ + from bmad_loop.engine import RunPaused + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) + engine, _ = make_engine(project, [], policy=in_place) + engine.state.target_branch = "main" + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(engine.run_dir / "worktrees" / "gone"), + branch="bmad-loop/test-run/1-1-a", + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="is gone"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + assert engine.workspace.root == project.project + + def test_worktree_crash_restart_discards_stale_worktree(project): """A unit interrupted before the spec gate is restarted fresh: the stale worktree is discarded and a new one mounted, not stacked on top.""" @@ -2540,9 +3198,14 @@ def test_worktree_resume_committing_finishes_and_merges(project): state = load_state(engine.run_dir) state.clear_pause() adapter = MockAdapter([]) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) resumed = Engine( paths=project, - policy=wt_policy(), + policy=in_place, adapter=adapter, run_dir=engine.run_dir, journal=engine.journal, diff --git a/tests/test_model.py b/tests/test_model.py index 3ced6da2..2840a243 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from conftest import refuse_to_resolve from bmad_loop.model import ( SWEEP_REFUSED_DIRTY, @@ -380,6 +381,124 @@ def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): assert task.dispatched_spec_file is None +def test_project_local_absolute_accepted_spec_becomes_canonical_relative(tmp_path): + project = tmp_path / "project" + spec = project / "artifacts" / "spec.md" + spec.parent.mkdir(parents=True) + spec.write_text("spec\n", encoding="utf-8") + (project / "hop").mkdir() + raw = str(project / "hop" / ".." / "artifacts" / "spec.md") + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == "artifacts/spec.md" + + +def test_prior_attempt_binding_survives_accepted_spec_relocation(tmp_path): + """Only accepted-spec portability changes before fresh attempt binding.""" + project = tmp_path / "project" + spec = project / "spec.md" + project.mkdir() + spec.write_text("spec\n", encoding="utf-8") + old_dispatch = str(tmp_path / "old-worktree" / "spec.md") + old_snapshot = b"prior attempt bytes\x00" + task = StoryTask( + story_key="1-1-a", + epic=1, + spec_file=str(spec), + dispatched_spec_file=old_dispatch, + dispatched_spec_snapshot=old_snapshot, + ) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == "spec.md" + assert task.dispatched_spec_file == old_dispatch + assert task.dispatched_spec_snapshot == old_snapshot + + +def test_external_and_symlink_external_accepted_specs_keep_their_spelling(tmp_path): + project = tmp_path / "project" + external = tmp_path / "external" + project.mkdir() + external.mkdir() + spec = external / "spec.md" + spec.write_text("spec\n", encoding="utf-8") + link = project / "linked" + link.symlink_to(external, target_is_directory=True) + + for raw in (str(spec), str(link / "spec.md")): + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + task.relativize_project_local_accepted_spec(project) + assert task.spec_file == raw + + +def test_relative_accepted_spec_keeps_its_exact_spelling(): + """A relative value already carries the intended dispatch authority. + + Ablation: delete the absolute-path guard and ``./pyproject.toml`` is normalized + to ``pyproject.toml``. + """ + raw = "./pyproject.toml" + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(Path.cwd()) + + assert task.spec_file == raw + + +def test_missing_absolute_accepted_spec_is_unchanged(tmp_path): + """A missing target has no canonical containment fact to transfer. + + INVERSE ablation: resolve the target non-strictly; the missing in-project + spelling is rewritten despite having no accepted artifact. + """ + project = tmp_path / "project" + project.mkdir() + raw = str(project / "missing.md") + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == raw + + +def test_non_file_absolute_accepted_spec_is_unchanged(tmp_path): + """A contained directory cannot gain relative fallback authority. + + Ablation: remove the regular-file guard and the accepted directory is + rewritten to a relative spelling that can probe an unrelated artifact root. + """ + project = tmp_path / "project" + directory = project / "artifacts" / "spec.md" + directory.mkdir(parents=True) + raw = str(directory) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == raw + + +def test_resolution_fault_accepted_spec_is_unchanged(tmp_path, monkeypatch): + """Uncertain canonical containment fails safe with the exact original spelling. + + INVERSE ablation: fall back to lexical containment after the resolution error; + the faulted in-project absolute value is rewritten. + """ + project = tmp_path / "project" + project.mkdir() + faulted = project / "faulted.md" + refuse_to_resolve(monkeypatch, faulted) + raw = str(faulted) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == raw + + def test_dispatched_spec_snapshot_round_trips_byte_exactly(): snapshot = b"---\r\nstatus: ready-for-dev\r\n---\r\n\xffoperator intent\r\n" task = StoryTask(story_key="1-1-a", epic=1, dispatched_spec_snapshot=snapshot) diff --git a/tests/test_recovery_flow.py b/tests/test_recovery_flow.py index 877c3a7a..a72fae3c 100644 --- a/tests/test_recovery_flow.py +++ b/tests/test_recovery_flow.py @@ -1876,6 +1876,35 @@ def test_preserve_attempt_commits_parks_committed_work(project): assert task.preserve_ref == ref # #333: the ref reaches run state, not just the journal +def test_preserve_attempt_commits_pins_the_observed_head(project, monkeypatch): + """Range enumeration and ref creation use one HEAD observed before either.""" + repo = project.project + flow = _make_flow(workspace=Workspace.default(project)) + task = _task(repo) + baseline = task.baseline_commit + assert baseline is not None + git(repo, "checkout", "-q", "-b", "unrelated", baseline) + git(repo, "commit", "--allow-empty", "-q", "-m", "unrelated commit") + unrelated = rev_parse_head(repo) + git(repo, "checkout", "-q", "-b", "attempt", baseline) + git(repo, "commit", "--allow-empty", "-q", "-m", "attempt commit") + intended = rev_parse_head(repo) + real_commits_above = verify.commits_above + + def move_then_enumerate(repo, baseline, revision="HEAD"): + assert revision == intended + git(repo, "checkout", "-q", "unrelated") + return real_commits_above(repo, baseline, revision) + + monkeypatch.setattr(verify, "commits_above", move_then_enumerate) + + flow.preserve_attempt_commits(task, allow_pause=True) + + assert rev_parse_head(repo) == unrelated + assert task.preserve_ref is not None + assert git(repo, "rev-parse", task.preserve_ref) == intended + + def test_preserve_attempt_commits_noop_without_commits(project): ws = Workspace.default(project) flow = _make_flow(workspace=ws) @@ -1968,8 +1997,8 @@ def boom(*a, **k): def test_preserve_attempt_commits_pauses_when_head_read_fails(project, monkeypatch): - # The second unguarded call (#343): the range enumerated fine, but the tip the - # ref would park at could not be read — still work we cannot park. + # The pinned-tip read fails before range enumeration; uncertainty still means + # there may be work above baseline that cannot safely be reset away. repo = project.project ws = Workspace.default(project) flow = _make_flow(workspace=ws) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 97ae64ab..cbd27ba4 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -10,6 +10,7 @@ from conftest import ( _OK, _file_exists_cmd, + _spec_baseline, attach_profile, bundle_dev_effect, bundle_dev_escalates, @@ -1205,6 +1206,30 @@ def effect(spec): return effect +def wt_bundle_review(project, name="fix"): + """Follow-up review effect that resolves the accepted spec from ``spec.cwd``.""" + + def effect(spec): + wt = project.rebased(spec.cwd) + sp = wt.implementation_artifacts / f"spec-dw-{name}.md" + baseline = _spec_baseline(sp) + write_spec(sp, "done", baseline) + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": f"dw-{name}", + "spec_file": str(sp), + "baseline_commit": baseline, + "status": "done", + "followup_review_recommended": False, + "escalations": [], + }, + ) + + return effect + + def bundle_plan(dw_ids=("DW-1",), name="fix"): return triage_result( list(dw_ids), @@ -1768,6 +1793,64 @@ def review_after_sync(spec): assert saved.pre_harvest_ledger_captured is False +def test_isolation_flip_resumes_accepted_bundle_in_mount_and_integrates(project, monkeypatch): + """A sweep receipt owns its recorded tree until sync, review, commit, and merge. + + Ablation: gate accepted DEV_VERIFY reopening on live isolation and review sees + main's still-open ledger instead of the accepted close in the mounted workspace. + """ + write_ledger(project, {"DW-1": "open"}) + plan = bundle_plan() + isolated = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="worktree", rollback_on_failure=True), + ) + engine, _ = make_sweep( + project, + [triage_effect(plan), wt_bundle_dev(project)], + policy=isolated, + ) + + def crash_before_accepted_sync(task, result_json): + raise RuntimeError("host died before accepted sync") + + monkeypatch.setattr(engine, "_post_dev_accepted_sync", crash_before_accepted_sync) + assert engine.run().crashed + crashed = load_state(engine.run_dir).tasks["dw-fix"] + mount = Path(crashed.worktree_path) + assert crashed.phase == Phase.DEV_VERIFY and mount.is_dir() + + engine.policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="none", rollback_on_failure=True), + ) + seen: list[tuple[Path, str]] = [] + review = wt_bundle_review(project) + + def review_mounted_close(spec): + wt = project.rebased(spec.cwd) + seen.append((spec.cwd, ledger_entries(wt)["DW-1"].status)) + return review(spec) + + resumed, adapter = resume_sweep(project, engine, [review_mounted_close]) + summary = resumed.run() + + assert not summary.crashed and not summary.paused + assert [session.role for session in adapter.sessions] == ["review"] + assert seen and seen[0][0] == mount and seen[0][1].startswith("done") + assert "change for dw-fix" in (project.project / "src.txt").read_text(encoding="utf-8") + assert ledger_entries(project)["DW-1"].status.startswith("done") + assert "unit-merged" in journal_kinds(resumed) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(resumed) + assert not mount.exists() + + def test_resume_dev_verify_bundle_after_repair_preserves_acceptance(project, monkeypatch): """A verify-green repair remains accepted across a DEV_VERIFY crash.""" write_ledger(project, {"DW-1": "open"}) @@ -4843,13 +4926,21 @@ def test_resume_committing_bundle_finishes_commit(project): plan = triage_result( ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}] ) + isolated = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="worktree", rollback_on_failure=True), + ) engine, _ = make_sweep( project, [ triage_effect(plan), - bundle_dev_effect(project, "fix", ["DW-1"]), - bundle_review_effect(project, "fix"), + wt_bundle_dev(project), + wt_bundle_review(project), ], + policy=isolated, ) def crashing_emit(stage, *args, **kwargs): @@ -4863,7 +4954,16 @@ def crashing_emit(stage, *args, **kwargs): crashed = load_state(engine.run_dir).tasks["dw-fix"] assert crashed.phase == Phase.COMMITTING assert not crashed.commit_sha # stamped only by the DONE save that never ran + mount = Path(crashed.worktree_path) + assert mount.is_dir() + engine.policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="none", rollback_on_failure=True), + ) resumed, adapter = resume_sweep(project, engine, []) summary = resumed.run() @@ -4875,6 +4975,88 @@ def crashing_emit(stage, *args, **kwargs): assert "resume-commit" in journal assert "resume-restart" not in journal assert ledger_entries(project)["DW-1"].status.startswith("done") + assert "change for dw-fix" in (project.project / "src.txt").read_text(encoding="utf-8") + assert "unit-merged" in journal_kinds(resumed) + assert not mount.exists() + + +def test_sweep_isolation_flip_restart_releases_mount_state_without_main_rollback( + project, monkeypatch +): + """Rejected/incomplete work restarts in main without carrying mount operands. + + Ablation: omit the mounted restart release and the rollback spy receives the + unit baseline while the mount claim remains attached. + """ + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none", rollback_on_failure=True), + ) + engine, _ = make_sweep(project, [], policy=in_place) + mount = engine.run_dir / "worktrees" / "dw-fix" + mount.mkdir(parents=True) + task = StoryTask( + "dw-fix", + 0, + phase=Phase.DEV_RUNNING, + worktree_path=str(mount), + branch="bmad-loop/sweep-run/dw-fix", + baseline_commit=verify.rev_parse_head(project.project), + baseline_untracked=[], + spec_file="_bmad-output/implementation-artifacts/spec-dw-fix.md", + dispatched_spec_file="_bmad-output/implementation-artifacts/spec-dw-fix.md", + dispatched_spec_snapshot=b"bound", + ) + engine.state.tasks[task.story_key] = task + rolled: list[str] = [] + monkeypatch.setattr(engine, "_rollback_or_pause", lambda _task, cause: rolled.append(cause)) + + assert engine._recover_inflight_bundle(task) is False + + assert rolled == [] + assert task.phase == Phase.PENDING + assert task.worktree_path == "" and task.branch == "" + assert task.baseline_commit is None and task.baseline_untracked is None + assert task.dispatched_spec_file is None and task.dispatched_spec_snapshot is None + assert task.spec_file == "_bmad-output/implementation-artifacts/spec-dw-fix.md" + assert mount.is_dir() # released and journaled, not destroyed + assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) + + +def test_sweep_missing_recorded_mount_escalates_without_finalizing_in_main(project, monkeypatch): + """A mounted COMMITTING receipt has no in-place fallback when its tree is gone. + + Ablation: gate the reopen on live isolation and the finalizer spy runs against + main rather than the bundle escalating. + """ + from bmad_loop.engine import RunPaused + + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) + engine, _ = make_sweep(project, [], policy=in_place) + task = StoryTask( + "dw-fix", + 0, + phase=Phase.COMMITTING, + worktree_path=str(engine.run_dir / "worktrees" / "gone"), + branch="bmad-loop/sweep-run/dw-fix", + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="is gone"): + engine._recover_inflight_bundle(task) + + assert finalized == [] + assert task.phase == Phase.ESCALATED + assert engine.workspace.root == project.project def test_regenerated_intent_when_bundle_file_missing(project): diff --git a/tests/test_verify.py b/tests/test_verify.py index e65d2594..c5ea7194 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -6755,6 +6755,77 @@ def test_commits_above_lists_attempt_commits_newest_first(project): assert commits == [head] +def test_pinned_revision_preservation_does_not_follow_checkout_head(project): + """A named story tip, not whichever branch the caller has checked out, is parked.""" + repo = project.project + baseline = verify.rev_parse_head(repo) + git(repo, "checkout", "-q", "-b", "story-tip") + (repo / "story.txt").write_text("story\n", encoding="utf-8") + git(repo, "add", "story.txt") + git(repo, "commit", "-q", "-m", "story work") + story_tip = verify.rev_parse_head(repo) + git(repo, "checkout", "-q", "main") + (repo / "main.txt").write_text("main\n", encoding="utf-8") + git(repo, "add", "main.txt") + git(repo, "commit", "-q", "-m", "main moved") + main_tip = verify.rev_parse_head(repo) + + commits = verify.commits_above(repo, baseline, story_tip) + ref = verify.preserve_commits( + repo, + baseline, + "attempt-preserve/run-story", + commits=commits, + revision=story_tip, + ) + + assert ref == "attempt-preserve/run-story" + assert git(repo, "rev-parse", ref) == story_tip + assert git(repo, "rev-parse", ref) != main_tip + + +def test_reset_branch_if_tip_refuses_a_concurrent_move(project): + """CAS failure leaves the rival tip intact. + + INVERSE ablation: replace ``update-ref `` with an unconditional + branch force-update and this test loses ``rival_tip``. + """ + repo = project.project + baseline = verify.rev_parse_head(repo) + git(repo, "branch", "story", baseline) + git(repo, "checkout", "-q", "story") + git(repo, "commit", "--allow-empty", "-q", "-m", "old story tip") + old_tip = verify.rev_parse_head(repo) + git(repo, "commit", "--allow-empty", "-q", "-m", "rival advances") + rival_tip = verify.rev_parse_head(repo) + git(repo, "checkout", "-q", "main") + + with pytest.raises(verify.GitError): + verify.reset_branch_if_tip(repo, "story", baseline, old_tip) + + assert git(repo, "rev-parse", "story") == rival_tip + + +def test_reset_branch_if_tip_does_not_follow_symbolic_story_ref(project): + """A story ref must never redirect its reset onto the branch it names. + + Ablation: remove ``--no-deref`` and update-ref resets ``main`` through the + symbolic story ref instead of replacing the named ref itself. + """ + repo = project.project + baseline = verify.rev_parse_head(repo) + (repo / "advance-symbolic-main.txt").write_text("main stays here\n", encoding="utf-8") + git(repo, "add", "advance-symbolic-main.txt") + git(repo, "commit", "-q", "-m", "advance main before symbolic reset") + main_tip = verify.rev_parse_head(repo) + git(repo, "symbolic-ref", "refs/heads/story", "refs/heads/main") + + verify.reset_branch_if_tip(repo, "story", baseline, main_tip) + + assert verify.rev_parse_head(repo) == main_tip + assert git(repo, "rev-parse", "refs/heads/story") == baseline + + def test_preserve_commits_survives_reset_and_gc(project): """The parked ref keeps committed attempt work reachable through the exact destructive sequence safe_rollback performs (reset --hard baseline) and a gc.""" diff --git a/tests/test_verify_worktree.py b/tests/test_verify_worktree.py index a28ff8ed..c79686bd 100644 --- a/tests/test_verify_worktree.py +++ b/tests/test_verify_worktree.py @@ -6,6 +6,7 @@ """ import subprocess +import sys import pytest from conftest import git, make_git_noisy, refuse_to_resolve @@ -117,6 +118,17 @@ def noisy_run(cmd, **kwargs): assert [p.resolve() for p in verify.worktree_list(repo)] == [repo.resolve()] +@pytest.mark.skipif(sys.platform == "win32", reason="Win32 forbids newlines in filenames") +def test_worktree_list_preserves_newlines_in_paths(project, tmp_path): + """NUL-delimited porcelain keeps a valid newline inside one path record.""" + repo = project.project + wt = tmp_path / "wt\nline" + verify.worktree_add(repo, wt, "newline-path", "main") + + assert wt.resolve() in [path.resolve() for path in verify.worktree_list(repo)] + assert verify.worktree_is_registered(repo, wt) + + def test_worktree_add_create_defaults_to_head(project, tmp_path): """create=True with no `base` cuts the branch from HEAD (git's own default) instead of passing None into git and crashing.""" diff --git a/tests/test_worktree_flow.py b/tests/test_worktree_flow.py index c780d9a1..b84a4d02 100644 --- a/tests/test_worktree_flow.py +++ b/tests/test_worktree_flow.py @@ -121,7 +121,9 @@ def _pause(reason, story_key="", *, cause=None): raise _Pause(reason, story_key) flow = WorktreeFlow( - paths=paths if paths is not None else SimpleNamespace(repo_root=tmp_path), + paths=( + paths if paths is not None else SimpleNamespace(repo_root=tmp_path, project=tmp_path) + ), policy=policy if policy is not None else _policy(), state=( state @@ -428,6 +430,41 @@ def test_ensure_target_branch_detached_head_pauses(project): # --------------------------------------------------------------- run / escalate +def test_run_isolated_relativizes_local_accepted_spec_before_open(tmp_path): + """Mount creation observes the portable spelling, never the main absolute path. + + Ablation: move normalization below ``_open_unit_workspace`` and the spy sees the + main-checkout absolute value. + """ + project = tmp_path / "project" + artifacts = project / "_bmad-output" / "implementation-artifacts" + artifacts.mkdir(parents=True) + spec = artifacts / "spec-1-1.md" + spec.write_text("spec\n", encoding="utf-8") + paths = ProjectPaths( + project=project, + implementation_artifacts=artifacts, + planning_artifacts=project / "_bmad-output" / "planning-artifacts", + ) + task = StoryTask(story_key="1-1", epic=1, spec_file=str(spec)) + observed: list[str | None] = [] + + def stop_after_observation(*_args, **_kwargs): + observed.append(task.spec_file) + raise verify.GitError("stop after observing pre-open state") + + flow = _make_flow( + tmp_path, + paths=paths, + state=SimpleNamespace(target_branch="main", run_id="run-1", tasks={}), + open_unit_workspace=stop_after_observation, + ) + + flow.run_isolated(task, lambda _task: pytest.fail("drive must not run")) + + assert observed == ["_bmad-output/implementation-artifacts/spec-1-1.md"] + + def test_run_isolated_defers_on_open_failure(tmp_path): def boom(*a, **k): raise verify.GitError("branch held by a kept-failed unit") From bc18a0ba9c284ba638c3976878c0fa420d4b86eb Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 22:48:51 -0700 Subject: [PATCH 26/45] sweep dw2-path-assertion-test-hardening: DW-43, DW-44 via bmad-loop --- tests/test_model.py | 10 +++++----- tests/test_stories_engine.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_model.py b/tests/test_model.py index 2840a243..978e718f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -335,7 +335,7 @@ def test_dispatched_spec_file_defaults_none_for_legacy_state(): assert StoryTask.from_dict(doc).dispatched_spec_file is None -def test_rebase_spec_paths_on_reanchors_both_ownership_fields(): +def test_rebase_spec_paths_on_reanchors_both_ownership_fields(tmp_path): """The read-side inverse of `_serialized_worktree_path`, on both fields at once. `to_dict` relativizes `spec_file` and `dispatched_spec_file` together, so a @@ -343,7 +343,7 @@ def test_rebase_spec_paths_on_reanchors_both_ownership_fields(): values are already anchored (a spec outside the mount persists verbatim) and must pass through, which is also what makes the call idempotent. """ - mount = Path("/repo/.bmad-loop/runs/r1/worktrees/1-1-a") + mount = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1-1-a" task = StoryTask( story_key="1-1-a", epic=1, @@ -362,7 +362,7 @@ def test_rebase_spec_paths_on_reanchors_both_ownership_fields(): assert task.dispatched_spec_file == str(mount / "_out/dispatched.md") -def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): +def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(tmp_path): """An out-of-mount spec and an unbound field are both already correct. `_serialized_worktree_path` keeps a path verbatim exactly when @@ -372,10 +372,10 @@ def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): becoming the mount root: `Path("")` is `.`, so a bare join would answer the tree root, which is a write target, not a spec. """ - outside = str(Path("/elsewhere/spec.md")) + outside = str(tmp_path / "outside" / "spec.md") task = StoryTask(story_key="1-1-a", epic=1, spec_file=outside) - task.rebase_spec_paths_on(Path("/repo/wt")) + task.rebase_spec_paths_on(tmp_path / "alternate-root" / "wt") assert task.spec_file == outside assert task.dispatched_spec_file is None diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 63eac7f0..a5bea118 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1069,11 +1069,12 @@ def test_plan_checkpoint_pause_journals_the_mount_anchored_spec(project): # this assertion no row in the repo observed ANY `gates.notify` body, so every # notification site could be reverted to a bare `task.spec_file` with the suite green. # - # Ablation: revert `_pause_plan_checkpoint`'s notify to `task.spec_file` and this - # reddens — the bare relpath appears and the anchored path does not. + # INVERSE ablation: replace only `_pause_plan_checkpoint`'s notification + # `_operator_spec_path(task)` call with `task.spec_file`; this focused test + # reddens because the bare relpath appears and the anchored path does not. attention = (engine.run_dir / "ATTENTION").read_text(encoding="utf-8") assert str(wt / rel) in attention - assert f"review {rel}," not in attention # not the un-anchored spelling + assert f"review the planned spec {rel}," not in attention # -------- MAJOR-B: a spec_checkpoint story can never commit without a plan review From a445f1c1a4e86af4909b0329f7298b41be5c430b Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 01:20:08 -0700 Subject: [PATCH 27/45] Fix session-authored park assertions --- CHANGELOG.md | 10 +- docs/FEATURES.md | 2 +- src/bmad_loop/adapters/generic.py | 85 +++++++++- src/bmad_loop/devcontract.py | 29 ++++ src/bmad_loop/engine.py | 88 +---------- src/bmad_loop/model.py | 28 ---- src/bmad_loop/verify.py | 71 +++------ tests/conftest.py | 4 + tests/test_devcontract.py | 64 ++++++++ tests/test_engine.py | 250 ++++-------------------------- tests/test_generic_tmux.py | 79 ++++++++++ tests/test_model.py | 54 +------ tests/test_verify.py | 104 +++++-------- 13 files changed, 369 insertions(+), 499 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5210d21a..b275a318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -325,14 +325,14 @@ argument` and failed the story; a `ts` key did not raise and instead silently re an environment fault, and escalated — budget untouched, re-armable. `bmad-loop confirm --reverify` reports it as a refusal too. A command that merely times out is unchanged: still an ordinary fixable retry. -- Require a dispatch-time expectation before an `awaiting-operator` park skips proof-of-work, - so a re-drive cannot verify green by inheriting an earlier in-run park; inherited parks with +- Require the current session's genuine `awaiting-operator` Auto Run Result marker before a + park skips proof-of-work, so previous-run, out-of-band, and re-armed specs cannot inherit + waiver authority through retained frontmatter or operator actions; unasserted parks with real changes still pass (#335, #676). Journal each waived artifact-gate pass as `park-proof-of-work-skipped`, with `zero_diff` reporting no non-excluded residue (`true`), residue (`false`), or an unanswerable probe (`null`). The record does not mean the park - committed; use the later `story-awaiting-operator` event for that. Cross-run, out-of-band, - and re-armed parks remain deferred. On upgrade, an in-flight legacy park defaults ineligible - and may retry; re-running the story is sufficient. + committed; use the later `story-awaiting-operator` event for that. Missing, repaired, legacy, + and malformed assertions fail closed to the ordinary proof gate. - Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run owns. Under isolation both resolved against the main checkout, so the review modals showed that copy of the spec and the replan reset it — reporting success while the run's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0ceb1e84..7c58fcde 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement, so a re-drive that does nothing does not verify green on the park it inherited. That expectation is read from the run's own binding for the story, which bounds what it catches: a park inherited from a **previous run**, or one written into the spec out of band, reaches a dispatch with nothing bound and is eligible; so does a story re-armed out of an escalation, since re-arming reopens the spec at `ready-for-dev` without clearing its `operator_actions:`. Both remain open and are tracked as deferred work. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so a park that clears this artifact gate with the waiver in force is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got through. Read that record for exactly what it says: a waiver refused by a later check inside the same gate leaves no entry, but the stages **after** it — your `[verify]` commands, deterministic review verification and repair, pre-commit workflows, and the commit — can still reject the attempt, and the entry stands regardless. It answers "which attempts cleared the artifact gate without proving work", never "which parks committed". The committed half is the post-commit `story-awaiting-operator` journal record, appended only after the commit lands and carrying its sha; correlate the two on the story key plus journal order — a committed park's waiver is the last `park-proof-of-work-skipped` for that story before that event. The waiver record does carry `attempt`; the terminal event does not, which is exactly why no attempt-keyed join is promised — and adding one would not help, since the attempt current at commit can be higher than the one on the waiver. Do **not** read `review-skipped-awaiting-operator` as that half: it is written when a park _enters_ the commit path, ahead of the review verification, the repair loop, the pre-commit workflows and the commit itself, every one of which can still reject it. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and `park_asserted: true` in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced `## Auto Run Result` marker reporting `awaiting-operator`; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or `operator_actions:` while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as `park-proof-of-work-skipped`; `zero_diff` reports whether the waived gate would have found non-excluded residue (`true` means none, `false` means some, `null` means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later `story-awaiting-operator` event. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 4071976f..3d383dc5 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -28,7 +28,7 @@ import time from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, cast from .. import devcontract, gates, runs from ..bmadconfig import ProjectPaths @@ -1269,6 +1269,12 @@ def read_usage(self, result: SessionResult) -> TokenUsage | None: time.sleep(RESULT_POLL_S) +class _SessionStarter(Protocol): + """Next concrete adapter in the dev mixin's cooperative MRO.""" + + def start_session(self, spec: SessionSpec) -> SessionHandle: ... + + class _DevSynthesisMixin(_ResultFileMixin): """Result synthesis for the generic ``bmad-build-auto`` skill, shared by every transport that drives it (tmux today; see GenericDevAdapter for the @@ -1327,6 +1333,71 @@ def _configure_dev_knobs(self) -> None: # apply — this budget is not a counter and touches no stall counters). self._contract_nudge_sent: set[str] = set() self._contract_nudge_enabled = self.policy.limits.dev_contract_nudge + # Marker identities present immediately before each real session launch. + # The adapter, not whole-file mtime, owns this attempt-relative evidence: + # touching another part of a parked spec must not make its retained marker + # look session-authored. None means launch capture was incomplete and + # therefore fails closed for the waiver. + self._launch_auto_run_results: dict[str, dict[str, tuple[int, str]] | None] = {} + + @staticmethod + def _marker_path_key(path: Path) -> str: + try: + return str(path.resolve()) + except OSError: + return str(path.absolute()) + + def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None: + """Snapshot real result markers before the child can write its spec.""" + paths: list[Path] = [] + complete = True + if spec.expected_spec: + expected = Path(spec.expected_spec) + paths = [expected if expected.is_absolute() else Path(spec.cwd) / expected] + else: + for artifacts in self._artifact_dirs(spec.cwd): + try: + paths.extend(artifacts.glob("*.md")) + except OSError: + complete = False + + captured: dict[str, tuple[int, str]] = {} + for path in paths: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + continue + except (OSError, UnicodeDecodeError): + complete = False + continue + fingerprint = devcontract.auto_run_result_fingerprint(text) + if fingerprint[0]: + captured[self._marker_path_key(path)] = fingerprint + self._launch_auto_run_results[spec.task_id] = captured if complete else None + + def start_session(self, spec: SessionSpec) -> SessionHandle: + self._capture_launch_auto_run_results(spec) + # The mixin is shared by two unrelated concrete transports. Keep the + # cooperative MRO dispatch rather than naming either host explicitly; + # the protocol gives Pyright the host contract without adding a runtime + # base that could alter method resolution. + return cast(_SessionStarter, super()).start_session(spec) + + def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> bool: + """Whether the live marker differs from this session's launch marker.""" + if spec.task_id not in self._launch_auto_run_results: + # Direct read-back callers predate launch capture; production always + # enters through start_session. Preserve that diagnostic/test seam. + return True + captured = self._launch_auto_run_results[spec.task_id] + if captured is None: + return False + try: + current = devcontract.auto_run_result_fingerprint(spec_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + return False + launch = captured.get(self._marker_path_key(spec_path), (0, "")) + return current != launch def _probe_alive(self, handle: SessionHandle) -> bool | None: """Liveness of the session's native surface (tmux window, server @@ -1418,10 +1489,13 @@ def _known_spec_synth_result( observation and the M1 launch-snapshot gate all still apply — scoped to the one legitimate path instead of a shared directory. - No launch-snapshot gate is needed on the marker branch itself: the + No whole-file launch-snapshot gate is needed on the marker branch itself: the pre-review-launch strip (`Engine._reset_spec_for_review`) REMOVES the marker, so a spec carrying one again has necessarily changed bytes since the snapshot and the gate would be a no-op (`_snapshot_verdict` → NEUTRAL). + Marker-level launch capture still runs for every real session: it prevents + an unrelated post-launch touch from lending a retained park marker to the + new attempt. Note this deliberately does NOT fall back to the scan when the expected spec yields nothing: a session that did not write the spec it owed produced no @@ -1447,7 +1521,12 @@ def _synthesize_from(self, spec_path: Path, spec: SessionSpec) -> devcontract.Sy story_key = spec.env.get("BMAD_LOOP_STORY_KEY") or None raw_dw_ids = (spec.env.get("BMAD_LOOP_DW_IDS") or "").split(",") dw_ids = [tok for tok in (i.strip() for i in raw_dw_ids) if tok] - return devcontract.synthesize_result(spec_path, story_key=story_key, dw_ids=dw_ids or None) + return devcontract.synthesize_result( + spec_path, + story_key=story_key, + dw_ids=dw_ids or None, + park_marker_session_authored=self._park_marker_session_authored(spec_path, spec), + ) def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None: """Mid-session status-transition observation (#276 M2), called each diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index e3819221..19c5e90b 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -159,6 +159,24 @@ def parse_auto_run_result(text: str) -> AutoRunResult: return AutoRunResult(present=True, status=status, detail=body.strip()) +def auto_run_result_fingerprint(text: str) -> tuple[int, str]: + """Identity of the current last real Auto Run Result marker. + + The count distinguishes a newly appended marker even when its text repeats + the previous result verbatim; the digest distinguishes an in-place rewrite. + Fenced examples remain invisible through the shared heading reader. + """ + matches = _section_headings(text) + if not matches: + return (0, "") + last = matches[-1] + section = text[last.start() : _next_heading_start(text, last.end())] + return ( + len(matches), + hashlib.sha256(section.encode("utf-8"), usedforsecurity=False).hexdigest(), + ) + + # ------------------------------------------------ deferred review findings # # Since BMAD-METHOD #2640 the dev primitive records findings triaged as `defer` @@ -334,6 +352,7 @@ def synthesize_result( story_key: str | None, dw_ids: list[str] | None = None, plan_halt: bool = False, + park_marker_session_authored: bool = True, ) -> SynthResult: """Build the legacy result dict from the generic skill's on-disk spec. @@ -410,6 +429,16 @@ def synthesize_result( "baseline_commit": baseline, "status": status, "escalations": escalations, + # Attempt ownership comes only from the current session's terminal marker. + # Frontmatter-only fallback is deliberately insufficient, and a marker + # written later by the orchestrator's missing-marker repair cannot + # retroactively authorize the session that omitted it. + "park_asserted": ( + arr.present + and arr.status == AWAITING_OPERATOR + and ORCHESTRATOR_SYNTH_NOTE not in arr.detail + and park_marker_session_authored + ), } if dw_ids: result["dw_ids"] = list(dw_ids) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 7f98f7df..edfe1177 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2267,15 +2267,6 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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() - # Whether this phase may newly ELECT a park, on the same anchor and - # for the same reason as the baseline above: the proof-of-work skip - # this authorizes is measured from that baseline, so the expectation - # and the diff it guards have to be captured at one instant. A fixable - # repair therefore inherits the phase's answer (it deliberately keeps - # the previous session's tree, park declaration included, so - # re-observing per attempt would make every repair of a malformed park - # ineligible), and a crash-replayed attempt keeps the persisted one. - task.park_eligible = self._park_eligible_at_dispatch(task) feedback: Path | None = None while True: replayed = resume_result is not None @@ -3526,76 +3517,6 @@ def _operator_park_enabled(self) -> bool: branch happens to sit.""" return self.policy.operator.enabled - def _park_eligible_at_dispatch(self, task: StoryTask) -> bool: - """Whether the attempt about to be dispatched could newly ELECT a park — - the orchestrator-side half of :func:`verify.verify_dev`'s two-part - proof-of-work skip selector (#335, #676). - - The skip used to be selected entirely by state a fresh session can - INHERIT: ``operator_park`` (a policy flag) plus the spec's own - ``awaiting-operator`` status, which an earlier attempt may already have - written. A re-drive over such a spec therefore selected #676's relaxation - while having done nothing at all, and verified green on someone else's - park declaration. This is the fact that cannot be inherited: at the moment - the phase is dispatched, was the story's bound spec ALREADY parked? - - ``False`` when parking is off (the skip is unreachable anyway, so this - costs no read), when the bound spec already reads ``awaiting-operator``, - and on the two genuinely unobservable shapes: a recorded ``spec_file`` - that no longer resolves to a trusted regular file, and one whose read - raises ``OSError`` (journaled ``spec-read-failed``). Those fail closed onto - the ordinary gated path, where an honest park with a real diff still - passes. - - An UNPARSEABLE spec is deliberately not in that list, and the distinction - is worth stating because it looks like a gap. ``read_frontmatter`` - degrades malformed YAML and non-UTF-8 to ``{}`` rather than raising, so - ``status_of`` reads ``""`` and this returns True. That is correct rather - than merely tolerated: an unparseable spec demonstrably does not say - "parked", and ``verify_dev``'s own gate reads the very same ``{}``, so - ``parked`` is False there too and the skip is unreachable on that leg no - matter what this answers. Only OSError and an unresolvable binding are - uncertainty about a spec that *does* say something. - - ``True`` when nothing is bound at all — the ordinary case, not a fallback. - Note precisely what that tests: ``task.spec_file`` is an IN-RUN binding, - set only after a session returns and its artifacts verify, so "unbound" - means "this task object has no binding", NOT "no earlier park exists on - disk". A story whose spec was parked by a previous RUN, or edited into the - park status out of band, presents as unbound here and is eligible. The - residual is recorded as a deferred finding on this change's spec rather - than closed silently; closing it means keying eligibility on the spec the - story resolves to rather than on the task's binding, which is a wider - change than the one this gate makes. - - Called only from ``_dev_phase``'s ``resume_result is None`` block, beside - the baseline capture — see the comment there for why the anchor is the - PHASE and not the attempt. Reuses ``_dispatched_spec_for_attempt`` for the - symlink/roots checks rather than re-deriving them: a second, laxer - resolution here would be a second answer to "which file is this attempt's - spec", and recovery already owns that question. - - Consequence worth knowing before touching either caller: that resolver is - now invoked TWICE per dev phase — once here at phase entry, and once by - the binder inside the attempt loop. They are two observations of the same - path at different instants and neither may be folded into the other (this - one must precede the first attempt; the binder's must be the one that - promotes). Any test that counts calls to it has to say which observation - it means — ``test_transient_initial_binding_fault_does_not_promote_after_bare_prompt`` - pins this one out for exactly that reason. - """ - if not self._operator_park_enabled(): - return False - if not task.spec_file: - return True - bound = self._dispatched_spec_for_attempt(task) - if bound is None: - return False - fm = self._observed_frontmatter(Path(bound), task.story_key, "park-eligibility") - if fm is None: - return False - return verify.status_of(fm) != verify.AWAITING_OPERATOR - 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 @@ -5336,11 +5257,6 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): result_json, review_enabled=self._dev_review_enabled(), operator_park=self._operator_park_enabled(), - # The dispatch-time half of the park's proof-of-work skip selector, - # read from the task rather than re-observed: it was captured on this - # phase's fresh entry, and re-deriving it now would answer about the - # spec the session just finished writing (#676). - park_eligible=task.park_eligible, engine_written=self._harvest_gate_exclude(task), ) # The record marks the WAIVED GATE, so it keys on the waiver itself @@ -6364,7 +6280,9 @@ def _operator_park_instruction(self) -> str: "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. Never use the blocked status for this: " + "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." ) diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 1cf99d53..1bc3f8ff 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -304,20 +304,6 @@ class StoryTask: # owes, and nothing re-derives it once the session that wrote the spec is # gone). operator_actions: list[str] = field(default_factory=list) - # Whether THIS dev phase was in a position to newly elect a park: captured - # once, on the fresh entry into `Engine._dev_phase` (`resume_result is None`), - # from the same instant and the same condition as `baseline_commit` — so the - # expectation and the diff it guards share one anchor. False when the bound - # spec was ALREADY at `awaiting-operator` on entry (an earlier attempt's park - # is on disk, so a park observed afterwards may be inherited rather than - # elected), when parking is disabled, or when the spec could not be read at - # all (fail closed). It gates exactly one thing: `verify_dev`'s proof-of-work - # skip on the park leg (#335, #676). Every other park gate still selects on the - # observed status alone, so an ineligible park with a real diff still passes. - # Deliberately per-PHASE, not per-attempt: a fixable repair keeps the previous - # session's tree, so re-observing would make every repair of a malformed park - # ineligible and fail it on the gate it just re-armed. - park_eligible: bool = False defer_reason: str | None = None # the recovery ref this attempt's work was parked on by the last auto-rollback # — an `attempt-preserve/*` branch (commits above baseline) or, when the tree @@ -466,7 +452,6 @@ def to_dict(self) -> dict[str, Any]: ), "commit_sha": self.commit_sha, "operator_actions": self.operator_actions, - "park_eligible": self.park_eligible, "defer_reason": self.defer_reason, "preserve_ref": self.preserve_ref, "preserve_partial": self.preserve_partial, @@ -678,19 +663,6 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot=dispatched_spec_snapshot, commit_sha=d.get("commit_sha"), operator_actions=[str(a) for a in d.get("operator_actions", [])], - # `is True`, not `bool(...)`, and this is the one field on this task - # where the difference is load-bearing. Every sibling bool above - # merely restores bookkeeping; this one AUTHORIZES a gate to be - # waived, so its failure direction is not symmetric — a wrong False - # costs one retryable proof-of-work refusal, a wrong True re-opens - # the inheritance hole the field exists to close (#335, #676). Under - # `bool()` every truthy non-boolean grants the waiver, and the - # likeliest one is the string "false" (a hand-edited state.json, a - # bridge that stringifies JSON scalars): `bool("false")` is True. - # Only a real JSON `true` may authorize; anything else — absent, - # null, a string, a number — fails closed onto the ordinary gated - # path, where an honest park with a real diff still passes. - park_eligible=d.get("park_eligible") is True, defer_reason=d.get("defer_reason"), preserve_ref=d.get("preserve_ref"), preserve_partial=bool(d.get("preserve_partial", False)), diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 554400fc..2376e7bb 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3726,7 +3726,6 @@ def verify_dev( review_enabled: bool = True, *, operator_park: bool = False, - park_eligible: bool = False, engine_written: tuple[str, ...] = (), ) -> VerifyOutcome: """Verify a dev session's on-disk artifacts against its result.json claims. @@ -3748,14 +3747,13 @@ def verify_dev( a terminal the gate knows, so it fails the ordinary status check and the session is retried with that mismatch as feedback. - The proof-of-work gate is skipped on a park that this attempt was in a - position to newly ELECT — ``skip_proof = parked and park_eligible``, a - two-part selector. ``parked`` is what the session left behind (the observed - spec status, plus the policy flag); ``park_eligible`` is what the orchestrator - knew at dispatch (:meth:`Engine._park_eligible_at_dispatch`, captured on the - fresh entry into ``Engine._dev_phase`` from the same instant and the same - condition as ``task.baseline_commit``): the story's bound spec did NOT already - read ``awaiting-operator``. Both halves are load-bearing. The skip exists + The proof-of-work gate is skipped only when the observed park intersects a + strict current-session result assertion — ``skip_proof = parked and + rj.get("park_asserted") is True``. ``parked`` comes from the independently + observed spec status plus policy. ``park_asserted`` is minted by + :func:`devcontract.synthesize_result` only from the last genuine, non-fenced + ``## Auto Run Result`` marker whose status is ``awaiting-operator``. Both + halves are load-bearing. The skip exists because a park's whole output can legitimately be its own spec's park declaration plus the board sync, both of which proof-of-work already excludes, so demanding a diff read a correct park as "no changes since baseline commit" @@ -3769,32 +3767,15 @@ def verify_dev( (``Engine._finalize_commit_phase``), and a reset discards that too, onto an ``attempt-preserve/*`` ref. - What the eligibility half defends is narrow and worth naming exactly. Before - it, the relaxation was selected entirely by state a fresh session could - INHERIT rather than produce: a spec an earlier attempt left at - ``awaiting-operator`` still reads ``awaiting-operator`` to the next session - that does nothing at all, so a re-drive over that spec selected the skip and - verified green on someone else's declaration, relaxing #676's skip for an - attempt that produced nothing. Requiring the - orchestrator's own dispatch-time answer means the leg that skips proof-of-work - is the leg that actually authored the park. It does NOT defend against a - session that elects a park it did not earn — one that writes the frontmatter, - lists plausible actions and implements nothing is eligible by construction and - still passes, because the actions gate tests list non-emptiness and never - content. It is a check on WHICH ATTEMPT owns the park, not on whether the park - is honest, and it is captured per PHASE rather than per attempt: a fixable - repair deliberately keeps the previous session's tree, so re-observing would - make every repair of a malformed park ineligible and fail it on the gate it - just re-armed. - - An INELIGIBLE park is not refused — it is merely held to proof-of-work like - any other terminal. The park's status pair, ``operator_actions`` - non-emptiness, workflow tag, baseline match and sprint pair all keep selecting - on the observed status alone, so an inherited park carrying a real diff passes - exactly as before; only the residue-free one now owes the diff it never - produced. - - Nothing else relaxes on the eligible leg either — the ``operator_actions`` + The assertion establishes attempt ownership, not honesty or a second status + authority. A frontmatter-only fallback, a legacy result, a malformed value, + or a marker carrying the orchestrator's missing-marker repair note cannot + authorize the waiver. Those parks are not otherwise refused: they take the + ordinary proof-of-work arm, so one carrying a real diff still passes. Crash + and fixable-retry replay preserve the already synthesized result rather than + deriving authority from retained frontmatter or ``operator_actions``. + + Nothing else relaxes on the asserted leg either — the ``operator_actions`` gate above still refuses a park that enumerates nothing, and the workflow-tag, status, baseline-match and sprint-pair gates all still run. Two of those four are not independent evidence on this leg, and saying so is the point: the @@ -3805,8 +3786,8 @@ def verify_dev( this gate runs, so it confirms the orchestrator's own write landed rather than anything the session did. What still binds a park to the attempt the orchestrator actually launched is the workflow tag, the baseline match, the - non-empty actions list — and now the dispatch-time eligibility, which is the - only one of the four the session cannot influence at all. Baseline-match also + non-empty actions list and the independent result-marker assertion. + Baseline-match also accepts a claim NEWER than the recorded baseline whenever it is a HEAD-reachable descendant, and the comment guarding that branch names the compensating control: such a commit "may have arrived in the shared checkout @@ -3879,12 +3860,10 @@ def verify_dev( actions = _operator_actions_gate(fm, task.story_key) if actions is not None: return actions - # The two-part selector: the session's observed park AND the orchestrator's - # dispatch-time answer that this phase could newly elect one. Deliberately a - # separate name from `parked` — every other park gate below still keys on - # `parked` alone, and collapsing the two would silently widen this expectation - # from "may skip proof-of-work" to "may park at all" (#335, #676). - skip_proof = parked and park_eligible + # The two-part selector: the independently observed park AND the strict + # current-session marker assertion. Every other park gate below still keys on + # `parked` alone; the result assertion authorizes only this waiver (#335, #676). + skip_proof = parked and rj.get("park_asserted") is True # With review disabled, the dev session runs its own internal review and # finalizes straight to done; otherwise it hands off at in-review. A park @@ -3897,12 +3876,12 @@ def verify_dev( expected_status=( AWAITING_OPERATOR if parked else ("in-review" if review_enabled else "done") ), - # Proof-of-work is the one gate an ELECTED park skips (``extra_exclude=None``, + # Proof-of-work is the one gate an ASSERTED park skips (``extra_exclude=None``, # the callee-blessed spelling): such a park's whole residue can legitimately # be the spec and the board, both already excluded (#676). The park paragraph # in this function's docstring carries the reasoning and, more importantly, - # what the skip does NOT relax. An inherited park (`park_eligible=False`) - # takes the ordinary arm and owes a diff like every other terminal. + # what the skip does NOT relax. An unasserted park takes the ordinary arm + # and owes a diff like every other terminal. extra_exclude=None if skip_proof else engine_written, # Same tuple, no gate: when the skip fires the probe still runs, purely so # the accepted park's zero-diff answer can be journaled (#676). diff --git a/tests/conftest.py b/tests/conftest.py index 159097b5..3420cc3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1121,6 +1121,7 @@ def dev_effect( write_src: bool = True, closes_deferred: object = None, operator_actions: object = None, + park_asserted: object = OMIT, deferred=None, ): """Simulate a successful bmad-dev-auto session: it self-finalizes the spec @@ -1179,6 +1180,9 @@ def effect(spec: SessionSpec) -> SessionResult: "verification": [], "escalations": [], "followup_review_recommended": followup_review, + "park_asserted": ( + final_status == "awaiting-operator" if park_asserted is OMIT else park_asserted + ), }, ) diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index 9816ead2..7bf0430c 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -182,6 +182,7 @@ def test_synth_success_maps_baseline_revision(tmp_path): assert rj["spec_file"] == str(sp) assert rj["baseline_commit"] == "abc123def456abc123def456abc123def456abcd" assert rj["escalations"] == [] + assert rj["park_asserted"] is False assert "dw_ids" not in rj @@ -368,6 +369,7 @@ def test_synth_awaiting_operator_is_terminal_and_folds_actions(tmp_path): rj = out.result_json assert rj is not None and rj["status"] == "awaiting-operator" assert rj["operator_actions"] == ["buy example.com", "publish the TXT record"] + assert rj["park_asserted"] is True def test_synth_awaiting_operator_synthesizes_no_escalation(tmp_path): @@ -381,6 +383,68 @@ def test_synth_awaiting_operator_synthesizes_no_escalation(tmp_path): assert out.result_json["escalations"] == [] assert "followup_review_recommended" not in out.result_json + assert out.result_json["park_asserted"] is False + + +def test_synth_park_assertion_uses_only_the_last_real_marker(tmp_path): + """A fenced example and an older genuine park cannot authorize a later result.""" + sp = _spec( + tmp_path / "s.md", + status="awaiting-operator", + auto_run=None, + actions="['do it']", + body_extra=( + "\n```md\n## Auto Run Result\n\nStatus: awaiting-operator\n```\n" + "\n## Auto Run Result\n\nStatus: awaiting-operator\n" + "\n## Auto Run Result\n\nStatus: done\n" + ), + ) + + assert ( + devcontract.synthesize_result(sp, story_key="1-1-a").result_json["park_asserted"] is False + ) + + +def test_synth_repaired_park_marker_cannot_assert_session_ownership(tmp_path): + sp = _spec( + tmp_path / "s.md", + status="awaiting-operator", + auto_run=None, + actions="['do it']", + body_extra=( + "\n## Auto Run Result\n\nStatus: awaiting-operator\n\n" + f"{devcontract.ORCHESTRATOR_SYNTH_NOTE}\n" + ), + ) + + assert ( + devcontract.synthesize_result(sp, story_key="1-1-a").result_json["park_asserted"] is False + ) + + +def test_synth_preexisting_genuine_park_marker_cannot_assert_session_ownership(tmp_path): + sp = _spec( + tmp_path / "s.md", + status="awaiting-operator", + auto_run="awaiting-operator", + actions="['do it']", + ) + + rj = devcontract.synthesize_result( + sp, + story_key="1-1-a", + park_marker_session_authored=False, + ).result_json + + assert rj["park_asserted"] is False + + +def test_auto_run_result_fingerprint_detects_an_identical_appended_marker(): + marker = "## Auto Run Result\n\nStatus: awaiting-operator\n" + + assert devcontract.auto_run_result_fingerprint(marker) != ( + devcontract.auto_run_result_fingerprint(marker + "\n" + marker) + ) @pytest.mark.parametrize( diff --git a/tests/test_engine.py b/tests/test_engine.py index 7ef3e4cd..762214fb 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1521,14 +1521,6 @@ def transient_first_fault(bound_task): return real_resolve(bound_task) monkeypatch.setattr(engine, "_dispatched_spec_for_attempt", transient_first_fault) - # The phase-entry park-eligibility read is a SECOND, unrelated consumer of the - # same resolver (`_park_eligible_at_dispatch`, DW-1) and would otherwise absorb - # the injected fault, handing the binder a clean second observation and - # inverting exactly what this row measures. Pin it out so `observations` counts - # the binder alone — this test is about prompt construction and recovery - # ownership, not about whether the story could newly elect a park. - monkeypatch.setattr(engine, "_park_eligible_at_dispatch", lambda _task: False) - assert engine._dev_phase(task) assert observations == 1 @@ -1888,6 +1880,7 @@ def _dev_verify_crash_state(project, engine, spec_status: str) -> tuple[str, Pat "baseline_commit": baseline, "escalations": [], "followup_review_recommended": False, + "park_asserted": False, }, ) ) @@ -3138,59 +3131,10 @@ def test_park_without_usable_actions_is_repaired_not_committed(project): assert "story-awaiting-operator" not in kinds and "story-done" in kinds -def test_dispatch_over_an_already_parked_spec_is_not_park_eligible(project): - """DW-1's engine half: the proof-of-work skip is authorized by an expectation - the orchestrator records at dispatch, and a story whose bound spec ALREADY - reads `awaiting-operator` cannot newly elect a park — whatever the session - that runs next leaves behind, the declaration on disk when it launched was - someone else's. - - The answer is captured on the fresh entry into `_dev_phase`, on the same - `resume_result is None` condition as `baseline_commit`, and persisted, so a - crash-replayed attempt reads back the same expectation rather than - re-deriving one from the tree the replayed session already wrote. - - Ablation (measured): drop the `!= AWAITING_OPERATOR` test and this fails — - the re-drive becomes eligible and #676's relaxation applies to a session that - inherited its park. Note what this row does NOT detect: moving the capture out - of the `resume_result is None` block leaves it green, because the parked spec - is on disk before the phase starts and attempt 1 therefore observes the same - status either way. The anchor is pinned one row down, by - `test_park_eligibility_is_captured_once_per_phase_not_per_attempt`, which is - the row that reddens on that mutation.""" - write_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, [dev_effect(project, "1-1-a")], policy=_park_policy()) - recorded = spec_path(project, "1-1-a") - write_spec( - recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS - ) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - engine.state.tasks[task.story_key] = task - - engine._dev_phase(task) - - assert task.park_eligible is False - assert load_state(engine.run_dir).tasks["1-1-a"].park_eligible is False - - def test_inherited_park_is_refused_end_to_end_through_the_engine(project): - """The JOIN, which both halves being pinned separately does not cover: that - `_verify_dev_artifacts` actually forwards `task.park_eligible` into - `verify_dev`. Its sibling row stops at the flag, and every refusal row in - `test_verify.py` hand-passes `park_eligible=False` straight into the gate — so - the one wiring point between them was untested, and the whole fix could be - reverted there with the suite green. - - Driven through the engine's own binding lifecycle: the story's spec_file is - bound to a spec ALREADY at `awaiting-operator`, so eligibility is reached via - the bound branch (every other `engine.run()`-level park row reaches it - unbound, and therefore eligible). The re-driven session writes no code and - re-declares the same park — the inherited-park shape — and must NOT verify - green. - - Ablation: replace `park_eligible=task.park_eligible` with the literal `True` - in `_verify_dev_artifacts` and this row fails; without it that mutation passes - the entire suite.""" + """A markerless/inherited result must reach the strict selector through the + engine and owe ordinary proof-of-work. Setting its synthesized assertion true + makes the residue-free park verify, so the negative gate discriminates.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "awaiting-operator"}) engine, _ = make_engine( project, @@ -3201,6 +3145,7 @@ def test_inherited_park_is_refused_end_to_end_through_the_engine(project): final_status="awaiting-operator", operator_actions=ACTIONS, write_src=False, + park_asserted=False, ) ] * 3, @@ -3221,93 +3166,16 @@ def test_inherited_park_is_refused_end_to_end_through_the_engine(project): with pytest.raises(RunPaused): engine._dev_phase(task) - assert task.park_eligible is False reasons = [e["reason"] for e in engine.journal.entries() if e["kind"] == "dev-decision"] assert reasons and all(r == "no changes in worktree since baseline commit" for r in reasons) # the waiver never fired, so nothing was journaled as a skipped gate assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] -def test_dispatch_with_no_bound_spec_is_park_eligible(project): - """The ordinary case, not a fallback: a story's first attempt has no - `spec_file` yet, so there is no earlier declaration for it to inherit and the - #676 relaxation must remain available. Fail-CLOSED applies to uncertainty - about a spec that exists, not to the absence of one.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - - assert engine._park_eligible_at_dispatch(StoryTask(story_key="1-1-a", epic=1)) is True - - -def test_park_eligibility_fails_closed_on_an_unresolvable_binding(project): - """The OTHER fail-closed arm, and a genuinely separate one: this is the - `bound is None` refusal from `_dispatched_spec_for_attempt` (a symlinked - binding, the shape it exists to refuse), not the later `fm is None` OSError - arm its sibling row covers. A spec_file that will not resolve to a trusted - regular file is a spec whose status the orchestrator does not know, and an - unknown status must not authorize waiving proof-of-work. - - Ablation: invert this arm to `return True` and this row fails while the whole - rest of the suite stays green — nothing else reaches it, which is why it - needed its own row rather than sharing the unreadable-spec one.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - real = spec_path(project, "1-1-a") - write_spec(real, "ready-for-dev", rev_parse_head(project.project)) - link = real.parent / "spec-1-1-a-symlink.md" - link.symlink_to(real) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(link)) - - # the binding resolves to nothing usable, even though the TARGET is a - # perfectly readable non-parked spec — it is the binding that is untrusted - assert engine._dispatched_spec_for_attempt(task) is None - assert engine._park_eligible_at_dispatch(task) is False - - -def test_park_eligibility_fails_closed_on_an_unreadable_spec(project): - """Observation degrades, and here degrading means denying the relaxation: a - bound spec the orchestrator cannot read is a spec whose status it does not - know, and an unknown status must not authorize skipping proof-of-work. The - skip is what would be lost, not the park — an honest park with a real diff - still passes the ordinary gate. - - Silent it is not: the read goes through `_observed_frontmatter`, so the skip - lands a `spec-read-failed` entry naming this site.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - recorded = spec_path(project, "1-1-a") - write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - - def boom(_path): - raise OSError("spec vanished mid-read") - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(verify, "read_frontmatter", boom) - assert engine._park_eligible_at_dispatch(task) is False - - failures = [e for e in engine.journal.entries() if e["kind"] == "spec-read-failed"] - assert [e["site"] for e in failures] == ["park-eligibility"] - - -def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): - """A fixable repair deliberately keeps the previous session's tree, so the - malformed park it is repairing is on disk when it launches. Re-observing - eligibility per ATTEMPT would therefore make every such repair ineligible, - and its fix — one frontmatter block, which proof-of-work already excludes — - would fail the gate it just re-armed. The expectation is anchored to the - phase, on the same `resume_result is None` condition as `baseline_commit`, - precisely so the expectation and the diff it guards cannot disagree. - - Both sessions run with `write_src=False`, which is what makes this row - evidence: the tree never holds any code residue, so the ONLY thing that can - carry the repair past proof-of-work is the retained eligibility. - - Ablation (measured, not assumed): move `task.park_eligible = ...` out of the - `resume_result is None` block and into `_dev_phase`'s per-attempt branch, and - attempt 2 re-observes the parked spec attempt 1 left behind, turns ineligible, - and its `dev-decision` reads exactly `no changes in worktree since baseline - commit` -> DEFER. Note what the row then fails ON: the defer's spec-restore - finds the binding unusable and raises `RunPaused`, so the visible surface is a - pause, not the assertion below. The refusal is the cause and the journal - records it; the pause is its consequence.""" +def test_fixable_retry_uses_the_repair_sessions_park_assertion(project): + """A fixable retry keeps the prior tree but authorizes only from the repair + session's own result. Its genuine assertion carries a residue-free corrected + park through without re-observing retained frontmatter.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, adapter = make_engine( project, @@ -3338,7 +3206,6 @@ def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): assert engine._dev_phase(task) is True - assert task.park_eligible is True assert len(adapter.sessions) == 2 # the malformed park, then its repair # the repair's park was ACCEPTED with the gate waived, on a tree that holds no # code at all — the whole point of retaining the phase's answer @@ -3346,27 +3213,9 @@ def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): assert [(e["attempt"], e["zero_diff"]) for e in records] == [(2, True)] -def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): - """Crash replay: the host died after the dev session finished and before its - result was consumed, so `_finish_inflight` resets the task to PENDING and - re-enters `_dev_phase` with the recorded result instead of a session - (`engine.py`'s `resumable` arm). The fresh-entry block is skipped wholesale on - that path, which is exactly why eligibility is captured there — a replayed - attempt must read back the answer the DEAD phase recorded, never derive a new - one from the tree the session it is replaying already wrote. - - The setup makes the two answers differ: the spec on disk is ALREADY parked - (the replayed session's own work), so a re-observation at this point returns - False and the residue-free tree would then owe proof-of-work it cannot show. - The persisted `True` is the only thing that carries the park through. - - Ablation: move `task.park_eligible = self._park_eligible_at_dispatch(task)` - out of `_dev_phase`'s `if resume_result is None:` block and this fails — the - replay re-observes its own parked spec, turns ineligible, and the park is - refused for "no changes in worktree since baseline commit". This is the row - the sibling capture-once test explicitly does NOT cover: that one measures a - second ATTEMPT inside a live phase, this one a replayed phase with no - attempt of its own.""" +def test_replayed_attempt_reuses_the_persisted_park_assertion(project): + """Crash replay consumes the persisted synthesized result without launching + or re-deriving authority from the parked spec now on disk.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, adapter = make_engine(project, [], policy=_park_policy()) baseline = rev_parse_head(project.project) @@ -3379,8 +3228,6 @@ def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): task.spec_file = str(sp) task.baseline_commit = baseline task.baseline_untracked = untracked - # what the dead phase recorded at ITS dispatch, when the spec was unparked - task.park_eligible = True engine.state.tasks[task.story_key] = task result_json = { "workflow": "auto-dev", @@ -3389,6 +3236,7 @@ def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): "baseline_commit": baseline, "escalations": [], "followup_review_recommended": False, + "park_asserted": True, } # the persisted record the resume arm replays FROM — `_accept_current_dev_session` # latches it as the accepted tree owner, so its task_id has to be the one the @@ -3408,7 +3256,6 @@ def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): # the recorded result replaced the session: nothing was dispatched, so the # only observation available was the persisted one assert adapter.sessions == [] - assert task.park_eligible is True records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True)] @@ -3448,9 +3295,10 @@ def test_no_waiver_record_when_a_later_check_inside_the_gate_rejects_the_park(pr task.spec_file = str(sp) task.baseline_commit = baseline task.baseline_untracked = sorted(verify.untracked_files(project.project)) - task.park_eligible = True - - outcome = engine._verify_dev_artifacts(task, {"workflow": "auto-dev", "spec_file": str(sp)}) + outcome = engine._verify_dev_artifacts( + task, + {"workflow": "auto-dev", "spec_file": str(sp), "park_asserted": True}, + ) # refused for the sprint pair, NOT for proof-of-work: the waiver did fire assert not outcome.ok and outcome.retryable @@ -3505,55 +3353,15 @@ def test_the_waiver_record_stands_when_a_later_stage_rejects_the_park(project): assert len(adapter.sessions) == 2 -def test_eligible_phase_waives_on_a_different_spec_than_it_was_authorized_over(project): - """CHARACTERIZATION of behavior this change deliberately LEAVES OPEN — not a - guarantee, and not a gate. It is the shipped answer to the intent's I/O row - "Eligible phase, attempt resolves a DIFFERENT spec", and it is folded into the - first `deferred` entry on this change's spec ("the same authorization is also - never re-validated against spec IDENTITY"). A later change that closes that - deferral is EXPECTED to rewrite this row rather than be blocked by it. - - What it pins: `park_eligible` is a PHASE-level authorization answering one - question about ONE observation — was `task.spec_file` already parked at the - instant the phase was dispatched? Here it was not (the binding is a - `ready-for-dev` spec), so the phase is eligible. The session then returns a - result naming a DIFFERENT spec that was already at `awaiting-operator` before - the phase began, and writes no code at all. The authorization is not - re-validated against that identity, so the waiver is spent on an inherited - park declaration the phase was never authorized over, the residue-free tree is - accepted, and the attempt is journaled as a waived gate. - - ENGINE layer, and the choice is forced rather than preferred: `verify_dev` - takes `park_eligible` as a bare argument and has no notion of the phase - binding at all, so at that layer "the authorization was computed about another - spec" is not expressible — a caller can only assert the value it just passed - in. Only `_dev_phase` holds both halves: it computes eligibility from - `task.spec_file` at fresh entry and later hands the session's own - `result_json["spec_file"]` to the gate. Nothing between the two compares them, - which is precisely the finding. (No binding or roots gate refuses the - construction: the foreign spec sits inside `implementation_artifacts`, so - `spec_within_roots` admits it, and no verify gate reads `result.json`'s - `story_key`.) - - Ablation, measured rather than predicted: bind eligibility to the returned - spec identity in `verify_dev` with - `skip_proof = parked and park_eligible and - (not task.spec_file or str(spec_path) == task.spec_file)` — and this row fails - with `ScriptExhausted: no scripted result for session 1-1-a-dev-2`. Note the - surface it fails on, because it is a consequence and not the assertion below: - the waiver no longer fires, the residue-free tree owes a diff it never - produced, `verify_dev` refuses it with "no changes in worktree since baseline - commit", and the engine asks for the retry the one-entry script cannot supply. - The refusal is the cause and the `dev-decision` journal entry records it; the - exhausted script is only how the retry becomes visible. That failure is the - expected shape of CLOSING the deferral, which is why this row is - characterization rather than warranty — close it and rewrite this row.""" +def test_asserted_result_selects_the_spec_named_by_that_result(project): + """The assertion belongs to the synthesized result, while the parked half is + independently observed from the result's claimed spec path.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) baseline = rev_parse_head(project.project) - # the phase's binding: NOT parked, so the dispatch-time answer is "eligible" + # the phase's prior binding is intentionally a different spec bound = spec_path(project, "1-1-a") write_spec(bound, "ready-for-dev", baseline) - # somebody else's park, on disk before this phase ever starts + # the result names this separately parked spec inherited = spec_path(project, "1-2-b") write_spec(inherited, "awaiting-operator", baseline, operator_actions=ACTIONS) @@ -3569,12 +3377,11 @@ def resolves_the_other_spec(_spec): "baseline_commit": baseline, "escalations": [], "followup_review_recommended": False, + "park_asserted": True, }, ) - # exactly ONE scripted session: the shipped behavior accepts on the first - # attempt, and under the ablation below the engine's request for a second is - # the whole signal + # exactly one result owns the assertion and claims the parked spec engine, adapter = make_engine(project, [resolves_the_other_spec], policy=_park_policy()) task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(bound)) engine.state.tasks[task.story_key] = task @@ -3582,10 +3389,9 @@ def resolves_the_other_spec(_spec): assert engine._dev_phase(task) is True assert len(adapter.sessions) == 1 # accepted on the first attempt, never retried - # authorized over the bound spec... - assert task.park_eligible is True + # the bound spec remains unchanged... assert read_frontmatter(bound)["status"] == "ready-for-dev" - # ...and spent on the other one, which the gate then rebound the task to + # ...and the result's spec becomes the accepted task binding assert task.spec_file == str(inherited) records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True)] @@ -6260,6 +6066,9 @@ def test_expected_spec_withheld_from_labeled_workflow_session(project): ) PARK_HEAD = "If this story's acceptance criteria include actions only a HUMAN" PARK_NEVER_BLOCKED = "Never use the blocked status" +PARK_RESULT_ASSERTION = ( + "final Auto Run Result must also report the matching status: awaiting-operator" +) PARK_TAIL = "blocked halts the whole run, and this story is finished as far as you can take it." LEDGER_BOARD_JOIN = "their status and resolution. sprint-status.yaml is owned by the orchestrator" BOARD_REDIRECT_JOIN = "not proof that the work is verified. If the story cannot be finished" @@ -6448,6 +6257,7 @@ def test_board_clause_rides_every_dev_leg_ahead_of_the_park_clause(project, tmp_ for prompt in (bare, explicit, restore, repair): assert BOARD_OWNED in prompt assert PARK_HEAD in prompt + assert PARK_RESULT_ASSERTION in prompt assert prompt.index(BOARD_OWNED) < prompt.index(PARK_HEAD) assert BOARD_PARK_JOIN in prompt # the board→park separator itself assert prompt.endswith(PARK_TAIL) # nothing may be appended after the park diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index d7737e1c..a76afa38 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -3924,10 +3924,28 @@ def test_frontmatter_fallback_synthesizes_on_second_stable_stop(tmp_path, monkey assert rj["baseline_commit"] == "abc123" assert rj["synthesized_from_frontmatter"] is True assert rj["escalations"] == [] + assert rj["park_asserted"] is False # the harvest pass writes no breadcrumb assert len(_breadcrumbs(adapter)) == 1 +def test_frontmatter_fallback_park_cannot_assert_session_ownership(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + markerless_park = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n" + ) + (impl / "spec-3-1-foo.md").write_text(markerless_park) + + assert adapter._result_json(_dev_handle(), _dev_spec(tmp_path), wait=True) is None + rj = adapter._result_json(_dev_handle(), _dev_spec(tmp_path), wait=True) + + assert rj["status"] == "awaiting-operator" + assert rj["park_asserted"] is False + assert rj["synthesized_from_frontmatter"] is True + + def test_frontmatter_fallback_stamps_story_key_and_dw_ids(tmp_path, monkeypatch): """The fallback shares _synthesize_from with the marker path, so bundle dev sessions get their exported dw ids stamped for verify_dev_bundle.""" @@ -4735,6 +4753,67 @@ def test_expected_spec_synthesizes_its_own_marker_spec(tmp_path, monkeypatch): assert rj["spec_file"] == str(ours) +@pytest.mark.parametrize("known_spec", [False, True], ids=["scan", "known-spec"]) +def test_prelaunch_park_marker_survives_unrelated_touch_without_asserting_ownership( + tmp_path, monkeypatch, known_spec +): + """A whole-file mtime bump cannot lend a retained park marker to this session.""" + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + parked = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + ours.write_text(parked) + launch_floor = ours.stat().st_mtime_ns + 1 + spec = _dev_spec(tmp_path) + if known_spec: + spec = dataclasses.replace(spec, expected_spec=str(ours)) + + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + # Rewrite only the body before the retained marker, then lift whole-file mtime + # past launch. The old implementation treated that mtime as marker provenance. + ours.write_text(parked.replace("# Story", "# Story\n\nUnrelated post-launch touch.")) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["status"] == "awaiting-operator" + assert rj["park_asserted"] is False + + +def test_new_session_marker_asserts_park_after_launch_capture(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text("---\nstatus: in-progress\nbaseline_revision: abc123\n---\n\n# Story\n") + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is True + + def test_expected_spec_ignores_foreign_markerless_spec(tmp_path, monkeypatch): """Same regression through the #224 missing-marker fallback, which #261 predates but which added a second identical mtime-only scan of the shared dir. A foreign diff --git a/tests/test_model.py b/tests/test_model.py index 978e718f..fbe7060d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -189,57 +189,15 @@ def test_followup_review_recommended_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).followup_review_recommended is False -def test_park_eligible_round_trips(): - """The dispatch-time expectation gating the park's proof-of-work skip is - captured once per dev phase, so it has to survive the crash/resume boundary — - a replayed attempt that re-derived it would answer about the spec the session - it is replaying already parked.""" - task = StoryTask(story_key="1-1-a", epic=1, park_eligible=True) - assert StoryTask.from_dict(task.to_dict()).park_eligible is True - - -def test_park_eligible_defaults_false_for_legacy_state(): - """And it defaults to the FAIL-CLOSED value, which is the load-bearing half: a - run resumed from a state.json written before the field existed has no recorded - answer, and the absent one must deny the skip rather than grant it. Defaulting - True would make every legacy resume the exact DW-1 hole this field closes.""" +def test_legacy_park_eligible_state_loads_but_is_not_persisted(): + """Retired authorization state is tolerated but cannot influence new runs.""" doc = StoryTask(story_key="1-1-a", epic=1).to_dict() - del doc["park_eligible"] # state.json from before the field existed - assert StoryTask.from_dict(doc).park_eligible is False + doc["park_eligible"] = True + loaded = StoryTask.from_dict(doc) -@pytest.mark.parametrize( - "stored", - ["false", "true", "", 0, 1, None, [], ["x"], {}], - ids=["str-false", "str-true", "str-empty", "int-0", "int-1", "null", "list", "list-x", "dict"], -) -def test_park_eligible_only_a_real_boolean_true_authorizes_the_waiver(stored): - """`from_dict` reads this one field STRICTLY, and the asymmetry is the reason. - Every sibling bool on the task restores bookkeeping; this one authorizes the - dev gate's proof-of-work check to be WAIVED, so a wrong `False` costs one - retryable refusal while a wrong `True` re-opens the inheritance hole the field - exists to close. - - Under the ordinary `bool(...)` spelling every truthy non-boolean grants that - waiver, and the likeliest one is the string `"false"` — a hand-edited - state.json, or any bridge that stringifies JSON scalars — for which - `bool("false")` is True. The `"true"`/`1` rows are here for the same reason - from the other side: reading them as authorization would be GUESSING that a - non-boolean meant yes, and fail-closed does not guess. - - Ablation: restore `bool(d.get("park_eligible", False))` and the `str-false`, - `str-true`, `int-1` and `list-x` rows all fail.""" - doc = StoryTask(story_key="1-1-a", epic=1).to_dict() - doc["park_eligible"] = stored - assert StoryTask.from_dict(doc).park_eligible is False - - -def test_park_eligible_round_trips_the_authorized_value(): - """The other direction, so strictness is not mistaken for "always False": a - real JSON `true` — the only value `to_dict` ever writes — survives.""" - doc = StoryTask(story_key="1-1-a", epic=1, park_eligible=True).to_dict() - assert doc["park_eligible"] is True - assert StoryTask.from_dict(doc).park_eligible is True + assert not hasattr(loaded, "park_eligible") + assert "park_eligible" not in loaded.to_dict() def test_verify_outcome_park_fields_are_absent_by_default(): diff --git a/tests/test_verify.py b/tests/test_verify.py index c5ea7194..4fdbeee0 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -40,8 +40,11 @@ def make_task(paths, story_key="1-1-a"): return task -def dev_result(sp): - return {"workflow": "auto-dev", "spec_file": str(sp)} +def dev_result(sp, *, park_asserted: object = False): + result = {"workflow": "auto-dev", "spec_file": str(sp)} + if park_asserted is not OMIT: + result["park_asserted"] = park_asserted + return result def _codec_rejects_bad_byte() -> bool: @@ -853,11 +856,9 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): (awaiting-operator, awaiting-operator) either way — so the flag must not reach the outcome, and the `True` leg is what would catch a future edit that let it. - `park_eligible=True` is the engine-side half of the selector the skip now - needs: the orchestrator's answer, recorded at dispatch, that this phase could - newly ELECT a park rather than inherit one (DW-1). Without it this row fails - on proof-of-work — which is exactly what - `test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work` asserts. + `park_asserted=True` is the current session's independent marker assertion. + Without it this row fails on proof-of-work — exactly what the strict-value + sibling asserts. `park_zero_diff` is the accepted skip's record: the tree really was residue-free, and the outcome says so instead of the skip passing silently (DW-6).""" task, sp = _residue_free( @@ -867,10 +868,9 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=review_enabled, operator_park=True, - park_eligible=True, ) assert out.ok @@ -878,14 +878,14 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): assert out.park_proof_skipped is True and out.park_zero_diff is True -@pytest.mark.parametrize("park_eligible", [False, True]) +@pytest.mark.parametrize("park_asserted", [False, True]) @pytest.mark.parametrize("operator_park", [False, True]) @pytest.mark.parametrize( "status, sprint, review_enabled", [("in-review", "review", True), ("done", "done", False)], ) def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( - project, status, sprint, review_enabled, operator_park, park_eligible + project, status, sprint, review_enabled, operator_park, park_asserted ): """The control for the row above, and the reason that row proves anything: the SAME residue-free tree at an ordinary terminal must still be refused. Without @@ -906,13 +906,9 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( `test_engine.py` that are about harvest, not about park. A run with parking enabled but a session that finished ordinarily must still owe a diff. - `park_eligible` is parametrized for the identical reason, one selector later: - the skip is now `parked and park_eligible`, so the engine-side half is the - other input that could widen it past the park. Rewriting it as - `None if park_eligible` — the dispatch-time expectation alone, ignoring the - observed status — is green everywhere without this dimension, and it would let - every ordinary session on a story that had never parked skip proof-of-work - entirely. Neither half selects the skip on its own. + `park_asserted` is parametrized for the identical reason, one selector later: + the assertion alone must not widen the waiver past an observed park. Neither + half selects the skip on its own. Ablation: delete the `if extra_exclude is not None and task.baseline_commit:` proof-of-work block in `_verify_shared_gates` and all four rows fail on @@ -923,10 +919,9 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=park_asserted), review_enabled=review_enabled, operator_park=operator_park, - park_eligible=park_eligible, ) assert not out.ok and out.retryable @@ -935,7 +930,12 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( assert out.park_proof_skipped is False and out.park_zero_diff is None -def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): +@pytest.mark.parametrize( + "park_asserted", + [OMIT, False, None, 0, 1, "true"], + ids=["missing", "false", "null", "zero", "one", "truthy-string"], +) +def test_verify_dev_unasserted_park_with_no_residue_owes_proof_of_work(project, park_asserted): """DW-1, and the reason the row above needs its new argument: the skip used to be selected entirely by state a fresh session can INHERIT — the policy flag plus the spec's own status. A spec an earlier attempt left at @@ -943,18 +943,12 @@ def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): nothing at all, so a re-drive over it selected #676's relaxation and verified green on someone else's park declaration. - `park_eligible=False` is the orchestrator saying "the bound spec was ALREADY - parked when I dispatched this". The park is not refused for being inherited — - it is merely held to proof-of-work like every other terminal, and this tree has - none to show. Note the reason: the ordinary proof-of-work message, not a - park-specific refusal, because the eligibility flag gates the SKIP and nothing - else. - - This row and `test_verify_dev_park_with_no_code_residue_passes` differ in - exactly one argument over byte-identical state, which is what makes either one - evidence. Ablation: rewrite the selector as `skip_proof = parked` (drop the - `and park_eligible`) and this fails on `assert not out.ok` while its twin stays - green — the pre-DW-1 behavior exactly.""" + Missing, false, null, numeric, and truthy-string assertions are all held to + proof-of-work like every other terminal, and this tree has none to show. The + ordinary proof-of-work message proves the assertion gates only the waiver. + + Ablation: rewrite the selector as `skip_proof = parked` and every row fails on + `assert not out.ok` while the asserted twin stays green.""" task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) @@ -962,10 +956,9 @@ def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=park_asserted), review_enabled=False, operator_park=True, - park_eligible=False, ) assert not out.ok and out.retryable @@ -982,8 +975,8 @@ def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): on its own and passes — status pair, actions list, workflow tag, baseline match and sprint pair all still select on the OBSERVED status exactly as before. - This is the row that would catch the over-correction: making `park_eligible` - select the park's status pair as well (rather than only the skip) turns a + This is the row that catches making `park_asserted` select the park's status + pair as well (rather than only the skip), which turns a legitimate repair-then-park into a status mismatch, and refuses work that was actually done. `park_zero_diff` stays None because no skip fired — a passing park is not automatically a recorded one.""" @@ -995,7 +988,6 @@ def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): dev_result(sp), review_enabled=False, operator_park=True, - park_eligible=False, ) assert out.ok @@ -1018,10 +1010,9 @@ def test_verify_dev_elected_park_with_code_residue_records_a_non_zero_diff(proje out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1064,10 +1055,9 @@ def boom(*_a, **_kw): out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1115,10 +1105,9 @@ def test_verify_dev_park_zero_diff_is_unknown_when_git_refuses_the_probe(project out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1181,10 +1170,9 @@ def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(projec out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1218,10 +1206,9 @@ def test_verify_dev_park_zero_diff_excludes_the_orchestrators_own_writes(project out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, engine_written=("ledger.md",), ) @@ -1248,13 +1235,11 @@ def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) - rj = {"workflow": "quick-dev", "spec_file": str(sp)} + rj = {"workflow": "quick-dev", "spec_file": str(sp), "park_asserted": True} - # park_eligible=True so the skip really is in place: without it proof-of-work + # park_asserted=True so the skip really is in place: without it proof-of-work # would also refuse this tree and the row would pass for a compound reason. - out = verify.verify_dev( - task, project, rj, review_enabled=False, operator_park=True, park_eligible=True - ) + out = verify.verify_dev(task, project, rj, review_enabled=False, operator_park=True) assert not out.ok and out.retryable assert "auto-dev" in out.reason @@ -1281,16 +1266,15 @@ def test_verify_dev_park_still_faces_the_baseline_match_gate(project): baseline="deadbeef" * 5, ) - # Same reason as the workflow-tag row above: with park_eligible left False the + # Same reason as the workflow-tag row above: with park_asserted left False the # tree would also owe proof-of-work, and baseline-match would stop being the # only thing that could refuse here. out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert not out.ok and out.retryable @@ -7366,13 +7350,7 @@ def test_engine_written_is_keyword_only_on_all_dev_verifiers(): parameter = inspect.signature(fn).parameters["engine_written"] assert parameter.kind is inspect.Parameter.KEYWORD_ONLY assert "operator_park" in inspect.signature(verify.verify_dev).parameters - # The park skip's second selector (DW-1). Keyword-only for the same reason - # `engine_written` is: `verify_dev`'s positional tail is `review_enabled`, and - # a positional eligibility flag would be one transposed argument away from - # silently authorizing the skip on every leg. - park_eligible = inspect.signature(verify.verify_dev).parameters["park_eligible"] - assert park_eligible.kind is inspect.Parameter.KEYWORD_ONLY - assert park_eligible.default is False + assert "park_eligible" not in inspect.signature(verify.verify_dev).parameters # --------------------------------------------------- the git support floor (GIT_FLOOR) From 8221ebefbbff37c1a5cd61a34221ca482e288fb6 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 01:52:36 -0700 Subject: [PATCH 28/45] Harden park marker provenance --- src/bmad_loop/adapters/generic.py | 39 +++++-- src/bmad_loop/devcontract.py | 2 +- tests/test_devcontract.py | 23 ++-- tests/test_generic_tmux.py | 172 ++++++++++++++++++++++++++++++ tests/test_opencode_http.py | 27 +++++ 5 files changed, 244 insertions(+), 19 deletions(-) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 3d383dc5..e1538fec 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -1336,9 +1336,11 @@ def _configure_dev_knobs(self) -> None: # Marker identities present immediately before each real session launch. # The adapter, not whole-file mtime, owns this attempt-relative evidence: # touching another part of a parked spec must not make its retained marker - # look session-authored. None means launch capture was incomplete and - # therefore fails closed for the waiver. - self._launch_auto_run_results: dict[str, dict[str, tuple[int, str]] | None] = {} + # look session-authored. A task-level None means directory enumeration was + # incomplete; a path-level None means that one launch file was unreadable. + # Both fail closed at the affected scope without letting an unrelated bad + # Markdown file suppress a newly created, readable story spec. + self._launch_auto_run_results: dict[str, dict[str, tuple[int, str] | None] | None] = {} @staticmethod def _marker_path_key(path: Path) -> str: @@ -1361,18 +1363,19 @@ def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None: except OSError: complete = False - captured: dict[str, tuple[int, str]] = {} + captured: dict[str, tuple[int, str] | None] = {} for path in paths: + key = self._marker_path_key(path) try: text = path.read_text(encoding="utf-8") except FileNotFoundError: continue except (OSError, UnicodeDecodeError): - complete = False + captured[key] = None continue fingerprint = devcontract.auto_run_result_fingerprint(text) if fingerprint[0]: - captured[self._marker_path_key(path)] = fingerprint + captured[key] = fingerprint self._launch_auto_run_results[spec.task_id] = captured if complete else None def start_session(self, spec: SessionSpec) -> SessionHandle: @@ -1386,9 +1389,9 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> bool: """Whether the live marker differs from this session's launch marker.""" if spec.task_id not in self._launch_auto_run_results: - # Direct read-back callers predate launch capture; production always - # enters through start_session. Preserve that diagnostic/test seam. - return True + # Production always enters through start_session. A direct diagnostic + # read-back has no attempt-relative evidence and therefore fails closed. + return False captured = self._launch_auto_run_results[spec.task_id] if captured is None: return False @@ -1396,8 +1399,22 @@ def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> b current = devcontract.auto_run_result_fingerprint(spec_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError): return False - launch = captured.get(self._marker_path_key(spec_path), (0, "")) - return current != launch + key = self._marker_path_key(spec_path) + if key in captured: + launch = captured[key] + if launch is None: + return False + # Appending another marker is authorship even when its text repeats; + # an in-place rewrite is authorship when the final section changes. + # Deleting older sections while retaining the same final marker is not. + return current[0] > launch[0] or (current[0] == launch[0] and current[1] != launch[1]) + + # A marker moved or copied from another launch path is inherited evidence, + # not a marker authored by this attempt. A genuinely new marker whose text + # happens to collide also fails closed; byte identity cannot prove authorship. + if current in (fingerprint for fingerprint in captured.values() if fingerprint): + return False + return current[0] > 0 def _probe_alive(self, handle: SessionHandle) -> bool | None: """Liveness of the session's native surface (tmux window, server diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 19c5e90b..6b229e4c 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -352,7 +352,7 @@ def synthesize_result( story_key: str | None, dw_ids: list[str] | None = None, plan_halt: bool = False, - park_marker_session_authored: bool = True, + park_marker_session_authored: bool = False, ) -> SynthResult: """Build the legacy result dict from the generic skill's on-disk spec. diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index 7bf0430c..24ec2063 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -363,7 +363,11 @@ def test_synth_awaiting_operator_is_terminal_and_folds_actions(tmp_path): auto_run="awaiting-operator", actions="['buy example.com', 'publish the TXT record']", ) - out = devcontract.synthesize_result(sp, story_key="1-1-a") + out = devcontract.synthesize_result( + sp, + story_key="1-1-a", + park_marker_session_authored=True, + ) assert out.status_consistent rj = out.result_json @@ -422,7 +426,7 @@ def test_synth_repaired_park_marker_cannot_assert_session_ownership(tmp_path): ) -def test_synth_preexisting_genuine_park_marker_cannot_assert_session_ownership(tmp_path): +def test_synth_genuine_park_marker_defaults_to_unasserted_without_session_provenance(tmp_path): sp = _spec( tmp_path / "s.md", status="awaiting-operator", @@ -430,11 +434,7 @@ def test_synth_preexisting_genuine_park_marker_cannot_assert_session_ownership(t actions="['do it']", ) - rj = devcontract.synthesize_result( - sp, - story_key="1-1-a", - park_marker_session_authored=False, - ).result_json + rj = devcontract.synthesize_result(sp, story_key="1-1-a").result_json assert rj["park_asserted"] is False @@ -447,6 +447,15 @@ def test_auto_run_result_fingerprint_detects_an_identical_appended_marker(): ) +def test_auto_run_result_fingerprint_detects_an_in_place_marker_rewrite(): + before = "## Auto Run Result\n\nStatus: done\n" + after = "## Auto Run Result\n\nStatus: awaiting-operator\n" + + assert devcontract.auto_run_result_fingerprint(before) != ( + devcontract.auto_run_result_fingerprint(after) + ) + + @pytest.mark.parametrize( "actions, expected, why", [ diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index a76afa38..ba2dc70f 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -4814,6 +4814,178 @@ def test_new_session_marker_asserts_park_after_launch_capture(tmp_path, monkeypa assert rj is not None and rj["park_asserted"] is True +def test_marker_readback_without_launch_capture_fails_closed(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + + rj = adapter._result_json(_dev_handle(), spec, wait=False) + + assert rj is not None and rj["park_asserted"] is False + + +def test_launch_capture_precedes_transport_that_writes_park_marker(tmp_path, monkeypatch): + """A child that finishes during transport startup still owns its new marker.""" + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text("---\nstatus: in-progress\nbaseline_revision: abc123\n---\n\n# Story\n") + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + + def launch(_adapter, _spec): + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + return _dev_handle(launched_ns=launch_floor) + + monkeypatch.setattr(generic.GenericAdapter, "start_session", launch) + + handle = adapter.start_session(spec) + rj = adapter._result_json(handle, spec, wait=False) + + assert rj is not None and rj["park_asserted"] is True + + +def test_in_place_marker_rewrite_asserts_session_ownership(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text( + "---\nstatus: done\nbaseline_revision: abc123\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: done\nFinished.\n" + ) + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is True + + +def test_deleting_older_marker_does_not_assert_retained_last_marker(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + final_marker = "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + prefix = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + ) + ours.write_text(prefix + "## Auto Run Result\n\nStatus: done\nOld.\n\n" + final_marker) + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text(prefix + final_marker) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is False + + +def test_moved_launch_marker_does_not_assert_session_ownership(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + old = impl / "spec-old.md" + ours = impl / "spec-3-1-foo.md" + old.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + launch_floor = old.stat().st_mtime_ns + 1 + spec = _dev_spec(tmp_path) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + old.rename(ours) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is False + + +def test_unrelated_unreadable_markdown_does_not_poison_new_spec_capture(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + (impl / "notes.md").write_bytes(b"\xff") + ours = impl / "spec-3-1-foo.md" + launch_floor = (impl / "notes.md").stat().st_mtime_ns + 1 + spec = _dev_spec(tmp_path) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is True + + +def test_unreadable_launch_spec_fails_closed_after_becoming_readable(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_bytes(b"\xff") + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is False + + def test_expected_spec_ignores_foreign_markerless_spec(tmp_path, monkeypatch): """Same regression through the #224 missing-marker fallback, which #261 predates but which added a second identical mtime-only scan of the shared dir. A foreign diff --git a/tests/test_opencode_http.py b/tests/test_opencode_http.py index 0088f48a..669348c7 100644 --- a/tests/test_opencode_http.py +++ b/tests/test_opencode_http.py @@ -2680,6 +2680,33 @@ def test_e2e_dev_synthesizes_terminal_spec(tmp_path, fake_opencode): assert_server_gone(rec) +def test_e2e_dev_retained_park_marker_is_not_reowned_by_unrelated_rewrite(tmp_path, fake_opencode): + """The HTTP transport also captures marker provenance before prompting.""" + launcher, rec = fake_opencode + adapter, impl = make_dev_adapter(tmp_path, binary=str(launcher)) + spec_path = impl / "spec-3-1-foo.md" + parked = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + spec_path.write_text(parked) + spec = make_dev_spec( + tmp_path, + rec, + "completed", + spec_path, + spec_text=parked.replace("# Story", "# Story\n\nUnrelated session edit."), + ) + + result = adapter.run(spec) + + assert result.status == "completed" + assert result.result_json["status"] == "awaiting-operator" + assert result.result_json["park_asserted"] is False + assert_server_gone(rec) + + def test_e2e_dev_stories_mode_resolves_by_id(tmp_path, fake_opencode, monkeypatch): """Folder+id dispatch (BMAD_LOOP_SPEC_FOLDER): the story spec is resolved at its deterministic id-keyed path — never via the mtime scan.""" From bf682b8ae324b7d1bcd9bc54dde01f3fb43c7a6d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 08:28:01 -0700 Subject: [PATCH 29/45] sweep dw2-session-authored-park-assertion: DW-46, DW-47 via bmad-loop --- src/bmad_loop/devcontract.py | 21 ++++++++++++++------- tests/test_runs.py | 8 ++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 6b229e4c..e1ade5f8 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -9,13 +9,20 @@ that turns that on-disk spec into the legacy result dict that verify.py / escalation.py already consume, so the rest of the pipeline stays unchanged. -DOCTRINE — never trust prose for a gate. The frontmatter `status:` read straight -off disk is authoritative; the `## Auto Run Result` prose is only used to route -the blocked→PAUSE decision and to carry a human-readable detail. Where the two -disagree we surface it (`status_consistent=False`) so the caller can fail safe -(treat a mismatch as a retry rather than silently proceeding). Every real -deterministic gate (git baseline, worktree-changed, sprint advancement, dw_id -match) still runs in verify.py against actual on-disk state. +DOCTRINE — never let terminal prose override populated frontmatter status. The +frontmatter `status:` read straight off disk is authoritative whenever it is +present. A genuine `## Auto Run Result` marker has three narrow roles: its +status/detail route the blocked→PAUSE decision; its status is a compatibility +fallback that may populate the synthesized result when frontmatter is blank or +missing; and proof that the current session authored the marker supplies +attempt ownership for `park_asserted`. From that compatibility result, `done` +alone may be reconciled onto disk, `blocked` routes to PAUSE, and +`awaiting-operator` remains subject to the on-disk frontmatter/status gate. +Where the marker and populated frontmatter disagree we surface it +(`status_consistent=False`) so the caller can fail safe (treat a mismatch as a +retry rather than silently proceeding). Every real deterministic gate (git +baseline, worktree-changed, sprint advancement, dw_id match) still runs in +verify.py against actual on-disk state. """ from __future__ import annotations diff --git a/tests/test_runs.py b/tests/test_runs.py index d64121c6..5fec67d7 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2781,7 +2781,8 @@ def test_restamp_code_root_aims_the_mirror_the_rearm_reads(tmp_path, recorded): _SPEC_WITH_ARR = ( - "---\ntitle: t\nstatus: blocked\n---\n\n## Intent\n\nbody\n" + "---\ntitle: t\nstatus: blocked\noperator_actions:\n" + " - publish the TXT record\n---\n\n## Intent\n\nbody\n" "\n## Auto Run Result\n\n- Status: blocked\n\nboom\n" ) @@ -2821,7 +2822,10 @@ def test_rearm_plain_mode_sets_ready_for_dev_and_clears_stale_latch(tmp_path): task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING assert task.restore_patch is None # stale latch cleared - assert "status: ready-for-dev" in spec.read_text() + text = spec.read_text() + assert "status: ready-for-dev" in text + assert verify.operator_actions_of(verify.read_frontmatter(spec)) == ("publish the TXT record",) + assert "## Auto Run Result" not in text # stale attempt authority stripped entry = [e for e in Journal(run_dir).entries() if e["kind"] == "story-escalation-resolved"][-1] assert entry["restore"] is False From 9213f9a647428d52bf0eb84a210a3b3b1ef9be44 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 09:56:34 -0700 Subject: [PATCH 30/45] sweep dw2-proof-probe-consistency: DW-48, DW-49, DW-50 via bmad-loop --- docs/FEATURES.md | 1 + src/bmad_loop/engine.py | 16 +++- src/bmad_loop/model.py | 9 ++ src/bmad_loop/stories_engine.py | 25 +++++- src/bmad_loop/verify.py | 100 ++++++++++++--------- tests/test_engine.py | 23 ++++- tests/test_portability_guard.py | 57 ++++++++++++ tests/test_stories_engine.py | 115 +++++++++++++++++++++++- tests/test_verify.py | 153 ++++++++++++++++++++++++++++++++ 9 files changed, 449 insertions(+), 50 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7c58fcde..dc601e0e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -65,6 +65,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). - Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and `park_asserted: true` in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced `## Auto Run Result` marker reporting `awaiting-operator`; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or `operator_actions:` while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as `park-proof-of-work-skipped`; `zero_diff` reports whether the waived gate would have found non-excluded residue (`true` means none, `false` means some, `null` means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later `story-awaiting-operator` event. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- A successful stories-mode plan halt is likewise journaled at the artifact-gate boundary as `plan-halt-proof-of-work-skipped`. Its `zero_diff` uses the same tri-state projection (`true` = the waived gate found no non-excluded residue, `false` = it found changes, `null` = Git could not answer), including the stories manifest/spec and orchestrator-written exclusions the live gate would have used. The record is emitted only after the `ready-for-dev` artifact verification passes and never substitutes for the independent `result.json` `plan_halt: true` marker that authorizes the halt; an absent marker or an earlier gate failure produces no waiver record. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index edfe1177..9766efc0 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -4896,8 +4896,8 @@ def _legacy_ledger_changed_before_harvest(self, task: StoryTask) -> bool: 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 keeps the path excluded, so uncertainty - never credits the engine's own append as session work. + 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 @@ -4913,12 +4913,20 @@ def _legacy_ledger_changed_before_harvest(self, task: StoryTask) -> bool: except ValueError: return False try: - return verify.path_changed_since( + changed = verify._changes_since( root, task.baseline_commit, - rel, + 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", diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 1bc3f8ff..93790bc2 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -949,6 +949,13 @@ class VerifyOutcome: # A waived gate is recorded whatever the probe managed to say; `None` is a # truthful field value, not a reason to withhold the record. park_zero_diff: bool | None = None + # Stories plan halts waive the same proof-of-work gate for a different + # reason: the accepted output is the plan spec itself. Keep their observation + # independent of the park-only pair above — the result.json `plan_halt` + # marker remains the authority for which leg ran, while this field reports + # only what the waived gate would have found. `True` / `False` / `None` have + # the same no-diff / diff / unknown meanings as `park_zero_diff`. + plan_halt_zero_diff: bool | None = None @classmethod def passed( @@ -956,11 +963,13 @@ def passed( *, park_proof_skipped: bool = False, park_zero_diff: bool | None = None, + plan_halt_zero_diff: bool | None = None, ) -> "VerifyOutcome": return cls( ok=True, park_proof_skipped=park_proof_skipped, park_zero_diff=park_zero_diff, + plan_halt_zero_diff=plan_halt_zero_diff, ) @classmethod diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 7d38ffde..1097a6ef 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -519,7 +519,7 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # rearm clears it by recorded kind, not by re-deriving from the basename. task.sentinel_kind = state.sentinel_kind self._journal_sentinel_detected(task.story_key, state) - return verify.verify_dev_stories( + outcome = verify.verify_dev_stories( task, self.workspace.paths, result_json, @@ -528,6 +528,29 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): plan_halt=plan_halt, engine_written=self._harvest_gate_exclude(task), ) + # The marker remains the independent authority that this was a deliberate + # plan halt. Journal the proof waiver only after every artifact gate passes; + # `zero_diff` is an observation of the skipped gate, never an input to it. + waiver_already_recorded = ( + plan_halt + and outcome.ok + and any( + entry.get("kind") == "plan-halt-proof-of-work-skipped" + and entry.get("story_key") == task.story_key + and entry.get("attempt") == task.attempt + and entry.get("generation", 0) == task.generation + for entry in self.journal.entries() + ) + ) + if plan_halt and outcome.ok and not waiver_already_recorded: + self.journal.append( + "plan-halt-proof-of-work-skipped", + story_key=task.story_key, + attempt=task.attempt, + generation=task.generation, + zero_diff=outcome.plan_halt_zero_diff, + ) + return outcome def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | None) -> bool: # A plan-halt leg produced only the plan (spec at ready-for-dev); there is diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 2376e7bb..5f79b27d 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -786,6 +786,7 @@ def _changes_since( baseline: str, exclude: tuple[str, ...] = (), *, + literal_path: str | None = None, baseline_untracked: list[str] | None = None, include_untracked: bool = True, ) -> bool | None: @@ -801,6 +802,11 @@ def _changes_since( ``observe_skipped_proof`` arm) files "the gate would have found changes" about a question git never answered. + ``literal_path`` selects the exact-path form used by + :func:`path_changed_since`; ``None`` selects the whole-tree form. Both forms + share this one quiet-diff invocation and the same untracked-fault handling, + while preserving their established pathspec and baseline-snapshot semantics. + This is the body BOTH proof arms reach, and by only one route: the `proof_of_work_probe` closure in :func:`_verify_shared_gates`, which is what actually makes "the observation measures exactly what the gate would have" @@ -810,19 +816,29 @@ def _changes_since( body decides is what an unanswerable git call looks like; each arm then reads that `None` under its own policy. - :func:`has_changes_since` is the fail-open COLLAPSE of this tri-state, kept for - the gates that want it — it folds `None` into `True` and is what a caller - should reach for unless it can act on "git would not answer".""" - rc, _ = _git(repo, "diff", "--quiet", baseline, "--", ".", *_exclude_specs(exclude)) + :func:`has_changes_since` and :func:`path_changed_since` are the fail-open + COLLAPSES of this tri-state — each folds `None` into `True` at its public + boolean boundary.""" + pathspecs = ( + (f":(literal){literal_path}",) + if literal_path is not None + else (".", *_exclude_specs(exclude)) + ) + rc, _ = _git(repo, "diff", "--quiet", baseline, "--", *pathspecs) if rc not in (0, 1): return None if rc != 0: return True if not include_untracked: return False - created = untracked_files(repo) + try: + created = untracked_files(repo) + except GitError: + return None if baseline_untracked is not None: created -= set(baseline_untracked) + if literal_path is not None: + return literal_path in created created = {p for p in created if not _path_under_any(p, exclude)} return bool(created) @@ -843,19 +859,19 @@ def path_changed_since( counting every ordinary untracked path. Ignored paths are absent from :func:`untracked_files` and therefore cannot become proof of work here. - Any non-zero diff result fails open toward "changed", matching what the - proof-of-work gate does with :func:`_changes_since`'s unanswerable `None` (and - what :func:`has_changes_since` collapses it to). The literal pathspec is + Both a diff refusal and an untracked-enumeration fault fail open toward + "changed", matching :func:`has_changes_since`. The literal pathspec is required for operator-configured ledger paths containing Git wildmatch - characters. + characters. The tri-state body owns that pathspec so this caller cannot drift + from whole-tree proof handling. """ - rc, _ = _git(repo, "diff", "--quiet", baseline, "--", f":(literal){rel}") - if rc != 0: - return True - untracked = untracked_files(repo) - if rel not in untracked: - return False - return baseline_untracked is None or rel not in set(baseline_untracked) + answer = _changes_since( + repo, + baseline, + literal_path=rel, + baseline_untracked=baseline_untracked, + ) + return True if answer is None else answer def attempt_dirty( @@ -3498,15 +3514,11 @@ def _verify_shared_gates( gate would have passed and one it would have refused are otherwise indistinguishable after the fact. - Exactly one of the two skipping legs asks for it, and the asymmetry is - deliberate rather than an omission: only sprint mode's PARK passes it. - ``verify_dev_stories``' plan halt skips the gate and observes nothing, because - it already has an independent cross-check a park has no equivalent for — a - clean plan-halt carries ``devcontract``'s ``plan_halt`` marker in its - result.json (``rj.get("plan_halt") is not True`` refuses the leg outright), so - a died-mid-flight ``ready-for-dev`` cannot reach the skip in the first place. A - park's status is self-asserted with no such marker, which is why it is the leg - that needs a record of what the waived gate would have found. + Both skipping legs ask for it. Sprint mode's park and stories mode's plan halt + have independent selectors — the park's session-authored assertion and the + plan halt's strict ``result_json`` marker — while the observation records only + what each waived gate would have found. It never replaces either selector and + never changes acceptance. The two parameters are MUTUALLY EXCLUSIVE by construction: ``extra_exclude`` gates and ``observe_skipped_proof`` observes, and the arms below are ``if`` / @@ -3996,7 +4008,10 @@ def verify_dev_stories( and baseline gates still run, and ``task.spec_file`` is still recorded. A ``plan_halt`` leg also requires the ``result_json`` to carry the ``plan_halt`` marker ``devcontract`` emits on a clean plan-halt, so a died-mid-flight - ``ready-for-dev`` can't be mistaken for a successful plan. + ``ready-for-dev`` can't be mistaken for a successful plan. A passing halt + returns what the skipped proof gate would have found as + ``VerifyOutcome.plan_halt_zero_diff``; that observation never affects the + marker cross-check or the outcome. """ # Deferred to avoid a verify<->stories import cycle: stories imports # read_frontmatter/status_of from this module at top level, so verify must not @@ -4042,34 +4057,33 @@ def verify_dev_stories( else: expected = "in-review" if review_enabled else "done" - # A plan-halt leg produced only its own spec (the plan), which proof-of-work - # already excludes; skip it (extra_exclude=None) and record the plan spec. - # Otherwise stories mode adds the spec folder's stories/ subdir + stories.yaml - # on top of the gate's own file-granular exclude — NOT a whole-folder artifact - # exclusion, so a story whose entire authorized scope is ledger/spec - # reconciliation doesn't register as a false "no changes". Engine-written - # paths compose only on that live-gate leg; ``None`` must remain ``None`` for - # plan halt rather than being combined with a tuple. + # Stories mode adds the spec folder's stories/ subdir + stories.yaml on top of + # the gate's own file-granular exclude — NOT a whole-folder artifact exclusion, + # so a story whose entire authorized scope is ledger/spec reconciliation + # doesn't register as a false "no changes". A plan-halt leg produced only its + # own spec (the plan), so it skips the gate but passes this same tuple to the + # observer: the journal answer must measure exactly the gate that was waived, + # including engine-written paths. + stories_exclude = _stories_relpaths(paths.repo_root, spec_folder) + engine_written gate = _verify_shared_gates( spec_path, rj, task, paths, expected_status=expected, - extra_exclude=( - None - if plan_halt - # Rooted where the proof-of-work gate invokes git (`paths.repo_root`), - # not on `paths.project`: a pathspec relative to the other root matches - # nothing and the exclusion evaporates without an error (#716). - else _stories_relpaths(paths.repo_root, spec_folder) + engine_written - ), + # Rooted where the proof-of-work gate invokes git (`paths.repo_root`), not + # on `paths.project`: a pathspec relative to the other root matches nothing + # and the exclusion evaporates without an error (#716). + extra_exclude=None if plan_halt else stories_exclude, + observe_skipped_proof=stories_exclude if plan_halt else None, ) if gate.outcome is not None: return gate.outcome task.spec_file = str(spec_path) - return VerifyOutcome.passed() + return VerifyOutcome.passed( + plan_halt_zero_diff=(gate.skipped_proof_zero_diff if plan_halt else None) + ) def _stories_relpaths(root: Path, spec_folder: Path) -> tuple[str, ...]: diff --git a/tests/test_engine.py b/tests/test_engine.py index 762214fb..8707aecc 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -16329,7 +16329,10 @@ def crash_after_session_save(stage, *args, **kwargs): ] -def test_legacy_replay_does_not_credit_its_new_harvest_as_session_work(project): +@pytest.mark.parametrize("untracked_fault", [False, True], ids=["answered", "unknown"]) +def test_legacy_replay_does_not_credit_its_new_harvest_as_session_work( + project, monkeypatch, untracked_fault +): """Missing attribution must not let the upgrade's own append satisfy proof.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, _ = make_engine( @@ -16358,6 +16361,18 @@ def crash_after_session_save(stage, *args, **kwargs): _downgrade_harvest_state_to_legacy(engine) resumed, adapter = resume_engine(project, engine, []) + untracked_calls = 0 + real_untracked_files = verify.untracked_files + + def fault_once(repo): + nonlocal untracked_calls + untracked_calls += 1 + if untracked_calls == 1: + raise verify.GitError("untracked enumeration failed") + return real_untracked_files(repo) + + if untracked_fault: + monkeypatch.setattr(verify, "untracked_files", fault_once) summary = resumed.run() assert summary.deferred == 1 and not summary.done and not summary.crashed @@ -16367,6 +16382,12 @@ def crash_after_session_save(stage, *args, **kwargs): decisions = [e for e in resumed.journal.entries() if e["kind"] == "dev-decision"] assert [decision["action"] for decision in decisions] == ["defer"] assert "no changes in worktree" in decisions[0]["reason"] + if untracked_fault: + assert untracked_calls >= 2 # attribution faulted; the later proof gate re-probed + failed = [ + e for e in resumed.journal.entries() if e["kind"] == "legacy-ledger-attribution-failed" + ] + assert failed and "could not determine" in failed[-1]["error"] def test_retry_replay_recovers_session_ledger_attribution_after_prior_harvest(project): diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index ca8afb04..9023142e 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -335,6 +335,7 @@ "followup_damped", "followup_review_recommended", "frm", + "generation", "graceful", "harvest_attempt", "head", @@ -1870,6 +1871,62 @@ def test_no_git_invocation_outside_verify(): ) +def test_proof_quiet_diff_is_owned_by_the_central_tri_state_probe(): + """Production has one proof-of-work quiet-diff body across the source tree. + Whole-tree and literal public callers route through `_changes_since`; + `attempt_dirty` retains the one separate quiet diff whose contract is rollback + ownership, not proof of work. + + Ablation: restore `path_changed_since`'s inline `_git(..., "diff", + "--quiet", ...)` body and this fails twice — the unexpected owner appears and + the literal caller no longer calls the tri-state probe. + """ + quiet_diff_owners: list[tuple[str, str]] = [] + tri_state_callers: Counter[tuple[str, str]] = Counter() + + class Visitor(ast.NodeVisitor): + def __init__(self, rel: str) -> None: + self.rel = rel + self.functions: list[str] = [] + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.functions.append(node.name) + self.generic_visit(node) + self.functions.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Call(self, node: ast.Call) -> None: + owner = self.functions[-1] if self.functions else "" + callee = ( + node.func.id + if isinstance(node.func, ast.Name) + else node.func.attr if isinstance(node.func, ast.Attribute) else None + ) + if callee == "_changes_since": + tri_state_callers[(self.rel, owner)] += 1 + if ( + callee == "_git" + and len(node.args) >= 3 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value == "diff" + and isinstance(node.args[2], ast.Constant) + and node.args[2].value == "--quiet" + ): + quiet_diff_owners.append((self.rel, owner)) + self.generic_visit(node) + + for source in SRC.rglob("*.py"): + rel = source.relative_to(SRC).as_posix() + Visitor(rel).visit(ast.parse(source.read_text(encoding="utf-8"))) + + assert Counter(quiet_diff_owners) == Counter( + {("verify.py", "_changes_since"): 1, ("verify.py", "attempt_dirty"): 1} + ) + assert tri_state_callers[("verify.py", "has_changes_since")] == 1 + assert tri_state_callers[("verify.py", "path_changed_since")] == 1 + + def _verify_command_offenders(findings) -> list[tuple[str, int, str]]: """The review-gate chokepoint as a filter: a ``verify_commands_outcome`` call is sanctioned only in a ``VERIFY_COMMANDS_CHOKEPOINT`` file AND only from diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index a5bea118..7b3590e1 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -17,7 +17,7 @@ write_spec, ) -from bmad_loop import stories +from bmad_loop import stories, verify from bmad_loop.adapters.base import SessionResult from bmad_loop.adapters.mock import MockAdapter from bmad_loop.engine import Engine @@ -987,6 +987,10 @@ def test_plan_checkpoint_pause_then_resume_implements(project): assert leg1.prompt.endswith("Halt after planning.") assert leg1.env["BMAD_LOOP_PLAN_HALT"] == "1" assert _kinds(engine.journal, "plan-halt") + (waiver,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert waiver["story_key"] == "1" + assert waiver["zero_diff"] is True + assert "plan_halt" not in waiver # the result marker remains a separate fact assert _kinds(engine.journal, "checkpoint-pause")[-1]["checkpoint"] == "plan" resumed, radapter = resume_engine(project, engine, [stories_checkpoint_effect()]) @@ -1000,6 +1004,115 @@ def test_plan_checkpoint_pause_then_resume_implements(project): assert "BMAD_LOOP_PLAN_HALT" not in leg2.env +@pytest.mark.parametrize( + "result_json", + [ + {"workflow": "auto-dev"}, + {"workflow": "wrong-workflow", "plan_halt": True}, + ], + ids=["marker-absent", "earlier-gate-fails"], +) +def test_refused_plan_halt_does_not_journal_a_proof_waiver(project, result_json): + """Only a passing artifact outcome earns the plan-halt waiver record. + + Ablation: move the journal append before `verify_dev_stories`, or key it only + on a truthy marker, and the relevant row observes a record for a refused leg. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + + outcome = engine._verify_dev_artifacts(task, result_json) + + assert not outcome.ok + assert not _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + + +@pytest.mark.parametrize("zero_diff", [False, None], ids=["residue", "unknown"]) +def test_accepted_plan_halt_journals_the_non_clean_proof_observation( + project, monkeypatch, zero_diff +): + """The engine carries the verifier's full tri-state into the accepted-halt + record; neither residue nor an unanswerable probe may be rewritten as clean. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + + if zero_diff is False: + (project.repo_root / "src.txt").write_text("planning residue\n", encoding="utf-8") + else: + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + + outcome = engine._verify_dev_artifacts( + task, + {"workflow": "auto-dev", "plan_halt": True}, + ) + + assert outcome.ok + (record,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert record["zero_diff"] is zero_diff + + +def test_accepted_plan_halt_journal_excludes_engine_written(project, monkeypatch): + """The journal projects the gate after excluding orchestrator-owned residue. + + Ablation: stop composing ``engine_written`` into the stories observation and + this reports ``zero_diff: false`` even though the session itself wrote no code. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + (project.repo_root / "engine-owned.txt").write_text( + "orchestrator bookkeeping\n", encoding="utf-8" + ) + monkeypatch.setattr(engine, "_harvest_gate_exclude", lambda _task: ("engine-owned.txt",)) + + outcome = engine._verify_dev_artifacts( + task, + {"workflow": "auto-dev", "plan_halt": True}, + ) + + assert outcome.ok + (record,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert record["zero_diff"] is True + + +def test_replayed_plan_halt_does_not_duplicate_proof_waiver(project): + """Crash replay preserves the first observation for one session generation. + + The first verification journals a clean waiver. A host death before the + accepted-session save can replay the same result after unrelated residue has + appeared; that replay must not append a conflicting second audit record. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline, generation=2) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + result_json = {"workflow": "auto-dev", "plan_halt": True} + + first = engine._verify_dev_artifacts(task, result_json) + (project.repo_root / "late-residue.txt").write_text("later\n", encoding="utf-8") + replay = engine._verify_dev_artifacts(task, result_json) + + assert first.ok and first.plan_halt_zero_diff is True + assert replay.ok and replay.plan_halt_zero_diff is False + (record,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert record["generation"] == 2 + assert record["zero_diff"] is True + + def test_operator_spec_path_anchors_an_isolated_units_spec(project): """The pause notice is the FIRST surface an operator meets — before any dashboard. diff --git a/tests/test_verify.py b/tests/test_verify.py index 4fdbeee0..7b55cc17 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -250,6 +250,25 @@ def test_path_changed_since_detects_one_tracked_path(project): assert verify.path_changed_since(project.project, baseline, "src.txt") is True +def test_path_changed_since_treats_tracked_pathspec_magic_literally(project): + """A tracked exact-path probe must not interpret brackets as pathspec magic. + + Ablation: remove the `:(literal)` prefix in `_changes_since` and Git reports + the modified bracketed path clean, so both assertions fail. + """ + repo = project.project + rel = "tracked[1].txt" + (repo / rel).write_text("baseline\n", encoding="utf-8") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "tracked literal path baseline") + baseline = verify.rev_parse_head(repo) + + (repo / rel).write_text("changed\n", encoding="utf-8") + + assert verify._changes_since(repo, baseline, literal_path=rel) is True + assert verify.path_changed_since(repo, baseline, rel) is True + + def test_path_changed_since_respects_the_untracked_baseline(project): baseline = verify.rev_parse_head(project.project) (project.project / "ledger[1].md").write_text("finding\n", encoding="utf-8") @@ -268,6 +287,30 @@ def test_path_changed_since_respects_the_untracked_baseline(project): ) +def test_path_changed_since_routes_the_literal_path_through_the_tri_state(project, monkeypatch): + """The literal-path public boundary owns only the fail-open collapse; the + centralized tri-state probe owns the quiet diff and its literal pathspec. + + Ablation: restore an inline `_git(..., "diff", "--quiet", ...)` body in + `path_changed_since` and this fails because `_changes_since` is never called. + """ + seen = {} + + def probe(repo, baseline, exclude=(), **kwargs): + seen.update(repo=repo, baseline=baseline, exclude=exclude, kwargs=kwargs) + return None + + monkeypatch.setattr(verify, "_changes_since", probe) + + assert verify.path_changed_since(project.project, "baseline", "ledger[1].md") is True + assert seen == { + "repo": project.project, + "baseline": "baseline", + "exclude": (), + "kwargs": {"literal_path": "ledger[1].md", "baseline_untracked": None}, + } + + def test_attempt_dirty_excludes_untracked_artifact(project): """A new untracked spec under an orchestrator-owned artifact folder is not the dev attempt's dirtiness when that folder is excluded — but counts otherwise.""" @@ -1138,6 +1181,27 @@ def test_verify_dev_proof_of_work_gate_still_fails_open_on_a_refused_probe(proje assert out.park_proof_skipped is False and out.park_zero_diff is None +def test_verify_dev_proof_gate_fails_open_on_untracked_enumeration_fault(project, monkeypatch): + """A clean tracked diff followed by a failed untracked enumeration is an + unanswerable proof, not an escalation. The ordinary gate keeps its established + fail-open policy and accepts the attempt. + + Ablation: delete `_changes_since`'s `except GitError: return None` and this + changes from a passing outcome to a raised/escalated Git fault. + """ + task, sp = _residue_free(project, status="done", sprint="done") + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + + out = verify.verify_dev(task, project, dev_result(sp), review_enabled=False) + + assert out.ok + assert out.park_proof_skipped is False and out.park_zero_diff is None + + def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(project): """The SECOND documented cause of `zero_diff: null`, and the one a reader is likeliest to mistake for the first: not a git fault, but an attempt carrying @@ -3092,9 +3156,15 @@ def test_stories_relpaths_is_empty_when_resolution_is_uncertain(project, monkeyp def test_verify_dev_stories_plan_halt_expects_ready_for_dev(project): # plan-halt leg: the spec is at ready-for-dev (the plan), not done, and there # is NO code change — proof-of-work is skipped and the plan spec is recorded. + # Post-baseline stories bookkeeping is excluded from the observation too. spec_folder = project.planning_artifacts / "epic-a" task = make_stories_task(project, "1") sp = write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + (spec_folder / "stories.yaml").write_text("stories: []\n", encoding="utf-8") + write_story(spec_folder, "2", "sibling", "ready-for-dev", task.baseline_commit) + (project.repo_root / "engine-owned.txt").write_text( + "orchestrator bookkeeping\n", encoding="utf-8" + ) out = verify.verify_dev_stories( task, project, @@ -3102,9 +3172,66 @@ def test_verify_dev_stories_plan_halt_expects_ready_for_dev(project): spec_folder=spec_folder, review_enabled=False, plan_halt=True, + engine_written=("engine-owned.txt",), ) assert out.ok # no code change required for a plan assert task.spec_file == str(sp) + assert out.plan_halt_zero_diff is True + + +def test_verify_dev_stories_plan_halt_observes_a_non_zero_diff(project): + """The skipped gate is observed even when it would have passed: the marker + still authorizes the halt, while the independent observation reports residue. + + Ablation: replace the skipped-proof observation with a constant clean answer + and this fails without changing plan-halt acceptance. + """ + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + (project.repo_root / "src.txt").write_text("changed during planning\n", encoding="utf-8") + + out = verify.verify_dev_stories( + task, + project, + {"workflow": "auto-dev", "plan_halt": True}, + spec_folder=spec_folder, + review_enabled=False, + plan_halt=True, + ) + + assert out.ok + assert out.plan_halt_zero_diff is False + + +def test_verify_dev_stories_plan_halt_untracked_fault_is_unknown(project, monkeypatch): + """A bookkeeping probe fault cannot reject an otherwise valid plan halt; the + returned observation is unknown so the engine can journal JSON null. + + Ablation: delete `_changes_since`'s untracked `GitError` normalization and + this still passes only if the outer skipped-proof observer catches it; remove + that catch as well and the fault escapes. The helper-level sibling pins the + normalization itself. + """ + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + out = verify.verify_dev_stories( + task, + project, + {"workflow": "auto-dev", "plan_halt": True}, + spec_folder=spec_folder, + review_enabled=False, + plan_halt=True, + ) + + assert out.ok + assert out.plan_halt_zero_diff is None def test_verify_dev_stories_plan_halt_rejects_non_plan_status(project): @@ -3138,6 +3265,7 @@ def test_verify_dev_stories_plan_halt_requires_marker(project): plan_halt=True, ) assert not out.ok and "no plan_halt marker" in out.reason + assert out.plan_halt_zero_diff is None def test_plan_halt_status_matches_devcontract(): @@ -6302,6 +6430,31 @@ def test_changes_since_reports_a_git_refusal_and_has_changes_since_collapses_it( assert verify.has_changes_since(project.project, head) is False +@pytest.mark.parametrize("literal_path", [None, "src.txt"], ids=["whole-tree", "literal-path"]) +def test_changes_since_reports_untracked_enumeration_fault_as_unknown( + project, monkeypatch, literal_path +): + """Tracked diff and untracked enumeration are halves of one tri-state answer. + Once the clean tracked half succeeds, a `GitError` from the untracked half is + unknown too; both public boolean boundaries then fail open to changed. + + Ablation: delete `_changes_since`'s `except GitError: return None` and both + rows raise instead of reaching either fail-open boundary. + """ + baseline = verify.rev_parse_head(project.project) + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + + assert verify._changes_since(project.project, baseline, literal_path=literal_path) is None + if literal_path is None: + assert verify.has_changes_since(project.project, baseline) is True + else: + assert verify.path_changed_since(project.project, baseline, literal_path) is True + + def test_has_changes_since_subtracts_baseline_untracked(project): """Untracked files already on disk when the baseline snapshot was taken are not this session's work. `None` deliberately keeps counting all of them — From 1eea08eafc2f0848caba2ed60e8981b165141dc1 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 10:09:12 -0700 Subject: [PATCH 31/45] sweep dw2-document-optional-baseline-claim: DW-51 via bmad-loop --- CHANGELOG.md | 4 ++++ src/bmad_loop/verify.py | 15 ++++++++++----- tests/test_verify.py | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b275a318..a0b76262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,10 @@ breaking changes may land in a minor release. ### Fixed +- **Document spec baseline frontmatter as an optional `verify_dev` attestation** + (DW-51), with no-claim acceptance and no-work refusal regressions anchored to + the orchestrator-recorded baseline. + - **Restore the original spec when a TUI replan cannot strip its stale result** (DW-33), keeping the status reset and result removal atomic before resume. diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 5f79b27d..b4785049 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3744,11 +3744,16 @@ def verify_dev( Checks the claimed spec exists, carries the fixed ``auto-dev`` workflow tag, sits at the expected status (``in-review`` when a separate review session - follows, ``done`` when review is disabled), records a baseline matching the - orchestrator's, has produced changes since that baseline (every leg but the - park — see ``operator_park`` below), and that the story's sprint-status was - advanced to the matching stage. Returns a retryable VerifyOutcome on any - mismatch, escalates on git failure, passes otherwise. + follows, ``done`` when review is disabled), has produced changes (every leg + but the park — see ``operator_park`` below), and that the story's + sprint-status was advanced to the matching stage. Returns a retryable + VerifyOutcome on any mismatch, escalates on git failure, passes otherwise. + + The spec's baseline frontmatter is an OPTIONAL attestation: a usable + ``baseline_revision`` or legacy ``baseline_commit`` claim is checked against + the accepted orchestrator baseline, while absence of both claims is accepted. + Absence does not waive proof-of-work; without a claim, changes are still + measured from the orchestrator-recorded ``task.baseline_commit``. ``operator_park`` (``[operator] enabled``, engine-supplied) adds one more accepted spec/sprint pair: ``(awaiting-operator, awaiting-operator)``, the diff --git a/tests/test_verify.py b/tests/test_verify.py index 7b55cc17..d2ebfc12 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -6657,6 +6657,25 @@ def test_verify_dev_baseline_gate_reads_the_skills_baseline_revision_key(project assert verify.verify_dev(task, project, dev_result(sp)).ok +def test_verify_dev_accepts_an_optional_baseline_claim_but_still_requires_work(project): + """A missing baseline claim is valid, but it does not disable proof-of-work: + the orchestrator-owned task baseline remains the measurement authority.""" + write_sprint(project, {"1-1-a": "review"}) + task = make_task(project) + sp = spec_path(project, "1-1-a") + write_spec(sp, "in-review", OMIT) + body = sp.read_text() + assert "baseline_revision:" not in body and "baseline_commit:" not in body + + no_work = verify.verify_dev(task, project, dev_result(sp)) + assert not no_work.ok and "no changes in worktree since baseline commit" in no_work.reason + + (project.project / "src.txt").write_text("real work\n") + git(project.project, "add", "src.txt") + git(project.project, "commit", "-q", "-m", "real work after task baseline") + assert verify.verify_dev(task, project, dev_result(sp)).ok + + def test_verify_dev_baseline_gate_prefers_the_fresh_revision_over_a_stale_legacy_key(project): """#716: a spec carrying BOTH keys is what `runs.rearm_escalation` produces — it inserts `baseline_revision` and never removes a pre-existing From 64dc9831ee56ecb7f53c96547b1bea3c4b87c645 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 12:03:03 -0700 Subject: [PATCH 32/45] sweep dw3-authoritative-rearm-outcome: DW-40 via bmad-loop --- CHANGELOG.md | 3 + docs/FEATURES.md | 7 +- src/bmad_loop/cli.py | 38 ++++----- src/bmad_loop/runs.py | 49 ++++++++++- src/bmad_loop/tui/app.py | 33 ++++---- tests/test_cli.py | 148 +++++++++++++++++++++++++--------- tests/test_engine_worktree.py | 2 +- tests/test_resolve.py | 78 +++++++++++++++--- tests/test_runs.py | 30 +++++-- tests/test_tui_app.py | 107 ++++++++++++++++++++---- 10 files changed, 386 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b76262..a420fd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,9 @@ breaking changes may land in a minor release. ### Fixed +- Make successful escalation re-arms return authoritative ordered notices and a resume-hold + verdict, so a corrupt journal cannot hide a persisted hold from the CLI or TUI gesture. + - **Document spec baseline frontmatter as an optional `verify_dev` attestation** (DW-51), with no-claim acceptance and no-work refusal regressions anchored to the orchestrator-recorded baseline. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index dc601e0e..a6b321f5 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -162,7 +162,12 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w All of these warnings reach the TUI's re-arm as well as `resolve`'s — both route every kind through one shared table, so neither surface can silently learn a kind the other drops, though each still owns where it calls the echo from and the TUI drops the trailing "before - resuming" advice, since it otherwise resumes in the same gesture. Each re-arm also bumps a per-task + resuming" advice, since it otherwise resumes in the same gesture. A successful re-arm returns + those rendered notices and its hold verdict as one immutable authoritative outcome, captured + only after each journal append succeeds and in append order. The CLI and TUI consume that + outcome directly, so an unreadable journal cannot erase a successfully appended hold from the + combined re-arm/resume gesture; best-effort journal diffing remains only for diagnostics already + appended by a call that aborts before it can return an outcome. Each re-arm also bumps a per-task **generation**, so the re-minted session id cannot collide with the abandoned attempt's record — ids already on disk keep their exact spelling, since the suffix appears only above generation zero (#705). Sweep migration and triage tasks make the same rollover automatically when an diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 03467834..a0763792 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2964,7 +2964,7 @@ def _resolve_restore_patch( return str(patch), None -def _echo_rearm_events(run_dir: Path, before: list[dict[str, Any]] | None) -> bool: +def _echo_rearm_events(run_dir: Path, before: list[dict[str, Any]] | None) -> None: """Surface the events a just-completed re-arm journaled: the residue of the restore attempt it abandoned — the `stale-restore-*` records AND `rearm-commits-probe-failed`, all written by `runs._stale_restore_residue` — and the `rearm-*` records the status @@ -2993,33 +2993,30 @@ def _echo_rearm_events(run_dir: Path, before: list[dict[str, Any]] | None) -> bo the whole degrade is journal-only — the invisibility #640(b) exists to end, not to relocate. - Returns True when one of those records HOLDS the resume - (`runs.rearm_holds_the_resume`): the caller re-arms and resumes in a single gesture, - and a record proving the re-drive cannot route has to break that gesture, or its own - "before resuming" imperative is already unactionable the moment it prints. The - question is asked here because this is the one walk over the entries the re-arm - added, and the answer has to survive the `finally` it is computed in.""" + This is abort-only diagnostic recovery: a raised call has no authoritative outcome, + so the journal is the only place to recover records that were appended before the + abort. It deliberately does not infer a resume hold for a call that did not succeed.""" after = runs.journal_entries_or_none(run_dir) if before is None or after is None: # Either end of the diff is unreadable, so there is no trustworthy "new since # the re-arm" window. Skip rather than guess: this runs from a `finally`, and a # raise here would replace the `RearmError` the operator needs, while treating a # failed read as "no entries seen" would replay the whole journal as new. The - # hold degrades with the echo, for the same reason: an unproven hold is a guess, - # and this is what the gesture did before either existed. - return False - holds = False + return for entry in after[len(before) :]: - # asked of every entry, BEFORE the routing table can drop it — a `None` notice - # means "nothing to print here", never "nothing to decide here" - holds = runs.rearm_holds_the_resume(entry) or holds notice = runs.rearm_event_notice(entry) if notice is None: continue severity, message, next_step = notice tail = f"; {next_step}" if next_step else "" print(f"{severity}: {message}{tail}", file=sys.stderr) - return holds + + +def _echo_rearm_notices(notices: tuple[runs.RearmNotice, ...]) -> None: + """Render a successful re-arm's authoritative notices in append order.""" + for notice in notices: + tail = f"; {notice.next_step}" if notice.next_step else "" + print(f"{notice.severity}: {notice.message}{tail}", file=sys.stderr) def cmd_resolve(args: argparse.Namespace) -> int: @@ -3287,9 +3284,9 @@ def cmd_resolve(args: argparse.Namespace) -> int: if (moved := runs.restamp_code_root(run_dir, paths.repo_root)) is not None: print(f"warning: {moved}", file=sys.stderr) before_entries = runs.journal_entries_or_none(run_dir) - hold_resume = False + outcome: runs.RearmOutcome | None = None try: - runs.rearm_escalation( + outcome = runs.rearm_escalation( run_dir, story_key, restore_patch=restore_patch, @@ -3307,7 +3304,10 @@ def cmd_resolve(args: argparse.Namespace) -> int: # `rearm-commits-probe-failed` when it could not), whose whole point is that # nothing else will tell the human. An abort is when that residue matters most: the # re-arm half-ran and the operator has to decide what to do with the tree. - hold_resume = _echo_rearm_events(run_dir, before_entries) + if outcome is None: + _echo_rearm_events(run_dir, before_entries) + assert outcome is not None + _echo_rearm_notices(outcome.notices) print( f"re-armed {story_key}" + (" (restoring the attempted change for review)" if restore_patch else "") @@ -3315,7 +3315,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: if args.resume is False: print(f"resume when ready: bmad-loop resume {args.run_id}") return 0 - if hold_resume: + if outcome.hold_resume: # The re-arm SUCCEEDED — the task is armed and persisted — so this is a 0, and it # stops the GESTURE, not the run. `--resume` does not override it: that flag # skips the confirmation prompt, while the hold is not a question but a proof diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 73a16b45..6d3b57f4 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3879,6 +3879,45 @@ def restamp_code_root(run_dir: Path, repo_root: Path) -> str | None: ) +@dataclass(frozen=True) +class RearmNotice: + """One operator-facing notice produced by a successful re-arm.""" + + severity: Literal["note", "warning"] + message: str + next_step: str + + +@dataclass(frozen=True) +class RearmOutcome: + """Authoritative result of a successfully persisted escalation re-arm.""" + + story_key: str + notices: tuple[RearmNotice, ...] + hold_resume: bool + + +class _RearmJournal(Journal): + """Journal writer that captures successful re-arm notices at append time.""" + + def __init__(self, run_dir: Path): + super().__init__(run_dir) + self.notices: list[RearmNotice] = [] + self.hold_resume = False + + def append(self, kind: str, **fields: Any) -> None: + # Capture only after the durable append succeeds. The synthetic entry contains + # every producer-supplied field the shared classifiers consume; Journal's + # self-minted timestamp/log fields are not part of either contract. + super().append(kind, **fields) + entry = {"kind": kind, **fields} + self.hold_resume = rearm_holds_the_resume(entry) or self.hold_resume + rendered = rearm_event_notice(entry) + if rendered is not None: + severity, message, next_step = rendered + self.notices.append(RearmNotice(severity, message, next_step)) + + def rearm_escalation( run_dir: Path, story_key: str | None = None, @@ -3886,7 +3925,7 @@ def rearm_escalation( restore_patch: str | None = None, isolated_redrive: bool, resolution_recorded: bool, -) -> str: +) -> RearmOutcome: """Re-arm an escalation-paused story so the next resume re-drives it. Flips the escalated task out of its terminal ESCALATED phase back to @@ -3974,7 +4013,9 @@ def rearm_escalation( The generation bump stays UNCONDITIONAL beside the gated stamp: it answers session-id reuse (#705), which an abandoned attempt needs exactly as much as a resolved one. - Returns the re-armed story key. Raises RearmError when the run is not paused at + Returns the authoritative re-arm outcome: the story key, the ordered notices + whose journal appends succeeded during this call, and whether one of the appended + records holds the combined re-arm/resume gesture. Raises RearmError when the run is not paused at the escalation stage, the target story is not escalated, or a supplied `restore_patch` fails `validate_restore_latch` (the shared precondition set — sentinel wedge, spec-less escalation, worktree isolation). @@ -4003,7 +4044,7 @@ def rearm_escalation( if err is not None: raise RearmError(err) - journal = Journal(run_dir) + journal = _RearmJournal(run_dir) # Read before the unconditional overwrite below: they describe the restore # attempt this re-arm is abandoning, and the residue block needs both. old_latch = task.restore_patch @@ -4675,7 +4716,7 @@ def rearm_escalation( baseline=task.baseline_commit or "", restore=bool(restore_patch), ) - return key + return RearmOutcome(key, tuple(journal.notices), journal.hold_resume) def journal_entries_or_none(run_dir: Path) -> list[dict[str, Any]] | None: diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index c5277f64..3352ee9a 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -868,7 +868,7 @@ def _do_replan(self, run_id: str, spec_path: Path, confine_root: Path) -> None: self.notify("plan reset to draft — the next dispatch re-plans") self._do_resume(run_id) - def _echo_rearm_events(self, run_dir: Path, before: list[dict[str, Any]] | None) -> bool: + def _echo_rearm_events(self, run_dir: Path, before: list[dict[str, Any]] | None) -> None: """Toast the re-arm records `cli._echo_rearm_events` prints, same table. Reads through `runs.journal_entries_or_none`, shared with the CLI so the two @@ -880,24 +880,26 @@ def _echo_rearm_events(self, run_dir: Path, before: list[dict[str, Any]] | None) The table's `next_step` is deliberately dropped: it reads "... before resuming", and this path resumes in the same gesture. - Returns True when a record HOLDS that gesture (`runs.rearm_holds_the_resume`), - which is the one case where the dropped imperative was load-bearing rather than - moot — `_do_rearm` stops instead of resuming, and says so in its own words. + This is abort-only diagnostic recovery. A raised call has no authoritative + outcome, so this path must not infer a hold from partial journal residue. """ after = runs.journal_entries_or_none(run_dir) if before is None or after is None: - return False - holds = False + return for entry in after[len(before) :]: - # before the routing table can drop it: a `None` notice means "nothing to - # toast", never "nothing to decide" - holds = runs.rearm_holds_the_resume(entry) or holds notice = runs.rearm_event_notice(entry) if notice is None: continue severity, message, _next_step = notice self.notify(message, severity="warning" if severity == "warning" else "information") - return holds + + def _echo_rearm_notices(self, notices: tuple[runs.RearmNotice, ...]) -> None: + """Toast a successful re-arm's authoritative notices in append order.""" + for notice in notices: + self.notify( + notice.message, + severity="warning" if notice.severity == "warning" else "information", + ) def _do_rearm( self, run_id: str, run_dir: Path, story_key: str, *, restore_recorded: bool = False @@ -966,9 +968,9 @@ def _do_rearm( if (moved := runs.restamp_code_root(run_dir, paths.repo_root)) is not None: self.notify(moved, severity="warning") before_entries = runs.journal_entries_or_none(run_dir) - hold_resume = False + outcome: runs.RearmOutcome | None = None try: - runs.rearm_escalation( + outcome = runs.rearm_escalation( run_dir, story_key, isolated_redrive=isolation == "worktree", @@ -995,7 +997,10 @@ def _do_rearm( # path even after they were unified on routing — and an abort is when the # residue matters most: the re-arm half-ran and the operator has to decide # what to do with the tree. - hold_resume = self._echo_rearm_events(run_dir, before_entries) + if outcome is None: + self._echo_rearm_events(run_dir, before_entries) + assert outcome is not None + self._echo_rearm_notices(outcome.notices) if restore_recorded: self.notify( "recorded restore patch NOT honored — this re-arm re-drives from " @@ -1003,7 +1008,7 @@ def _do_rearm( severity="warning", ) self.notify(f"re-armed {story_key}") - if hold_resume: + if outcome.hold_resume: # The half of the gesture that still worked is kept: the story IS re-armed # and persisted. What stops is the resume this surface folds in behind it, # because the warning above proved the re-drive would read a spec it cannot diff --git a/tests/test_cli.py b/tests/test_cli.py index 9158c986..3ea3e756 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2628,7 +2628,7 @@ def fake_rearm( rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False ): seen.append(load_state(rd).code_root) - return key + return _rearm_outcome(key) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) @@ -2729,7 +2729,11 @@ def test_resolve_degrades_when_the_config_cannot_name_the_code_root(tmp_path, mo run_dir = _escalated_run(tmp_path, "r1") # no _bmad/bmm/config.yaml anywhere rearmed: list = [] - monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **k: rearmed.append(key) or key) + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda rd, key, **k: rearmed.append(key) or _rearm_outcome(key), + ) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) argv = ["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--resume"] @@ -2754,10 +2758,29 @@ def fake_rearm( rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False ): journal = Journal(rd) - journal.append("stale-restore-excluded", story_key=key, patch="a.patch", files=["new.txt"]) - journal.append("stale-restore-unparseable", story_key=key, patch="b.patch", error="OSErr") - journal.append("stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c"]) - return key + entries = ( + { + "kind": "stale-restore-excluded", + "story_key": key, + "patch": "a.patch", + "files": ["new.txt"], + }, + { + "kind": "stale-restore-unparseable", + "story_key": key, + "patch": "b.patch", + "error": "OSErr", + }, + { + "kind": "stale-restore-commits", + "story_key": key, + "old_baseline": "f" * 40, + "commits": ["c"], + }, + ) + for entry in entries: + journal.append(entry["kind"], **{k: v for k, v in entry.items() if k != "kind"}) + return _rearm_outcome(key, *entries) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -2766,9 +2789,15 @@ def fake_rearm( ) err = capsys.readouterr().err - assert "excluded the abandoned restore's new files from the re-drive baseline: new.txt" in err - assert "could not read the abandoned restore patch (b.patch)" in err - assert "1 commit(s) sit below the re-drive's new baseline (ffffffffffff..)" in err + ordered_messages = ( + "excluded the abandoned restore's new files from the re-drive baseline: new.txt", + "could not read the abandoned restore patch (b.patch)", + "1 commit(s) sit below the re-drive's new baseline (ffffffffffff..)", + ) + assert all(message in err for message in ordered_messages) + assert [err.index(message) for message in ordered_messages] == sorted( + err.index(message) for message in ordered_messages + ) assert "FROM-LAST-TIME.txt" not in err @@ -2796,22 +2825,24 @@ def fake_rearm( rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False ): journal = Journal(rd) - journal.append( - "rearm-baseline-advance-failed", - story_key=key, - repo=str(tmp_path), - baseline="a" * 40, - error="GitError: not a git repository", - ) - journal.append( - "rearm-baseline-restamped", - story_key=key, - spec_file="spec.md", - overwritten="b" * 40, - baseline="c" * 40, - restore=False, - ) - return key + advance = { + "kind": "rearm-baseline-advance-failed", + "story_key": key, + "repo": str(tmp_path), + "baseline": "a" * 40, + "error": "GitError: not a git repository", + } + restamped = { + "kind": "rearm-baseline-restamped", + "story_key": key, + "spec_file": "spec.md", + "overwritten": "b" * 40, + "baseline": "c" * 40, + "restore": False, + } + for entry in (advance, restamped): + journal.append(entry["kind"], **{k: v for k, v in entry.items() if k != "kind"}) + return _rearm_outcome(key, advance, restamped) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -2856,7 +2887,7 @@ def fake_rearm( baseline="c" * 40, restore=restore, ) - return key + return _journal_rearm_outcome(rd, key) return fake_rearm @@ -2884,8 +2915,8 @@ def fake_rearm( @pytest.mark.parametrize("outcome", ["ok", "rearm-error"]) def test_resolve_survives_a_corrupt_journal(tmp_path, monkeypatch, capsys, outcome): - """An undecodable byte in journal.jsonl costs the echo, never the gesture — and - never the exit code. + """An undecodable journal cannot suppress an authoritative successful hold, and + cannot replace the original error on an aborted call. The counterpart to `test_escalation_rearm_survives_a_corrupt_journal` in the TUI, which had no CLI twin: the TUI's reads were guarded while `cmd_resolve`'s two were @@ -2906,9 +2937,20 @@ def fake_rearm( ): if outcome == "rearm-error": raise runs.RearmError("cannot re-open story spec /x/spec.md") - return key + return runs.RearmOutcome( + key, + ( + runs.RearmNotice( + "warning", + "authoritative hold from the successful re-arm", + "Commit the corrected spec before resuming", + ), + ), + True, + ) - monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) + resumed: list[str] = [] + monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: resumed.append(rd.name) or 0) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) run_dir = _escalated_run(tmp_path, "r1") (run_dir / JOURNAL_FILE).write_bytes( @@ -2924,6 +2966,8 @@ def fake_rearm( assert "cannot re-open story spec" in err else: assert rc == 0 + assert resumed == [] + assert "authoritative hold from the successful re-arm" in err def test_resolve_echoes_a_skipped_restamp(tmp_path, monkeypatch, capsys): @@ -2948,7 +2992,7 @@ def fake_rearm( spec_file="wt/specs/s1.md", baseline="c" * 40, ) - return key + return _journal_rearm_outcome(rd, key) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -3080,7 +3124,7 @@ def fake_rearm( rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False ): Journal(rd).append(kind, story_key=key, **fields) - return key + return _journal_rearm_outcome(rd, key) return fake_rearm @@ -3123,6 +3167,19 @@ def fake_rearm( assert "NOT resuming in this gesture" not in out assert resumed == ["r2"] # ...and it still resumes: an advisory is not a proof + _escalated_run(tmp_path, "r3") + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda rd, key, **kwargs: runs.RearmOutcome(key, (), True), + ) + assert ( + cli.main(["resolve", "--project", str(tmp_path), "r3", "--no-interactive", "--resume"]) == 0 + ) + out, _err = capsys.readouterr() + assert "NOT resuming in this gesture" in out + assert resumed == ["r2"] # a hold is authoritative even when it has no notice + def test_resolve_appends_the_next_step_imperative(tmp_path, monkeypatch, capsys): """This surface renders `severity: message; next_step`; the TUI renders `message`. @@ -3159,7 +3216,7 @@ def fake_rearm( journal.append( # table row whose next_step is "" "stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c1"] ) - return key + return _journal_rearm_outcome(rd, key) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -3206,7 +3263,7 @@ def fake_rearm( error=f"GitError: git rev-list {baseline}..HEAD failed in /code: " "not a git repository", ) - return key + return _journal_rearm_outcome(rd, key) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) @@ -3315,7 +3372,7 @@ def fake_context(*args, **kwargs): "run_session", lambda adapter, project, *a, **k: seen.setdefault("cwd", project) or True, ) - monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: key) + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: _rearm_outcome(key)) assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 @@ -3347,7 +3404,7 @@ def fake_session(*args, **kwargs): return True monkeypatch.setattr(resolve, "run_session", fake_session) - monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: key) + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: _rearm_outcome(key)) argv = ["resolve", "--project", str(project.project), "r1", "--no-resume"] assert cli.main(argv) == 0 @@ -3592,6 +3649,23 @@ def _escalated_trail_run(tmp_path, run_id="r1", *, details=("first cycle",)): return run_dir +def _rearm_outcome(key: str, *entries: dict) -> runs.RearmOutcome: + notices = tuple( + runs.RearmNotice(*notice) + for entry in entries + if (notice := runs.rearm_event_notice(entry)) is not None + ) + return runs.RearmOutcome( + key, notices, any(runs.rearm_holds_the_resume(entry) for entry in entries) + ) + + +def _journal_rearm_outcome(run_dir: Path, key: str) -> runs.RearmOutcome: + from bmad_loop.journal import Journal + + return _rearm_outcome(key, *Journal(run_dir).entries()) + + def _redrive_escalates(run_dir, detail): """What a re-driven session that escalated again leaves behind, re-escalated so a second `bmad-loop resolve` is legal on it.""" @@ -4227,7 +4301,7 @@ def recording_rearm( rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False ): seen.append(isolated_redrive) - return key + return _rearm_outcome(key) monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 4a591048..2473e157 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -6142,7 +6142,7 @@ def commit_fails(*_a, **_k): assert ( runs.rearm_escalation( engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True - ) + ).story_key == "1-1-a" ) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index e6c7f55b..ec21306f 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -10,7 +10,7 @@ from bmad_loop import devcontract, platform_util, resolve, runs, verify from bmad_loop.engine import _session_task_id -from bmad_loop.journal import load_state, save_state +from bmad_loop.journal import JOURNAL_FILE, load_state, save_state from bmad_loop.model import ( PAUSE_ESCALATION, Phase, @@ -787,10 +787,10 @@ def test_rearm_flips_phase_and_spec_status(tmp_path): spec = tmp_path / "spec.md" spec.write_text(SPEC, encoding="utf-8") run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec)) - key = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) - assert key == "6-4-cli-list-command" + outcome = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + assert outcome.story_key == "6-4-cli-list-command" state = load_state(run_dir) - task = state.tasks[key] + task = state.tasks[outcome.story_key] assert task.phase == Phase.PENDING assert task.attempt == 0 assert task.review_cycle == 0 @@ -854,7 +854,7 @@ def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive tmp_path, spec_file=str(spec), worktree_path=str(tmp_path / "wt" / "u1") ) - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + outcome = runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] assert rec["story_key"] == "6-4-cli-list-command" @@ -866,6 +866,29 @@ def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive assert severity == "warning" assert "commit the corrected spec" in message assert next_step + assert runs.RearmNotice(severity, message, next_step) in outcome.notices + assert outcome.hold_resume is True + + +def test_rearm_real_hold_survives_an_unreadable_journal(tmp_path): + """Successful control flow comes from the outcome, never a journal re-read.""" + _resolve_repo(tmp_path) + spec = tmp_path / "spec.md" + spec.write_text(SPEC, encoding="utf-8") + run_dir, _, _ = _escalated_run( + tmp_path, spec_file=str(spec), worktree_path=str(tmp_path / "wt" / "u1") + ) + journal = run_dir / JOURNAL_FILE + journal.write_bytes(b"\xff\xfe pre-existing non-UTF-8 journal\n") + with pytest.raises(UnicodeDecodeError): + journal.read_text(encoding="utf-8") + + outcome = runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + + assert outcome.hold_resume is True + assert len(outcome.notices) == 1 + assert "land in a tree it discards" in outcome.notices[0].message + assert "Commit the corrected spec" in outcome.notices[0].next_step def test_rearm_completes_on_an_unreachable_spec_it_could_not_capture(tmp_path, monkeypatch): @@ -1488,8 +1511,8 @@ def test_rearm_clears_sentinel_preserving_a_copy(tmp_path): tmp_path, spec_file=str(sentinel), source="stories", sentinel_kind="unresolved" ) - returned = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) - assert returned == key + outcome = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + assert outcome.story_key == key # sentinel deleted from disk, a copy preserved under the run dir assert not sentinel.exists() @@ -1658,7 +1681,7 @@ def test_rearm_rejects_restore_patch_for_a_worktree_executed_task(tmp_path): assert task.restore_patch is None # a from-scratch re-arm of the same task is unaffected — the guard is latch-only assert ( - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True).story_key == "6-4-cli-list-command" ) @@ -2349,7 +2372,8 @@ def test_rearm_tolerates_non_utf8_sentinel(tmp_path): ) assert ( - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) == key + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True).story_key + == key ) # must not raise assert not sentinel.exists() # cleared by deletion assert (run_dir / "sentinels" / f"{key}-unresolved.md").is_file() # copy preserved @@ -3900,12 +3924,14 @@ def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redriv ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir, isolated_redrive=isolated, resolution_recorded=True) + outcome = runs.rearm_escalation(run_dir, isolated_redrive=isolated, resolution_recorded=True) assert not sentinel.exists() # the sentinel really was cleared on every row records = _upstream_records(run_dir) assert bool(records) is warns + assert outcome.hold_resume is warns if not warns: + assert outcome.notices == () return (rec,) = records # the FOLDER the correction lands in — the main checkout's, not `task_stories_root`'s @@ -3918,6 +3944,28 @@ def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redriv assert severity == "warning" assert "SPEC.md" in message and "stories.yaml" in message assert next_step == "Commit the corrected SPEC.md / stories.yaml on `main` before resuming" + assert outcome.notices == (runs.RearmNotice(severity, message, next_step),) + + +def test_rearm_hold_is_independent_of_notice_rendering(tmp_path, monkeypatch): + """A hold record remains authoritative even when it has no renderable notice.""" + run_dir, _, _ = _sentinel_run( + tmp_path, committed_intent=WEDGED_INTENT, working_intent=CORRECTED_INTENT + ) + monkeypatch.chdir(tmp_path) + real_notice = runs.rearm_event_notice + monkeypatch.setattr( + runs, + "rearm_event_notice", + lambda entry: ( + None if entry.get("kind") == "rearm-upstream-write-unreachable" else real_notice(entry) + ), + ) + + outcome = runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) + + assert outcome.hold_resume is True + assert outcome.notices == () @pytest.mark.parametrize( @@ -4066,13 +4114,19 @@ def test_rearm_of_a_sentinel_survives_a_project_that_is_not_a_repository(tmp_pat ) monkeypatch.chdir(tmp_path) - assert ( - runs.rearm_escalation(run_dir, isolated_redrive=True, resolution_recorded=True) == key + outcome = runs.rearm_escalation( + run_dir, isolated_redrive=True, resolution_recorded=True ) # no GitError + assert outcome.story_key == key assert not sentinel.exists() # the destructive half still completed (rec,) = _upstream_records(run_dir) assert runs.rearm_holds_the_resume(rec) is True + assert outcome.hold_resume is True + # The upstream hold is appended before the baseline diagnostics and must remain + # first in the immutable outcome. + assert "sentinel was cleared" in outcome.notices[0].message + assert "could not advance the re-drive baseline" in outcome.notices[1].message def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_path, monkeypatch): diff --git a/tests/test_runs.py b/tests/test_runs.py index 5fec67d7..1a2c6c72 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2913,6 +2913,12 @@ def _kinds(run_dir, prefix="stale-restore-"): return [e for e in Journal(run_dir).entries() if e["kind"].startswith(prefix)] +def _rendered_rearm_notice(entry): + rendered = runs.rearm_event_notice(entry) + assert rendered is not None + return runs.RearmNotice(*rendered) + + def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): """The abandoned attempt's applied new files must NOT be blessed as pre-existing, or finalize_commit's `add -A` sweeps them into the corrected @@ -2930,7 +2936,7 @@ def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): """ run_dir, _spec, patch = _stale_restore_tree(tmp_path) - runs.rearm_escalation( + outcome = runs.rearm_escalation( run_dir, isolated_redrive=False, resolution_recorded=True ) # from-scratch re-arm replaces the latch @@ -2942,6 +2948,7 @@ def test_rearm_excludes_stale_restore_residue_from_baseline_snapshot(tmp_path): assert len(excluded) == 1 assert excluded[0]["files"] == ["newfile.txt"] assert excluded[0]["patch"] == str(patch) + assert outcome.notices == (_rendered_rearm_notice(excluded[0]),) # the probe ran and answered "none" — neither commit record may appear assert not _kinds(run_dir, "stale-restore-commits") assert not _kinds(run_dir, "rearm-commits-probe-failed") @@ -2975,7 +2982,7 @@ def test_rearm_missing_stale_patch_degrades_loudly_without_raising(tmp_path): git(tmp_path, "add", "committed.txt") git(tmp_path, "commit", "-q", "-m", "attempt commit") - runs.rearm_escalation( + outcome = runs.rearm_escalation( run_dir, isolated_redrive=False, resolution_recorded=True ) # must not raise RearmError @@ -2986,7 +2993,11 @@ def test_rearm_missing_stale_patch_degrades_loudly_without_raising(tmp_path): assert "FileNotFoundError" in unparseable[0]["error"] assert not _kinds(run_dir, "stale-restore-excluded") # the unreadable patch must not also cost the human the commits warning - assert _kinds(run_dir, "stale-restore-commits") + (commits,) = _kinds(run_dir, "stale-restore-commits") + assert outcome.notices == ( + _rendered_rearm_notice(unparseable[0]), + _rendered_rearm_notice(commits), + ) def test_rearm_without_a_stale_latch_journals_no_stale_restore_events(tmp_path): @@ -3014,7 +3025,7 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): git(tmp_path, "commit", "-q", "-m", "attempt commit") old_baseline = load_state(run_dir).tasks["1-1-a"].baseline_commit - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + outcome = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.baseline_commit != old_baseline # baseline advanced past the commit @@ -3022,6 +3033,11 @@ def test_rearm_warns_about_commits_below_the_refreshed_baseline(tmp_path): assert len(warned) == 1 assert warned[0]["old_baseline"] == old_baseline assert warned[0]["commits"] == [git(tmp_path, "rev-parse", "HEAD")] + (excluded,) = _kinds(run_dir, "stale-restore-excluded") + assert outcome.notices == ( + _rendered_rearm_notice(excluded), + _rendered_rearm_notice(warned[0]), + ) def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_path): @@ -3049,7 +3065,7 @@ def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_p task.baseline_commit = "0" * 39 + "1" # sha-shaped, but names no object save_state(run_dir, state) - runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + outcome = runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING @@ -3069,6 +3085,10 @@ def test_rearm_survives_a_git_fault_reading_commits_above_the_old_baseline(tmp_p excluded = _kinds(run_dir, "stale-restore-excluded") assert len(excluded) == 1 assert excluded[0]["files"] == ["newfile.txt"] + assert outcome.notices == ( + _rendered_rearm_notice(excluded[0]), + _rendered_rearm_notice(probe[0]), + ) def test_rearm_survives_a_non_repo_code_tree_when_reading_commits(tmp_path): diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 54e0da6d..eed1092a 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -41,6 +41,7 @@ from bmad_loop import bmadconfig, documents from bmad_loop import policy as policy_mod +from bmad_loop import runs as runs_mod from bmad_loop import verify from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.journal import Journal, save_state @@ -90,6 +91,21 @@ ) +def _rearm_outcome(key: str, *entries: dict) -> runs_mod.RearmOutcome: + notices = tuple( + runs_mod.RearmNotice(*notice) + for entry in entries + if (notice := runs_mod.rearm_event_notice(entry)) is not None + ) + return runs_mod.RearmOutcome( + key, notices, any(runs_mod.rearm_holds_the_resume(entry) for entry in entries) + ) + + +def _journal_rearm_outcome(run_dir: Path, key: str) -> runs_mod.RearmOutcome: + return _rearm_outcome(key, *Journal(run_dir).entries()) + + def make_run( root: Path, run_id: str, @@ -4685,7 +4701,7 @@ async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypat monkeypatch.setattr( runs, "rearm_escalation", - lambda rd, sk, **_k: rearms.append(sk) or "ready-for-dev", + lambda rd, sk, **_k: rearms.append(sk) or _rearm_outcome(sk), ) run_dir, _spec = _stories_paused_run( project.project, @@ -4795,7 +4811,7 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, runs, "rearm_escalation", lambda rd, sk, *, isolated_redrive, resolution_recorded: seen.append(isolated_redrive) - or "ready-for-dev", + or _rearm_outcome(sk), ) run_dir, _spec = _stories_paused_run( project.project, @@ -4854,7 +4870,7 @@ async def test_escalation_rearm_refuses_when_the_policy_cannot_be_read(project, monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: None) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") monkeypatch.setattr( - runs, "rearm_escalation", lambda rd, sk, **_k: rearms.append(sk) or "ready-for-dev" + runs, "rearm_escalation", lambda rd, sk, **_k: rearms.append(sk) or _rearm_outcome(sk) ) orig_notify = BmadLoopApp.notify monkeypatch.setattr( @@ -4915,7 +4931,7 @@ async def test_escalation_rearm_warns_when_restore_recorded(project, monkeypatch monkeypatch.setattr( runs, "rearm_escalation", - lambda rd, sk, **_k: rearms.append(sk) or "ready-for-dev", + lambda rd, sk, **_k: rearms.append(sk) or _rearm_outcome(sk), ) orig_notify = BmadLoopApp.notify monkeypatch.setattr( @@ -4973,7 +4989,7 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): baseline="a" * 40, error="GitError: not a git repository", ) - return "ready-for-dev" + return _journal_rearm_outcome(rd, sk) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) orig_notify = BmadLoopApp.notify @@ -5027,7 +5043,7 @@ async def test_escalation_rearm_aims_the_code_root_before_it_rearms(project, mon def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): seen.append(load_state(rd).code_root) - return "ready-for-dev" + return _rearm_outcome(sk) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) orig_notify = BmadLoopApp.notify @@ -5219,7 +5235,7 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): error="OSError: [Errno 28] No space left on device", rollback="failed", ) - return "ready-for-dev" + return _journal_rearm_outcome(rd, sk) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) orig_notify = BmadLoopApp.notify @@ -5279,6 +5295,19 @@ def severity_of(fragment: str) -> str: # the CLI's trailing imperative is omitted here: the resume is already queued assert not any("before resuming" in n[0] for n in notes), notes assert any("re-armed 1" in n[0] for n in notes) # the ordinary notice still fires + ordered_messages = ( + "2 commit(s) sit below the re-drive's new baseline", + "excluded the abandoned restore's new files", + "could not list the commits above the abandoned attempt's baseline", + "is not a readable file from here", + "could not be re-opened to `ready-for-dev`", + "may be left part-written", + ) + positions = [ + next(i for i, note in enumerate(notes) if message in note[0]) + for message in ordered_messages + ] + assert positions == sorted(positions) async def test_escalation_rearm_holds_the_resume_it_folds_in(project, monkeypatch): @@ -5322,7 +5351,7 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): spec_file="wt/specs/s1.md", baseline="c" * 40, ) - return "ready-for-dev" + return _journal_rearm_outcome(rd, sk) monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) orig_notify = BmadLoopApp.notify @@ -5355,6 +5384,46 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): assert any("is not a readable file from here" in n for n in notes) +async def test_escalation_rearm_holds_without_a_renderable_notice(project, monkeypatch): + """The authoritative hold is independent of whether there is a toast to render.""" + from bmad_loop import resolve, runs + + calls: list[str] = [] + notes: list[str] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda rd, sk, **kwargs: runs.RearmOutcome(sk, (), True), + ) + orig_notify = BmadLoopApp.notify + monkeypatch.setattr( + BmadLoopApp, + "notify", + lambda self, msg, **kw: notes.append(str(msg)) or orig_notify(self, msg, **kw), + ) + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: needs a human decision on the auth scheme.", + ) + marker = resolve.resolution_path(run_dir, "1") + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, EscalationModal) + await pilot.click(await ready(pilot, "#act-rearm")) + await until(pilot, lambda: any("not resuming" in note for note in notes)) + + assert calls == [] + assert any("re-armed 1" in note for note in notes) + + async def test_escalation_rearm_echoes_residue_when_the_rearm_aborts(project, monkeypatch): """An aborted re-arm still surfaces what it already journalled — the CLI parity gap. @@ -5417,7 +5486,7 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch): - """An undecodable byte in journal.jsonl costs the echo, never the gesture. + """An undecodable journal cannot suppress a successful authoritative hold. `_do_rearm` reads the journal twice to diff what the re-arm appended, and before that echo existed it read it not at all — so `Journal.entries()`' strict UTF-8 @@ -5434,7 +5503,7 @@ async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch) `re-armed 1` notice ever fires. """ from bmad_loop import resolve, runs - from bmad_loop.journal import JOURNAL_FILE, Journal + from bmad_loop.journal import JOURNAL_FILE calls: list[str] = [] notes: list[str] = [] @@ -5443,10 +5512,15 @@ async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): - Journal(rd).append( - "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] + return runs.RearmOutcome( + sk, + ( + runs.RearmNotice( + "warning", "authoritative hold from the successful re-arm", "ignored" + ), + ), + True, ) - return "ready-for-dev" monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) orig_notify = BmadLoopApp.notify @@ -5473,11 +5547,12 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): async with app.run_test() as pilot: await _open_review(app, pilot, EscalationModal) await pilot.click(await ready(pilot, "#act-rearm")) - await until(pilot, lambda: calls == ["20260611-100000-aaaa"]) - # the re-arm ran and the run resumed: the corruption cost only the echo + await until(pilot, lambda: any("not resuming" in n for n in notes)) + # the re-arm ran, its outcome rendered, and the authoritative hold stopped resume assert any("re-armed 1" in n for n in notes) assert not any("re-arm failed" in n for n in notes), notes - assert not any("commit(s) sit below" in n for n in notes), notes + assert any("authoritative hold from the successful re-arm" in n for n in notes), notes + assert calls == [] async def test_escalation_rearm_disabled_without_resolution(project, monkeypatch): From 928270f6569e3cf51149dc7e4f79426326f498db Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 12:34:05 -0700 Subject: [PATCH 33/45] sweep dw3-verify-command-fault-contract: DW-53, DW-54 via bmad-loop --- CHANGELOG.md | 4 +++ src/bmad_loop/engine.py | 12 ++++----- src/bmad_loop/verify.py | 54 ++++++++++++++++++++++++----------------- tests/test_engine.py | 21 ++++++++++++++++ tests/test_verify.py | 52 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a420fd09..0e13b188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,10 @@ breaking changes may land in a minor release. ### Fixed +- **Treat embedded-NUL verify commands and working directories as typed environment + faults** (DW-53, DW-54), while documenting that stream retention degrades but + journal record writes remain fail-loud. + - Make successful escalation re-arms return authoritative ordered notices and a resume-hold verdict, so a corrupt journal cannot hide a persisted hold from the CLI or TUI gesture. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 9766efc0..d7e90fd7 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -4786,12 +4786,12 @@ def _journal_verify_command_results( still lands, still carrying the full byte count, because "nothing was retained" and "the command was silent" are different facts. - This is observation, so it degrades and never raises (AGENTS.md). An - ``OSError`` from the 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 the verification continues. - The alternative is a lost log killing a dev pass whose commands passed, - which trades a diagnostic for the run it was there to diagnose. + 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 diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index b4785049..0f4c085d 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -4168,12 +4168,13 @@ class CommandResult: code at all: the child was never started. The typical cause is the ``cwd`` it was to run in — missing, not a directory, or unsearchable — and the message names that directory as context, but the fault is caught as any - spawn-time ``OSError`` and the set is not closed: a missing shell, EMFILE - or ENOMEM reach the same field, and the wrapped exception is what says - which. ``None`` on every result that came from a process that actually ran — - including a timeout, which ran and hung. It is LAST and defaulted because the - construction sites pass three to seven POSITIONAL arguments; a field inserted - anywhere else would silently re-bind them. + spawn-time ``OSError`` or ``ValueError`` and the set is not closed: a missing + shell, EMFILE, ENOMEM, or an embedded NUL reach the same field, and the + wrapped exception is what says which. ``None`` on every result that came from + a process that actually ran — including a timeout, which ran and hung. It is + LAST and defaulted because the construction sites pass three to seven + POSITIONAL arguments; a field inserted anywhere else would silently re-bind + them. """ command: str @@ -4432,17 +4433,6 @@ def run_verify_commands(policy: Policy, cwd: Path) -> list[CommandResult]: errors="replace", timeout=COMMAND_TIMEOUT_S, ) - stdout, stdout_full = byte_tail(proc.stdout, MAX_STREAM_MEMORY_BYTES) - stderr, stderr_full = byte_tail(proc.stderr, MAX_STREAM_MEMORY_BYTES) - # merged from the ceilinged streams, not the raw pair: 2000 chars sits - # far below the ceiling, so the tail is identical while the full - # concatenation — a transient copy of both whole streams — is not built. - output = (stdout + stderr)[-2000:] - results.append( - CommandResult( - command, proc.returncode, output, stdout, stderr, stdout_full, stderr_full - ) - ) except subprocess.TimeoutExpired as exc: # the timeout leg is bounded too: a command killed at COMMAND_TIMEOUT_S # is exactly the one that may have been spewing output when it died. @@ -4451,15 +4441,18 @@ def run_verify_commands(policy: Policy, cwd: Path) -> list[CommandResult]: results.append( CommandResult(command, -1, "timed out", t_out, t_err, t_out_full, t_err_full) ) - except OSError as exc: + continue + except (OSError, ValueError) as exc: # The child was never started, so no exit status exists to classify: # `subprocess.run` raises out of the fork/exec (or CreateProcess) # itself when `cwd` is unusable — FileNotFoundError (missing), # NotADirectoryError (a regular file, or a path beneath one), - # PermissionError (a directory without +x). `except OSError` rather - # than the three names because they are the reachable shapes TODAY, - # not a closed set: the base class is what the platform actually - # guarantees, and one uncaught sibling here crashes the whole run. + # PermissionError (a directory without +x) — or raises ValueError + # before spawn when the command or cwd contains an embedded NUL. + # The OSError arm uses the base class rather than the three names + # because they are the reachable OS shapes TODAY, not a closed set: + # the base class is what the platform actually guarantees, and one + # uncaught sibling here crashes the whole run. # # Translated instead of raised, the same doctrine `_run_git` follows # for the faults that land before a return code exists (#343): left @@ -4487,6 +4480,23 @@ def run_verify_commands(policy: Policy, cwd: Path) -> list[CommandResult]: spawn_error=(f"child not started; cwd was {cwd}; {type(exc).__name__}: {exc}"), ) ) + continue + + # Keep result processing outside the spawn-fault handler. A ValueError + # here is a programmer defect, not rejected process configuration, and + # must remain fail-loud rather than being mislabeled as an environment + # fault. + stdout, stdout_full = byte_tail(proc.stdout, MAX_STREAM_MEMORY_BYTES) + stderr, stderr_full = byte_tail(proc.stderr, MAX_STREAM_MEMORY_BYTES) + # merged from the ceilinged streams, not the raw pair: 2000 chars sits + # far below the ceiling, so the tail is identical while the full + # concatenation — a transient copy of both whole streams — is not built. + output = (stdout + stderr)[-2000:] + results.append( + CommandResult( + command, proc.returncode, output, stdout, stderr, stdout_full, stderr_full + ) + ) return results diff --git a/tests/test_engine.py b/tests/test_engine.py index 8707aecc..43f16240 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -499,6 +499,27 @@ def test_verify_stream_capture_disabled_writes_no_files_and_still_journals(proje assert entry["output_tail"] == "tail" +def test_verify_result_journal_append_failure_propagates(project, monkeypatch): + """The durable record is fail-loud even though stream retention can degrade. + + Ablation: remove the append or catch its OSError and the expected exception + is not raised. + """ + engine = _capture_engine(project, 0) + + def failing_append(*_args, **_kwargs): + raise OSError("journal.jsonl is not writable") + + monkeypatch.setattr(engine.journal, "append", failing_append) + + with pytest.raises(OSError, match="journal.jsonl is not writable"): + engine._journal_verify_command_results( + StoryTask(story_key="1-1-a", epic=1), + "dev", + (verify.CommandResult("pytest -q", 0, "tail"),), + ) + + def test_verify_stream_capture_oserror_degrades_instead_of_killing_the_run(project, monkeypatch): """A failed retain is an observation loss, never a lost run (AGENTS.md). diff --git a/tests/test_verify.py b/tests/test_verify.py index d2ebfc12..f6682df6 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2229,6 +2229,58 @@ def test_unusable_cwd_yields_one_result_per_command(tmp_path): assert "second-check" not in outcome.reason and "third-check" not in outcome.reason +def test_embedded_nul_command_is_an_environment_fault_and_later_command_runs(tmp_path): + """A pre-spawn ValueError is typed without shortening the result list. + + The valid second command proves the loop continues after rejecting only the + first command. Ablation: remove ``ValueError`` from the spawn handler and the + raw exception escapes before the second command runs. + """ + invalid = f"{_OK}\x00ignored" + policy = Policy(verify=VerifyPolicy(commands=(invalid, _OK))) + + results = verify.run_verify_commands(policy, tmp_path) + + assert [result.command for result in results] == [invalid, _OK] + rejected, completed = results + assert rejected.returncode == verify.SPAWN_FAULT_RC + assert rejected.spawn_error is not None and "ValueError" in rejected.spawn_error + assert "ValueError" in rejected.output_tail + assert completed.returncode == 0 and completed.spawn_error is None + outcome = verify.verify_command_results_outcome(results, tmp_path) + assert not outcome.ok and outcome.env_fault + assert not outcome.retryable and not outcome.fixable + + +def test_embedded_nul_cwd_yields_one_spawn_fault_per_command(tmp_path): + """An invalid cwd rejects every spawn but still yields one typed result each.""" + cwd = Path(f"{tmp_path}\x00invalid") + commands = (_OK, _OK) + policy = Policy(verify=VerifyPolicy(commands=commands)) + + results = verify.run_verify_commands(policy, cwd) + + assert [result.command for result in results] == list(commands) + assert all(result.returncode == verify.SPAWN_FAULT_RC for result in results) + assert all(result.spawn_error and "ValueError" in result.spawn_error for result in results) + outcome = verify.verify_command_results_outcome(results, cwd) + assert not outcome.ok and outcome.env_fault + assert not outcome.retryable and not outcome.fixable + + +def test_value_error_after_process_creation_remains_fail_loud(tmp_path, monkeypatch): + """Only subprocess creation ValueErrors belong to the spawn-fault taxonomy.""" + + def broken_result_processing(_text, _max_bytes): + raise ValueError("result processing defect") + + monkeypatch.setattr(verify, "byte_tail", broken_result_processing) + policy = Policy(verify=VerifyPolicy(commands=(_OK,))) + + with pytest.raises(ValueError, match="result processing defect"): + verify.run_verify_commands(policy, tmp_path) + + def test_a_spawn_fault_unrelated_to_the_cwd_translates_too(tmp_path, monkeypatch): """The handler is `except OSError`, not three named cwd classes — and the record must not describe every one of them as a directory problem. From 2d8d4672fcf839317fcdf505cddb5fb242338f6d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 13:16:51 -0700 Subject: [PATCH 34/45] sweep dw3-root-divergence-fixture-hardening: DW-56, DW-57, DW-58, DW-59, DW-60, DW-61, DW-62, DW-63 via bmad-loop --- ...56-63-root-divergence-fixture-hardening.md | 140 ++++++++++++++++++ docs/testing.md | 29 ++++ src/bmad_loop/verify.py | 20 ++- tests/conftest.py | 72 ++++++--- tests/test_conftest.py | 113 ++++++++++++++ tests/test_engine.py | 45 ++++-- tests/test_engine_worktree.py | 65 +++++++- tests/test_hook_bus.py | 7 +- tests/test_verify.py | 26 ++++ 9 files changed, 476 insertions(+), 41 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-dw-56-63-root-divergence-fixture-hardening.md diff --git a/_bmad-output/implementation-artifacts/spec-dw-56-63-root-divergence-fixture-hardening.md b/_bmad-output/implementation-artifacts/spec-dw-56-63-root-divergence-fixture-hardening.md new file mode 100644 index 00000000..fb55e2db --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-dw-56-63-root-divergence-fixture-hardening.md @@ -0,0 +1,140 @@ +--- +title: 'DW-56 through DW-63: harden divergent-root fixtures and cwd seams' +type: 'chore' +created: '2026-09-01' +baseline_revision: '928270f6569e3cf51149dc7e4f79426326f498db' +baseline_commit: '928270f6569e3cf51149dc7e4f79426326f498db' +status: 'done' +review_loop_iteration: 0 +followup_review_recommended: false +context: + - 'docs/testing.md' +warnings: ['multiple-goals', 'oversized'] +deferred: [] +--- + + + +## Intent + +**Problem:** The divergent-root test family does not yet model its supported sibling, nested-monorepo, and isolated-worktree shapes end to end. The nested helper is not config-loadable or robust to symlinked temp roots, carries an incomplete init ignore shape, and leaves restore-patch and isolated verify-command cwd seams under-specified; seven older mocks still accept any cwd, while production and testing prose overgeneralize the sibling topology. + +**Approach:** Make the nested builder create and load a canonical project configuration, commit the complete init-like seed, and add non-blind seam and fixture-contract coverage. Pin verify execution and classification to an isolated unit worktree, make all existing command mocks assert their expected root, update topology-aware production prose, and document when each divergent-root shape is appropriate. + +## Boundaries & Constraints + +**Always:** Preserve production behavior; resolve and return canonical fixture paths; commit every nested seed file that could otherwise count as proof of work; use real relative commands for the isolated-worktree cwd row; pin both command execution and result classification; retain existing sibling and nested coverage; assert the specific refusal or path selected rather than absence alone; use portable helpers and resolved-path comparisons; add tests at the lowest relevant seam. + +**Never:** Do not edit `_bmad-output/implementation-artifacts/deferred-work.md` or any other deferred-work ledger. Do not add a new production completion path, change the supported `repo_root`/worktree-isolation policy, call a real coding CLI, replace the sibling shape, or let test residue satisfy proof-of-work. Do not change the behavior of `verify_dev_exclude_relpaths`, `_stories_relpaths`, or `Engine._verify_commands_with_results` unless a test exposes an actual defect rather than a coverage gap. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Config-loaded nested monorepo | Outer git root with BMAD project under `app/` | `load_paths(app)` returns canonical paths with outer `repo_root` and nested artifact roots | Fixture assertions fail before a misleading test runs | +| Symlink/non-canonical temp root | Nested builder receives an alias spelling | Every returned path is canonical and nested pathspecs remain non-empty | No lexical/physical mismatch is hidden as `()` | +| Nested restore patch | Relative `restore.patch` exists at both outer and nested roots | Exclusion rooted at `repo_root` selects the intended outer candidate, not the nested decoy | Wrong-root anchoring fails the value assertion | +| Isolated verify commands | Worktree isolation and a relative marker created only in the unit worktree | Execution and classification use the mounted unit worktree; dev verification succeeds | Main-checkout execution fails specifically at the command seam | +| Existing scripted verify mocks | Default workspace with mocked command results | Each mock asserts cwd resolves to `project.repo_root` before returning its scripted result | Any caller-root regression fails at the mock boundary | + + + +## Code Map + +- `src/bmad_loop/verify.py` -- `verify_dev_exclude_relpaths` and `_stories_relpaths` behavior is correct; update their sibling-only docstrings to describe both disjoint sibling and nested-monorepo outcomes. Keep implementations unchanged unless new coverage proves otherwise. +- `src/bmad_loop/engine.py` -- `_verify_commands_with_results` passes `self.workspace.root` to both `run_verify_commands` and `verify_command_results_outcome`; read-only expected behavior for the new isolation test. +- `src/bmad_loop/worktree_flow.py` -- `WorktreeFlow.run_isolated` swaps `engine.workspace` to the unit worktree before driving a story, establishing the third-root contract; read-only. +- `src/bmad_loop/bmadconfig.py` -- `load_paths` canonicalizes config-derived paths and `worktree_isolation_conflict` refuses divergent roots only with worktree isolation; reuse in fixture contract tests. +- `src/bmad_loop/install.py` -- `install_into` owns the canonical four ignore entries: `.bmad-loop/runs/`, `.bmad-loop/cache/`, `.bmad-loop/policy.toml`, and `_bmad/render/`; use as the observable reference without coupling tests to private implementation unnecessarily. +- `tests/conftest.py` -- `_file_exists_cmd`, `write_repo_root_override`, `plant_root_markers`, and `nested_repo_root_paths` are the shared seams. Canonicalize the outer root, write nested config, seed the complete ignore file, commit all seed/config files, return `load_paths(app)`, and add a cwd-asserting scripted-command helper. +- `tests/test_conftest.py` -- add exact config-load/canonicalization, non-canonical alias, ignore-shape, and isolation-conflict contracts for the nested builder. +- `tests/test_verify.py` -- preserve sibling rows and existing monorepo value/outcome rows; add nested `restore_patch` coverage that distinguishes the intended outer file from a real nested decoy. +- `tests/test_engine_worktree.py` -- reuse `wt_policy`, `commit_sprint`, `wt_dev_effect`, and `make_engine` for a full isolated run with a real relative marker command; spy only on the classifier while delegating to it. +- `tests/test_engine.py` -- replace the six `lambda policy, cwd:` command mocks with the shared cwd-asserting helper while preserving each result iterator and scenario; update stale nearby prose. +- `tests/test_hook_bus.py` -- replace its one cwd-blind command mock with the same helper. +- `docs/testing.md` -- extend the fixture/helper doctrine with sibling versus nested selection, config-loaded overrides, two-direction marker probes, and required premise guards. + +## Tasks & Acceptance + +**Execution:** +- [x] `tests/conftest.py`, `tests/test_conftest.py` -- make `nested_repo_root_paths` canonical and loadable through `_bmad/bmm/config.yaml`, seed and commit the full init ignore/config shape, return `load_paths`, and pin these contracts including an intentionally non-canonical input spelling -- prevents platform aliases and fixture residue from hiding failures. +- [x] `src/bmad_loop/verify.py`, `tests/test_verify.py` -- make docstrings topology-aware and exercise the nested `restore_patch` exclusion against an on-disk wrong-root decoy -- aligns prose and pins the restore candidate to the caller-supplied git root. +- [x] `tests/conftest.py`, `tests/test_engine.py`, `tests/test_hook_bus.py` -- add a reusable scripted verify runner that checks canonical cwd, then replace all seven cwd-discarding mocks without changing their scripted results -- prevents future callers from silently regressing to `project`. +- [x] `tests/test_engine_worktree.py` -- add a real-command full-run row proving the dev verifier executes and classifies in the mounted unit worktree rather than the main checkout -- covers the supported third-root shape. +- [x] `docs/testing.md` -- document selection and assertion rules for default, sibling, nested, and isolated-worktree roots -- makes future tests choose a shape capable of separating the behavior they claim. +- [x] Run targeted tests, perform required negative-test ablations with recoverable file copies, then run formatting, lint, typecheck, and the full suite; confirm the ledger is unchanged. + +**Acceptance Criteria:** +- Given a plain sandbox project, when `nested_repo_root_paths` builds `app/`, then `bmadconfig.load_paths(paths.project)` reproduces its canonical `ProjectPaths`, the config is committed, and isolation conflict is absent for `none` but present for `worktree`. +- Given a non-canonical alias to an existing sandbox root, when the nested builder returns, then every path is resolved, `project.parent == repo_root`, and monorepo exclude pathspecs remain non-empty. +- Given the nested builder, when its `.gitignore` is inspected, then it contains exactly the four ignore entries written by init and representative run/cache/policy/render paths are ignored. +- Given outer and nested `restore.patch` files, when `verify_dev_exclude_relpaths` is rooted on `repo_root`, then its restore entry resolves to the outer file and not the nested decoy. +- Given worktree isolation and a marker created only in the mounted unit worktree, when the dev verify command runs, then the story completes, its dev command record has return code 0, the main checkout has no marker, and both command execution and classification cwd equal the session worktree. +- Given any of the seven pre-existing scripted command mocks, when its caller invokes `run_verify_commands`, then the mock refuses a cwd whose resolved path differs from the expected `project.repo_root` while preserving the scenario's original result sequence. +- Given sibling and nested divergent-root shapes, when contributors read production and testing documentation, then the sibling-only `()` behavior and nested non-empty/separable behavior are distinguished and the required premise guards are stated. +- Given each new refusal/absence test, when its named gating or root-selection behavior is ablated, then that test fails for the asserted reason and passes again after restoration. +- Given the completed change, when targeted tests, `uv run pytest -q`, `uv run pyright`, `trunk fmt`, and `trunk check` run, then all pass and the deferred-work ledger has no diff. + +## Spec Change Log + +## Review Triage Log + +### 2026-09-01 — Review pass +- verdicts: 9 findings — high 0, medium 1, low 2, false 6, maybe-false 0 +- findings: + - `[low]` `[patch]` `docs/testing.md` implied every sibling-root shape is config-loaded, while `_repo_root_override` is a hand-built lower-level fixture — corrected the guide to distinguish hand-built seam rows from callers that use `write_repo_root_override` and `load_paths`. + - `[low]` `[patch]` `nested_repo_root_paths` missed a pre-existing dangling `app` symlink and leaked an opaque `FileExistsError` from setup — extended the precondition to reject symlinks and added a no-write contract row. + - `[false]` `[reject]` The seven scripted mocks still accept `paths.project` — in every cited scenario `project` and `repo_root` resolve to the same directory, so no bad outcome follows; the separate nested and isolated rows pin behavior where the roots differ. + - `[false]` `[reject]` The nested shape lacks a new `cli.main` row — the named gap is config loadability, which the helper now exercises by writing configuration and returning `load_paths(app)`; the reviewer demonstrated no CLI-specific bad outcome. + - `[false]` `[reject]` The fixture duplicates init's four ignore entries instead of invoking `install_into` — the intent requires seeding the complete init shape, and existing init tests plus exact fixture and `git check-ignore` assertions cover the two surfaces without a demonstrated divergence. + - `[medium]` `[patch]` The canonicalization matrix depended on a directory-symlink test that could skip on hosts without symlink capability — replaced it with a portable, non-skipping `..` alias row that still fails against unresolved returned paths and retains non-empty pathspec assertions. + - `[false]` `[reject]` No new sibling behavior was added — the bundle's sibling work is accurate selection guidance and preservation of existing coverage; no ledger entry requires another sibling implementation row. + - `[false]` `[reject]` Worktree isolation is tested only through the dev stage — both dev and fix use the same exercised `_verify_commands_with_results` method, and the existing fix-stage divergent-root row already pins both hops; no distinct isolated fix failure was demonstrated. + - `[false]` `[reject]` Restore-patch anchoring is covered at the helper rather than a complete engine restoration flow — the ledger names `verify_dev_exclude_relpaths`, and the real outer candidate plus nested decoy directly discriminate its root contract without an untested production hop. + +## Design Notes + +The three relevant cwd shapes are intentionally distinct: default/sibling tests distinguish project from configured code root; nested tests make wrong pathspecs plausible and non-empty; isolated-worktree tests introduce a third live checkout and must assert against the mounted workspace, not either original root. A single fixture cannot faithfully grade all three contracts. + +## Verification + +**Commands:** +- `uv run pytest tests/test_conftest.py tests/test_verify.py tests/test_engine.py tests/test_engine_worktree.py tests/test_hook_bus.py -q` -- expected: affected contracts pass. +- `rg -n 'lambda policy, cwd:|lambda .*cwd:' tests/test_engine.py tests/test_hook_bus.py` -- expected: no cwd-discarding verify-command mock remains. +- `uv run pytest -q` -- expected: full suite passes with zero live LLM usage. +- `uv run pyright` -- expected: zero errors. +- `trunk fmt` and `trunk check` -- expected: clean. +- `git diff --check` -- expected: no whitespace errors. +- `git diff -- _bmad-output/implementation-artifacts/deferred-work.md` -- expected: empty. + +## Auto Run Result + +Status: done + +Summary: Completed the divergent-root fixture family across sibling, nested-monorepo, and isolated-worktree shapes. The nested builder now round-trips through canonical project configuration, commits the complete init-like ignore/config seed, and supports non-blind pathspec tests; verify-command cwd coverage now includes the isolated unit worktree and every previously blind scripted mock. + +Files changed: +- `src/bmad_loop/verify.py` — make divergent-root docstrings topology-aware. +- `tests/conftest.py` — add cwd-asserting scripted verification and harden the canonical, config-loaded nested builder. +- `tests/test_conftest.py` — cover config round-trip, canonical aliases, ignore behavior, helper guards, and isolation conflict. +- `tests/test_verify.py` — pin nested restore-patch selection against a real wrong-root decoy. +- `tests/test_engine.py` — migrate six cwd-blind mocks to the shared asserting helper. +- `tests/test_engine_worktree.py` — prove real verify execution and classification use the mounted unit worktree. +- `tests/test_hook_bus.py` — migrate the remaining cwd-blind hook-bus mock. +- `docs/testing.md` — document divergent-root topology selection and assertion rules. +- `_bmad-output/implementation-artifacts/spec-dw-56-63-root-divergence-fixture-hardening.md` — record the implementation and review result. + +Review findings breakdown: 3 patches applied (high 0, medium 1, low 2), 0 items deferred, and 6 findings rejected. Rejections were: collapsed default roots have no distinct project-root failure; config loadability does not require a new CLI row; reproducing the pinned init shape has no demonstrated divergence; no new sibling behavior was required; the shared dev/fix verifier plus existing fix coverage leaves no demonstrated isolated fix gap; and direct restore-exclusion coverage observes the ledger's named surface. + +Follow-up review recommendation: false — this pass patched no high finding and only one medium finding. + +Verification performed: +- Affected suite after review patches: `1202 passed, 23 skipped`. +- Matrix audit: all six covering tests passed without skips. +- Full suite after review patches: `7826 passed, 51 skipped`. +- `uv run pyright`: 0 errors, 0 warnings, 0 informations. +- `trunk fmt`, `trunk check`, and `git diff --check`: clean. +- Search for cwd-discarding mocks: no matches in `tests/test_engine.py` or `tests/test_hook_bus.py`. +- Deferred-work ledger diff: empty. + +Residual risks: The dangling-symlink guard row skips only where the host cannot create directory symlinks; the non-skipping `..` alias row independently covers canonical path returns on every platform. Existing platform-specific skipped tests remain outside this bundle. No executable production behavior changed. diff --git a/docs/testing.md b/docs/testing.md index e116298b..511828ed 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -115,6 +115,35 @@ test: review sessions do and the orchestrator re-verifies; the bundle twins write none, because bundles have no sprint-status entry.) +Divergent-root tests choose a topology for the distinction they need to prove: + +- **Default** (`project`) keeps `project == repo_root`. Use it for ordinary sandbox behavior, + never for an assertion that claims to distinguish those roots. +- **Sibling** places `repo_root` beside or otherwise outside the BMAD project. Lower-level seam + rows may hand-build this shape (`test_verify.py::_repo_root_override`); caller/config coverage + writes it with `write_repo_root_override` and reloads it through `load_paths`. Use it to prove + that code commands run in the configured root while artifact reads stay in the project. + Artifact-derived excludes correctly collapse to `()` from the disjoint code tree; a non-empty + project-relative spelling may still match nothing there, so this shape cannot grade pathspec + selection by outcome alone. +- **Nested monorepo** (`nested_repo_root_paths`) puts the BMAD project at `/app`, writes + and commits `_bmad/bmm/config.yaml`, and returns `load_paths(app)`. Use it when both right- + and wrong-root pathspecs must be non-empty and separable: the correct value carries `app/`, + while the wrong value can select a plausible outer-tree decoy. The helper accepts alias + spellings but returns canonical paths and commits every seed/config file, so fixture residue + cannot masquerade as session work. +- **Isolated worktree** adds a third live root. Use a real relative command against a marker + created only in the mounted unit worktree; assert the main checkout lacks it, the command + record passed, and classification received the same mounted cwd. Keep the marker under a + gitignored generated-state path so it cannot merge back as proof of work. + +Every divergent-root row guards its premise before its outcome: compare resolved roots, assert +the nested parent relation when nesting matters, and for cwd tests plant/probe both directions +(`plant_root_markers`, `REPO_ROOT_MARKER_CMD`, `PROJECT_MARKER_CMD`). A positive marker identifies +the intended root; the opposite-root marker rules out the tempting alternative. When a wrong +root could still name a real path, create that decoy and assert the selected value rather than +asserting only that some path is absent or a gate refused. + **`MockAdapter` is production code** — `src/bmad_loop/adapters/mock.py`, shipped in the wheel, scripted with a list of `SessionResult`s or `callable(spec) -> SessionResult` effects. It is not reachable from configuration (no `mock` profile exists; `runsetup.make_adapters` builds diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 0f4c085d..7668de08 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3331,11 +3331,13 @@ def verify_dev_exclude_relpaths( describes. The requirement buys a caller who must think about the root, not a checker that knows the right answer. - A relpath computed against the wrong root does not raise: it simply - matches nothing on git's side, so the exclusion silently disappears and a bare - status flip starts counting as real work. The latched `restore_patch` is - anchored on the SAME root for the same reason (a relative latch names a path - in the tree it will be applied to).""" + The wrong-root symptom depends on topology. With disjoint sibling project and + code roots, a code-root relative artifact path collapses to ``()`` and a + project-root spelling is non-empty but still matches nothing in the code tree. + In a nested monorepo both spellings are non-empty: omitting the project prefix + can select a plausible outer-tree file instead of the nested artifact. The + latched `restore_patch` is anchored on the SAME root for the same reason (a + relative latch names a path in the tree it will be applied to).""" candidates: list[Path] = [paths.sprint_status, spec_path] if restore_patch: candidates.append(resolve_restore_path(restore_patch, root)) @@ -4097,9 +4099,11 @@ def _stories_relpaths(root: Path, spec_folder: Path) -> tuple[str, ...]: Empty when the spec folder is outside that tree (nothing to exclude there). ``root`` is the tree git is invoked against — `paths.repo_root` at the one - production call site, which under the `repo_root` override is NOT - `paths.project` (the spec folder then sits outside the code tree and this - correctly returns ``()``).""" + production call site. Under a disjoint sibling `repo_root` override the spec + folder sits outside the code tree and this correctly returns ``()``. Under a + nested-monorepo override it remains inside that tree and returns non-empty + paths carrying the project prefix; dropping that prefix would instead name a + plausible outer-tree location.""" from .stories import STORIES_FILENAME, STORIES_SUBDIR try: diff --git a/tests/conftest.py b/tests/conftest.py index 3420cc3c..53996b05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations -import dataclasses import io import json import shutil @@ -17,7 +16,7 @@ from bmad_loop import cli, documents, envvars, platform_util, runs from bmad_loop.adapters.base import SessionResult, SessionSpec -from bmad_loop.bmadconfig import ProjectPaths +from bmad_loop.bmadconfig import ProjectPaths, load_paths from bmad_loop.checks import ValidationReport from bmad_loop.journal import save_state from bmad_loop.model import PAUSE_ESCALATION, Phase, RunState, SessionRecord, StoryTask @@ -196,6 +195,25 @@ def _file_exists_cmd(path) -> str: return f'test -f "{path}"' +def scripted_verify_runner(expected_root: Path, next_results): + """Return a scripted ``run_verify_commands`` double that pins its cwd. + + ``next_results`` is a zero-argument callable so callers can return one fixed + result list, advance an iterator, or provide an iterator fallback without + this helper changing the scenario's existing script semantics. + """ + expected = expected_root.resolve() + + def run(_policy, cwd: Path): + actual = cwd.resolve() + assert ( + actual == expected + ), f"scripted verify runner called in the wrong root: expected {expected}, got {actual}" + return next_results() + + return run + + def passes_once(marker) -> str: """Return a host-shell command that succeeds once, then fails. @@ -641,8 +659,8 @@ def nested_repo_root_paths(paths: ProjectPaths) -> ProjectPaths: """The MONOREPO shape of the override: `repo_root` an ANCESTOR of `project`. The BMAD project lives at ``/app`` inside a checkout whose root is the - git root — `repo_root` stays `paths.project` (the sandbox repo) while - `project` and all three artifact dirs move under ``app/``. + git root. The helper writes the same config shape production loads, then + returns :func:`load_paths`' canonical snapshot rather than hand-building one. Why a second shape at all. `tests/test_verify.py::_repo_root_override` builds the SIBLING shape, where the artifact tree is disjoint from the code tree — so @@ -661,10 +679,11 @@ def nested_repo_root_paths(paths: ProjectPaths) -> ProjectPaths: for the reason `plant_root_markers` gives: a session's edit to a TRACKED file is proof of work the attempt's baseline snapshot cannot absorb. - Also writes ``app/.gitignore`` with the `bmad-loop init` run-state entry. + Also writes ``app/.gitignore`` with all four entries `bmad-loop init` owns. Init writes that file next to the project it initializes, and the sandbox - template's own root-anchored ``.bmad-loop/runs/`` does not match a nested one — - so without it a nested engine run's journal would show up as untracked work. + template's root-anchored entries do not match nested state — so without the + nested file run state, caches, policy, and renderer output can show up as + untracked work. The subdirectory is FIXED at `NESTED_SUBDIR` rather than a parameter because every consumer's assertions spell the ``app/`` prefix literally. A parameter @@ -677,20 +696,21 @@ def nested_repo_root_paths(paths: ProjectPaths) -> ProjectPaths: naming neither the helper nor the precondition. Both guards below fail with the precondition instead. """ - assert paths.project == paths.repo_root, ( + assert paths.project.resolve() == paths.repo_root.resolve(), ( "nested_repo_root_paths builds the divergence; it cannot be applied to paths " "that already have one. Pass the plain `project` fixture." ) - staged = git(paths.project, "diff", "--cached", "--name-only") + repo_root = paths.project.resolve() + staged = git(repo_root, "diff", "--cached", "--name-only") assert not staged, ( "nested_repo_root_paths commits its seed files and requires an empty index; " f"already staged: {staged}" ) - project = paths.project / NESTED_SUBDIR - assert not project.exists(), ( - f"{NESTED_SUBDIR}/ already exists under {paths.project}: this helper seeds and " - "COMMITS it, so a second call (or a caller that pre-created it) would reach " - "`git commit` with nothing staged." + project = repo_root / NESTED_SUBDIR + assert not project.exists() and not project.is_symlink(), ( + f"{NESTED_SUBDIR}/ already exists or is a symlink under {repo_root}: this helper " + "seeds and COMMITS it, so a second call (or a caller that pre-created it) would " + "reach setup/commit with an opaque filesystem or git error." ) output_folder = project / "_bmad-output" impl = output_folder / "implementation-artifacts" @@ -698,17 +718,29 @@ def nested_repo_root_paths(paths: ProjectPaths) -> ProjectPaths: impl.mkdir(parents=True, exist_ok=True) plan.mkdir(parents=True, exist_ok=True) (project / "src.txt").write_text("original\n", encoding="utf-8") - (project / ".gitignore").write_text(".bmad-loop/runs/\n", encoding="utf-8") - git(paths.project, "add", f"{NESTED_SUBDIR}/src.txt", f"{NESTED_SUBDIR}/.gitignore") - git(paths.project, "commit", "-q", "-m", f"seed the {NESTED_SUBDIR}/ project") - return dataclasses.replace( - paths, + (project / ".gitignore").write_text( + "\n".join( + ( + ".bmad-loop/runs/", + ".bmad-loop/cache/", + ".bmad-loop/policy.toml", + "_bmad/render/", + ) + ) + + "\n", + encoding="utf-8", + ) + configured = ProjectPaths( project=project, implementation_artifacts=impl, planning_artifacts=plan, output_folder=output_folder, - repo_root=paths.project, + repo_root=repo_root, ) + write_repo_root_override(configured, repo_root) + git(repo_root, "add", NESTED_SUBDIR) + git(repo_root, "commit", "-q", "-m", f"seed the {NESTED_SUBDIR}/ project") + return load_paths(project) UNRESOLVABLE = "stubbed: the provider is registered but not serving" diff --git a/tests/test_conftest.py b/tests/test_conftest.py index 447c9a10..747c077c 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -117,6 +117,100 @@ def test_write_repo_root_override_refuses_a_relative_code_root(project): assert not (project.project / conftest.BMAD_CONFIG_REL).exists() +def test_scripted_verify_runner_refuses_the_wrong_canonical_cwd(tmp_path): + """A cwd-discarding command double cannot hide a caller-root regression. + + Ablation: delete the cwd equality assertion in `scripted_verify_runner` and + this row fails because the wrong-root call no longer raises. + """ + expected = tmp_path / "expected" + wrong = tmp_path / "wrong" + expected.mkdir() + wrong.mkdir() + runner = conftest.scripted_verify_runner(expected, lambda: ["scripted"]) + + with pytest.raises(AssertionError, match="wrong root"): + runner(None, wrong) + + assert runner(None, expected / ".." / expected.name) == ["scripted"] + + +def test_nested_repo_root_paths_round_trips_committed_config_and_conflict(project): + """The nested fixture is a production-loadable config, not a hand-built snapshot. + + Ablation: short-circuit `worktree_isolation_conflict` for the worktree mode + and this row fails because the divergent loaded config is no longer refused. + """ + paths = conftest.nested_repo_root_paths(project) + + assert bmadconfig.load_paths(paths.project) == paths + assert paths.project == paths.project.resolve() + assert paths.repo_root == paths.repo_root.resolve() + assert paths.project.parent == paths.repo_root + config_rel = (paths.project / conftest.BMAD_CONFIG_REL).relative_to(paths.repo_root) + assert conftest.git(paths.repo_root, "ls-files", "--error-unmatch", config_rel.as_posix()) + assert bmadconfig.worktree_isolation_conflict(paths, "none") is None + conflict = bmadconfig.worktree_isolation_conflict(paths, "worktree") + assert conflict is not None and "not supported" in conflict + + +def test_nested_repo_root_paths_canonicalizes_a_dotdot_input(project): + """A portable alias spelling cannot collapse pathspecs through mixed roots.""" + alias = project.project / ".." / project.project.name + assert alias != alias.resolve() + assert alias.resolve() == project.project.resolve() + aliased = replace( + project, + project=alias, + implementation_artifacts=alias / "_bmad-output" / "implementation-artifacts", + planning_artifacts=alias / "_bmad-output" / "planning-artifacts", + output_folder=alias / "_bmad-output", + repo_root=alias, + ) + + paths = conftest.nested_repo_root_paths(aliased) + + assert all( + path == path.resolve() + for path in ( + paths.project, + paths.implementation_artifacts, + paths.planning_artifacts, + paths.output_folder, + paths.repo_root, + ) + ) + assert paths.project.parent == paths.repo_root == project.project.resolve() + spec = paths.implementation_artifacts / "spec-1-1-a.md" + assert verify.verify_dev_exclude_relpaths(paths, spec, root=paths.repo_root) + assert verify._stories_relpaths(paths.repo_root, paths.planning_artifacts / "epic-a") + + +def test_nested_repo_root_paths_seeds_the_complete_init_ignore_shape(project): + """Nested generated state cannot become proof-of-work residue.""" + paths = conftest.nested_repo_root_paths(project) + expected = [ + ".bmad-loop/runs/", + ".bmad-loop/cache/", + ".bmad-loop/policy.toml", + "_bmad/render/", + ] + assert (paths.project / ".gitignore").read_text(encoding="utf-8").splitlines() == expected + + candidates = [ + ".bmad-loop/runs/run/state.json", + ".bmad-loop/cache/plugin/cache.bin", + ".bmad-loop/policy.toml", + "_bmad/render/skill/workflow.md", + ] + for rel in candidates: + path = paths.project / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("generated\n", encoding="utf-8") + ignored = conftest.git(paths.repo_root, "check-ignore", *[f"app/{rel}" for rel in candidates]) + assert ignored.splitlines() == [f"app/{rel}" for rel in candidates] + + def test_nested_repo_root_paths_refuses_a_nonempty_index(project): """Its seed commit must never absorb setup another fixture already staged.""" staged = project.project / "staged.txt" @@ -154,6 +248,25 @@ def test_nested_repo_root_paths_refuses_an_existing_nested_project(project): assert not (nested / ".gitignore").exists() +def test_nested_repo_root_paths_refuses_a_dangling_nested_symlink(project): + """A dangling `app` alias hits the helper precondition before any writes.""" + nested = project.project / conftest.NESTED_SUBDIR + missing = project.project / "missing-app-target" + try: + nested.symlink_to(missing, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + assert nested.is_symlink() and not nested.exists() + status_before = conftest.git(project.project, "status", "--porcelain") + + with pytest.raises(AssertionError, match="already exists or is a symlink"): + conftest.nested_repo_root_paths(project) + + assert nested.is_symlink() and not nested.exists() + assert not missing.exists() + assert conftest.git(project.project, "status", "--porcelain") == status_before + + def test_template_leaves_no_detached_git_maintenance_writing_into_the_copies(project, tmp_path): """No background git process may outlive a commit into the sandbox. diff --git a/tests/test_engine.py b/tests/test_engine.py index 43f16240..e8dc114c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -34,6 +34,7 @@ plant_root_markers, refuse_to_resolve, review_effect, + scripted_verify_runner, set_sprint, spec_path, write_gated_ledger, @@ -184,7 +185,11 @@ def test_post_dev_verify_exposes_journaled_command_results(project, monkeypatch) capture = _PostDevVerifyCaptureBus() engine._bus = capture result = verify.CommandResult("pytest -q", 0, "out\nerr\n", "out\n", "err\n") - monkeypatch.setattr(verify, "run_verify_commands", lambda policy, cwd: [result]) + monkeypatch.setattr( + verify, + "run_verify_commands", + scripted_verify_runner(project.repo_root, lambda: [result]), + ) summary = engine.run() @@ -238,7 +243,11 @@ def test_review_gate_verify_commands_are_journalled_under_the_review_stage(proje ), ) result = verify.CommandResult("pytest -q", 0, "out\nerr\n", "out\n", "err\n") - monkeypatch.setattr(verify, "run_verify_commands", lambda policy, cwd: [result]) + monkeypatch.setattr( + verify, + "run_verify_commands", + scripted_verify_runner(project.repo_root, lambda: [result]), + ) summary = engine.run() @@ -544,7 +553,10 @@ def test_verify_stream_capture_oserror_degrades_instead_of_killing_the_run(proje monkeypatch.setattr( verify, "run_verify_commands", - lambda policy, cwd: [verify.CommandResult("pytest -q", 0, "tail", "out\n", "err\n")], + scripted_verify_runner( + project.repo_root, + lambda: [verify.CommandResult("pytest -q", 0, "tail", "out\n", "err\n")], + ), ) def _enospc(*_args, **_kwargs): @@ -773,7 +785,11 @@ def _dev_then_fix_run(project, monkeypatch, capture): [verify.CommandResult("pytest -q", 0, "final", "final-out", "")], ] ) - monkeypatch.setattr(verify, "run_verify_commands", lambda policy, cwd: next(calls)) + monkeypatch.setattr( + verify, + "run_verify_commands", + scripted_verify_runner(project.repo_root, lambda: next(calls)), + ) return engine, engine.run() @@ -881,7 +897,11 @@ def test_a_critical_session_emits_post_dev_verify_on_both_legs(project, monkeypa [verify.CommandResult("pytest -q", 0, "fix", "fix-out", "")], ] ) - monkeypatch.setattr(verify, "run_verify_commands", lambda policy, cwd: next(calls)) + monkeypatch.setattr( + verify, + "run_verify_commands", + scripted_verify_runner(project.repo_root, lambda: next(calls)), + ) summary = engine.run() @@ -2438,7 +2458,10 @@ def test_dev_retry_notice_collapses_a_multiline_reason(project, monkeypatch): monkeypatch.setattr( verify, "run_verify_commands", - lambda policy, cwd: next(failing, [verify.CommandResult("pytest -q", 0, "ok")]), + scripted_verify_runner( + project.repo_root, + lambda: next(failing, [verify.CommandResult("pytest -q", 0, "ok")]), + ), ) assert engine.run().done == 1 @@ -2553,11 +2576,11 @@ def resolve_fault(self, *args, **kwargs): # # `Engine._verify_commands_with_results` runs the commands in `self.workspace.root` # — which `Workspace.default` sets to `paths.repo_root`, the CODE tree. Both of its -# stages (`dev` and `fix`) were unpinned: every other engine row mocks -# `verify.run_verify_commands` with a `lambda policy, cwd:` that DISCARDS the cwd, -# so moving the root back to `paths.project` left the whole suite green. These two -# rows therefore run the real commands, and use `conftest.nested_repo_root_paths` -# so the two roots are genuinely different directories. +# stages (`dev` and `fix`) once had only cwd-discarding scripted doubles, so moving +# the root back to `paths.project` left the whole suite green. The shared scripted +# helper now pins the default-root scenarios too; these two rows still run real +# commands under `conftest.nested_repo_root_paths` to grade the divergent behavior +# an operator sees. @pytest.mark.parametrize("marker_root", ["repo_root", "project"]) diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 2473e157..f4c6a7ee 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -10,12 +10,14 @@ import shutil import sys +from dataclasses import replace from pathlib import Path import pytest from conftest import ( _OK, _exists_run, + _file_exists_cmd, _seeded_then_touch, _spec_baseline, _touch_run, @@ -45,7 +47,14 @@ ) from bmad_loop.journal import Journal, load_state from bmad_loop.model import Phase, RunState, SessionRecord, StoryTask, TokenUsage -from bmad_loop.policy import GatesPolicy, LimitsPolicy, NotifyPolicy, Policy, ScmPolicy +from bmad_loop.policy import ( + GatesPolicy, + LimitsPolicy, + NotifyPolicy, + Policy, + ScmPolicy, + VerifyPolicy, +) from bmad_loop.verify import ( branch_exists, current_branch, @@ -237,6 +246,60 @@ def test_worktree_happy_path_merges_to_target(project): assert "worktree-teardown-degraded" not in kinds +def test_isolated_verify_commands_execute_and_classify_in_the_unit_worktree(project, monkeypatch): + """A relative dev command is rooted on the live isolated checkout. + + The marker exists only under the mounted unit worktree and is gitignored, so + its successful real command pins execution without merging test residue back + to the main checkout. The classifier spy delegates to production and pins the + second cwd hop independently. + + Ablation: hand `project.repo_root` to either verifier call in + `Engine._verify_commands_with_results`; execution then records rc 1, while a + classifier-only change leaves the command green but reddens the cwd assertion. + """ + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + marker = Path(".bmad-loop") / "runs" / "unit-only-verify.marker" + assert not (project.repo_root / marker).exists() + mounted: dict[str, Path] = {} + base_effect = wt_dev_effect(project, "1-1-a", followup_review=False) + + def dev_with_marker(spec): + mounted["root"] = spec.cwd.resolve() + marker_path = spec.cwd / marker + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text("unit only\n", encoding="utf-8") + return base_effect(spec) + + classified: list[Path] = [] + real_classify = verify.verify_command_results_outcome + + def spy_classify(results, cwd): + classified.append(cwd.resolve()) + return real_classify(results, cwd) + + monkeypatch.setattr(verify, "verify_command_results_outcome", spy_classify) + policy = replace( + wt_policy(), + verify=VerifyPolicy(commands=(_file_exists_cmd(marker.as_posix()),)), + ) + engine, _ = make_engine(project, [dev_with_marker], policy=policy) + + summary = engine.run() + + assert summary.done == 1 and not summary.paused + unit_root = mounted["root"] + assert unit_root != project.repo_root.resolve() + assert not (project.repo_root / marker).exists() + (dev_record,) = [ + entry + for entry in engine.journal.entries() + if entry["kind"] == "verify-command-result" and entry["verification_stage"] == "dev" + ] + assert dev_record["returncode"] == 0 + assert classified and all(cwd == unit_root for cwd in classified) + + def test_local_absolute_ignored_accepted_spec_is_seeded_and_bound_in_mount(project): """Relativizing an accepted spec also delivers it to a tracked-only checkout.""" rel = "_bmad-output/implementation-artifacts/accepted-untracked.md" diff --git a/tests/test_hook_bus.py b/tests/test_hook_bus.py index 2440940d..4a976967 100644 --- a/tests/test_hook_bus.py +++ b/tests/test_hook_bus.py @@ -23,6 +23,7 @@ dev_effect, needs_strict_codec, review_effect, + scripted_verify_runner, write_sprint, ) @@ -446,7 +447,11 @@ def on_post_dev_verify(self, c): seen.append((c.verification_stage, c.verification_sequence, c.command_results)) result = verify.CommandResult("pytest -q", 0, "tail", "out", "err") - monkeypatch.setattr(verify, "run_verify_commands", lambda policy, cwd: [result]) + monkeypatch.setattr( + verify, + "run_verify_commands", + scripted_verify_runner(project.repo_root, lambda: [result]), + ) engine, _ = make_engine(project, one_story(project), registry_of(py_plugin(P, "verifyobs"))) summary = engine.run() diff --git a/tests/test_verify.py b/tests/test_verify.py index f6682df6..1e24b632 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -6290,6 +6290,32 @@ def test_verify_dev_exclude_relpaths_separates_the_two_roots_in_a_monorepo(proje assert (paths.repo_root / from_project[0]) != paths.sprint_status +def test_verify_dev_exclude_relpaths_roots_restore_patch_on_the_outer_repo(project): + """A relative restore latch selects the code-root file, not a nested decoy. + + Both candidates exist so the assertion grades root selection by value; an + absence-only assertion would pass if setup simply forgot to create the decoy. + + Ablation: resolve `restore_patch` against `paths.project` instead of `root` + and the final equality selects `app/restore.patch`, reddening this row. + """ + paths = nested_repo_root_paths(project) + assert paths.project.parent == paths.repo_root + outer = paths.repo_root / "restore.patch" + decoy = paths.project / "restore.patch" + outer.write_text("outer\n", encoding="utf-8") + decoy.write_text("nested decoy\n", encoding="utf-8") + assert outer.is_file() and decoy.is_file() and outer.resolve() != decoy.resolve() + sp = paths.implementation_artifacts / "spec-1-1-a.md" + + relpaths = verify.verify_dev_exclude_relpaths(paths, sp, "restore.patch", root=paths.repo_root) + + restore_rel = relpaths[-1] + assert restore_rel == "restore.patch" + assert (paths.repo_root / restore_rel).resolve() == outer.resolve() + assert (paths.repo_root / restore_rel).resolve() != decoy.resolve() + + def test_stories_relpaths_separates_the_two_roots_in_a_monorepo(project): """Same rule, same shape, for the stories-mode exclude. From 2ce281c5cbd41d306690598bfd46793d52673f14 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 13:29:04 -0700 Subject: [PATCH 35/45] sweep dw3-session-artifact-contract-docs: DW-67, DW-75 via bmad-loop --- docs/FEATURES.md | 2 +- docs/adapter-authoring-guide.md | 12 +++++++++++- tests/test_portability_guard.py | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a6b321f5..1b34e387 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -212,7 +212,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev, repair and review legs alike, carrying `verification_stage` and a per-story `verification_sequence` that orders the passes across all three; the two passes that leave no record are `bmad-loop confirm --reverify`, which runs after the run is over, and any pass with no `[verify] commands` configured, which records nothing because nothing ran — each entry also carrying `spawn_error`, set when the verify command's child could not be started at all — typically because its working directory is missing, is not a directory, or cannot be searched, though any spawn-time `OSError` (a missing shell, EMFILE, ENOMEM) reaches the same field and the wrapped exception is what names the cause — which is an environment fault that pauses the run rather than a command that failed — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it); `tasks//` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). +- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev, repair and review legs alike, carrying `verification_stage` and a per-story `verification_sequence` that orders the passes across all three; the two passes that leave no record are `bmad-loop confirm --reverify`, which runs after the run is over, and any pass with no `[verify] commands` configured, which records nothing because nothing ran — each entry also carrying `spawn_error`, set when the verify command's child could not be started at all — typically because its working directory is missing, is not a directory, or cannot be searched, though any spawn-time `OSError` (a missing shell, EMFILE, ENOMEM) reaches the same field and the wrapped exception is what names the cause — which is an environment fault that pauses the run rather than a command that failed — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it); `tasks//` (per-session prompt + shared artifacts: [`result.json`, `escalation.json`] — respectively the per-session result and escalation outputs — plus adapter-specific breadcrumbs: `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 883f585f..3cbae97e 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -581,7 +581,17 @@ Three frozen dataclasses cross the seam: Required (abstract): -- `start_session(spec) -> SessionHandle` — launch the session. +- `start_session(spec) -> SessionHandle` — launch the session. An adapter that + persists the standard `tasks//` directory must reset its shared cycle + artifacts when an id is reused: after creating the task directory and before + launching the session, remove every file named by + `journal.TASK_CYCLE_ARTIFACTS` (shared artifacts: [`result.json`, + `escalation.json`]). + Use missing-safe deletion; a missing artifact is a normal no-op and must not + make startup fail. This tuple covers only artifacts shared across adapters and + readers. Adapter-private breadcrumbs such as `heartbeat.json`, + `resultless-stops.jsonl`, `session-lifecycle.jsonl`, and `messages.json` remain + outside the shared cleanup contract and are managed by their owning adapter. - `wait_for_completion(handle, spec) -> SessionResult` — block until the session ends (or stalls/times out), then report status. Poll `runs.read_stop_request_mode(run_dir) == "hard"` on both sides of the loop's diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 9023142e..2b31d0bf 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -2119,6 +2119,33 @@ def test_task_cycle_artifacts_named_only_through_the_constant(): ) +def test_task_cycle_artifact_docs_track_the_canonical_tuple(): + """The run inventory and extension boundary keep pace with the shared list.""" + project_root = Path(__file__).resolve().parents[1] + features = (project_root / "docs/FEATURES.md").read_text(encoding="utf-8") + inventory = features.split("- All run state in", 1)[1].split("\n- ", 1)[0] + guide = (project_root / "docs/adapter-authoring-guide.md").read_text(encoding="utf-8") + start_session_contract = guide.split("- `start_session", 1)[1].split( + "- `wait_for_completion", 1 + )[0] + + def shared_artifacts(contract: str) -> set[str]: + listing = contract.split("shared artifacts: [", 1)[1].split("]", 1)[0] + return set(listing.split("`")[1::2]) + + canonical = set(TASK_CYCLE_ARTIFACTS) + assert shared_artifacts(inventory) == canonical + assert shared_artifacts(start_session_contract) == canonical + + assert "`journal.TASK_CYCLE_ARTIFACTS`" in start_session_contract + assert ( + "after creating the task directory and before\n launching the session" + in start_session_contract + ) + assert "a missing artifact is a normal no-op" in start_session_contract + assert "Adapter-private breadcrumbs" in start_session_contract + + def _session_task_id_offenders(findings) -> list[tuple[str, int, str]]: """The chokepoint invariant as a filter: a composed task id is sanctioned only in a ``SESSION_TASK_ID_CHOKEPOINT`` file AND only inside that file's one listed From 5399d2032ae9d5225dca1b8a41a183b6123e1da0 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 14:14:49 -0700 Subject: [PATCH 36/45] sweep dw3-adapter-task-dir-confinement: DW-74 via bmad-loop --- CHANGELOG.md | 4 + src/bmad_loop/adapters/base.py | 92 ++++++++ src/bmad_loop/adapters/generic.py | 30 ++- src/bmad_loop/adapters/opencode_http.py | 25 +- tests/test_generic_tmux.py | 288 +++++++++++++++++++++++- tests/test_opencode_http.py | 255 ++++++++++++++++++++- 6 files changed, 686 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e13b188..9331c73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,10 @@ breaking changes may land in a minor release. ### Fixed +- **Confine built-in adapter task directories** (DW-74), refusing unsafe task ids and + symlink- or junction-redirected task directories before prompt, artifact, log, or + transport side effects. + - **Treat embedded-NUL verify commands and working directories as typed environment faults** (DW-53, DW-54), while documenting that stream retention degrades but journal record writes remain fail-loud. diff --git a/src/bmad_loop/adapters/base.py b/src/bmad_loop/adapters/base.py index 7c1daef6..f021ff5e 100644 --- a/src/bmad_loop/adapters/base.py +++ b/src/bmad_loop/adapters/base.py @@ -14,12 +14,104 @@ from __future__ import annotations +import stat from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path from typing import Any from ..model import TokenUsage +from ..platform_util import is_link_like, safe_segment + + +class AdapterTaskDirectoryError(ValueError): + """A built-in adapter refused an unsafe or redirected task directory.""" + + +def validated_task_directory(tasks_dir: Path, task_id: str) -> Path: + """Return ``tasks/`` only when its authored name is confined. + + Validation is deliberately identity-based rather than sanitizing: callers use + ``task_id`` for handles, environment, logs, and artifacts, so rewriting it here + would split one session across multiple identities. The link-like check covers + both symlinks and Windows directory junctions through ``platform_util``. + + This is a pre-write boundary, not descriptor-anchored I/O; callers must invoke + it before any operation derived from the task id. + """ + if safe_segment(task_id) != task_id: + raise AdapterTaskDirectoryError( + f"unsafe adapter task id {task_id!r}: expected one clean path segment" + ) + + if is_link_like(tasks_dir): + raise AdapterTaskDirectoryError( + f"adapter tasks directory is a symlink or junction: {tasks_dir}" + ) + + task_dir = tasks_dir / task_id + if is_link_like(task_dir): + raise AdapterTaskDirectoryError( + f"adapter task directory is a symlink or junction: {task_dir}" + ) + return task_dir + + +def validate_adapter_artifact_paths(root_dir: Path, paths: tuple[Path, ...]) -> None: + """Refuse redirecting or special standing entries before adapter writes. + + A regular file with one link is the only existing leaf an adapter may open in + place. Symlinks, junctions, hardlinks, FIFOs, and devices can redirect or + block a later write; callers provide every leaf they will write during the + session and invoke this boundary before mutating any task or log artifact. + """ + if is_link_like(root_dir): + raise AdapterTaskDirectoryError( + f"adapter artifact directory is a symlink or junction: {root_dir}" + ) + + for path in paths: + if is_link_like(path): + raise AdapterTaskDirectoryError(f"adapter artifact is a symlink or junction: {path}") + try: + entry = path.lstat() + except FileNotFoundError: + continue + except OSError as exc: + raise AdapterTaskDirectoryError( + f"cannot inspect adapter artifact before writing: {path}" + ) from exc + if not stat.S_ISREG(entry.st_mode) or entry.st_nlink != 1: + raise AdapterTaskDirectoryError( + f"adapter artifact is special or multiply linked: {path}" + ) + + +def reset_task_prompt(task_dir: Path, prompt: str) -> None: + """Write ``prompt.txt`` without following a redirecting filesystem entry. + + A normal single-link file is truncated in place so its inode and metadata keep + the clean-session behavior. A symlink, hardlink, FIFO, or device is unlinked + first so the replacement is an ordinary file and no outside target is touched. + """ + prompt_path = task_dir / "prompt.txt" + try: + entry = prompt_path.lstat() + except FileNotFoundError: + pass + except OSError as exc: + raise AdapterTaskDirectoryError( + f"cannot inspect adapter prompt before writing: {prompt_path}" + ) from exc + else: + if not stat.S_ISREG(entry.st_mode) or entry.st_nlink != 1: + try: + prompt_path.unlink() + except OSError as exc: + raise AdapterTaskDirectoryError( + f"cannot replace unsafe adapter prompt: {prompt_path}" + ) from exc + prompt_path.write_text(prompt + "\n", encoding="utf-8") @dataclass(frozen=True) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index e1538fec..1d1440c5 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -39,7 +39,16 @@ from ..signals import SignalWatcher from ..tokens import read_usage as tally_usage from ..verify import read_frontmatter, status_of -from .base import CodingCLIAdapter, SessionHandle, SessionResult, SessionSpec, SpecSnapshot +from .base import ( + CodingCLIAdapter, + SessionHandle, + SessionResult, + SessionSpec, + SpecSnapshot, + reset_task_prompt, + validate_adapter_artifact_paths, + validated_task_directory, +) # Re-exported for importers that predate the env_fault module split (#194 landed # these names on this module); the definitions now live in .env_fault. The @@ -538,9 +547,24 @@ def build_command(self, spec: SessionSpec) -> str: # --------------------------------------------------------------- adapter def start_session(self, spec: SessionSpec) -> SessionHandle: - task_dir = self.tasks_dir / spec.task_id + task_dir = validated_task_directory(self.tasks_dir, spec.task_id) + validate_adapter_artifact_paths( + task_dir, + tuple( + task_dir / name + for name in ( + "heartbeat.json", + "resultless-stops.jsonl", + "session-lifecycle.jsonl", + ) + ), + ) + validate_adapter_artifact_paths( + self.logs_dir, + (self.logs_dir / f"{spec.task_id}.log",), + ) task_dir.mkdir(parents=True, exist_ok=True) - (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8") + reset_task_prompt(task_dir, spec.prompt) # Task ids are supplied by the caller, so defensively reset cycle-scoped # outputs if one is reused. A silent session must not inherit a stale result. # The list is `journal.TASK_CYCLE_ARTIFACTS` rather than two literals here: diff --git a/src/bmad_loop/adapters/opencode_http.py b/src/bmad_loop/adapters/opencode_http.py index 032c4f81..bba082ad 100644 --- a/src/bmad_loop/adapters/opencode_http.py +++ b/src/bmad_loop/adapters/opencode_http.py @@ -150,7 +150,15 @@ from ..model import TokenUsage from ..policy import Policy from ..process_host import ProcessHostError, get_process_host -from .base import CodingCLIAdapter, SessionHandle, SessionResult, SessionSpec +from .base import ( + CodingCLIAdapter, + SessionHandle, + SessionResult, + SessionSpec, + reset_task_prompt, + validate_adapter_artifact_paths, + validated_task_directory, +) from .env_fault import EnvFaultMixin from .generic import ( BUDGET_NUDGE_TEXT, @@ -619,9 +627,20 @@ def _await_healthy(self, sess: _ServerSession) -> bool: # -------------------------------------------------------------- adapter def start_session(self, spec: SessionSpec) -> SessionHandle: - task_dir = self.tasks_dir / spec.task_id + task_dir = validated_task_directory(self.tasks_dir, spec.task_id) + validate_adapter_artifact_paths( + task_dir, + (task_dir / "messages.json",), + ) + log_paths = [ + self.logs_dir / f"{spec.task_id}.log", + self.logs_dir / f"{spec.task_id}.server.out", + ] + if self.sse_trace: + log_paths.append(self.logs_dir / f"{spec.task_id}.sse.jsonl") + validate_adapter_artifact_paths(self.logs_dir, tuple(log_paths)) task_dir.mkdir(parents=True, exist_ok=True) - (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8") + reset_task_prompt(task_dir, spec.prompt) # Task ids are supplied by the caller, so defensively reset cycle-scoped # outputs if one is reused. A silent session must not inherit a stale result. # Iterating `journal.TASK_CYCLE_ARTIFACTS` is what makes the parity with diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index ba2dc70f..55fd7e66 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -23,8 +23,15 @@ import regex from bmad_loop import devcontract, runs +from bmad_loop.adapters import base as adapter_base from bmad_loop.adapters import env_fault, generic, tmux_base -from bmad_loop.adapters.base import SessionHandle, SessionResult, SessionSpec, SpecSnapshot +from bmad_loop.adapters.base import ( + AdapterTaskDirectoryError, + SessionHandle, + SessionResult, + SessionSpec, + SpecSnapshot, +) from bmad_loop.adapters.generic import GenericDevAdapter, GenericTmuxAdapter from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.adapters.profile import get_profile @@ -3094,8 +3101,10 @@ class _StartSessionMux: def __init__(self): self.piped: list[tuple[str, Path]] = [] + self.windows: list[tuple[str, str, Path]] = [] def new_window(self, session_name, window_name, cwd, env, cmd): + self.windows.append((session_name, window_name, Path(cwd))) return "@1" def pipe_pane(self, window_id, log_file): @@ -3107,6 +3116,283 @@ def has_session(self, name): return True +@pytest.mark.parametrize( + "task_id_kind", ["absolute", "parent-traversal", "empty", "windows-reserved"] +) +def test_start_session_refuses_unconfined_task_id_without_side_effects(tmp_path, task_id_kind): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "prompt.txt").write_text("theirs", encoding="utf-8") + for artifact in TASK_CYCLE_ARTIFACTS: + (outside / artifact).write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in outside.iterdir()} + task_ids = { + "absolute": str(outside), + "parent-traversal": str(Path("..") / ".." / "outside"), + "empty": "", + "windows-reserved": "CON", + } + task_id = task_ids[task_id_kind] + escaped_log = adapter.logs_dir / f"{task_id}.log" + + with pytest.raises(AdapterTaskDirectoryError, match="expected one clean path segment"): + adapter.start_session(make_spec(tmp_path, task_id=task_id)) + + assert {path.name: path.read_bytes() for path in outside.iterdir()} == before + assert list(adapter.tasks_dir.iterdir()) == [] + assert list(adapter.logs_dir.iterdir()) == [] + assert not escaped_log.exists() + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +def test_start_session_refuses_symlinked_task_directory_without_side_effects(tmp_path): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "prompt.txt").write_text("theirs", encoding="utf-8") + for artifact in TASK_CYCLE_ARTIFACTS: + (outside / artifact).write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in outside.iterdir()} + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + try: + task_dir.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + + with pytest.raises(AdapterTaskDirectoryError, match="symlink or junction"): + adapter.start_session(make_spec(tmp_path, task_id=task_id)) + + assert task_dir.is_symlink() + assert {path.name: path.read_bytes() for path in outside.iterdir()} == before + assert list(adapter.logs_dir.iterdir()) == [] + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +def test_start_session_refuses_symlinked_tasks_root_without_side_effects(tmp_path): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "theirs.txt").write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in outside.iterdir()} + adapter.tasks_dir.rmdir() + try: + adapter.tasks_dir.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + + with pytest.raises(AdapterTaskDirectoryError, match="tasks directory is a symlink"): + adapter.start_session(make_spec(tmp_path, task_id="clean-task")) + + assert adapter.tasks_dir.is_symlink() + assert {path.name: path.read_bytes() for path in outside.iterdir()} == before + assert list(adapter.logs_dir.iterdir()) == [] + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +def test_start_session_refuses_junction_like_tasks_root_before_mutation(tmp_path, monkeypatch): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + monkeypatch.setattr(adapter_base, "is_link_like", lambda path: Path(path) == adapter.tasks_dir) + + with pytest.raises(AdapterTaskDirectoryError, match="tasks directory is a symlink"): + adapter.start_session(make_spec(tmp_path, task_id="clean-task")) + + assert list(adapter.tasks_dir.iterdir()) == [] + assert list(adapter.logs_dir.iterdir()) == [] + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +def test_start_session_replaces_prompt_symlink_without_following_it(tmp_path): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + adapter._ensure_session = lambda cwd: None + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + outside_prompt = tmp_path / "outside-prompt.txt" + outside_prompt.write_text("theirs", encoding="utf-8") + prompt_path = task_dir / "prompt.txt" + try: + prompt_path.symlink_to(outside_prompt) + except OSError as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + spec = make_spec(tmp_path, task_id=task_id) + + adapter.start_session(spec) + + assert outside_prompt.read_text(encoding="utf-8") == "theirs" + assert not prompt_path.is_symlink() + assert prompt_path.read_text(encoding="utf-8") == spec.prompt + "\n" + assert len(mux.windows) == 1 + assert len(mux.piped) == 1 + + +def test_start_session_replaces_prompt_hardlink_without_following_it(tmp_path): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + adapter._ensure_session = lambda cwd: None + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + outside_prompt = tmp_path / "outside-prompt.txt" + outside_prompt.write_text("theirs", encoding="utf-8") + prompt_path = task_dir / "prompt.txt" + try: + os.link(outside_prompt, prompt_path) + except OSError as exc: + pytest.skip(f"hardlinks unavailable: {exc}") + spec = make_spec(tmp_path, task_id=task_id) + + adapter.start_session(spec) + + assert outside_prompt.read_text(encoding="utf-8") == "theirs" + assert prompt_path.stat().st_ino != outside_prompt.stat().st_ino + assert prompt_path.read_text(encoding="utf-8") == spec.prompt + "\n" + + +def test_start_session_preserves_existing_regular_prompt_inode(tmp_path): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + adapter._ensure_session = lambda cwd: None + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + prompt_path = task_dir / "prompt.txt" + prompt_path.write_text("old", encoding="utf-8") + inode = prompt_path.stat().st_ino + spec = make_spec(tmp_path, task_id=task_id) + + adapter.start_session(spec) + + assert prompt_path.stat().st_ino == inode + assert prompt_path.read_text(encoding="utf-8") == spec.prompt + "\n" + + +@pytest.mark.parametrize( + "artifact_name", + ["heartbeat.json", "resultless-stops.jsonl", "session-lifecycle.jsonl"], +) +def test_start_session_refuses_redirected_task_artifact_before_mutation(tmp_path, artifact_name): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + task_dir = adapter.tasks_dir / "clean-task" + task_dir.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("theirs", encoding="utf-8") + try: + (task_dir / artifact_name).symlink_to(outside) + except OSError as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + + with pytest.raises(AdapterTaskDirectoryError, match="artifact is a symlink"): + adapter.start_session(make_spec(tmp_path, task_id="clean-task")) + + assert outside.read_text(encoding="utf-8") == "theirs" + assert not (task_dir / "prompt.txt").exists() + assert list(adapter.logs_dir.iterdir()) == [] + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +@pytest.mark.parametrize("redirect_kind", ["root", "junction-like-root", "log-file"]) +def test_start_session_refuses_redirected_log_path_before_mutation( + tmp_path, redirect_kind, monkeypatch +): + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + outside = tmp_path / "outside" + outside.mkdir() + outside_file = outside / "theirs.log" + outside_file.write_text("theirs", encoding="utf-8") + try: + if redirect_kind == "root": + adapter.logs_dir.rmdir() + adapter.logs_dir.symlink_to(outside, target_is_directory=True) + elif redirect_kind == "junction-like-root": + monkeypatch.setattr( + adapter_base, + "is_link_like", + lambda path: Path(path) == adapter.logs_dir, + ) + else: + (adapter.logs_dir / "clean-task.log").symlink_to(outside_file) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + with pytest.raises(AdapterTaskDirectoryError, match="artifact (directory )?is a symlink"): + adapter.start_session(make_spec(tmp_path, task_id="clean-task")) + + assert outside_file.read_text(encoding="utf-8") == "theirs" + assert not (adapter.tasks_dir / "clean-task").exists() + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +def test_start_session_refuses_junction_like_task_directory_before_mutation(tmp_path, monkeypatch): + """The adapter consumes the shared predicate's Windows-junction verdict. + + platform_util's reparse-tag tests own junction detection itself; an ordinary + directory standing in here makes this composition arm run on every platform. + """ + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + ensure_calls = [] + adapter._ensure_session = lambda cwd: ensure_calls.append(Path(cwd)) + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + (task_dir / "prompt.txt").write_text("theirs", encoding="utf-8") + for artifact in TASK_CYCLE_ARTIFACTS: + (task_dir / artifact).write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in task_dir.iterdir()} + monkeypatch.setattr(adapter_base, "is_link_like", lambda path: Path(path) == task_dir) + + with pytest.raises(AdapterTaskDirectoryError, match="symlink or junction"): + adapter.start_session(make_spec(tmp_path, task_id=task_id)) + + assert {path.name: path.read_bytes() for path in task_dir.iterdir()} == before + assert list(adapter.logs_dir.iterdir()) == [] + assert ensure_calls == [] + assert mux.windows == [] + assert mux.piped == [] + + +def test_validated_task_directory_refuses_direct_junction_verdict(tmp_path, monkeypatch): + tasks_dir = tmp_path / "tasks" + task_dir = tasks_dir / "clean-task" + monkeypatch.setattr(adapter_base, "is_link_like", lambda path: Path(path) == task_dir) + + with pytest.raises(AdapterTaskDirectoryError, match="task directory is a symlink"): + adapter_base.validated_task_directory(tasks_dir, "clean-task") + + def test_start_session_resets_reused_task_log(tmp_path): """A re-armed run reuses task_ids and both mux backends APPEND to logs/.log, so a prior cycle's transport-failure line would linger in the diff --git a/tests/test_opencode_http.py b/tests/test_opencode_http.py index 669348c7..169068c6 100644 --- a/tests/test_opencode_http.py +++ b/tests/test_opencode_http.py @@ -25,8 +25,14 @@ from conftest import write_script_launcher from bmad_loop import runs +from bmad_loop.adapters import base as adapter_base from bmad_loop.adapters import generic, opencode_http -from bmad_loop.adapters.base import SessionHandle, SessionResult, SessionSpec +from bmad_loop.adapters.base import ( + AdapterTaskDirectoryError, + SessionHandle, + SessionResult, + SessionSpec, +) from bmad_loop.adapters.generic import BUDGET_NUDGE_TEXT, NUDGE_TEXT, STALL_NUDGE_TEXT from bmad_loop.adapters.opencode_http import ( _RESET, @@ -1291,6 +1297,253 @@ def test_missing_binary_is_a_clean_error(tmp_path): adapter.start_session(spec) +@pytest.mark.parametrize( + "task_id_kind", ["absolute", "parent-traversal", "empty", "windows-reserved"] +) +def test_start_session_refuses_unconfined_task_id_without_side_effects(tmp_path, task_id_kind): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "prompt.txt").write_text("theirs", encoding="utf-8") + for artifact in TASK_CYCLE_ARTIFACTS: + (outside / artifact).write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in outside.iterdir()} + task_ids = { + "absolute": str(outside), + "parent-traversal": str(Path("..") / ".." / "outside"), + "empty": "", + "windows-reserved": "CON", + } + task_id = task_ids[task_id_kind] + escaped_log = adapter.logs_dir / f"{task_id}.server.out" + spec = SessionSpec(task_id=task_id, role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="expected one clean path segment"): + adapter.start_session(spec) + + assert {path.name: path.read_bytes() for path in outside.iterdir()} == before + assert list(adapter.tasks_dir.iterdir()) == [] + assert list(adapter.logs_dir.iterdir()) == [] + assert not escaped_log.exists() + assert spawn_calls == [] + assert adapter._sessions == {} + + +def test_start_session_refuses_symlinked_task_directory_without_side_effects(tmp_path): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "prompt.txt").write_text("theirs", encoding="utf-8") + for artifact in TASK_CYCLE_ARTIFACTS: + (outside / artifact).write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in outside.iterdir()} + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + try: + task_dir.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + spec = SessionSpec(task_id=task_id, role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="symlink or junction"): + adapter.start_session(spec) + + assert task_dir.is_symlink() + assert {path.name: path.read_bytes() for path in outside.iterdir()} == before + assert list(adapter.logs_dir.iterdir()) == [] + assert spawn_calls == [] + assert adapter._sessions == {} + + +def test_start_session_refuses_symlinked_tasks_root_without_side_effects(tmp_path): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "theirs.txt").write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in outside.iterdir()} + adapter.tasks_dir.rmdir() + try: + adapter.tasks_dir.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + spec = SessionSpec(task_id="clean-task", role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="tasks directory is a symlink"): + adapter.start_session(spec) + + assert adapter.tasks_dir.is_symlink() + assert {path.name: path.read_bytes() for path in outside.iterdir()} == before + assert list(adapter.logs_dir.iterdir()) == [] + assert spawn_calls == [] + assert adapter._sessions == {} + + +def test_start_session_refuses_junction_like_tasks_root_before_mutation(tmp_path, monkeypatch): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + monkeypatch.setattr(adapter_base, "is_link_like", lambda path: Path(path) == adapter.tasks_dir) + spec = SessionSpec(task_id="clean-task", role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="tasks directory is a symlink"): + adapter.start_session(spec) + + assert list(adapter.tasks_dir.iterdir()) == [] + assert list(adapter.logs_dir.iterdir()) == [] + assert spawn_calls == [] + assert adapter._sessions == {} + + +def test_start_session_replaces_prompt_symlink_without_following_it(tmp_path): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + outside_prompt = tmp_path / "outside-prompt.txt" + outside_prompt.write_text("theirs", encoding="utf-8") + prompt_path = task_dir / "prompt.txt" + try: + prompt_path.symlink_to(outside_prompt) + except OSError as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + spec = SessionSpec(task_id=task_id, role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(OpencodeServerError, match="not found on PATH"): + adapter.start_session(spec) + + assert outside_prompt.read_text(encoding="utf-8") == "theirs" + assert not prompt_path.is_symlink() + assert prompt_path.read_text(encoding="utf-8") == "p\n" + assert adapter._sessions == {} + + +def test_start_session_replaces_prompt_hardlink_without_following_it(tmp_path): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + outside_prompt = tmp_path / "outside-prompt.txt" + outside_prompt.write_text("theirs", encoding="utf-8") + prompt_path = task_dir / "prompt.txt" + try: + os.link(outside_prompt, prompt_path) + except OSError as exc: + pytest.skip(f"hardlinks unavailable: {exc}") + spec = SessionSpec(task_id=task_id, role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(OpencodeServerError, match="not found on PATH"): + adapter.start_session(spec) + + assert outside_prompt.read_text(encoding="utf-8") == "theirs" + assert prompt_path.stat().st_ino != outside_prompt.stat().st_ino + assert prompt_path.read_text(encoding="utf-8") == "p\n" + assert adapter._sessions == {} + + +def test_start_session_refuses_redirected_messages_before_mutation(tmp_path): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + task_dir = adapter.tasks_dir / "clean-task" + task_dir.mkdir() + outside = tmp_path / "outside-messages.json" + outside.write_text("theirs", encoding="utf-8") + try: + (task_dir / "messages.json").symlink_to(outside) + except OSError as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + spec = SessionSpec(task_id="clean-task", role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="artifact is a symlink"): + adapter.start_session(spec) + + assert outside.read_text(encoding="utf-8") == "theirs" + assert not (task_dir / "prompt.txt").exists() + assert list(adapter.logs_dir.iterdir()) == [] + assert spawn_calls == [] + assert adapter._sessions == {} + + +@pytest.mark.parametrize("suffix", [".log", ".server.out", ".sse.jsonl"]) +def test_start_session_refuses_redirected_log_path_before_mutation(tmp_path, suffix): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + outside = tmp_path / "outside.log" + outside.write_text("theirs", encoding="utf-8") + try: + (adapter.logs_dir / f"clean-task{suffix}").symlink_to(outside) + except OSError as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + spec = SessionSpec(task_id="clean-task", role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="artifact is a symlink"): + adapter.start_session(spec) + + assert outside.read_text(encoding="utf-8") == "theirs" + assert not (adapter.tasks_dir / "clean-task").exists() + assert spawn_calls == [] + assert adapter._sessions == {} + + +def test_start_session_refuses_redirected_logs_root_before_mutation(tmp_path): + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + outside = tmp_path / "outside" + outside.mkdir() + outside_file = outside / "clean-task.server.out" + outside_file.write_text("theirs", encoding="utf-8") + adapter.logs_dir.rmdir() + try: + adapter.logs_dir.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + spec = SessionSpec(task_id="clean-task", role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="artifact directory is a symlink"): + adapter.start_session(spec) + + assert outside_file.read_text(encoding="utf-8") == "theirs" + assert not (adapter.tasks_dir / "clean-task").exists() + assert spawn_calls == [] + assert adapter._sessions == {} + + +def test_start_session_refuses_junction_like_task_directory_before_mutation(tmp_path, monkeypatch): + """The adapter consumes the shared predicate's Windows-junction verdict. + + platform_util's reparse-tag tests own junction detection itself; an ordinary + directory standing in here makes this composition arm run on every platform. + """ + adapter = make_adapter(tmp_path, binary="definitely-not-a-real-binary-xyz") + spawn_calls = [] + adapter._spawn_server = lambda spec: spawn_calls.append(spec) + task_id = "clean-task" + task_dir = adapter.tasks_dir / task_id + task_dir.mkdir() + (task_dir / "prompt.txt").write_text("theirs", encoding="utf-8") + for artifact in TASK_CYCLE_ARTIFACTS: + (task_dir / artifact).write_text("theirs", encoding="utf-8") + before = {path.name: path.read_bytes() for path in task_dir.iterdir()} + monkeypatch.setattr(adapter_base, "is_link_like", lambda path: Path(path) == task_dir) + spec = SessionSpec(task_id=task_id, role="triage", prompt="p", cwd=tmp_path) + + with pytest.raises(AdapterTaskDirectoryError, match="symlink or junction"): + adapter.start_session(spec) + + assert {path.name: path.read_bytes() for path in task_dir.iterdir()} == before + assert list(adapter.logs_dir.iterdir()) == [] + assert spawn_calls == [] + assert adapter._sessions == {} + + def test_start_session_drops_every_reused_task_cycle_artifact(tmp_path): """Parity with GenericAdapter: both adapters own a tasks// dir, so both must drop a prior cycle's artifacts before a re-armed run reusing the id lands there. From 0dc96261dbfcb3b8a085605fa52f906e7e7c9421 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 15:02:25 -0700 Subject: [PATCH 37/45] sweep dw4-diagnostic-journal-sanitization: DW-76, DW-77, DW-80, DW-84 via bmad-loop --- CHANGELOG.md | 7 + src/bmad_loop/diagnostics.py | 108 ++++++++++-- src/bmad_loop/sanitize.py | 5 +- tests/test_cli.py | 4 +- tests/test_diagnostics.py | 299 +++++++++++++++++++++++++++++++- tests/test_portability_guard.py | 64 ++++--- 6 files changed, 441 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9331c73e..a7a4209f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,13 @@ breaking changes may land in a minor release. ### Changed +- **`bmad-loop diagnose --json` reports `schema_version: 4`.** Journal `path` values + become `path_present`; stale-restore and merge filename lists become counts. + +- **Sanitize remaining diagnostic journal identifiers.** Commit residue and sentinel + names are aliased explicitly, excluded and merge filenames are counted, overloaded + paths are presence-only, and derived fields win same-named raw-field collisions. + - **Remove the unused whole-artifact-folder exclusion helper** (DW-15). Proof-of-work exclusions remain file-granular and rollback protection keeps its workspace-rooted path derivation. diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 99c72ee6..eff895eb 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -86,7 +86,11 @@ # serialized to `journal.jsonl` and read back, and JSON has no tuple type, so every # sequence arrives as a `list` and takes the same arm it always did. The new arm is # reachable only by a shape no round-tripped entry can hold. -SCHEMA_VERSION = 3 +# v4 removes the overloaded journal-entry `path` value in favour of `path_present` +# and replaces `stale-restore-excluded.files` with `files_count`. The same release +# explicitly aliases `stale-restore-commits.commits` and `sentinel-cleared.sentinel`; +# those two fields keep their names, but their values no longer carry source ids. +SCHEMA_VERSION = 4 DEFAULT_JOURNAL_CAP = 200 # Subdirectories whose mere existence/size is diagnostic but whose CONTENTS are @@ -202,8 +206,8 @@ # Kind-scoped routing, consulted BEFORE the by-name table above and losing to # `_JOURNAL_DROP_FIELDS`, which is stricter than any alias. # -# It exists for ONE field, and the by-name rule genuinely cannot express it: `target` -# carries the target BRANCH on the three merge kinds below and a sprint STATUS on the +# `target` is the field for which a by-name rule genuinely cannot work: it carries +# the target BRANCH on the three merge kinds below and a sprint STATUS on the # `board-advance-*` family (`board-advance-carried`, `-carry-failed`, # `-carry-foreign-dirt`, `-carry-uncommitted`). Aliasing it by NAME would pseudonymize # statuses as branches, turning a legible `"target": "done"` into `branch-3f2a` and @@ -223,13 +227,16 @@ # correlate, silently skipping the carry replay so a resumed sweep re-triages work that # already landed. The scrub is what is wrong, so the scrub is where the fix belongs. # -# A closed set, not a growing one: any NEW producer should pick a name the by-name table -# already routes (`branch`, or `target_branch` — see `runs.rearm_escalation`) rather than -# add a row here. +# Any new branch producer should pick a name the by-name table already routes +# (`branch`, or `target_branch` — see `runs.rearm_escalation`) rather than add a target +# row here. `sentinel` is scoped for a different reason: its sole producer carries a +# spec basename, so that known shape is aliased without making the same claim about a +# future kind that reuses the generic name. _JOURNAL_KIND_ALIAS_FIELDS: dict[str, dict[str, str]] = { "unit-merge-started": {"target": "branch"}, "unit-merged": {"target": "branch"}, "resume-unit-merge": {"target": "branch"}, + "sentinel-cleared": {"sentinel": "spec"}, } # Namespaces whose journalled value arrives in more than one shape and must be # reduced to its basename before it is aliased. `spec` is one: engine.py's @@ -335,6 +342,11 @@ # and spec filename. Drop rather than create a second spec correlation; # the fallback redacts it only by virtue of its current separators. "stashed_to", + # An overloaded path spelling used for a generated sweep intent and for + # isolated worktrees. None of those host/customer paths adds useful + # correlation beyond the record's story key, so every kind gets the same + # presence-only treatment. + "path", } ) # Journal fields whose value is a LIST of identifiers, aliased element-wise rather @@ -347,6 +359,21 @@ # beside it in the neighbouring record was aliased. _JOURNAL_KEYLIST_FIELDS = frozenset({"keys", "dw_ids", "story_keys"}) +# Kind-scoped container policies for names whose other producers carry a different +# shape. `commits` is a SHA list on the stale-restore record but an integer count on +# `rollback-manual-required`, so a by-name list rule would destroy that useful count. +# Filename lists are reduced to counts rather than aliases: filename correlation +# adds no diagnostic value and would put the proprietary names into the legend. +_JOURNAL_KIND_KEYLIST_FIELDS: dict[str, dict[str, str]] = { + "stale-restore-commits": {"commits": "commit"}, +} +_JOURNAL_KIND_COUNTLIST_FIELDS: dict[str, frozenset[str]] = { + "merge-preflight-refused": frozenset({"tolerated"}), + "merge-target-cleaned": frozenset({"paths"}), + "merge-target-tolerated": frozenset({"paths"}), + "stale-restore-excluded": frozenset({"files"}), +} + # ``kind -> the field names that kind's record is DECLARED to carry``. On a kind # listed here the usual ``scrub_json`` fallback is replaced by a fail-closed one: # any key outside its declared set renders as ``_present`` rather than as a @@ -837,6 +864,45 @@ def _alias_input(value: Any, ns: str) -> Any: return _PATH_SEP_RE.split(value)[-1] or value +def _reserved_output_names( + entry: dict, + kind_aliases: dict[str, str], + kind_keylists: dict[str, str], + kind_countlists: frozenset[str], + declared: frozenset[str] | None, +) -> frozenset[str]: + """Names synthesized from this complete raw entry. + + Computing them before the entry is traversed makes a derived presence marker or + count authoritative when the source also contains that name, independent of JSON + key order. The branches deliberately mirror `_scrub_entry`'s routing precedence: + a routed alias on a declared-schema kind does not also generate a presence name. + """ + # ``ts_offset`` is synthesized before the raw fields are traversed. It is never + # a meaningful producer field, so reserve it even for a malformed entry whose + # timestamp cannot produce the derived value; a caller-supplied value must not + # replace the truthful offset or leak through the generic scrubber. + generated: set[str] = {"ts_offset"} + for key, value in entry.items(): + if key in ("ts", "kind"): + continue + if key in _JOURNAL_DROP_FIELDS: + generated.add(f"{key}_present") + elif key in _JOURNAL_KEYLIST_FIELDS: + if not isinstance(value, list): + generated.add(f"{key}_present") + elif key in kind_keylists: + if not isinstance(value, list): + generated.add(f"{key}_present") + elif key in kind_countlists: + generated.add(f"{key}_{'count' if isinstance(value, list) else 'present'}") + elif key in kind_aliases or key in _JOURNAL_ALIAS_FIELDS: + continue + elif declared is not None and key not in declared and key not in SELF_MINTED_FIELDS: + generated.add(f"{key}_present") + return frozenset(generated) + + def _scrub_entry( entry: dict, pseudo: sanitize.Pseudonymizer, @@ -847,11 +913,10 @@ def _scrub_entry( verbatim, identifier fields aliased, free-text fields collapsed to a presence boolean, and every remaining/unknown field scrub_json'd. - Two kinds of field never reach that last fallback, because for them - ``scrub_json`` fails closed only by accident of a value's shape. A name in - ``_JOURNAL_KEYLIST_FIELDS`` carrying something other than a list collapses to a - presence key, and on a kind with a declared schema - (``_JOURNAL_KIND_SCHEMAS``) so does every key the schema does not name.""" + Several field classes never reach that last fallback, because for them + ``scrub_json`` fails closed only by accident of a value's shape. Identifier-list + and count-list policies validate their containers, while a kind with a declared + schema (``_JOURNAL_KIND_SCHEMAS``) collapses every unnamed key to presence.""" out: dict[str, Any] = {} ts = entry.get("ts") if isinstance(ts, (int, float)) and first_ts is not None: @@ -862,10 +927,20 @@ def _scrub_entry( # `looks_like_identifier` is not one of the three below anyway, and keying on the # placeholder would silently unroute every entry in a dump that had one. by_kind = _JOURNAL_KIND_ALIAS_FIELDS.get(kind, {}) + kind_keylists = _JOURNAL_KIND_KEYLIST_FIELDS.get(kind, {}) + kind_countlists = _JOURNAL_KIND_COUNTLIST_FIELDS.get(kind, frozenset()) declared = _JOURNAL_KIND_SCHEMAS.get(kind) + reserved_outputs = _reserved_output_names( + entry, by_kind, kind_keylists, kind_countlists, declared + ) for k, v in entry.items(): if k in ("ts", "kind"): continue + if k in reserved_outputs: + # A raw field cannot overwrite a value derived from another field in this + # entry. The reservation is computed above from the complete shape, so + # this is deterministic in both source-key orders. + continue kind_ns = by_kind.get(k) if k in _JOURNAL_DROP_FIELDS: out[f"{k}_present"] = v is not None and v != "" @@ -889,6 +964,17 @@ def _scrub_entry( # genuinely unknown: a dict or an int has no sensible alias, and the # one thing worth reporting is that the field was set. out[f"{k}_present"] = v is not None and v != "" + elif k in kind_keylists: + if isinstance(v, list): + ns = kind_keylists[k] + out[k] = [pseudo.alias(x, ns=ns) for x in v] + else: + out[f"{k}_present"] = v is not None and v != "" + elif k in kind_countlists: + if isinstance(v, list): + out[f"{k}_count"] = len(v) + else: + out[f"{k}_present"] = v is not None and v != "" elif kind_ns is not None or k in _JOURNAL_ALIAS_FIELDS: ns = kind_ns or _JOURNAL_ALIAS_FIELDS[k] v = _alias_input(v, ns) diff --git a/src/bmad_loop/sanitize.py b/src/bmad_loop/sanitize.py index 63b30461..a0a4c676 100644 --- a/src/bmad_loop/sanitize.py +++ b/src/bmad_loop/sanitize.py @@ -413,7 +413,10 @@ def alias(self, value: Any, *, ns: str = "id", epic: int | None = None) -> Any: # collision re-hash with a counter until the alias is free. counter = 0 while True: - material = self._salt + value.encode("utf-8") + # Journal strings can carry surrogateescape code points when a POSIX + # filename contains undecodable bytes. Hash those values losslessly + # instead of letting one malformed identifier make its run unreadable. + material = self._salt + value.encode("utf-8", errors="surrogatepass") if counter: material += counter.to_bytes(4, "big") alias = f"{prefix}-{hashlib.blake2s(material, digest_size=6).hexdigest()}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 3ea3e756..9b041a7c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5680,7 +5680,7 @@ def test_diagnose_json_emits_pure_document(project, capsys): _seed_run(project.project) doc = machine_json(["diagnose", "--project", str(project.project), "--json"], capsys) - assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 3 + assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 4 assert doc["runs"], "the document carries the run it resolved" for canary in CANARIES: assert canary not in json.dumps(doc), f"LEAK via CLI: {canary!r}" @@ -5700,7 +5700,7 @@ def test_diagnose_json_out_writes_document_and_keeps_stdout_empty(project, tmp_p assert "written to" in err # the confirmation moved to stderr written = out_file.read_text() doc = json.loads(written) - assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 3 + assert doc["schema_version"] == diagnostics.SCHEMA_VERSION == 4 assert "```" not in written # no fences in a file written in JSON mode for canary in CANARIES: assert canary not in written, f"LEAK via CLI: {canary!r}" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 01be5c00..51b62ecc 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -681,9 +681,9 @@ def test_the_two_commit_probe_records_alias_one_baseline_to_one_name(): grade: depending on its entropy, the fallback may redact a sha as a secret rather than preserving the correlatable alias this table promises. - `commits` remains deliberately outside this test and outside DW-81's routing - change. It is a list on `stale-restore-commits` but an integer count on - `rollback-manual-required`; routing it requires a separate, kind-scoped policy. + `commits` is now routed by kind because it is a list here but an integer count on + `rollback-manual-required`; this row therefore also sees the residue SHA enter + the same commit namespace without changing the baseline's alias. """ pseudo = sanitize.Pseudonymizer(salt=b"fixed") probe_failed = diagnostics._scrub_entry( @@ -714,8 +714,9 @@ def test_the_two_commit_probe_records_alias_one_baseline_to_one_name(): alias = next(a for ns, orig, a in pseudo.entries() if ns == "commit" and orig == SHA) # aliased, not dropped — the key stays and only the VALUE is replaced assert probe_failed["old_baseline"] == commits["old_baseline"] == alias != SHA - # one legend entry for the shared baseline, not one per record spelling - assert {orig for ns, orig, _a in pseudo.entries() if ns == "commit"} == {SHA} + # one legend entry for the shared baseline, not one per record spelling, plus + # the independently aliased residue commit + assert {orig for ns, orig, _a in pseudo.entries() if ns == "commit"} == {SHA, "c" * 40} # the free-text sibling on the probe record quotes both the sha and a host path # back, and is reached by the drop set rather than aliased assert "error" not in probe_failed and probe_failed["error_present"] is True @@ -725,6 +726,199 @@ def test_the_two_commit_probe_records_alias_one_baseline_to_one_name(): assert canary not in rendered, f"LEAK: {canary!r}" +def test_remaining_journal_shapes_route_by_kind_and_preserve_safe_structure(): + """The overloaded names are handled according to the producer shape, not by + their generic scalar fallback.""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + commit_values = [SHA, "0f" * 20] + commits = diagnostics._scrub_entry( + {"kind": "stale-restore-commits", "commits": commit_values}, pseudo, {}, None + ) + files = diagnostics._scrub_entry( + { + "kind": "stale-restore-excluded", + "files": ["AcmePayrollExport.py", "AcmeMergerPlan.md"], + }, + pseudo, + {}, + None, + ) + bare_sentinel = diagnostics._scrub_entry( + {"kind": "sentinel-cleared", "sentinel": SPEC_NAME}, pseudo, {}, None + ) + qualified_sentinel = diagnostics._scrub_entry( + {"kind": "sentinel-cleared", "sentinel": SPEC_ABS}, pseudo, {}, None + ) + manual_count = diagnostics._scrub_entry( + {"kind": "rollback-manual-required", "commits": 2}, pseudo, {}, None + ) + + assert commits["commits"] != commit_values + assert len(commits["commits"]) == 2 + assert all(value.startswith("commit-") for value in commits["commits"]) + expected_commit_aliases = [ + next( + alias + for ns, original, alias in pseudo.entries() + if ns == "commit" and original == value + ) + for value in commit_values + ] + assert commits["commits"] == expected_commit_aliases + assert len(set(commits["commits"])) == len(commit_values) + assert files == {"kind": "stale-restore-excluded", "files_count": 2} + assert bare_sentinel["sentinel"] == qualified_sentinel["sentinel"] + assert bare_sentinel["sentinel"].startswith("spec-") + assert manual_count["commits"] == 2 + + legend = pseudo.legend() + assert SPEC_ABS not in legend.values() + assert "AcmePayrollExport.py" not in legend.values() + assert "AcmeMergerPlan.md" not in legend.values() + + +def test_non_string_sentinel_is_safely_pseudonymized(): + """A malformed sentinel still takes the explicit alias route. + + Ablation: remove the `sentinel-cleared` kind route and `scrub_json` preserves + the identifier-shaped nested value instead of returning one opaque spec alias. + """ + raw = {"customer_spec": "AcmeVaultRotation"} + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + {"kind": "sentinel-cleared", "sentinel": raw}, pseudo, {}, None + ) + + assert isinstance(scrubbed["sentinel"], str) + assert scrubbed["sentinel"].startswith("spec-") + assert raw["customer_spec"] not in json.dumps(scrubbed) + assert pseudo.entries() == [("spec", str(raw), scrubbed["sentinel"])] + + +def test_journal_alias_routes_accept_lone_unicode_surrogates(project): + run_dir = _seed_run(project.project) + sentinel_value = chr(0xDC80) + commit_values = [chr(0xDC81), chr(0xDC82)] + journal = Journal(run_dir) + journal.append("sentinel-cleared", sentinel=sentinel_value) + journal.append("stale-restore-commits", commits=commit_values) + + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + diag = diagnostics.collect([run_dir], pseudo=pseudo, project=project.project) + rendered = diagnostics.render_json(diag, pseudo=pseudo) + entries = json.loads(rendered)["runs"][0]["journal"]["entries"] + sentinel = next(entry for entry in entries if entry["kind"] == "sentinel-cleared") + commits = next(entry for entry in entries if entry["kind"] == "stale-restore-commits") + + assert sentinel["sentinel"].startswith("spec-") + assert all(value.startswith("commit-") for value in commits["commits"]) + assert len(set(commits["commits"])) == len(commit_values) + assert sentinel_value not in rendered + assert all(value not in rendered for value in commit_values) + + +@pytest.mark.parametrize( + ("kind", "field", "value"), + [ + ("stale-restore-commits", "commits", "AcmeCommitResidue"), + ("stale-restore-excluded", "files", "AcmePayrollExport.py"), + ], +) +def test_kind_scoped_container_routes_fail_closed_on_malformed_shapes(kind, field, value): + scrubbed = diagnostics._scrub_entry( + {"kind": kind, field: value}, sanitize.Pseudonymizer(salt=b"fixed"), {}, None + ) + + assert field not in scrubbed + assert scrubbed[f"{field}_present"] is True + assert f"{field}_count" not in scrubbed + assert value not in json.dumps(scrubbed) + + +@pytest.mark.parametrize("raw_first", [True, False], ids=["raw-first", "raw-last"]) +def test_derived_files_count_wins_raw_count_collision_in_both_orders(raw_first): + fields = [("files_count", 999), ("files", ["one.py", "two.py"])] + if not raw_first: + fields.reverse() + scrubbed = diagnostics._scrub_entry( + {"kind": "stale-restore-excluded", **dict(fields)}, + sanitize.Pseudonymizer(salt=b"fixed"), + {}, + None, + ) + + assert scrubbed["files_count"] == 2 + assert "files" not in scrubbed + + +@pytest.mark.parametrize( + ("kind", "field", "malformed"), + [ + ("run-start", "story_keys", "AcmeStoryKey"), + ("stale-restore-commits", "commits", "AcmeCommitResidue"), + ("stale-restore-excluded", "files", "AcmePayrollExport.py"), + ], + ids=["global-keylist", "kind-keylist", "kind-countlist"], +) +@pytest.mark.parametrize("raw_first", [True, False], ids=["raw-first", "raw-last"]) +def test_derived_malformed_presence_wins_raw_collision_in_both_orders( + kind, field, malformed, raw_first +): + presence = f"{field}_present" + raw_value = f"AcmeRaw{field.title()}Presence" + fields = [(presence, raw_value), (field, malformed)] + if not raw_first: + fields.reverse() + scrubbed = diagnostics._scrub_entry( + {"kind": kind, **dict(fields)}, sanitize.Pseudonymizer(salt=b"fixed"), {}, None + ) + + assert scrubbed[presence] is True + assert field not in scrubbed + assert raw_value not in json.dumps(scrubbed) + + +def test_declared_schema_reservation_respects_routed_field_precedence(): + raw_presence = "AcmeRawStoryPresence" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + { + "kind": "preference-escalation", + "story_key": STORY_KEY, + "story_key_present": raw_presence, + }, + pseudo, + {}, + None, + ) + + assert scrubbed["story_key"].startswith("story-") + assert "story_key_present" not in scrubbed + assert scrubbed["story_key_present_present"] is True + assert raw_presence not in json.dumps(scrubbed) + + +@pytest.mark.parametrize("value", [None, ""]) +def test_empty_path_becomes_a_false_presence_flag(value): + scrubbed = diagnostics._scrub_entry( + {"kind": "worktree-opened", "path": value}, + sanitize.Pseudonymizer(salt=b"fixed"), + {}, + None, + ) + assert scrubbed == {"kind": "worktree-opened", "path_present": False} + + +def test_unrelated_raw_presence_field_is_not_suppressed(): + scrubbed = diagnostics._scrub_entry( + {"kind": "attempt-restored", "patch_present": False}, + sanitize.Pseudonymizer(salt=b"fixed"), + {}, + None, + ) + assert scrubbed["patch_present"] is False + + def test_sentinel_upstream_record_drops_the_stories_root_it_names(): """`rearm-upstream-write-unreachable` carries an absolute host path naming the folder a sentinel's upstream correction has to land in. @@ -891,6 +1085,101 @@ def test_patch_and_stash_paths_are_absent_from_public_diagnostic_renders(project assert dropped not in legend_values +def test_remaining_journal_sanitization_contract_reaches_both_public_renders(project): + """Separator-free canaries grade the explicit routes; the decoded document + grades the retained aliases, counts, and authoritative presence booleans.""" + run_dir = _seed_run(project.project) + commit_value = "0f" * 20 + filename = "AcmePayrollExport.py" + sentinel_path = f"{HOME_PATH}/stories/{SPEC_NAME}" + sweep_path = "AcmeBundleIntent" + worktree_path = f"{HOME_PATH}/worktrees/AcmePrivateTree" + patch_value = "AcmePatchLatch" + raw_before = "AcmeRawPresenceBefore" + raw_after = "AcmeRawPresenceAfter" + raw_ts_offset = "AcmePrivateClock" + tolerated_filename = "AcmeToleratedScene.unity" + cleaned_filename = "AcmeCleanedPrefab.prefab" + refused_filename = "AcmeRefusedAsset.asset" + journal = Journal(run_dir) + journal.append("stale-restore-commits", commits=[commit_value]) + journal.append("stale-restore-excluded", files=[filename]) + journal.append("sentinel-cleared", sentinel=sentinel_path) + journal.append("sweep-intent-regenerated", path=sweep_path) + journal.append("worktree-opened", path=worktree_path, ts_offset=raw_ts_offset) + journal.append("merge-target-tolerated", paths=[tolerated_filename]) + journal.append("merge-target-cleaned", paths=[cleaned_filename]) + journal.append("merge-preflight-refused", tolerated=[refused_filename]) + journal.append( + "attempt-restored", + case="before", + **{"patch_present": raw_before, "patch": patch_value}, + ) + journal.append( + "attempt-restored", + case="after", + **{"patch": patch_value, "patch_present": raw_after}, + ) + + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + diag = diagnostics.collect([run_dir], pseudo=pseudo, project=project.project) + markdown = diagnostics.render_markdown(diag, pseudo=pseudo) + json_text = diagnostics.render_json(diag, pseudo=pseudo) + document = json.loads(json_text) + entries = document["runs"][0]["journal"]["entries"] + + commits = next(e for e in entries if e["kind"] == "stale-restore-commits") + excluded = next(e for e in entries if e["kind"] == "stale-restore-excluded") + sentinel = next(e for e in entries if e["kind"] == "sentinel-cleared") + sweep = next(e for e in entries if e["kind"] == "sweep-intent-regenerated") + worktree = next(e for e in entries if e["kind"] == "worktree-opened") + merge_tolerated = next(e for e in entries if e["kind"] == "merge-target-tolerated") + merge_cleaned = next(e for e in entries if e["kind"] == "merge-target-cleaned") + merge_refused = next(e for e in entries if e["kind"] == "merge-preflight-refused") + collisions = [e for e in entries if e["kind"] == "attempt-restored"] + + assert commits["commits"][0].startswith("commit-") + assert excluded["files_count"] == 1 and "files" not in excluded + assert sentinel["sentinel"].startswith("spec-") + assert sweep["path_present"] is True and "path" not in sweep + assert worktree["path_present"] is True and "path" not in worktree + assert isinstance(worktree["ts_offset"], (int, float)) + assert merge_tolerated["paths_count"] == 1 and "paths" not in merge_tolerated + assert merge_cleaned["paths_count"] == 1 and "paths" not in merge_cleaned + assert merge_refused["tolerated_count"] == 1 and "tolerated" not in merge_refused + assert {entry["case"] for entry in collisions} == {"before", "after"} + assert all(entry["patch_present"] is True for entry in collisions) + + rendered = markdown + json_text + for canary in ( + commit_value, + filename, + sentinel_path, + sweep_path, + worktree_path, + patch_value, + raw_before, + raw_after, + raw_ts_offset, + tolerated_filename, + cleaned_filename, + refused_filename, + ): + assert canary not in rendered, f"LEAK: {canary!r}" + legend_values = set(pseudo.legend().values()) + for canary in ( + filename, + sentinel_path, + sweep_path, + worktree_path, + patch_value, + tolerated_filename, + cleaned_filename, + refused_filename, + ): + assert canary not in legend_values, f"LEAK via legend: {canary!r}" + + def test_target_field_routes_by_kind_because_it_carries_two_kinds_of_value(): """`target` is a BRANCH on the merge kinds and a sprint STATUS on `board-advance-*`. diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 2b31d0bf..701cae52 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -242,14 +242,22 @@ ) # ``kind -> the field names routed on THAT kind only``, read off the same module so -# the guard still cannot drift from it. A name here is routed on its own kinds and -# unrouted everywhere else, which is the distinction the flattened union destroyed. -JOURNAL_KIND_ROUTED_FIELDS = { - kind: frozenset(row) for kind, row in diagnostics._JOURNAL_KIND_ALIAS_FIELDS.items() -} +# the guard still cannot drift from it. Alias, identifier-list, and count-list rules +# share this inventory because all three claim the same `(kind, field)` boundary. +JOURNAL_KIND_ROUTING_TABLES = ( + diagnostics._JOURNAL_KIND_ALIAS_FIELDS, + diagnostics._JOURNAL_KIND_KEYLIST_FIELDS, + diagnostics._JOURNAL_KIND_COUNTLIST_FIELDS, +) +JOURNAL_KIND_ROUTED_FIELDS: dict[str, frozenset[str]] = {} +for _routing_table in JOURNAL_KIND_ROUTING_TABLES: + for _kind, _row in _routing_table.items(): + JOURNAL_KIND_ROUTED_FIELDS[_kind] = JOURNAL_KIND_ROUTED_FIELDS.get( + _kind, frozenset() + ) | frozenset(_row) # ``kind -> field names declared benign on that kind alone`` — the kind-scoped twin of -# ``JOURNAL_BENIGN_FIELDS``, and it exists for the same field the routing table does. +# ``JOURNAL_BENIGN_FIELDS`` for overloaded names whose other shapes are routed. # ``engine``'s board-advance carry paths journal ``target`` carrying a sprint STATUS # ("done"), not a branch; ``diagnostics``' ``_JOURNAL_KIND_ALIAS_FIELDS`` comment is # explicit that aliasing those would destroy the field a maintainer reads the record @@ -261,6 +269,9 @@ "board-advance-carry-failed": frozenset({"target"}), "board-advance-carry-foreign-dirt": frozenset({"target"}), "board-advance-carry-uncommitted": frozenset({"target"}), + # The stale-restore record carries SHA strings under this name and is routed; + # this recovery notice carries only the already-derived integer count. + "rollback-manual-required": frozenset({"commits"}), } # Every OTHER field name journalled today: a declared inventory, not a per-name @@ -306,7 +317,6 @@ "checkpoint", "code_root_changed", "command_index", - "commits", "condition", "contradiction", "converted", @@ -328,7 +338,6 @@ "expired_clock", "failed", "field", - "files", "finished", "fired_at", "flat_remainder", @@ -369,8 +378,6 @@ "open_now", "original", "owed_after_implement", - "path", - "paths", "phase", "platform", "plugin", @@ -408,7 +415,6 @@ "run_type", "security_config_changed", "seen_again", - "sentinel", "sentinel_kind", "session_status", "session_vanished", @@ -431,7 +437,6 @@ "to", "tokens", "tokens_weighted", - "tolerated", "total", "trigger", "verification_sequence", @@ -2262,11 +2267,10 @@ def _journal_field_offenders(findings) -> list[tuple[str, int, str, str]]: declared itself a hole. Routing is checked BY NAME first and then BY KIND, mirroring ``_scrub_entry``'s - own order rather than a flattened union of the two. A kind-scoped name — today - only ``target`` — is routed on its own kinds, declared benign on the - ``board-advance-*`` family that carries a sprint status under the same name, and - an offender everywhere else, INCLUDING at a call whose kind the scan could not - resolve. That is the case a by-name union got wrong in the dangerous direction: + own order rather than a flattened union of the two. A kind-scoped name is routed + only on its declared shapes and is an offender everywhere else unless that other + shape is explicitly benign. That includes a call whose kind the scan could not + resolve. `target` is the dangerous example: flattening it made ``journal.append("unit-merge-failed", target=branch)`` read as routed.""" offenders: list[tuple[str, int, str, str]] = [] for _, rel, ln, txt, (field, fn, kind) in findings: @@ -4067,19 +4071,25 @@ def test_journal_routing_tables_are_read_from_diagnostics(): diagnostics._JOURNAL_KEYLIST_FIELDS, ): assert set(table) <= JOURNAL_ROUTED_FIELDS - assert JOURNAL_KIND_ROUTED_FIELDS == { - kind: frozenset(row) for kind, row in diagnostics._JOURNAL_KIND_ALIAS_FIELDS.items() - } + expected_kind_routing: dict[str, frozenset[str]] = {} + for table in JOURNAL_KIND_ROUTING_TABLES: + for kind, row in table.items(): + expected_kind_routing[kind] = expected_kind_routing.get(kind, frozenset()) | frozenset( + row + ) + assert JOURNAL_KIND_ROUTED_FIELDS == expected_kind_routing # …and the kind-scoped names are deliberately NOT in the by-name union. This is # the assertion that would have caught the flattening: `target` routed by name # says the board-advance family is covered when `_scrub_entry` does not cover it. - for row in diagnostics._JOURNAL_KIND_ALIAS_FIELDS.values(): - assert not set(row) & JOURNAL_ROUTED_FIELDS, ( - "a kind-scoped field name leaked into the by-name routed union; " - "`_scrub_entry` consults `_JOURNAL_KIND_ALIAS_FIELDS` per kind, so a " - "by-name claim about it is false on every other kind" - ) - # `_JOURNAL_KIND_SCHEMAS` is the FOURTH table `_scrub_entry` consults, and it was + for table in JOURNAL_KIND_ROUTING_TABLES: + for row in table.values(): + assert not set(row) & JOURNAL_ROUTED_FIELDS, ( + "a kind-scoped field name leaked into the by-name routed union; " + "`_scrub_entry` consults its kind tables per kind, so a by-name " + "claim about it is false on every other kind" + ) + # `_JOURNAL_KIND_SCHEMAS` is the fail-closed schema table `_scrub_entry` consults, + # and it was # coupled to this guard by prose alone: deleting its `preference-escalation` row # left every assertion here green while the fail-closed arm stopped running and # `customer="AcmeVault"` went back to shipping verbatim (measured). Read it here From b660ca66bf195447867a09dc32c22cbecc620aad Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 16:23:10 -0700 Subject: [PATCH 38/45] sweep dw4-session-artifact-json-hardening: DW-86, DW-89 via bmad-loop --- CHANGELOG.md | 3 ++ src/bmad_loop/adapters/generic.py | 19 +++++-- src/bmad_loop/escalation.py | 13 +++-- src/bmad_loop/resolve.py | 36 +++++++++---- tests/test_escalation.py | 23 +++++++++ tests/test_generic_tmux.py | 86 +++++++++++++++++++++++++++++++ tests/test_resolve.py | 77 +++++++++++++++++++++++---- 7 files changed, 228 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a4209f..a08acfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,9 @@ breaking changes may land in a minor release. ### Fixed +- Reject malformed session escalation/result artifacts and non-finite resolve JSON + (DW-86, DW-89). + - **Confine built-in adapter task directories** (DW-74), refusing unsafe task ids and symlink- or junction-redirected task directories before prompt, artifact, log, or transport side effects. diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 1d1440c5..c3917dda 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -21,6 +21,7 @@ from __future__ import annotations +import copy import enum import hashlib import json @@ -420,13 +421,23 @@ def _write_heartbeat(self, task_id: str, payload: dict) -> None: def _read_result(self, task_id: str) -> dict | None: path = self._result_path(task_id) - if not path.is_file(): - return None try: + if not path.is_file(): + return None data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): + if not isinstance(data, dict): + return None + # Plugin HookContext makes this same defensive copy before exposing + # result data, so reject a shape that would recurse there while the + # artifact is still inside the shared observation boundary. + copy.deepcopy(data) + # JSON accepts escaped lone surrogates, but the default ATTENTION + # sink writes reasons as UTF-8. Validate every parsed string without + # imposing stricter numeric semantics on completed session results. + json.dumps(data, ensure_ascii=False).encode("utf-8") + except (OSError, ValueError, RecursionError): return None - return data if isinstance(data, dict) else None + return data def _await_result(self, task_id: str, grace_s: float = RESULT_GRACE_S) -> dict | None: deadline = time.monotonic() + grace_s diff --git a/src/bmad_loop/escalation.py b/src/bmad_loop/escalation.py index 5baaeb9b..cc9524fa 100644 --- a/src/bmad_loop/escalation.py +++ b/src/bmad_loop/escalation.py @@ -44,22 +44,25 @@ class Decision: reason: str = "" -def critical_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]: +def _escalation_list(result_json: dict[str, Any] | None) -> list[Any]: if not result_json: return [] + escalations = result_json.get("escalations", []) + return escalations if isinstance(escalations, list) else [] + + +def critical_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]: return [ e - for e in result_json.get("escalations", []) + for e in _escalation_list(result_json) if isinstance(e, dict) and str(e.get("severity", "")).upper() == SEVERITY_CRITICAL ] def preference_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]: - if not result_json: - return [] return [ e - for e in result_json.get("escalations", []) + for e in _escalation_list(result_json) if isinstance(e, dict) and str(e.get("severity", "")).upper() != SEVERITY_CRITICAL ] diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 04246fc3..9a778726 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import math import os import subprocess from pathlib import Path @@ -37,6 +38,17 @@ RESOLVE_DIR = "resolve" +def _reject_json_constant(value: str) -> None: + raise ValueError(f"non-finite JSON constant: {value}") + + +def _parse_finite_json_float(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError(f"non-finite JSON float: {value}") + return parsed + + def _story_dir(run_dir: Path, story_key: str) -> Path: return run_dir / RESOLVE_DIR / safe_segment(story_key) @@ -154,7 +166,7 @@ def _gather_escalations( global across the pass, not per directory; it removes only exact repeats, so a directory holding CRITICAL A in one file and A + B in the other still yields both. - * the ``except`` tuple and the ``list`` check — ``build_context`` is an + * the ``except`` tuple and shared list guard — ``build_context`` is an OBSERVATION path: a malformed artifact must cost its own contents and nothing more, never raise out to the interactive resolve command. ``UnicodeDecodeError`` is a ``ValueError``, not an ``OSError`` (the same @@ -162,11 +174,9 @@ def _gather_escalations( also raise a plain ``ValueError`` when an integer exceeds Python's configured digit limit. Deeply nested input can raise ``RecursionError`` while either parsing the document or canonicalizing an entry, so both operations live - under the same artifact-level guard. Meanwhile, - ``critical_escalations`` iterates ``escalations`` with no list guard of its - own, so a ``{"escalations": null}`` artifact would raise ``TypeError`` - here. The guard belongs in this caller; the shared predicate stays the - single definition of CRITICAL. + under the same artifact-level guard. ``critical_escalations`` owns the + list-only shape guard shared by every control-loop caller, so malformed + ``escalations`` values contribute nothing here just as they do elsewhere. The watermark is a FOURTH concern layered onto that same single walk, not a second pass: ``reversed(task.sessions)`` reaches the unanswered tail first, so @@ -194,11 +204,15 @@ def _gather_escalations( task_dir = run_dir / "tasks" / session.task_id for fname in TASK_CYCLE_ARTIFACTS: fpath = task_dir / fname - if not fpath.is_file(): - continue try: - doc = json.loads(fpath.read_text(encoding="utf-8")) - if not isinstance(doc, dict) or not isinstance(doc.get("escalations"), list): + if not fpath.is_file(): + continue + doc = json.loads( + fpath.read_text(encoding="utf-8"), + parse_constant=_reject_json_constant, + parse_float=_parse_finite_json_float, + ) + if not isinstance(doc, dict): continue artifact_entries: dict[str, dict[str, Any]] = {} for esc in critical_escalations(doc): @@ -351,7 +365,7 @@ def build_context( context["stories"] = stories_ctx path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(context, indent=2), encoding="utf-8") + path.write_text(json.dumps(context, indent=2, allow_nan=False), encoding="utf-8") return path, withheld diff --git a/tests/test_escalation.py b/tests/test_escalation.py index bb62cc7b..d79b4fb0 100644 --- a/tests/test_escalation.py +++ b/tests/test_escalation.py @@ -4,8 +4,10 @@ from bmad_loop.adapters.base import SessionResult from bmad_loop.escalation import ( Action, + critical_escalations, decide_dev, decide_review_session, + preference_escalations, review_retry_or_exhaust, ) from bmad_loop.model import StoryTask @@ -20,6 +22,27 @@ FAILING = VerifyOutcome.retry("spec status is 'in-progress', expected 'done'") +def test_escalation_selectors_preserve_valid_list_semantics(): + critical = {"severity": "critical", "detail": "stop"} + preferences = [ + {}, + {"severity": "PREFERENCE", "detail": "explicit"}, + {"detail": "implicit"}, + {"severity": 1, "detail": "non-critical"}, + ] + result = {"escalations": [None, "junk", critical, *preferences]} + + assert critical_escalations(result) == [critical] + assert preference_escalations(result) == preferences + + +def test_escalation_selectors_reject_every_non_list_shape(): + for value in (None, 1, "escalation", {"severity": "CRITICAL"}, ("tuple",)): + result = {"escalations": value} + assert critical_escalations(result) == [] + assert preference_escalations(result) == [] + + def _task(**kw) -> StoryTask: return StoryTask(story_key="9-0-x", epic=9, **kw) diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 55fd7e66..4f5349b3 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -7,6 +7,7 @@ propagation / hook-signal waiting / kill end-to-end for any profile. """ +import copy import dataclasses import hashlib import json @@ -190,6 +191,91 @@ def test_read_result_variants(tmp_path): assert adapter._read_result("t1") is None # malformed (task_dir / "result.json").write_text('["not a dict"]') assert adapter._read_result("t1") is None # wrong shape + (task_dir / "result.json").write_bytes(_BAD_UTF8) + assert adapter._read_result("t1") is None # invalid UTF-8 + (task_dir / "result.json").write_text('{"clean": true}') + assert adapter._read_result("t1") == {"clean": True} # valid rewrite + + +def test_read_result_degrades_a_plain_decoder_value_error(tmp_path, monkeypatch): + adapter = make_adapter(tmp_path) + task_dir = adapter.tasks_dir / "t1" + task_dir.mkdir(parents=True) + marker = '{"value":"decoder-value-error"}' + (task_dir / "result.json").write_text(marker) + real_loads = json.loads + + def loads_with_value_error(data, *args, **kwargs): + if data == marker: + raise ValueError("synthetic decoder value error") + return real_loads(data, *args, **kwargs) + + with monkeypatch.context() as mp: + mp.setattr(generic.json, "loads", loads_with_value_error) + assert adapter._read_result("t1") is None + + valid_unicode = {"escalations": [{"severity": "PREFERENCE", "detail": "café 🚀"}]} + (task_dir / "result.json").write_text( + json.dumps(valid_unicode, ensure_ascii=False), encoding="utf-8" + ) + assert adapter._read_result("t1") == valid_unicode + + +def test_read_result_degrades_a_decoder_recursion_error(tmp_path): + adapter = make_adapter(tmp_path) + task_dir = adapter.tasks_dir / "t1" + task_dir.mkdir(parents=True) + depth = sys.getrecursionlimit() * 20 + nested = '{"value":' + ("[" * depth) + "0" + ("]" * depth) + "}" + with pytest.raises(RecursionError): + json.loads(nested) + + (task_dir / "result.json").write_text(nested) + + assert adapter._read_result("t1") is None + + +def test_read_result_degrades_an_unreadable_existence_probe(tmp_path, monkeypatch): + adapter = make_adapter(tmp_path) + path = adapter._result_path("t1") + real_is_file = Path.is_file + + def is_file_with_permission_error(candidate): + if candidate == path: + raise PermissionError("task directory is not searchable") + return real_is_file(candidate) + + monkeypatch.setattr(Path, "is_file", is_file_with_permission_error) + + assert adapter._read_result("t1") is None + + +def test_read_result_rejects_data_that_plugin_deepcopy_cannot_handle(tmp_path): + adapter = make_adapter(tmp_path) + task_dir = adapter.tasks_dir / "t1" + task_dir.mkdir(parents=True) + depth = sys.getrecursionlimit() // 2 + nested = '{"value":' + ("[" * depth) + "0" + ("]" * depth) + "}" + parsed = json.loads(nested) + with pytest.raises(RecursionError): + copy.deepcopy(parsed) + (task_dir / "result.json").write_text(nested) + + assert adapter._read_result("t1") is None + + +def test_read_result_rejects_lone_surrogates_and_recovers_after_rewrite(tmp_path): + adapter = make_adapter(tmp_path) + task_dir = adapter.tasks_dir / "t1" + task_dir.mkdir(parents=True) + escaped_surrogate = '{"escalations":[{"severity":"CRITICAL","detail":"\\ud800"}]}' + parsed = json.loads(escaped_surrogate) + with pytest.raises(UnicodeEncodeError): + json.dumps(parsed, ensure_ascii=False).encode("utf-8") + (task_dir / "result.json").write_text(escaped_surrogate) + + assert adapter._read_result("t1") is None + (task_dir / "result.json").write_text('{"clean": true}') assert adapter._read_result("t1") == {"clean": True} diff --git a/tests/test_resolve.py b/tests/test_resolve.py index ec21306f..9ed23bb2 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2713,6 +2713,53 @@ def test_gather_escalations_skips_a_non_utf8_artifact(tmp_path): assert [e["detail"] for e in ctx["escalations"]] == ["still readable"] +def test_gather_escalations_skips_an_unreadable_existence_probe(tmp_path, monkeypatch): + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + unreadable = task_dir / "result.json" + sibling = {"severity": "CRITICAL", "detail": "sibling survives"} + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [sibling]}), encoding="utf-8" + ) + real_is_file = Path.is_file + + def is_file_with_permission_error(candidate): + if candidate == unreadable: + raise PermissionError("task directory is not searchable") + return real_is_file(candidate) + + with monkeypatch.context() as mp: + mp.setattr(Path, "is_file", is_file_with_permission_error) + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["escalations"] == [sibling] + + +@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity", "1e999", "-1e999"]) +def test_gather_escalations_skips_a_nonfinite_artifact(tmp_path, constant): + """A numeric spelling decoded as non-finite poisons only its own artifact; a valid + sibling still reaches context, whose output is accepted by a strict parser.""" + run_dir, state, task = _escalated_run(tmp_path) + task_dir = _task_dir(run_dir, task) + (task_dir / "result.json").write_text( + '{"escalations":[{"severity":"CRITICAL","detail":' + constant + "}]}", + encoding="utf-8", + ) + sibling = {"severity": "CRITICAL", "detail": "sibling survives", "score": 0.5} + (task_dir / "escalation.json").write_text( + json.dumps({"escalations": [sibling]}), encoding="utf-8" + ) + + path = _context(state, run_dir, "6-4-cli-list-command", isolation="") + + def reject_constant(value): + raise ValueError(f"non-finite JSON constant: {value}") + + ctx = json.loads(path.read_text(encoding="utf-8"), parse_constant=reject_constant) + assert ctx["escalations"] == [sibling] + + def test_gather_escalations_skips_a_plain_json_value_error(tmp_path, monkeypatch): """`json.loads` raises plain ValueError, not JSONDecodeError, when an integer exceeds Python's configured digit limit. That malformed file costs only its @@ -2796,14 +2843,11 @@ def dumps_with_recursion_error(value, *args, **kwargs): @pytest.mark.parametrize("bad", [None, 1, "x", {}]) def test_gather_escalations_skips_a_non_list_escalations_field(tmp_path, monkeypatch, bad): - """DW-70/73's other half. `escalation.critical_escalations` iterates - `escalations` with no list guard of its own, so `{"escalations": null}` raised - `TypeError` straight out of `build_context`. The guard sits in this caller; the - shared predicate stays the single definition of CRITICAL. - - Every parameter must fail when the list guard is ablated. ``None`` and ``1`` - raise without it; the call trace below distinguishes the iterable ``"x"`` and - ``{}`` shapes, which the shared filter would otherwise accept as empty.""" + """The shared selector owns the list guard, including for resolve artifacts. + + Every parameter fails when that shared guard is ablated. The call trace also + proves this reader delegates malformed shapes instead of retaining a private + guard that could drift from engine and sweep behavior.""" run_dir, state, task = _escalated_run(tmp_path) task_dir = _task_dir(run_dir, task) (task_dir / "result.json").write_text(json.dumps({"escalations": bad}), encoding="utf-8") @@ -2825,15 +2869,30 @@ def recording_critical_escalations(doc): ctx = json.loads(path.read_text(encoding="utf-8")) assert filtered == [ + {"escalations": bad}, { "escalations": [ {"severity": "CRITICAL", "detail": "sibling survives"}, ] - } + }, ] assert [e["detail"] for e in ctx["escalations"]] == ["sibling survives"] +@pytest.mark.parametrize( + "nonfinite", [float("nan"), float("inf"), float("-inf")], ids=["nan", "inf", "-inf"] +) +def test_build_context_refuses_nonfinite_in_memory_values(tmp_path, nonfinite): + run_dir, state, _task = _escalated_run(tmp_path) + state.paused_reason = nonfinite + path = resolve.context_path(run_dir, "6-4-cli-list-command") + + with pytest.raises(ValueError, match="Out of range float values"): + resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") + + assert not path.exists() + + def test_gather_escalations_preference_only_yields_nothing(tmp_path): """The CRITICAL-only filter is unchanged by the de-duplication rewrite: a directory carrying only non-CRITICAL entries contributes nothing, and mirroring From 46732d3b970727712620a834dfd3faa94c092b42 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 16:32:59 -0700 Subject: [PATCH 39/45] sweep dw4-resolve-context-contract-docs: DW-87 via bmad-loop --- .../data/skills/bmad-loop-resolve/SKILL.md | 3 +++ tests/test_resolve_skill_contract.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md index 095b4d50..188237ad 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -49,6 +49,9 @@ These environment variables are set: } ``` +The `escalations` array is ordered newest-first. +Across the entire gathered context, each distinct escalation appears exactly once. + The interactive session's working directory is always `project_root`. That tree holds the BMAD artifacts and specs you inspect or clarify. `code_root` is the tree where the run's code and git work belong; it may be different. When the roots differ, do not diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index c17b0f82..918dad5b 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -84,6 +84,23 @@ def test_every_emitted_context_key_is_documented(skill_md): ) +def test_skill_documents_escalation_ordering_and_global_uniqueness(skill_md): + """The context consumer can rely on the reader's cross-session guarantees. + + These assertions are deliberately separate so removing either the ordering + promise or the global de-duplication promise fails the contract guard. + """ + after_schema = skill_md.split("}\n```\n\n", maxsplit=1)[1] + contract_lines = after_schema.split("\n\n", maxsplit=1)[0].splitlines() + + assert "The `escalations` array is ordered newest-first." in contract_lines + assert ( + "Across the entire gathered context, each distinct escalation appears exactly once." + in contract_lines + ) + assert len(contract_lines) == 2 + + def test_skill_routes_project_artifacts_and_code_work_to_their_distinct_roots(skill_md): """Mentioning both keys is inert unless the skill explains the operational split. From bc9959ca66656afaec97b083a029421375ccf08f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 17:05:05 -0700 Subject: [PATCH 40/45] sweep dw4-decision-dw-91: DW-91 via bmad-loop --- CHANGELOG.md | 3 ++ docs/FEATURES.md | 2 +- .../data/skills/bmad-loop-resolve/SKILL.md | 22 +++++++-- tests/test_resolve_skill_contract.py | 45 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a08acfe6..cb676ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,9 @@ breaking changes may land in a minor release. ### Fixed +- Let interactive resolve present `paused_reason` when watermark filtering leaves no newer + recorded escalation detail, without recovering or inventing an escalation (DW-91). + - Reject malformed session escalation/result artifacts and non-finite resolve JSON (DW-86, DW-89). diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1b34e387..00791230 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -69,7 +69,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and 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, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. -- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second `bmad-loop resolve` used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a `resolution.json` now **watermarks** the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's `context.json` (the agent-facing contract is unchanged). The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, `resolve --no-interactive`, and the TUI's Re-arm button all leave it where it stands. Leaving a watermark is not clearing it: a watermark already standing still filters on those paths, which show everything recorded since the last accepted resolution rather than the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the `esc-upto` column of `bmad-loop diagnose`'s markdown task table, and as `escalations_resolved_upto` under `--json` (that is the key to grep in a support bundle), which is what explains a short `context.json` on a bug report. +- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second `bmad-loop resolve` used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a `resolution.json` now **watermarks** the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's `context.json`. When that filtered list contains entries, the resolve skill presents them newest-first under the existing globally de-duplicated contract. When a new pause precedes any newer recorded escalation and the filtered list is empty, the skill presents `paused_reason` as the available current-pause evidence and discloses that no newer recorded detail exists; it does not read below the watermark, recover an older artifact entry, or synthesize an escalation object. The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, `resolve --no-interactive`, and the TUI's Re-arm button all leave it where it stands. Leaving a watermark is not clearing it: a watermark already standing still filters on those paths, which show everything recorded since the last accepted resolution rather than the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the `esc-upto` column of `bmad-loop diagnose`'s markdown task table, and as `escalations_resolved_upto` under `--json` (that is the key to grep in a support bundle), which is what explains a short or empty `context.json` escalation list on a bug report. - A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most `max_dev_attempts` per story) and has no suppression knob of its own; it follows `[notify]` like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is reduced to its first line and capped, with a `[…]` marker when it was trimmed, because a `Decision.reason` routinely carries a verify-output tail that would otherwise spill into `ATTENTION` and a desktop bubble; the untruncated reason stays in the `dev-decision` journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices. - Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (`sh` reports rc `126`/`127`; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time `OSError` counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507); `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: `has_session` maps every nonzero backend result to False, so a negative lookup is "the backend did not confirm it" rather than proof the session is gone — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. diff --git a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md index 188237ad..407864f9 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -152,10 +152,24 @@ case below — omit it entirely for an ordinary resolution. especially its `` block (the intent the dev/review sessions treat as authoritative). The escalation is almost always that this block is silent on, or contradicts, a case the implementation hit. -2. **Present the escalation plainly** to the human: what is ambiguous or - contradictory, why it blocks safe implementation, and **2–4 concrete - resolution options** with a clear recommendation and its trade-offs. Keep it - tight — quote the relevant spec lines. +2. **Present the current pause evidence plainly** to the human: + - When the `escalations` array is non-empty, present its recorded entries in + their existing newest-first order. Do not replace recorded escalation + detail with `paused_reason`. + - When the `escalations` array is empty, first require `paused_reason` to be + text containing at least one non-whitespace character. If it is missing, + `null`, non-text, or blank after trimming, report a malformed resolve + context and do not write the resolution marker. Otherwise, present + `paused_reason` verbatim as the available evidence for the current pause + and disclose that no newer recorded escalation detail is available. Do not + read below the watermark, unfilter or recover an older artifact escalation, + or synthesize an escalation object from `paused_reason`. + + Using the selected evidence, explain what is ambiguous or contradictory, why + it blocks safe implementation, and offer **2–4 concrete resolution options** + with a clear recommendation and its trade-offs. Keep it tight — quote the + relevant spec lines. + 3. **Get the human's decision.** Ask follow-ups if the choice is unclear. Do not invent requirements; if the human is unsure, help them reason, don't guess. 4. **Update the frozen spec** to encode the decision unambiguously: amend the diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index 918dad5b..7052ac1d 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -101,6 +101,51 @@ def test_skill_documents_escalation_ordering_and_global_uniqueness(skill_md): assert len(contract_lines) == 2 +def test_skill_presents_paused_reason_when_no_newer_escalation_detail(skill_md): + """A watermarked pause can have no new session escalation to present. + + The positive non-empty assertion keeps the empty-path prohibitions from being + satisfied by deleting recorded-entry handling altogether. + """ + presentation_step = skill_md.split( + "2. **Present the current pause evidence plainly**", maxsplit=1 + )[1].split("\n3. **Get the human's decision.**", maxsplit=1)[0] + normalized = " ".join(presentation_step.split()) + + assert "When the `escalations` array is non-empty" in normalized + assert "present its recorded entries in their existing newest-first order" in normalized + assert "Do not replace recorded escalation detail with `paused_reason`." in normalized + assert "When the `escalations` array is empty" in normalized + assert ( + "present `paused_reason` verbatim as the available evidence for the current pause" + in normalized + ) + assert "no newer recorded escalation detail is available" in normalized + assert "Do not read below the watermark" in normalized + assert "unfilter or recover an older artifact escalation" in normalized + assert "synthesize an escalation object from `paused_reason`" in normalized + assert ( + "require `paused_reason` to be text containing at least one non-whitespace character" + in normalized + ) + assert "missing, `null`, non-text, or blank after trimming" in normalized + assert "report a malformed resolve context and do not write the resolution marker" in normalized + + shared_requirement = ( + "Using the selected evidence, explain what is ambiguous or contradictory, why it " + "blocks safe implementation, and offer **2–4 concrete resolution options** with a " + "clear recommendation and its trade-offs." + ) + assert shared_requirement in normalized + assert normalized.index(shared_requirement) > normalized.index( + "When the `escalations` array is empty" + ) + assert ( + "\n\n Using the selected evidence, explain what is ambiguous or contradictory" + in presentation_step + ) + + def test_skill_routes_project_artifacts_and_code_work_to_their_distinct_roots(skill_md): """Mentioning both keys is inert unless the skill explains the operational split. From baf463db1e9e8ccb223c457b44ee5f5d963ace8b Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 18:43:50 -0700 Subject: [PATCH 41/45] sweep dw4-decision-dw-93: DW-93 via bmad-loop --- CHANGELOG.md | 2 + docs/FEATURES.md | 1 + src/bmad_loop/cli.py | 86 +++++++++++-- src/bmad_loop/journal.py | 56 +++++++- src/bmad_loop/runs.py | 213 ++++++++++++++++-------------- src/bmad_loop/runsetup.py | 29 +++-- src/bmad_loop/tui/app.py | 81 +++++++++--- tests/conftest.py | 10 +- tests/test_cli.py | 174 +++++++++++++++++++++++++ tests/test_diagnostics.py | 7 +- tests/test_engine.py | 9 ++ tests/test_journal.py | 221 +++++++++++++++++++++++++++++++- tests/test_portability_guard.py | 99 +++++++++----- tests/test_runs.py | 206 ++++++++++++++++++++++++++++- tests/test_runsetup.py | 95 +++++++++++++- tests/test_tui_app.py | 188 ++++++++++++++++++++++++++- 16 files changed, 1291 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb676ff9..808ad431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,8 @@ breaking changes may land in a minor release. ### Fixed +- Serialize every run-state writer and control read-modify-write transaction with one canonical per-run advisory lock (DW-93). + - Let interactive resolve present `paused_reason` when watermark filtering leaves no newer recorded escalation detail, without recovering or inventing an escalation (DW-91). diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 00791230..b00ad08a 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -212,6 +212,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. +- Every `state.json` publication is serialized by one advisory lock per run, keyed on the resolved run directory plus the logical `state.json` name and stored under the user state root rather than in git. Ignoring a final-component `state.json` symlink keeps that identity stable when atomic publication replaces the directory entry; alternate spellings of the run directory still converge. Multi-step control mutations (`resolve`, `resume`, code-root restamping, and stop's external fallback) hold that same lock from their authoritative read through atomic publication, so a waiter reloads the state its predecessor left instead of overwriting it from a stale snapshot. A fresh run or sweep likewise holds it from its initial state save through trusted-digest and PID publication, preventing an explicit-id resume from observing resumable state before the composer is live. Readers remain lock-free because publication is atomic. Stop does not hold the lock while it requests, signals, polls, or kills: a live engine must be able to publish its own stopped state; only the fallback's final reload/check/write is serialized. That final check preserves an engine that finished during delivery and retries against any newer live engine generation a concurrent resume published. POSIX lock acquisition blocks, while Windows can surface an `OSError` after its bounded wait; either failure aborts the mutation rather than writing unlocked. - All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev, repair and review legs alike, carrying `verification_stage` and a per-story `verification_sequence` that orders the passes across all three; the two passes that leave no record are `bmad-loop confirm --reverify`, which runs after the run is over, and any pass with no `[verify] commands` configured, which records nothing because nothing ran — each entry also carrying `spawn_error`, set when the verify command's child could not be started at all — typically because its working directory is missing, is not a directory, or cannot be searched, though any spawn-time `OSError` (a missing shell, EMFILE, ENOMEM) reaches the same field and the wrapped exception is what names the cause — which is an environment fault that pauses the run rather than a command that failed — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it); `tasks//` (per-session prompt + shared artifacts: [`result.json`, `escalation.json`] — respectively the per-session result and escalation outputs — plus adapter-specific breadcrumbs: `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194), a mux session lost under the run (`session-vanished`, #489) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). - One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index a0763792..430658eb 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -70,7 +70,7 @@ validate_document, ) from .engine import Engine -from .journal import Journal, load_state, save_state +from .journal import Journal, load_state, save_state, state_lock from .model import RunState from .platform_util import MAX_SEGMENT, resolve_or_lexical, walk_files_unlinked from .process_host import ProcessHostError @@ -2554,9 +2554,8 @@ def _sweep_dry_run(paths: bmadconfig.ProjectPaths, pol) -> int: return 0 -def _resume_paused_run(project: Path, run_dir: Path) -> int: - """Resume the engine for a paused/interrupted run. Shared by `resume` and - the re-arm step of `resolve`.""" +def _prepare_resume_locked(project: Path, run_dir: Path): + """Publish resume state while the caller holds this run's state lock.""" # An id that aliases a control session (`ctl` / `ctl-<16hex>` — # runs.run_id_aliases_control_session; NOT the mint's broader reservation, # since a historical `ctl-foo` run has a genuine agent session and resumes @@ -2806,6 +2805,30 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int: # SweepEngine and _make_adapters are handed in from this module's namespace so # the test suite's `monkeypatch.setattr(cli, "SweepEngine"/"Engine"/..., ...)` # still applies. + return paths, state, pol, journal, new_digest, profiles + + +def _resume_paused_run(project: Path, run_dir: Path) -> int: + """Resume a paused/interrupted run without holding its lock across execution.""" + with state_lock(run_dir): + # Repeat the command's liveness decision after exclusion. A concurrent + # resume publishes its pid under this same hold, so the waiter refuses + # instead of reloading the predecessor's old paused state and double-driving. + if runs.engine_liveness(run_dir) == "alive": + print( + f"run {run_dir.name} is still live — resuming would double-drive it; " + "stop it first", + file=sys.stderr, + ) + return 1 + prepared = _prepare_resume_locked(project, run_dir) + if isinstance(prepared, int): + return prepared + paths, state, pol, journal, new_digest, profiles = prepared + + # Adapter construction and the engine lifetime are deliberately outside the + # state hold. The pid/state publication above makes a rival control command + # observe this process as live while these unbounded operations proceed. composed = runsetup.compose_resume( project=project, paths=paths, @@ -3234,6 +3257,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: try: paths = bmadconfig.load_paths(project) except (bmadconfig.BmadConfigError, OSError) as e: + paths = None # An observation, so it degrades: without the config this process cannot NAME # the tree, and re-pointing the mirror at a guess is the one outcome worse than # leaving it alone. The re-arm then reads the root the run recorded — precisely @@ -3281,18 +3305,54 @@ def cmd_resolve(args: argparse.Namespace) -> int: # config lecture about a gesture they did not make. if (rc := _reject_isolation_conflict(paths, pol)) is not None: return rc - if (moved := runs.restamp_code_root(run_dir, paths.repo_root)) is not None: - print(f"warning: {moved}", file=sys.stderr) before_entries = runs.journal_entries_or_none(run_dir) outcome: runs.RearmOutcome | None = None try: - outcome = runs.rearm_escalation( - run_dir, - story_key, - restore_patch=restore_patch, - isolated_redrive=pol.scm.isolation == "worktree", - resolution_recorded=resolution_recorded, - ) + with state_lock(run_dir): + # The pre-session checks intentionally stay lock-free; this is the + # mutation boundary, so repeat every state/liveness precondition from + # the snapshot left by the preceding writer before restamping anything. + fresh_state = load_state(run_dir) + if fresh_state.paused_stage != PAUSE_ESCALATION: + print( + f"run {args.run_id} is not paused at an escalation " + f"(stage: {fresh_state.paused_stage or 'none'})", + file=sys.stderr, + ) + return 1 + fresh_live = runs.engine_liveness(run_dir) + if fresh_live == "alive": + print(f"run {args.run_id} is still live — stop it first", file=sys.stderr) + return 1 + if fresh_live == "unknown" and not args.force: + print( + f"run {args.run_id}: engine may still be live (unverifiable pid) — " + "refusing to re-arm. Confirm the engine process is gone, then re-run " + "with --force (`stop` cannot verify or clear an unverifiable pid).", + file=sys.stderr, + ) + return 1 + fresh_task = fresh_state.tasks.get(story_key) + if fresh_task is None or fresh_task.phase != Phase.ESCALATED: + print(f"no escalated story to resolve in run {args.run_id}", file=sys.stderr) + return 1 + if fresh_task.generation != task.generation: + print( + f"the escalation for {story_key} changed while resolve was in progress " + "— not re-arming", + file=sys.stderr, + ) + return 1 + if paths is not None: + if (moved := runs.restamp_code_root(run_dir, paths.repo_root)) is not None: + print(f"warning: {moved}", file=sys.stderr) + outcome = runs.rearm_escalation( + run_dir, + story_key, + restore_patch=restore_patch, + isolated_redrive=pol.scm.isolation == "worktree", + resolution_recorded=resolution_recorded, + ) except runs.RearmError as e: print(f"error: {e}", file=sys.stderr) return 1 diff --git a/src/bmad_loop/journal.py b/src/bmad_loop/journal.py index 4ef72b3b..124676cb 100644 --- a/src/bmad_loop/journal.py +++ b/src/bmad_loop/journal.py @@ -4,7 +4,10 @@ import json import os +import threading import time +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from typing import Any @@ -14,6 +17,7 @@ atomic_replace, atomic_write_text, atomic_write_text_at, + file_lock, is_link_like, open_dir_confined, ) @@ -77,6 +81,8 @@ # name; neither restates the pair. SELF_MINTED_FIELDS: frozenset[str] = frozenset({"log_task", "log_pos"}) +_STATE_LOCK_LOCAL = threading.local() + class Journal: def __init__(self, run_dir: Path): @@ -217,12 +223,52 @@ def entries(self) -> list[dict[str, Any]]: return out +@contextmanager +def state_lock(run_dir: Path) -> Iterator[None]: + """Serialize one run's state mutations, re-entering only for the same run. + + The sidecar identity comes from :func:`runs.lock_path_for`, so alternate path + spellings of one ``state.json`` rendezvous on the same out-of-tree lock. The + import is deliberately lazy: ``runs`` imports this module's persistence helpers. + + Reentrancy is thread-local and intentionally limited to one run. An outer + read-modify-write transaction can call the self-locking :func:`save_state` + without acquiring the OS lock twice, while nested mutation of another run is + refused before a second lock can introduce an ordering cycle. + """ + from . import runs + + lock_path = runs.lock_path_for(run_dir / STATE_FILE, follow_final_symlink=False) + held_path = getattr(_STATE_LOCK_LOCAL, "path", None) + if held_path is not None: + if held_path != lock_path: + raise RuntimeError( + f"cannot nest run-state locks for different runs: {held_path} then {lock_path}" + ) + _STATE_LOCK_LOCAL.depth += 1 + try: + yield + finally: + _STATE_LOCK_LOCAL.depth -= 1 + return + + with file_lock(lock_path): + _STATE_LOCK_LOCAL.path = lock_path + _STATE_LOCK_LOCAL.depth = 1 + try: + yield + finally: + del _STATE_LOCK_LOCAL.depth + del _STATE_LOCK_LOCAL.path + + def save_state(run_dir: Path, state: RunState) -> None: - run_dir.mkdir(parents=True, exist_ok=True) - target = run_dir / STATE_FILE - tmp = target.with_suffix(".json.tmp") - tmp.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8") - atomic_replace(tmp, target) + with state_lock(run_dir): + run_dir.mkdir(parents=True, exist_ok=True) + target = run_dir / STATE_FILE + tmp = target.with_suffix(".json.tmp") + tmp.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8") + atomic_replace(tmp, target) def load_state(run_dir: Path) -> RunState: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 6d3b57f4..bae97e3b 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -40,7 +40,7 @@ mux_usable, ) from .frontmatter import auto_dev_baseline_of, parse_frontmatter, status_of -from .journal import STATE_FILE, VERIFY_DIR, Journal, load_state, save_state +from .journal import STATE_FILE, VERIFY_DIR, Journal, load_state, save_state, state_lock from .model import PAUSE_ESCALATION, Phase, RunState, StoryTask from .platform_util import ( MAX_SEGMENT, @@ -1370,7 +1370,7 @@ def accepted_tags(project: Path) -> frozenset[str]: return frozenset({project_tag(project), str(project.resolve())}) -def lock_path_for(data_path: Path) -> Path: +def lock_path_for(data_path: Path, *, follow_final_symlink: bool = True) -> Path: """The advisory-lock sidecar for a mutable data file: ``/locks/-.lock``. @@ -1399,7 +1399,15 @@ def lock_path_for(data_path: Path) -> Path: usable state root (see :func:`state_root`); the caller fails rather than silently locking somewhere else. """ - resolved = data_path.resolve() + # Run-state publication atomically replaces ``state.json``. Its transaction + # lock therefore needs the identity of that *logical directory entry*, not the + # current referent of a planted final-component symlink: following that link + # would change the sidecar halfway through an outer transaction when + # ``save_state`` replaces it. Other mutable artifacts retain the historical + # referent-based behavior by default (notably shared external ledgers). + resolved = ( + data_path.resolve() if follow_final_symlink else data_path.parent.resolve() / data_path.name + ) digest = hashlib.sha256(os.fsencode(str(resolved))).hexdigest()[:16] return state_root() / "locks" / f"{digest}-{resolved.name}.lock" @@ -2100,6 +2108,14 @@ def request_graceful_stop(run_dir: Path) -> str: def stop_run(run_dir: Path) -> bool: + """Stop the engine generation current at completion of the gesture.""" + while True: + result = _stop_run_once(run_dir) + if result is not None: + return result + + +def _stop_run_once(run_dir: Path) -> bool | None: """Stop a live run. Returns False if it was already finished. The request is delivered two ways at once, and the engine wins whichever race @@ -2181,6 +2197,7 @@ def stop_run(run_dir: Path) -> bool: # the pid we recorded is already gone, or was reused by an unrelated # process before stop_run ran — never signal a stranger; mark stopped below. pid = None + addressed_engine = (pid, identity) # Whether this call ever proved the engine dead. Only a confirmed death licenses # the fallback below to discard the request we lodged: while the engine may still # be running, that file is the one channel left that can stop it (on native @@ -2268,8 +2285,53 @@ def stop_run(run_dir: Path) -> bool: # addresses the registry this process exported, and `cleanup`'s legacy pass is # what reaches a session left in an older one. kill_session(run_dir.name) - state = load_state(run_dir) - if state.stopped: + + already_stopped = False + finished_during_stop = False + retry_new_engine = False + clear_request = False + with state_lock(run_dir): + # Authoritative post-delivery snapshot. A rival writer that completed while + # stop was signalling is observed here, after exclusion, rather than being + # overwritten by the stale state loaded at entry. + state = load_state(run_dir) + current_engine = read_pid_identity(run_dir) + current_liveness = engine_liveness(run_dir) + rival_published_engine = current_liveness != "dead" and current_engine != addressed_engine + if state.finished: + # The engine completed while the stop channels were in flight. Its + # terminal state is authoritative; do not rewrite it as a fallback stop. + finished_during_stop = True + clear_request = current_liveness == "dead" + elif rival_published_engine: + # Resume publishes pid + state under this same lock. If that happened + # while this attempt was signalling an older generation, release before + # delivering to the new process and retry from its fresh identity. + retry_new_engine = True + elif state.stopped: + already_stopped = True + clear_request = True + elif engine_may_live and not lodged: + Journal(run_dir).append("run-stop-undelivered", pid=pid) + raise StopRunError( + f"run {run_dir.name}: the stop request could not be written to the run " + "directory and the engine could not be proved dead, so no stop is pending. " + "Its agent session was killed as a backstop. Free space in the run directory " + "and retry, or stop the process yourself" + ) + else: + state.stopped = True + save_state(run_dir, state) + clear_request = not engine_may_live + + if clear_request: + clear_graceful_stop(run_dir) + if finished_during_stop: + return False + if retry_new_engine: + return None + + if already_stopped: # The engine honored the stop and is gone, and its own `run-stop` already # stands in the journal. Stamping `fallback=True` on top would describe an # engine that did its own teardown as one that had to be stopped from @@ -2286,47 +2348,11 @@ def stop_run(run_dir: Path) -> bool: # re-stop at its first item. Safe on the `engine_may_live` paths too: a # written `stopped` *is* the engine reporting it honored the request, so # there is no live consumer left to strand. - clear_graceful_stop(run_dir) return True - # Neither channel was delivered: nothing is lodged, and we never proved the engine - # dead. This is the one outcome `stop` must not report as success — the operator is - # left believing a request is in flight that was never written, while an engine we - # could not signal keeps mutating the project. The pid-reuse guard above already - # refuses for its own path; these are its siblings, and the only reason they stayed - # quiet is that they clear `pid` and skip that block. Not a regression — on the - # merge-base this was the state of *every* refused signal, because `stop_run` cleared - # the request as its first statement — but the earlier decision to report success - # rested on the request being retained, which is exactly what did not happen here. - # - # Placement is load-bearing, twice over. It sits *after* the session backstop - # because refusing to report a stop is no reason to leak the window, and *after* the - # `state.stopped` return because a run the engine already honored must not be - # reported as a failure. Journal the attempt before raising: the `run-stop` append - # below is skipped, and an unrecorded stop attempt is its own trap. - if engine_may_live and not lodged: - Journal(run_dir).append("run-stop-undelivered", pid=pid) - raise StopRunError( - f"run {run_dir.name}: the stop request could not be written to the run " - "directory and the engine could not be proved dead, so no stop is pending. " - "Its agent session was killed as a backstop. Free space in the run directory " - "and retry, or stop the process yourself" - ) - - # Fallback: no live engine (or it never confirmed). Mark it stopped here. Discard - # the request first — nothing is left alive to consume it, and a file outliving - # the run it asked to stop is a trap for the next resume. - # - # Unless we never actually proved that. Where the engine may still be running, - # the request stays lodged and the stop is genuinely still in flight: the engine - # honors the file at its next poll and writes `stopped` itself. Discarding it here - # would leave a live engine with no channel left while we report the run stopped — - # the stale-request trap above is the lesser of the two, and it only bites a run - # that is later resumed, which this one cannot be until that engine exits. - if not engine_may_live: - clear_graceful_stop(run_dir) - state.stopped = True - save_state(run_dir, state) + # The locked branch above performed the external fallback's final + # read-modify-write. The journal remains outside the state transaction: it is + # append-only observation, not part of state publication. Journal(run_dir).append("run-stop", pid=pid, fallback=True) return True @@ -3688,41 +3714,18 @@ def _rearm_commit_landed(run_dir: Path, story_key: str, task: StoryTask) -> bool only if some other writer had minted the same bump, and `phase` alone moves for reasons a re-arm does not own. - Those two conjuncts are a sufficient identity ONLY because `rearm_escalation` runs as - the SOLE writer of this run's `state.json`, and that model is the probe's premise - rather than an assumption left implicit. Exactly TWO call sites reach this - transaction — `cli.cmd_resolve` and `tui.TuiApp._do_rearm` — and each consults - liveness before any side effect: :func:`engine_liveness` in the CLI, its pid-file - sibling :func:`liveness` in the TUI (`probe_liveness` is the shared body). A third - control command, `cli.cmd_resume`, never re-arms but DOES write this run's - `state.json` (through `_resume_paused_run`), which is why the sole-writer claim has - to account for it as well as for the two callers. - `tests/test_portability_guard.py::test_rearm_escalation_called_only_behind_a_liveness_gate` - holds that enumeration, which is otherwise prose a third call site could falsify - silently. - - Those gates establish that no engine is PROVABLY ALIVE — not that one is proven - dead — and the premise rests on the difference, so it is stated rather than rounded - off. `"alive"` is refused outright at all three. `"unknown"` is not: `cmd_resolve` - proceeds on it under `--force`, `cmd_resume` warns and proceeds by design (it is the - recovery path that rewrites engine.pid), and the TUI counts it as blocking only for a - pid-backed run. So the model this probe leans on is the engine stopped AND the - operator driving one control command at a time. Under it only THIS caller can have - moved either field, which is exactly what the exact-phase predicate reports — the - predicate is correct for the reason it is narrow. - - Two overlapping control commands are OUTSIDE that model rather than handled by it, - and deliberately so. `journal.save_state` stages through a FIXED `state.json.tmp` - sibling before its `atomic_replace` — the collision `_write_stop_request` documents - under #379, which names the stop-request file as the ONE control file with genuinely - *concurrent* writers — so two overlapping re-arms lose a `save_state` to - `FileNotFoundError` long before this probe's identity could matter. Answering them - here was weighed and declined: a lock taken by only `rearm_escalation` excludes - nobody (the honest fix is a run-level one shared with `_resume_paused_run` and the - engine's own `save_state`), and a durable per-re-arm token stamped on `StoryTask` - would buy this probe a precision the `save_state` writer beneath it cannot honour, at - the cost of a new persisted model field. Tracked as DW-93; the probe stays two - conjuncts over the reloaded task. + Those two conjuncts are a sufficient identity because the entire re-arm — including + this error-path probe — runs inside :func:`journal.state_lock`. Every state writer + participates through the self-locking :func:`journal.save_state`, and every external + read-modify-write gesture holds the same canonical run sidecar from its deciding read + through publication. Therefore no rival can supply the observed generation/phase + while this transaction is in flight: a waiter reloads only after this hold exits. + + The two operator call sites still repeat liveness under their outer transaction + holds. That is a separate safety rule: serialization prevents stale publication, + while liveness prevents deliberately taking a turn after an engine known to be live. + The portability guard keeps both the writer/transaction inventory and the two re-arm + surface gates executable rather than relying on this prose. Degrades to `False` — roll back, the pre-existing behavior — on ANY failure to read or parse the state file. This is observation feeding a repair decision, and the safe @@ -3861,22 +3864,23 @@ def restamp_code_root(run_dir: Path, repo_root: Path) -> str | None: run has changed repositories, and the paths are the half that would put an attacker-controlled string on their terminal. """ - state = load_state(run_dir) - new = str(repo_root) - if state.repo_root == new: - return None - moved = bool(state.repo_root) - state.repo_root = new - save_state(run_dir, state) - if not moved: - return None - return ( - f"run {run_dir.name}: the code root in _bmad/bmm/config.yaml has changed since " - "this run started — the re-drive works in the tree configured now, while the " - "baselines, preserve refs and branches this run already recorded name objects " - "in the previous one. Restore the previous `repo_root:` value if you did not " - "intend the move." - ) + with state_lock(run_dir): + state = load_state(run_dir) + new = str(repo_root) + if state.repo_root == new: + return None + moved = bool(state.repo_root) + state.repo_root = new + save_state(run_dir, state) + if not moved: + return None + return ( + f"run {run_dir.name}: the code root in _bmad/bmm/config.yaml has changed since " + "this run started — the re-drive works in the tree configured now, while the " + "baselines, preserve refs and branches this run already recorded name objects " + "in the previous one. Restore the previous `repo_root:` value if you did not " + "intend the move." + ) @dataclass(frozen=True) @@ -3925,6 +3929,25 @@ def rearm_escalation( restore_patch: str | None = None, isolated_redrive: bool, resolution_recorded: bool, +) -> RearmOutcome: + """Run the complete spec/git/state re-arm transaction under the run lock.""" + with state_lock(run_dir): + return _rearm_escalation_locked( + run_dir, + story_key, + restore_patch=restore_patch, + isolated_redrive=isolated_redrive, + resolution_recorded=resolution_recorded, + ) + + +def _rearm_escalation_locked( + run_dir: Path, + story_key: str | None = None, + *, + restore_patch: str | None = None, + isolated_redrive: bool, + resolution_recorded: bool, ) -> RearmOutcome: """Re-arm an escalation-paused story so the next resume re-drives it. diff --git a/src/bmad_loop/runsetup.py b/src/bmad_loop/runsetup.py index 071c8ada..4b4e258d 100644 --- a/src/bmad_loop/runsetup.py +++ b/src/bmad_loop/runsetup.py @@ -43,7 +43,7 @@ from . import policy as policy_mod from . import runs from .checks import Finding -from .journal import Journal, save_state +from .journal import Journal, save_state, state_lock from .model import RunState from .platform_util import atomic_replace, is_wsl_unc_path from .runs import RUNS_DIR @@ -1025,12 +1025,17 @@ def compose_run( spec_folder=spec_folder, trusted_config_digest=trusted_config_digest, ) - save_state(run_dir, state) - # After the run dir exists (Journal mkdir'd it above) and before the pid lands: - # the ordering `reconcile_orphan_state_dirs` reads runs in, and a stamp that - # cannot be written fails the launch before an observer can see a live run. - runs.write_trusted_config_digest(project, run_id, trusted_config_digest) - runs.write_pid(run_dir) + # State becoming resumable and the pid making this process live are one + # publication. An explicit-id resume waits for the pid rather than entering + # between these writes and double-driving the freshly composed run. + with state_lock(run_dir): + save_state(run_dir, state) + # After the run dir exists (Journal mkdir'd it above) and before the pid + # lands: the ordering `reconcile_orphan_state_dirs` reads runs in, and a + # stamp that cannot be written fails the launch before an observer can + # see a live run. + runs.write_trusted_config_digest(project, run_id, trusted_config_digest) + runs.write_pid(run_dir) adapters = make_adapters(project, run_dir, policy, profiles=profiles) journal.append( "run-start", @@ -1158,10 +1163,12 @@ def compose_sweep( run_type="sweep", trusted_config_digest=trusted_config_digest, ) - save_state(run_dir, state) - # Out of the tree, same ordering and same reason as compose_run's stamp. - runs.write_trusted_config_digest(project, run_id, trusted_config_digest) - runs.write_pid(run_dir) + # Same indivisible state/pid publication as compose_run. + with state_lock(run_dir): + save_state(run_dir, state) + # Out of the tree, same ordering and same reason as compose_run's stamp. + runs.write_trusted_config_digest(project, run_id, trusted_config_digest) + runs.write_pid(run_dir) options = { "prompting": prompting, "decisions_only": decisions_only, diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 3352ee9a..d0fa8bc2 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -26,7 +26,7 @@ from .. import bmadconfig, decisions, devcontract, policy, resolve, runs, stories, verify from ..adapters.multiplexer import MultiplexerError, mux_usable -from ..journal import load_state +from ..journal import load_state, state_lock from ..model import ( PAUSE_EPIC_BOUNDARY, PAUSE_ESCALATION, @@ -34,6 +34,7 @@ PAUSE_SPEC_APPROVAL, PAUSE_STORY_CHECKPOINT, PAUSE_STORY_GATE, + Phase, RunState, StoryTask, ) @@ -723,6 +724,8 @@ def done(verb: str | None) -> None: def _review_escalation(self, run_id: str, run_dir: Path, state: RunState) -> None: story_key = state.paused_story_key or "?" + task = state.tasks.get(story_key) + expected_generation = task.generation if task is not None else None spec_path, spec_text, readable = self._paused_spec(state) title, description = self._story_context(state, story_key) restore_recorded = self._restore_recorded(run_dir, story_key) @@ -751,7 +754,13 @@ def done(verb: str | None) -> None: return self._launch_resolve(run_id) elif verb == "rearm": - self._do_rearm(run_id, run_dir, story_key, restore_recorded=restore_recorded) + self._do_rearm( + run_id, + run_dir, + story_key, + restore_recorded=restore_recorded, + expected_generation=expected_generation, + ) self.push_screen(modal, done) @@ -902,7 +911,13 @@ def _echo_rearm_notices(self, notices: tuple[runs.RearmNotice, ...]) -> None: ) def _do_rearm( - self, run_id: str, run_dir: Path, story_key: str, *, restore_recorded: bool = False + self, + run_id: str, + run_dir: Path, + story_key: str, + *, + restore_recorded: bool = False, + expected_generation: int | None = None, ) -> None: """Re-arm a resolved escalation + resume — the `resolve --no-interactive` path (rearm_escalation handles sentinel auto-delete-with-preservation).""" @@ -945,6 +960,7 @@ def _do_rearm( try: paths = bmadconfig.load_paths(self.project) except (bmadconfig.BmadConfigError, OSError) as e: + paths = None self.notify( f"cannot read the project config to confirm the code root ({e}) — " "re-arming against the root this run recorded", @@ -965,25 +981,52 @@ def _do_rearm( if conflict is not None: self.notify(conflict, severity="error") return - if (moved := runs.restamp_code_root(run_dir, paths.repo_root)) is not None: - self.notify(moved, severity="warning") before_entries = runs.journal_entries_or_none(run_dir) outcome: runs.RearmOutcome | None = None try: - outcome = runs.rearm_escalation( - run_dir, - story_key, - isolated_redrive=isolation == "worktree", - # DW-11. This gesture runs no resolve session, so it accepted nothing: - # the escalation watermark must not advance. A `resolution.json` on - # disk is NOT evidence to the contrary here — `_restore_recorded` - # already records the governing fact for this surface, that a stale - # marker is indistinguishable from a fresh one, which is why this path - # declines the restore latch too. Stamping on its presence would bury - # escalations raised since the marker was written. - resolution_recorded=False, - ) - except RearmError as e: + with state_lock(run_dir): + # Repeat the liveness decision after exclusion. Config/policy work + # above is deliberately lock-free; only this bounded restamp+re-arm + # mutation gesture is serialized. + if self._resolve_blocked_by_liveness(run_id, run_dir): + return + fresh_state = load_state(run_dir) + fresh_task = fresh_state.tasks.get(story_key) + if ( + fresh_state.paused_stage != PAUSE_ESCALATION + or fresh_task is None + or fresh_task.phase != Phase.ESCALATED + ): + self.notify( + f"run {run_id} is no longer paused at escalation for {story_key} " + "— not re-arming", + severity="warning", + ) + return + if expected_generation is not None and fresh_task.generation != expected_generation: + self.notify( + f"the escalation for {story_key} changed while its review was open " + "— not re-arming", + severity="warning", + ) + return + if paths is not None: + if (moved := runs.restamp_code_root(run_dir, paths.repo_root)) is not None: + self.notify(moved, severity="warning") + outcome = runs.rearm_escalation( + run_dir, + story_key, + isolated_redrive=isolation == "worktree", + # DW-11. This gesture runs no resolve session, so it accepted nothing: + # the escalation watermark must not advance. A `resolution.json` on + # disk is NOT evidence to the contrary here — `_restore_recorded` + # already records the governing fact for this surface, that a stale + # marker is indistinguishable from a fresh one, which is why this path + # declines the restore latch too. Stamping on its presence would bury + # escalations raised since the marker was written. + resolution_recorded=False, + ) + except (RearmError, OSError, runs.StateRootError) as e: self.notify(f"re-arm failed: {e}", severity="error") return finally: diff --git a/tests/conftest.py b/tests/conftest.py index 53996b05..e0a58c8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ from bmad_loop.adapters.base import SessionResult, SessionSpec from bmad_loop.bmadconfig import ProjectPaths, load_paths from bmad_loop.checks import ValidationReport -from bmad_loop.journal import save_state +from bmad_loop.journal import STATE_FILE, save_state from bmad_loop.model import PAUSE_ESCALATION, Phase, RunState, SessionRecord, StoryTask from bmad_loop.verify import finalize_commit, rev_parse_head @@ -69,6 +69,14 @@ def _codec_rejects_bad_byte() -> bool: ) +def assert_run_state_lock_held(run_dir: Path) -> None: + """Fail unless this process already owns the canonical logical state lock.""" + sidecar = runs.lock_path_for(run_dir / STATE_FILE, follow_final_symlink=False) + with pytest.raises(OSError): + with platform_util.file_lock(sidecar, blocking=False): + pytest.fail("the run-state publication boundary was outside its outer lock") + + def opencode_runs() -> bool: """Whether this host has an ``opencode`` binary that actually RUNS. diff --git a/tests/test_cli.py b/tests/test_cli.py index 9b041a7c..653623a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,6 +17,7 @@ PROJECT_MARKER_CMD, REPO_ROOT_MARKER_CMD, UNRESOLVABLE, + assert_run_state_lock_held, escalated_run, fault_read_text, git, @@ -2548,6 +2549,88 @@ def test_resolve_force_unknown_proceeds(tmp_path, monkeypatch, capsys): assert load_state(run_dir).tasks["s1"].phase == Phase.PENDING # past the gate, re-armed +def test_resolve_reloads_state_after_waiting_for_mutation_lock(tmp_path, monkeypatch, capsys): + """Ablation: delete cmd_resolve's fresh in-lock state check and both concurrent + gestures reach rearm_escalation instead of the waiter refusing the rival's result.""" + import contextlib + + from bmad_loop import runs + from bmad_loop.journal import load_state, save_state + from bmad_loop.model import Phase + + run_dir = _escalated_run(tmp_path, "r1") + monkeypatch.setattr(runs, "engine_liveness", lambda _rd: "dead") + monkeypatch.setattr(cli, "_resume_paused_run", lambda *_a: pytest.fail("resumed")) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("double re-armed")) + + @contextlib.contextmanager + def rival_first(_run_dir): + rival = load_state(run_dir) + rival.tasks["s1"].phase = Phase.PENDING + save_state(run_dir, rival) + yield + + monkeypatch.setattr(cli, "state_lock", rival_first) + + rc = cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--resume"]) + + assert rc == 1 + assert "no escalated story" in capsys.readouterr().err + + +def test_resolve_refuses_a_newer_escalation_after_waiting_for_mutation_lock( + tmp_path, monkeypatch, capsys +): + """A same-story re-escalation can have the same phase after a rival re-drive. + + Ablation: delete the generation comparison in ``cmd_resolve`` and this stale + gesture consumes the newer escalation even though its resolve session never saw it. + """ + import contextlib + + from bmad_loop import runs + from bmad_loop.journal import load_state, save_state + + run_dir = _escalated_run(tmp_path, "r1") + original_generation = load_state(run_dir).tasks["s1"].generation + monkeypatch.setattr(runs, "engine_liveness", lambda _rd: "dead") + monkeypatch.setattr(cli, "_resume_paused_run", lambda *_a: pytest.fail("resumed")) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("stale rearm")) + + @contextlib.contextmanager + def rival_first(_run_dir): + rival = load_state(run_dir) + rival.tasks["s1"].generation = original_generation + 1 + save_state(run_dir, rival) + yield + + monkeypatch.setattr(cli, "state_lock", rival_first) + + rc = cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--resume"]) + + assert rc == 1 + assert "changed while resolve was in progress" in capsys.readouterr().err + + +def test_resolve_retains_outer_lock_through_rearm_call(tmp_path, monkeypatch): + run_dir = _escalated_run(tmp_path, "r1") + rearms: list[Path] = [] + monkeypatch.setattr(runs, "engine_liveness", lambda _rd: "dead") + monkeypatch.setattr(cli, "_resume_paused_run", lambda *_a: 0) + + def checked_rearm(rd, key, **_kwargs): + assert_run_state_lock_held(rd) + rearms.append(rd) + return _rearm_outcome(key) + + monkeypatch.setattr(runs, "rearm_escalation", checked_rearm) + + assert ( + cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--resume"]) == 0 + ) + assert rearms == [run_dir] + + def test_resolve_no_escalated_story(tmp_path, capsys): _make_run_with_state( tmp_path, "r1", paused_stage="escalation", paused_reason="x", paused_story_key="ghost" @@ -5050,10 +5133,101 @@ def _paused_run_for_resume(project, monkeypatch, *, snapshot=LAUNCH_SNAPSHOT, ** **state_kwargs, ) monkeypatch.setattr(runs, "kill_session", lambda rid: None) + # These tests call the private helper repeatedly in one pytest process to inspect + # successive policy snapshots. A real command exits or keeps driving after + # publishing its pid; suppress that unrelated liveness artifact in this harness. + monkeypatch.setattr(runs, "write_pid", lambda _run_dir: None) monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES}) return run_dir +def test_resume_rechecks_liveness_inside_the_state_lock(tmp_path, monkeypatch, capsys): + """Ablation: remove _resume_paused_run's in-lock liveness check and preparation + runs even though a rival resume published its pid while this caller waited.""" + import contextlib + + entered = False + + @contextlib.contextmanager + def recording_lock(_run_dir): + nonlocal entered + entered = True + yield + + monkeypatch.setattr(cli, "state_lock", recording_lock) + + def liveness(_run_dir): + assert entered + return "alive" + + monkeypatch.setattr(cli.runs, "engine_liveness", liveness) + monkeypatch.setattr(cli, "_prepare_resume_locked", lambda *_a: pytest.fail("double drove")) + + assert cli._resume_paused_run(tmp_path, tmp_path / "run") == 1 + assert "double-drive" in capsys.readouterr().err + + +def test_resume_liveness_and_publication_share_one_lock_acquisition(tmp_path, monkeypatch): + """The freshness check and preparation are one uninterrupted transaction. + + Ablation: split ``_resume_paused_run`` into consecutive lock blocks around the + liveness check and preparation; both in-lock assertions still pass, but the + acquisition-count assertion reddens because a rival can enter between them. + """ + import contextlib + + acquisitions = 0 + active = False + + @contextlib.contextmanager + def recording_lock(_run_dir): + nonlocal acquisitions, active + acquisitions += 1 + assert not active + active = True + try: + yield + finally: + active = False + + def liveness(_run_dir): + assert active + return "dead" + + def prepare(_project, _run_dir): + assert active + return 1 + + monkeypatch.setattr(cli, "state_lock", recording_lock) + monkeypatch.setattr(cli.runs, "engine_liveness", liveness) + monkeypatch.setattr(cli, "_prepare_resume_locked", prepare) + + assert cli._resume_paused_run(tmp_path, tmp_path / "run") == 1 + assert acquisitions == 1 + + +def test_resume_retains_outer_lock_through_pid_publication(project, monkeypatch): + run_dir = _paused_run_for_resume(project, monkeypatch) + publications: list[str] = [] + real_save = cli.save_state + + def checked_write_pid(target): + assert_run_state_lock_held(target) + publications.append("pid") + + def checked_save(target, state): + assert_run_state_lock_held(target) + publications.append("state") + real_save(target, state) + + monkeypatch.setattr(cli.runs, "write_pid", checked_write_pid) + monkeypatch.setattr(cli, "save_state", checked_save) + monkeypatch.setattr(cli, "Engine", _StubEngine) + + assert cli._resume_paused_run(project.project, run_dir) == 0 + assert publications == ["pid", "state"] + + def _state_reading_engine(seen): """A stub engine that records state.json as it stood when the engine started. _StubEngine never saves, so anything `seen` contains was written by the CLI diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 51b62ecc..701c4e0b 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -279,12 +279,13 @@ def test_env_names_the_platform_and_the_win32_on_wsl_path_verdict(project, monke # `collect_env` reaches `get_multiplexer()`, an lru_cache(maxsize=1) that selects # on `sys.platform`; without these clears the patched window caches the Windows # pick for every later test in the worker. + run_dir = _seed_run(project.project) get_multiplexer.cache_clear() try: monkeypatch.setattr(diagnostics.sys, "platform", "win32") pseudo = sanitize.Pseudonymizer() unc = Path("\\\\wsl.localhost\\Ubuntu-24.04\\home\\u\\p") - diag = diagnostics.collect([_seed_run(project.project)], pseudo=pseudo, project=unc) + diag = diagnostics.collect([run_dir], pseudo=pseudo, project=unc) finally: # pytest undoes the patch on its own, but only at teardown — a raise in # `collect` would leave the Windows pick cached past this test without this. @@ -1872,10 +1873,10 @@ def test_events_degrade_to_the_legacy_root_when_the_state_root_is_underivable( count this degradation gives up.""" from bmad_loop import envvars, runs - monkeypatch.delenv(envvars.STATE_DIR, raising=False) - monkeypatch.setattr(runs, "state_root", _raise_no_state_root) run_dir = _seed_bare_run(project.project) _write_events(run_dir / "events", 2) + monkeypatch.delenv(envvars.STATE_DIR, raising=False) + monkeypatch.setattr(runs, "state_root", _raise_no_state_root) group = _events_group(run_dir, project.project) assert group is not None and group.count == 2 diff --git a/tests/test_engine.py b/tests/test_engine.py index e8dc114c..6852f2d6 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -11836,6 +11836,7 @@ def test_journal_log_position_covers_post_session_entries(project): ) def test_windows_console_ctrl_signal_is_ignored(project, monkeypatch, signal_name, fallback_signum): import bmad_loop.engine as engine_mod + from bmad_loop import journal as journal_mod signum = getattr(signal, signal_name, fallback_signum) if signal_name == "SIGBREAK": @@ -11854,6 +11855,14 @@ def fake_signal(sig, handler): return previous[sig] monkeypatch.setattr(engine_mod.sys, "platform", "win32") + + @contextlib.contextmanager + def native_test_lock(_path): + # This Linux-hosted test patches the process-wide sys.platform token only to + # drive Engine's Windows signal branch; msvcrt is intentionally unavailable. + yield + + monkeypatch.setattr(journal_mod, "file_lock", native_test_lock) monkeypatch.setattr(signal, "signal", fake_signal) monkeypatch.setattr(engine_mod, "kill_session", lambda rid: None) diff --git a/tests/test_journal.py b/tests/test_journal.py index d3c78b0d..7466da16 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -7,12 +7,14 @@ import os import stat +import threading +from contextlib import contextmanager import pytest from bmad_loop import journal as journal_mod -from bmad_loop import platform_util -from bmad_loop.journal import Journal, load_state, save_state +from bmad_loop import platform_util, runs +from bmad_loop.journal import Journal, load_state, save_state, state_lock from bmad_loop.model import RunState @@ -20,6 +22,7 @@ def test_save_state_retries_transient_sharing_violation(tmp_path, monkeypatch): """On win32, os.replace denied by a concurrent reader is retried, not fatal.""" monkeypatch.setattr(platform_util.sys, "platform", "win32") monkeypatch.setattr(platform_util.time, "sleep", lambda _s: None) # no real backoff + monkeypatch.setattr(journal_mod, "file_lock", contextmanager(lambda _path: iter((None,)))) real_replace = os.replace calls = {"n": 0} @@ -38,6 +41,220 @@ def flaky_replace(src, dst): assert load_state(tmp_path).run_id == "r1" +def test_state_lock_holds_the_canonical_run_sidecar(tmp_path): + run_dir = tmp_path / "run" + lock_path = runs.lock_path_for(run_dir / journal_mod.STATE_FILE, follow_final_symlink=False) + + with state_lock(run_dir): + with pytest.raises(OSError): + with platform_util.file_lock(lock_path, blocking=False): + pytest.fail("a rival acquired the held run-state sidecar") + + +def test_state_lock_same_run_nesting_acquires_os_lock_once(tmp_path, monkeypatch): + acquired: list[object] = [] + + @contextmanager + def recording_lock(path): + acquired.append(path) + yield + + monkeypatch.setattr(journal_mod, "file_lock", recording_lock) + + with state_lock(tmp_path): + with state_lock(tmp_path / "."): + save_state( + tmp_path, + RunState(run_id="r1", project="p", started_at="2026-09-01T00:00:00"), + ) + + assert acquired == [ + runs.lock_path_for(tmp_path / journal_mod.STATE_FILE, follow_final_symlink=False) + ] + + +def test_state_lock_same_run_symlink_spellings_acquire_os_lock_once(tmp_path, monkeypatch): + run_dir = tmp_path / "run" + run_dir.mkdir() + alias = tmp_path / "run-alias" + try: + alias.symlink_to(run_dir, target_is_directory=True) + except (NotImplementedError, OSError) as e: + pytest.skip(f"directory symlinks unavailable: {e}") + acquired: list[object] = [] + + @contextmanager + def recording_lock(path): + acquired.append(path) + yield + + monkeypatch.setattr(journal_mod, "file_lock", recording_lock) + + with state_lock(run_dir): + with state_lock(alias): + pass + + assert acquired == [ + runs.lock_path_for(run_dir / journal_mod.STATE_FILE, follow_final_symlink=False) + ] + + +def test_state_lock_identity_survives_replacing_a_final_state_symlink(tmp_path): + """Ablation: follow the final state.json symlink in state_lock and nested + save_state changes sidecars when atomic_replace replaces the link.""" + run_dir = tmp_path / "run" + run_dir.mkdir() + elsewhere = tmp_path / "elsewhere.json" + elsewhere.write_text("{}", encoding="utf-8") + state_path = run_dir / journal_mod.STATE_FILE + state_path.symlink_to(elsewhere) + logical_lock = runs.lock_path_for(state_path, follow_final_symlink=False) + + # The default remains referent-based for ledgers and every other caller. + assert runs.lock_path_for(state_path) == runs.lock_path_for(elsewhere) + assert runs.lock_path_for(state_path) != logical_lock + + with state_lock(run_dir): + save_state( + run_dir, + RunState(run_id="r1", project="p", started_at="2026-09-01T00:00:00"), + ) + assert not state_path.is_symlink() + with state_lock(run_dir / "."): + with pytest.raises(OSError): + with platform_util.file_lock(logical_lock, blocking=False): + pytest.fail("a rival acquired the original logical sidecar") + + assert elsewhere.read_text(encoding="utf-8") == "{}" + assert load_state(run_dir).run_id == "r1" + + +def test_state_lock_refuses_different_run_nesting_before_second_acquire(tmp_path, monkeypatch): + acquired: list[object] = [] + + @contextmanager + def recording_lock(path): + acquired.append(path) + yield + + monkeypatch.setattr(journal_mod, "file_lock", recording_lock) + + with state_lock(tmp_path / "one"): + with pytest.raises(RuntimeError, match="different runs"): + with state_lock(tmp_path / "two"): + pytest.fail("cross-run nesting was allowed") + + assert len(acquired) == 1 + + +def test_state_lock_failure_clears_thread_guard(tmp_path, monkeypatch): + acquired: list[object] = [] + + @contextmanager + def recording_lock(path): + acquired.append(path) + yield + + monkeypatch.setattr(journal_mod, "file_lock", recording_lock) + + with pytest.raises(ValueError, match="boom"): + with state_lock(tmp_path / "one"): + raise ValueError("boom") + with state_lock(tmp_path / "two"): + pass + + assert len(acquired) == 2 + + +def test_save_state_acquisition_error_writes_nothing(tmp_path, monkeypatch): + run_dir = tmp_path / "run" + + @contextmanager + def refusing_lock(_path): + raise OSError("lock unavailable") + yield + + monkeypatch.setattr(journal_mod, "file_lock", refusing_lock) + + with pytest.raises(OSError, match="lock unavailable"): + save_state( + run_dir, + RunState(run_id="r1", project="p", started_at="2026-09-01T00:00:00"), + ) + + assert not run_dir.exists() + + +def test_save_state_root_error_writes_nothing(tmp_path, monkeypatch): + run_dir = tmp_path / "run" + + def no_state_root(_path, **_kwargs): + raise runs.StateRootError("no state root") + + monkeypatch.setattr(runs, "lock_path_for", no_state_root) + + with pytest.raises(runs.StateRootError, match="no state root"): + save_state( + run_dir, + RunState(run_id="r1", project="p", started_at="2026-09-01T00:00:00"), + ) + + assert not run_dir.exists() + + +def test_two_concurrent_saves_never_share_the_fixed_temp_file(tmp_path, monkeypatch): + """Ablation: remove save_state's state_lock and the second replace enters while + the first is paused, so both calls race on state.json.tmp and one loses it.""" + real_replace = journal_mod.atomic_replace + real_file_lock = journal_mod.file_lock + first_entered = threading.Event() + second_attempted = threading.Event() + release_first = threading.Event() + replace_threads: list[str] = [] + + @contextmanager + def observed_file_lock(path): + if threading.current_thread().name == "second": + second_attempted.set() + with real_file_lock(path): + yield + + def controlled_replace(src, dst): + replace_threads.append(threading.current_thread().name) + if len(replace_threads) == 1: + first_entered.set() + assert release_first.wait(2) + real_replace(src, dst) + + monkeypatch.setattr(journal_mod, "atomic_replace", controlled_replace) + monkeypatch.setattr(journal_mod, "file_lock", observed_file_lock) + errors: list[BaseException] = [] + + def writer(run_id: str) -> None: + try: + save_state( + tmp_path, + RunState(run_id=run_id, project="p", started_at="2026-09-01T00:00:00"), + ) + except BaseException as e: + errors.append(e) + + first = threading.Thread(target=writer, args=("first",), name="first") + second = threading.Thread(target=writer, args=("second",), name="second") + first.start() + assert first_entered.wait(2) + second.start() + assert second_attempted.wait(2) + assert replace_threads == ["first"] + release_first.set() + first.join(2) + second.join(2) + + assert errors == [] + assert sorted(replace_threads) == ["first", "second"] + assert load_state(tmp_path).run_id in {"first", "second"} + + def _planted_verify_symlink(tmp_path): """A run dir whose `verify/` a session has already replaced with a link out.""" run_dir, elsewhere = tmp_path / "run", tmp_path / "elsewhere" diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 701cae52..c16e2afc 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -185,27 +185,42 @@ SESSION_TASK_ID_CHOKEPOINT = {"engine.py": "_session_task_id"} # The complete set of ``runs.rearm_escalation`` call sites, as -# ``(file, enclosing function)``. The re-arm transaction's own commit probe -# (``runs._rearm_commit_landed``) proves "did MY save_state land?" with nothing but -# ``(generation, phase)`` over the reloaded task, and that is a sufficient IDENTITY -# only under a sole-writer model: no engine advancing the task underneath, and one -# control command at a time. Its docstring argues that model from this enumeration. -# -# Prose cannot hold it. A third call site — or either existing gate deleted — leaves -# every test in the repo green while the probe's premise quietly becomes false, and -# the failure it opens is DW-79/DW-83's own shape: a spec left re-armed against a task -# the run still calls ESCALATED. So the enumeration is scanned instead of asserted. -# -# Deliberately NOT a lock and not a durable per-re-arm token: the spec's ``Never`` -# forbids both (a lock only ``rearm_escalation`` takes excludes nobody; a token buys a -# precision ``save_state`` cannot honour). It forbids no guard, and this is the cheap -# half — it does not make overlapping callers safe, it makes the day someone adds one -# impossible to miss. Overlapping control commands stay out of the model, as DW-93. +# ``(file, enclosing function)``. Serialization now comes from the shared run-state +# lock, not this liveness inventory. The gates remain independently load-bearing: a +# serialized control command still must not take its turn after an engine known to be +# live, and a new operator surface must make that policy explicit. REARM_ESCALATION_CALLERS = { ("cli.py", "cmd_resolve"), ("tui/app.py", "_do_rearm"), } +# Every production state publication and every explicit multi-step state transaction. +# ``save_state`` itself serializes the leaf write, so a new direct publisher is safe +# from the fixed-temp collision but still appears here for review: if it reads state +# before deciding what to publish, it also belongs in RUN_STATE_TRANSACTIONS with an +# outer hold. Exact inventories make a newly added writer fail loudly instead of +# relying on a reviewer to find it by grep. +SAVE_STATE_CALLERS = { + ("cli.py", "_prepare_resume_locked"), + ("engine.py", "_save"), + ("runs.py", "_rearm_escalation_locked"), + ("runs.py", "restamp_code_root"), + ("runs.py", "_stop_run_once"), + ("runsetup.py", "compose_run"), + ("runsetup.py", "compose_sweep"), +} +RUN_STATE_TRANSACTIONS = { + ("cli.py", "_resume_paused_run"), + ("cli.py", "cmd_resolve"), + ("journal.py", "save_state"), + ("runs.py", "rearm_escalation"), + ("runs.py", "restamp_code_root"), + ("runs.py", "_stop_run_once"), + ("runsetup.py", "compose_run"), + ("runsetup.py", "compose_sweep"), + ("tui/app.py", "_do_rearm"), +} + # What counts as consulting liveness, matched as a substring of the callee's name # because the two sites legitimately spell it differently and neither spelling is more # correct: the CLI calls ``runs.engine_liveness`` directly, the TUI goes through @@ -2203,26 +2218,20 @@ def test_rearm_escalation_called_only_behind_a_liveness_gate(): """``runs.rearm_escalation`` is reached from exactly two places, and each consults liveness before it. - ``runs._rearm_commit_landed`` decides whether the re-arm transaction COMMITTED — - and therefore whether to roll the spec back — from ``(generation, phase)`` over the - reloaded task, nothing more. Those two conjuncts are a sufficient identity only - while ``rearm_escalation`` is the sole writer of that run's ``state.json``, and that - model is argued from this enumeration: two callers, each behind a liveness - consultation, with no engine running. A third caller, or either gate deleted, makes - the premise false — and the defect it reopens is DW-79/DW-83's own: a spec left - flipped against a task the run still calls ESCALATED. + ``runs._rearm_commit_landed`` is protected by the shared run-state transaction + lock, so this enumeration no longer supplies its writer-identity premise. It pins + the separate safety rule that an operator surface refuses a provably-live engine + before entering that serialized mutation turn. Note what the gate does and does not establish. It proves the engine is not PROVABLY alive, not that it is dead: ``"alive"`` is refused outright, while ``"unknown"`` proceeds under ``--force`` in ``cmd_resolve`` and counts as blocking in the TUI only for a pid-backed run. So this grades the falsifiable half — that - an earlier liveness decision BLOCKS fall-through before the call. The rest of the - model (one control command at a time) is out of scope here and tracked as DW-93. + an earlier liveness decision BLOCKS fall-through before the call. - ``cli.cmd_resume`` is deliberately absent: it writes this run's ``state.json`` - through ``_resume_paused_run``, so the sole-writer claim must account for it, but it - never re-arms and so is not a call site. Listing it here would make the enumeration - unfalsifiable in the direction that matters. + ``cli.cmd_resume`` is deliberately absent because it never re-arms. Its state + publication is covered separately by the writer/transaction inventory below; + listing it here would make this call-site enumeration unfalsifiable. ⚠️ What this assertion grades, precisely — the two halves differ, and the difference is the reason the probe rows below exist: @@ -2245,10 +2254,9 @@ def test_rearm_escalation_called_only_behind_a_liveness_gate(): sites = _rearm_callsite_counts(findings) declared = Counter(REARM_ESCALATION_CALLERS) assert sites == declared, ( - "the count of runs.rearm_escalation call sites moved. That enumeration is what " - "runs._rearm_commit_landed's (generation, phase) commit probe argues its " - "sole-writer premise from — a new caller needs that docstring revisited (and " - "DW-93 consulted), not this constant widened:\n" + "the count of runs.rearm_escalation call sites moved. A new operator surface " + "must retain the liveness refusal as well as the shared run-state transaction; " + "do not widen this constant without reviewing both:\n" f" scanned: {sorted(sites.elements())}\n" f" declared: {sorted(declared.elements())}" ) @@ -2260,6 +2268,29 @@ def test_rearm_escalation_called_only_behind_a_liveness_gate(): ) +def _production_call_sites(name: str) -> set[tuple[str, str | None]]: + sites: set[tuple[str, str | None]] = set() + for source in SRC.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8")) + enclosing = _enclosing_function_names(tree) + rel = source.relative_to(SRC).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.Call) and _called_name(node.func) == name: + sites.add((rel, enclosing.get(id(node)))) + return sites + + +def test_run_state_writer_and_transaction_inventory_is_complete(): + """Every publisher uses save_state, and every known RMW gesture holds state_lock. + + Ablations: add ``save_state(run_dir, state)`` to a new production function, or + delete the outer ``state_lock`` from ``runs.restamp_code_root``; the respective + exact-set comparison reddens and names the changed site. + """ + assert _production_call_sites("save_state") == SAVE_STATE_CALLERS + assert _production_call_sites("state_lock") == RUN_STATE_TRANSACTIONS + + def _journal_field_offenders(findings) -> list[tuple[str, int, str, str]]: """The routing invariant as a filter, in the two directions a finding can fail: a field name that neither ``diagnostics`` nor the benign inventory accounts for, diff --git a/tests/test_runs.py b/tests/test_runs.py index 1a2c6c72..53eabd3a 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -14,7 +14,7 @@ from unittest import mock import pytest -from conftest import escalated_run, git, refuse_to_resolve +from conftest import assert_run_state_lock_held, escalated_run, git, refuse_to_resolve from bmad_loop import envvars, platform_util, runs, verify from bmad_loop.adapters import tmux_base @@ -768,6 +768,116 @@ def test_stop_run_fallback_clears_hard_request(tmp_path, monkeypatch): assert '"fallback": true' in (run_dir / "journal.jsonl").read_text() +def test_stop_run_takes_state_lock_only_after_signal_and_wait(tmp_path, monkeypatch): + """Ablation: move stop_run's state_lock above terminate and this reddens on order; + the engine would be unable to save its own stopped state while stop waits.""" + order: list[str] = [] + alive = True + run_dir = _make_state_run(tmp_path, "r1") + (run_dir / "engine.pid").write_text("4242 100.0") + + def on_terminate(_pid): + nonlocal alive + order.append("terminate") + alive = False + + host = _FakeHost(alive=lambda: alive, identity=100.0, on_terminate=on_terminate) + monkeypatch.setattr(runs, "get_process_host", lambda: host) + monkeypatch.setattr(runs, "kill_session", lambda _rid: order.append("kill-session")) + + @contextlib.contextmanager + def recording_state_lock(_run_dir): + order.append("state-lock") + yield + + monkeypatch.setattr(runs, "state_lock", recording_state_lock) + + assert runs.stop_run(run_dir) is True + assert order == ["terminate", "kill-session", "state-lock"] + + +def test_stop_run_fallback_save_retains_the_outer_state_lock(tmp_path, monkeypatch): + run_dir = _make_state_run(tmp_path, "r1") + monkeypatch.setattr(runs, "kill_session", lambda _rid: None) + real_save = runs.save_state + + def checked_save(target, state): + assert_run_state_lock_held(target) + real_save(target, state) + + monkeypatch.setattr(runs, "save_state", checked_save) + + assert runs.stop_run(run_dir) is True + assert load_state(run_dir).stopped is True + + +def test_stop_run_does_not_fallback_over_engine_completion(tmp_path, monkeypatch): + """The final locked snapshot wins when the engine finishes while stop delivers.""" + run_dir = _make_state_run(tmp_path, "r1") + (run_dir / "engine.pid").write_text("4242 100.0", encoding="utf-8") + monkeypatch.setattr(runs, "kill_session", lambda _rid: None) + + def finish(_pid): + state = load_state(run_dir) + state.finished = True + save_state(run_dir, state) + + monkeypatch.setattr( + runs, + "get_process_host", + lambda: _FakeHost(alive=False, identity=100.0, on_terminate=finish), + ) + + assert runs.stop_run(run_dir) is False + persisted = load_state(run_dir) + assert persisted.finished is True + assert persisted.stopped is False + journal = run_dir / "journal.jsonl" + assert not journal.exists() or '"fallback": true' not in journal.read_text() + + +def test_stop_run_retries_when_resume_publishes_a_new_engine(tmp_path, monkeypatch): + """A rival resume that wins during delivery is itself sent the hard stop.""" + run_dir = _make_state_run(tmp_path, "r1") + (run_dir / "engine.pid").write_text("4242 100.0", encoding="utf-8") + monkeypatch.setattr(runs, "kill_session", lambda _rid: None) + alive = {4242: True, 5252: True} + identities = {4242: 100.0, 5252: 200.0} + + class GenerationalHost(_FakeHost): + def __init__(self): + super().__init__(alive=False) + + def is_alive(self, pid): + return alive.get(pid, False) + + def identity(self, pid): + return identities.get(pid) + + def terminate(self, pid): + self.terminated.append(pid) + alive[pid] = False + if pid == 4242: + # Resume clears the old gesture, then publishes its new pid and + # state atomically under the lock stop will next acquire. + with runs.state_lock(run_dir): + runs.clear_graceful_stop(run_dir) + rival = load_state(run_dir) + rival.crashed = True + (run_dir / "engine.pid").write_text("5252 200.0", encoding="utf-8") + save_state(run_dir, rival) + + host = GenerationalHost() + monkeypatch.setattr(runs, "get_process_host", lambda: host) + + assert runs.stop_run(run_dir) is True + assert host.terminated == [4242, 5252] + persisted = load_state(run_dir) + assert persisted.stopped is True + assert persisted.crashed is True + assert runs.read_stop_request_mode(run_dir) is None + + def test_stop_run_engine_confirmed_leaves_nothing_pending(tmp_path, monkeypatch): """When the engine confirms the stop itself the request is consumed too. The engine normally clears it on the way out; this is the belt-and-braces half, and @@ -2780,6 +2890,43 @@ def test_restamp_code_root_aims_the_mirror_the_rearm_reads(tmp_path, recorded): assert message is None +def test_restamp_code_root_reloads_after_a_rival_writer(tmp_path, monkeypatch): + """Ablation: move restamp_code_root's load above state_lock and the rival's + ``crashed`` update is overwritten by the stale snapshot.""" + run = escalated_run(tmp_path, "r1", story_key="s1") + save_state(run.run_dir, run.state) + + @contextlib.contextmanager + def rival_first(_run_dir): + rival = load_state(run.run_dir) + rival.crashed = True + save_state(run.run_dir, rival) + yield + + monkeypatch.setattr(runs, "state_lock", rival_first) + + runs.restamp_code_root(run.run_dir, tmp_path / "new-code") + + persisted = load_state(run.run_dir) + assert persisted.crashed is True + assert persisted.code_root == tmp_path / "new-code" + + +def test_restamp_code_root_retains_outer_lock_through_save(tmp_path, monkeypatch): + run = escalated_run(tmp_path, "r1", story_key="s1") + save_state(run.run_dir, run.state) + real_save = runs.save_state + + def checked_save(target, state): + assert_run_state_lock_held(target) + real_save(target, state) + + monkeypatch.setattr(runs, "save_state", checked_save) + + runs.restamp_code_root(run.run_dir, tmp_path / "new-code") + assert load_state(run.run_dir).code_root == tmp_path / "new-code" + + _SPEC_WITH_ARR = ( "---\ntitle: t\nstatus: blocked\noperator_actions:\n" " - publish the TXT record\n---\n\n## Intent\n\nbody\n" @@ -2830,6 +2977,63 @@ def test_rearm_plain_mode_sets_ready_for_dev_and_clears_stale_latch(tmp_path): assert entry["restore"] is False +def test_rearm_reloads_state_after_waiting_for_the_run_lock(tmp_path, monkeypatch): + """Ablation: load state before rearm_escalation's state_lock and this stale + gesture re-arms after the rival has already completed it.""" + from bmad_loop.model import Phase + + run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) + original = spec.read_bytes() + + @contextlib.contextmanager + def rival_first(_run_dir): + rival = load_state(run_dir) + rival.tasks["1-1-a"].phase = Phase.PENDING + save_state(run_dir, rival) + yield + + monkeypatch.setattr(runs, "state_lock", rival_first) + + with pytest.raises(runs.RearmError, match="is not escalated"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == original + + +def test_rearm_lock_acquisition_failure_leaves_spec_and_state_unchanged(tmp_path, monkeypatch): + from bmad_loop import journal as journal_mod + + run_dir, spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) + spec_before = spec.read_bytes() + state_before = (run_dir / journal_mod.STATE_FILE).read_bytes() + + @contextlib.contextmanager + def refusing_lock(_path): + raise OSError("state lock unavailable") + yield + + monkeypatch.setattr(journal_mod, "file_lock", refusing_lock) + + with pytest.raises(OSError, match="state lock unavailable"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert spec.read_bytes() == spec_before + assert (run_dir / journal_mod.STATE_FILE).read_bytes() == state_before + + +def test_rearm_locked_body_retains_outer_lock_through_state_save(tmp_path, monkeypatch): + run_dir, _spec = _escalated_run(tmp_path, _SPEC_WITH_ARR) + real_save = runs.save_state + + def checked_save(target, state): + assert_run_state_lock_held(target) + real_save(target, state) + + monkeypatch.setattr(runs, "save_state", checked_save) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + def test_rearm_aborts_when_the_spec_status_cannot_be_reopened(tmp_path): """The seam that proves the silent-`False` defect mattered. This spec reads as `status: blocked` — the reader resolves the block scalar fine — so it clears diff --git a/tests/test_runsetup.py b/tests/test_runsetup.py index f98a0e77..b1a37a79 100644 --- a/tests/test_runsetup.py +++ b/tests/test_runsetup.py @@ -16,16 +16,19 @@ import dataclasses import shutil +import threading import types +from contextlib import contextmanager from pathlib import Path import pytest from bmad_loop import bmadconfig +from bmad_loop import journal as journal_mod from bmad_loop import policy as policy_mod from bmad_loop import runs, runsetup from bmad_loop.adapters.profile import ProfileError -from bmad_loop.journal import Journal, load_state +from bmad_loop.journal import Journal, load_state, state_lock # A profile overlay carrying the whole launch surface the digest covers. It lives # under .bmad-loop/profiles/, inside the tree every driven session can write. @@ -436,6 +439,96 @@ def test_composition_persists_the_code_root(tmp_path, run_type): assert persisted.code_root != Path(persisted.project) +@pytest.mark.parametrize("run_type", ["run", "sweep"]) +def test_initial_state_and_pid_are_one_locked_publication(tmp_path, monkeypatch, run_type): + """A rival explicit-id resume cannot enter after state.json becomes readable + but before the fresh composer publishes engine.pid.""" + run_dir = runs.run_dir_for(tmp_path, RUN_ID) + stamp_entered = threading.Event() + rival_attempted = threading.Event() + release_stamp = threading.Event() + rival_observed: list[tuple[bool, bool]] = [] + errors: list[BaseException] = [] + real_file_lock = journal_mod.file_lock + + @contextmanager + def observed_file_lock(path, *args, **kwargs): + if threading.current_thread().name == "rival-resume": + rival_attempted.set() + with real_file_lock(path, *args, **kwargs): + yield + + def paused_stamp(_project, _run_id, _digest): + stamp_entered.set() + assert release_stamp.wait(2) + + monkeypatch.setattr(journal_mod, "file_lock", observed_file_lock) + monkeypatch.setattr(runs, "write_trusted_config_digest", paused_stamp) + + def compose() -> None: + try: + if run_type == "run": + runsetup.compose_run( + project=tmp_path, + paths=_fake_paths(tmp_path), + policy=policy_mod.loads(""), + run_id=RUN_ID, + epic_filter=None, + story_filter=None, + max_stories=None, + stories_on=False, + spec_folder="", + sweep_factory=lambda _trigger, *, started: None, + make_adapters=_accepting_adapters, + engine_cls=_AcceptingEngine, + stories_engine_cls=_AcceptingEngine, + trusted_config_digest="deadbeef", + ) + else: + runsetup.compose_sweep( + project=tmp_path, + paths=_fake_paths(tmp_path), + policy=policy_mod.loads(""), + run_id=RUN_ID, + prompting=False, + decisions_only=False, + max_bundles=None, + repeat=None, + max_cycles=None, + trigger="auto", + make_adapters=_accepting_adapters, + sweep_engine_cls=_AcceptingEngine, + trusted_config_digest="deadbeef", + ) + except BaseException as e: + errors.append(e) + + def rival_resume() -> None: + try: + with state_lock(run_dir): + rival_observed.append( + ((run_dir / "state.json").is_file(), (run_dir / "engine.pid").is_file()) + ) + except BaseException as e: + errors.append(e) + + composer = threading.Thread(target=compose, name="composer") + rival = threading.Thread(target=rival_resume, name="rival-resume") + composer.start() + assert stamp_entered.wait(2) + assert (run_dir / "state.json").is_file() + assert not (run_dir / "engine.pid").exists() + rival.start() + assert rival_attempted.wait(2) + assert rival_observed == [] + release_stamp.set() + composer.join(2) + rival.join(2) + + assert errors == [] + assert rival_observed == [(True, True)] + + @pytest.fixture def unwinding(tmp_path): """A project plus a `make_adapters` that fails the way the real one does. diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index eed1092a..331e5077 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -16,6 +16,7 @@ import pytest from conftest import ( + assert_run_state_lock_held, git, install_bmad_config, make_validate_document, @@ -3496,7 +3497,11 @@ def _stories_paused_run( if blocked_result: body += f"\n## Auto Run Result\n\n- Status: blocked\n\n{blocked_result}\n" spec.write_text(body, encoding="utf-8") - task = StoryTask(story_key=story_key, epic=0, phase=Phase.DEV_VERIFY) + task = StoryTask( + story_key=story_key, + epic=0, + phase=Phase.ESCALATED if stage == "escalation" else Phase.DEV_VERIFY, + ) task.spec_file = str(spec) if worktree_path: task.worktree_path = worktree_path @@ -5082,6 +5087,187 @@ def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): assert any("the code root in _bmad/bmm/config.yaml has changed" in n for n in notes) +def test_escalation_rearm_rechecks_liveness_inside_state_lock(project, monkeypatch): + """Ablation: delete _do_rearm's second liveness check and the TUI re-arms after + a rival resume published its pid while this gesture waited for the state lock.""" + from bmad_loop import runs + + install_bmad_config(project) + run_dir = project.project / ".bmad-loop" / "runs" / "20260611-100000-aaaa" + checks: list[str] = [] + + def liveness_gate(_self, _run_id, _run_dir): + checks.append("checked") + return len(checks) == 2 + + monkeypatch.setattr(BmadLoopApp, "_resolve_blocked_by_liveness", liveness_gate) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("double re-armed")) + + BmadLoopApp(project.project)._do_rearm(run_dir.name, run_dir, "1") + + assert checks == ["checked", "checked"] + + +def test_escalation_rearm_reloads_state_before_restamping(project, monkeypatch): + """Ablation: delete _do_rearm's fresh state check and the TUI restamps a run + whose escalation a rival already consumed while this gesture waited for the lock.""" + import contextlib + + from bmad_loop import runs + from bmad_loop.journal import load_state, save_state + from bmad_loop.tui import app as app_mod + + install_bmad_config(project) + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: rival resolved this escalation.", + ) + notes: list[str] = [] + monkeypatch.setattr(BmadLoopApp, "_resolve_blocked_by_liveness", lambda *_a: False) + monkeypatch.setattr( + BmadLoopApp, + "notify", + lambda _self, message, **_kwargs: notes.append(str(message)), + ) + + @contextlib.contextmanager + def rival_first(_run_dir): + rival = load_state(run_dir) + rival.tasks["1"].phase = Phase.PENDING + save_state(run_dir, rival) + yield + + monkeypatch.setattr(app_mod, "state_lock", rival_first) + monkeypatch.setattr(runs, "restamp_code_root", lambda *_a: pytest.fail("stale restamp")) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("double re-armed")) + + BmadLoopApp(project.project)._do_rearm(run_dir.name, run_dir, "1") + + assert any("no longer paused at escalation" in note for note in notes) + + +def test_escalation_rearm_refuses_a_newer_generation_from_an_open_review(project, monkeypatch): + """An old modal must not consume a later escalation for the same story. + + Ablation: delete ``_do_rearm``'s generation comparison and the rival's newer + escalation reaches ``rearm_escalation`` even though the modal never displayed it. + """ + import contextlib + + from bmad_loop import runs + from bmad_loop.journal import load_state, save_state + from bmad_loop.tui import app as app_mod + + install_bmad_config(project) + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: the original escalation.", + ) + expected_generation = load_state(run_dir).tasks["1"].generation + notes: list[str] = [] + monkeypatch.setattr(BmadLoopApp, "_resolve_blocked_by_liveness", lambda *_a: False) + monkeypatch.setattr( + BmadLoopApp, + "notify", + lambda _self, message, **_kwargs: notes.append(str(message)), + ) + + @contextlib.contextmanager + def rival_first(_run_dir): + rival = load_state(run_dir) + rival.tasks["1"].generation = expected_generation + 1 + save_state(run_dir, rival) + yield + + monkeypatch.setattr(app_mod, "state_lock", rival_first) + monkeypatch.setattr(runs, "restamp_code_root", lambda *_a: pytest.fail("stale restamp")) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("stale rearm")) + + BmadLoopApp(project.project)._do_rearm( + run_dir.name, + run_dir, + "1", + expected_generation=expected_generation, + ) + + assert any("changed while its review was open" in note for note in notes) + + +def test_escalation_rearm_retains_outer_lock_through_rearm_call(project, monkeypatch): + from bmad_loop import runs + + install_bmad_config(project) + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: needs a human decision.", + ) + rearms: list[Path] = [] + monkeypatch.setattr(BmadLoopApp, "_resolve_blocked_by_liveness", lambda *_a: False) + + def checked_rearm(rd, key, **_kwargs): + assert_run_state_lock_held(rd) + rearms.append(rd) + return _rearm_outcome(key) + + monkeypatch.setattr(runs, "rearm_escalation", checked_rearm) + app = BmadLoopApp(project.project) + monkeypatch.setattr(app, "notify", lambda *_a, **_k: None) + monkeypatch.setattr(app, "_do_resume", lambda _run_id: None) + + app._do_rearm(run_dir.name, run_dir, "1") + + assert rearms == [run_dir] + + +@pytest.mark.parametrize( + "failure", + [OSError("lock unavailable"), runs_mod.StateRootError("no usable state root")], +) +def test_escalation_rearm_reports_state_lock_failures(project, monkeypatch, failure): + import contextlib + + from bmad_loop import runs + from bmad_loop.tui import app as app_mod + + install_bmad_config(project) + run_dir, _spec = _stories_paused_run( + project.project, + stage="escalation", + spec_status="blocked", + spec_checkpoint=False, + blocked_result="Blocked: needs a human decision.", + ) + notes: list[tuple[str, str | None]] = [] + monkeypatch.setattr(BmadLoopApp, "_resolve_blocked_by_liveness", lambda *_a: False) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("wrote unlocked")) + + @contextlib.contextmanager + def refusing_lock(_run_dir): + raise failure + yield + + monkeypatch.setattr(app_mod, "state_lock", refusing_lock) + app = BmadLoopApp(project.project) + monkeypatch.setattr( + app, + "notify", + lambda message, **kwargs: notes.append((str(message), kwargs.get("severity"))), + ) + + app._do_rearm(run_dir.name, run_dir, "1") + + assert notes == [(f"re-arm failed: {failure}", "error")] + + async def test_escalation_rearm_refuses_the_isolation_conflict_before_it_mutates( project, monkeypatch ): From 19d2a4c53cfff0c7b626022c3c3b99dbfa9e651d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 20:03:02 -0700 Subject: [PATCH 42/45] sweep dw5-run-lifecycle-resume-exclusion: DW-94 via bmad-loop --- CHANGELOG.md | 4 + README.md | 10 +- docs/FEATURES.md | 9 +- src/bmad_loop/cli.py | 33 +++-- src/bmad_loop/runs.py | 93 +++++++++++--- src/bmad_loop/runsetup.py | 34 ++++-- src/bmad_loop/tui/app.py | 14 +-- tests/test_cleanup.py | 61 ++++++++++ tests/test_cli.py | 78 +++++++++++- tests/test_portability_guard.py | 2 + tests/test_runs.py | 209 +++++++++++++++++++++++++++++++- tests/test_runsetup.py | 98 ++++++++++++++- tests/test_tui_app.py | 48 ++++++++ 13 files changed, 635 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808ad431..f8b1ee62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,10 @@ breaking changes may land in a minor release. ### Fixed +- Serialize run deletion/archive against resume (DW-94), refusing a newly live + engine under the per-run lock and preventing a waiting resume from recreating a + run cleanup already removed. + - Serialize every run-state writer and control read-modify-write transaction with one canonical per-run advisory lock (DW-93). - Let interactive resolve present `paused_reason` when watermark filtering leaves no newer diff --git a/README.md b/README.md index 05cacb27..9a3c86fe 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ bmad-loop tui # …or drive everything from the dashboard | `bmad-loop adapters` | List registered coding-CLI adapter **kinds** — name, builtin/external, whether the family drives a multiplexer, and which profiles select each — the CLI axis's counterpart to `mux`. Unlike `mux` there is no global choice to persist: a kind is selected per profile by its `adapter` field. A profile naming an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get a `warning:` on stderr. | | `bmad-loop run` | Drive the dev → review → verify → commit loop. `--epic N`, `--story KEY`, `--max-stories N`, `--dry-run`. `--spec ` forces **stories mode** (folder+id dispatch off `/stories.yaml`), overriding `[stories].source`; `--story` then filters by story id. | | `bmad-loop sweep` | Triage + execute open `deferred-work.md` entries. `--no-prompt`, `--decisions-only`, `--max-bundles N`, `--repeat`, `--max-cycles N`, `--dry-run`. `--archive [--before DATE]` instead moves closed ledger entries to `deferred-work-archive.md`, leaving id-preserving stubs. | -| `bmad-loop resume ` | Continue a run paused at a gate, escalation, or interruption. | +| `bmad-loop resume ` | Continue a run paused at a gate, escalation, or interruption. The resume command rendezvouses with delete/archive on the run lifecycle lock; if cleanup removed the run while resume waited, resume reports it missing without recreating files or launching an engine. | | `bmad-loop resolve ` | Resolve a CRITICAL escalation: open an interactive resolve agent to fix the frozen spec, then re-arm the story and resume. On an _intent gap_ the re-drive can resume review on the attempted change instead of re-implementing it. `--story KEY`, `--no-interactive`, `--restore-patch ` (intent-gap patch-restore), `--resume` / `--no-resume`, `--force` (proceed when engine liveness is unverifiable; a provably-live engine still blocks). | | `bmad-loop decisions` | Answer deferred-work decisions earlier sweeps left unanswered (skipped by `--no-prompt`, or an abandoned interactive sweep). Recorded so the next sweep acts on them without re-asking. `--list` shows them without answering; `--json` emits them as a stable machine-readable document — id, question, context, recommendation, and every option's key/label/effect/intent/resolution/bundle-name with a derived `recommended` flag. It implies the listing and never prompts, so a script can select an option by policy instead of scraping the text. | | `bmad-loop confirm ` | Complete a story parked at `awaiting-operator` once you have carried out the external actions it owes (buy the domain, publish the DNS record). Acknowledges each action in turn, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair — nothing is re-driven. `--list` shows every parked story and what it owes; `--yes` skips the prompts; `--reverify` re-runs the project's `[verify]` commands first and blocks the confirmation if they fail; `--json` emits the parked set as a stable machine-readable document. Every write is checked and the spec is read back from disk, so a story is never declared done over a write that did not land; a confirmation interrupted before its board write is **finished** by re-running the command, with no second prompt and no second audit section. The index it reads is machine-local, so a park is confirmed on the machine that ran it. | @@ -91,10 +91,10 @@ bmad-loop tui # …or drive everything from the dashboard | `bmad-loop diagnose []` (`diag`) | Emit a **sanitized** diagnostic dump of a run/sweep to hand maintainers when reporting a bug — phase/token/session histograms, escalation counts, adapter/model, env, and run-dir file sizes, with no code, spec content, prompts, transcripts, paths, or PII. Identifiers are pseudonymized to stable per-dump aliases and the output is re-scanned by a fail-closed leak check before writing; a stray pseudonymized identifier is auto-substituted with its alias (disclosed in the report), while PII/secret hits still refuse to emit. Defaults to the latest run. `--all`, `--out`, `--max-journal-entries N`; `--json` emits the dump as a stable JSON document (one object on stdout, no fences) instead of the markdown report. | | `bmad-loop attach []` | tmux-attach to a run's live agent session. | | `bmad-loop stop ` | Stop a live run — the engine and its agent tmux session. `--graceful` instead finishes the in-flight item (a story through commit, a sweep bundle through commit), then stops cleanly and stays resumable, and suppresses pending auto-sweeps; `--cancel-graceful` withdraws a pending request. The hard stop is the default and always wins over a pending graceful one. | -| `bmad-loop delete ` | Delete a run directory. `--force` stops the run first if it is still live. | -| `bmad-loop archive ` | Compress a run into `.bmad-loop/archive` and remove the run dir. `--force` stops the run first if it is still live. | +| `bmad-loop delete ` | Delete a run directory. `--force` stops the run first if it is still live, but cannot override a rival resume that becomes live before removal. Unknown liveness still warns and proceeds. | +| `bmad-loop archive ` | Compress a run into `.bmad-loop/archive` and remove the run dir. `--force` stops the run first if it is still live, but cannot override a rival resume that becomes live before archival. Unknown liveness still warns and proceeds. | | `bmad-loop cleanup` | Remove leftover tmux artifacts **for the current project**: kill `bmad-loop-` sessions for finished/stopped/interrupted runs (and orphans whose run dir is gone) and close parked `bmad-loop-ctl` windows. `--dry-run` lists without killing. Live runs — and any session/window belonging to another project — are never touched. `--json` emits a stable machine-readable document instead of the text — the run ids whose sessions were removed, the live ids left alone, the ctl windows closed, and a `dry_run` flag — so a preview and the real run share one schema and can be compared. | -| `bmad-loop clean` | Reclaim **disk** from concluded runs per `[cleanup]`: tear down git worktrees a mid-flight stop orphaned (freeing their Unity `Library/` + MCP-server builds), trim the heavy `worktrees/` tree from runs kept for history (they stay viewable in the TUI), and archive/delete runs past the retention window. Only finished/stopped runs are touched; `--dry-run` previews, `--keep ` protects, `--retain N` overrides the window, `--hard` deletes instead of archiving. `--json` emits a stable machine-readable document instead of the text — the effective retention policy, `freed_bytes` as a raw integer, and the worktree paths and run ids reclaimed, trimmed, archived, deleted or protected. | +| `bmad-loop clean` | Reclaim **disk** from concluded runs per `[cleanup]`: tear down git worktrees a mid-flight stop orphaned (freeing their Unity `Library/` + MCP-server builds), trim the heavy `worktrees/` tree from runs kept for history (they stay viewable in the TUI), and archive/delete runs past the retention window. Only finished/stopped runs are touched; a run that resumes during clean is recorded as protected or trimmed according to work already done, and siblings continue. `--dry-run` previews, `--keep ` protects, `--retain N` overrides the window, `--hard` deletes instead of archiving. `--json` emits one stable machine-readable document instead of the text — the effective retention policy, `freed_bytes` as a raw integer, and the worktree paths and run ids reclaimed, trimmed, archived, deleted or protected. | | `bmad-loop tui` | The interactive dashboard (needs the `[tui]` extra). `--low-frame-rate` caps it to 15fps + disables animations (fixes repaint tearing over slow/SSH links; also `[tui] low_frame_rate`). | | `bmad-loop probe-adapter ` (`collect-adapter-data`) | Collect + sanitize the data needed to finalize a CLI adapter profile (hook payload shape, transcript location/format, token schema). Default is a zero-launch **scan**; `--probe` opts into a live capture (`--model` picks the probe turn's model, `--timeout` bounds it, default 90s). `--transcript`, `--session-dir`, `--binary` (CLIs with no profile yet), `--out`; `--json` emits the finding as a stable JSON document instead of the report. See the [adapter authoring guide](docs/adapter-authoring-guide.md). | @@ -170,7 +170,7 @@ Press **`g`** to edit `.bmad-loop/policy.toml` in a form grouped by section — | `a` | attach to the live agent session (or the orchestrator window) | | `x` | stop the selected live run immediately (engine + agent session) | | `S` | graceful stop: finish the in-flight item (through commit), then stop cleanly — stays resumable | -| `D` / `A` | delete / archive the selected run (force-stops a live run first) | +| `D` / `A` | delete / archive the selected run (refuses while its engine is live) | | `c` | clean up tmux sessions/windows for finished & stopped runs | | `v` | run `bmad-loop validate`, output in a modal | | `g` | settings editor for `.bmad-loop/policy.toml` | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b00ad08a..2057268c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -300,6 +300,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - `bmad-loop clean` reclaims **disk** (distinct from `cleanup`, which is only tmux). It tears down git worktrees a mid-flight stop left mounted — the main accumulation source: each carries a real Unity `Library/` (incl. the MCP-server build), which `git worktree remove` cannot reach once the engine was killed before teardown. It then trims the heavy `worktrees/` tree from runs kept for history (the run still lists in the dashboard — discovery reads `state.json`, not the worktree), and archives or deletes runs past the retention window. - It also collects the **out-of-tree** half of a run. Removing a run dir no longer removes everything the run owns (#494), so `delete`/`archive`/`clean` remove the run's control-plane dir under the state root too, and `clean` additionally sweeps this project's orphans there — subtrees whose run dir is gone, from a hand-removed run or a delete that predates this. The sweep keys on the run directory _existing_, not on its `state.json` parsing, so a corrupt run an operator is trying to recover keeps its control plane; a trimmed run keeps its own for the same reason (it is still resumable). Their bytes are not in the reclaim estimate — a state dir holds consumed event files, the run's `config-digest` (#498), and little else. Known limit: the state root is keyed by the project's resolved path, so a project that is deleted, moved or renamed leaves its old subtree unsweepable — after a move the project keys somewhere new, and no project can name the old key. A move does not cost the run its config-change baseline, though: `state.json` carries a second copy that travels with the run directory, and `resume` falls back to it exactly when the out-of-tree file is out of reach (#498). - Safe by construction: only **finished or stopped** runs are touched; running, unknown-host, paused and interrupted (resumable) runs are never reclaimed. `--keep ` protects a specific run (e.g. a finished one whose Editor is still live), `--dry-run` previews, `--retain N`/`--hard` tune the window and archive-vs-delete. +- Delete/archive and resume serialize on the same per-run lifecycle lock. Cleanup re-checks engine liveness after acquiring it and holds exclusion through archive snapshot/publication, run removal, and control-plane cleanup; a provably live engine refuses even under `--force`, while unverifiable liveness remains warn-and-proceed. If cleanup wins first, a waiting resume re-checks existence inside the hold and reports the run missing without recreating files or constructing an engine. `clean` records a racing refusal per run (protected when untouched, trimmed when earlier reclaim steps already ran) and continues with sibling candidates; the TUI reports the refusal and keeps the run visible. - `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) (schema-versioned; the effective retention policy, `freed_bytes` as a raw integer, and the paths and run ids under `worktrees`/`trimmed`/`archived`/`deleted`/`protected`, and `state_dirs_swept` as a count) instead of the text. Plan and outcome share one schema, with `dry_run` saying which one you are holding, so a script can pre-flight a reclaim and compare it against what happened — though values are each invocation's own sample, not a promise the two agree. It names every item the text only counts or renders, and the unverifiable-pid warning text mode writes to stderr becomes `unverifiable_pid` in the document, leaving stderr empty. - Prevention is automatic: every `run`/`sweep` start reconciles worktrees leaked by a prior **finished** run (`[cleanup] auto_clean_on_finish`), and the Unity plugin's `post_run` hook removes the IvanMurzak MCP server's downloaded `/tmp///*.zip` and truncates its unbounded editor log (`[cleanup] clean_tmp`). For recurring housekeeping of stopped runs, schedule `bmad-loop clean`. @@ -318,7 +319,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - `bmad-loop adapters` — list registered coding-CLI adapter **kinds** (name · builtin/external · whether the family drives a multiplexer · which profiles select it), the CLI axis's counterpart to `mux`. Unlike `mux` there is no global choice to persist: a kind is selected per profile by its `adapter` field. A profile referencing an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get a `warning:` on stderr; `validate` reports the same as `adapter.kind` / `adapter.external` / `adapter.external-profile`. - `bmad-loop run` — drive the dev → review → verify → commit loop. - `bmad-loop sweep` — triage + execute open deferred-work entries. -- `bmad-loop resume ` — continue a paused/interrupted run. A resume is fresh intent, so a stop request the prior run left behind is discarded first, in either mode — and if it cannot be removed, resume refuses and names the file rather than re-arming into a run that would stop again at its first item. +- `bmad-loop resume ` — continue a paused/interrupted run. A resume is fresh intent, so a stop request the prior run left behind is discarded first, in either mode — and if it cannot be removed, resume refuses and names the file rather than re-arming into a run that would stop again at its first item. Resume also rendezvouses with delete/archive on the run's lifecycle lock; when cleanup removed the run while resume waited, it reports `no such run` before any state helper can recreate the directory. - `bmad-loop resolve ` — resolve a CRITICAL escalation, then re-arm + resume (`--story`, `--no-interactive`, `--restore-patch ` for intent-gap patch-restore, `--resume`/`--no-resume`). - `bmad-loop decisions` — answer deferred-work decisions past sweeps left unanswered (`--list` to just show them). `--json` instead emits a stable machine-readable document (schema-versioned; per decision the id, question, context, recommendation and every option's key/label/effect/intent/resolution/bundle-name plus a derived `recommended` flag) per the [contract below](#machine-readable-output---json); it implies the listing and never prompts, and nothing pending yields a valid empty document. - `bmad-loop confirm ` — complete a story parked at `awaiting-operator` once you have carried out the external actions it owes: acknowledges each in turn (`--yes` skips the prompts), writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair. `--list` shows every parked story and what it owes; `--reverify` re-runs your `[verify]` commands first and blocks on failure; re-running it on an interrupted confirmation finishes that confirmation. `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) — per parked story the key, actions, spec file, spec/board status, the parking run and the `commit` carrying the park (empty until the record is in a commit), plus derived `confirmable`/`resumable` flags, the `confirmation_recorded` reading behind the latter, and a human `drift` reason; it implies the listing and never prompts, and nothing parked yields a valid empty document. @@ -327,11 +328,11 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - `bmad-loop diagnose []` (`diag`) — emit a sanitized diagnostic dump of a run/sweep to hand maintainers (histograms, counts, env, file sizes — no code/spec/prompts/paths/PII); a stray pseudonymized identifier is auto-substituted with its alias (disclosed in the report), while PII/secret hits still refuse to emit; defaults to the latest run (`--all`, `--out`, `--max-journal-entries`). `--json` emits the same dump as a stable machine-readable document per the [contract below](#machine-readable-output---json) instead of the markdown report. - `bmad-loop attach []` — tmux-attach to a run's live agent session. - `bmad-loop stop ` — stop a live run. The default is a **hard stop**: stop now, abandoning the in-flight item and killing the agent session. `--graceful` instead requests a **graceful stop** — the engine finishes the in-flight item (a story through commit, a sweep bundle through commit, or an in-progress sweep triage — after which no bundles start), then finalizes cleanly and stops as a resumable `stopped` run, suppressing any pending auto-sweeps; `--cancel-graceful` withdraws a pending request. Both modes ride the same `stop-request.json` control file, which carries the mode: a hard stop lodges `mode: "hard"` **before** it signals, and that atomic, project-confined write is also what supersedes a pending graceful request. The engine honors a hard request at the next item boundary and mid-session, where each adapter's wait loop polls it twice per iteration — before and after the loop's own up-to-5s wait — so a quiet session normally lands the stop well inside the 10s grace window. That is the common case rather than a bound: an iteration blocked on a transport call, or waiting out `RESULT_GRACE_S` for an artifact, can exceed the window on either adapter before the next poll — an in-flight socket read or tmux call cannot be interrupted from the polling thread, so no placement of the check makes the interval unconditionally short. What the file does guarantee is reach: a hard stop lands on every platform and multiplexer backend, including one where an inter-process signal is never delivered at all (#319). A nested auto-sweep runs inside its parent but mints its own run dir, so it also polls the _owning_ run's channel: stopping the parent stops the child mid-session rather than leaving it to the force-kill backstop. The child's read of the parent channel is hard-only — a graceful stop already keeps a child sweep from starting, and lets one already in flight finish. Teardown is unbounded on top of that — the opencode HTTP adapter then asks the server to abort and to report usage, and a server that will not answer those leaves the stop to the force-kill backstop, exactly as it would have before #319. SIGTERM still goes out alongside it as the POSIX fast path, but it is no longer the mechanism; the engine stays the single writer of `stopped`, and the external force-kill + `run-stop fallback=True` past the grace window now marks a stop this tool had to finish from outside — a teardown that outran the window reaches it as readily as an engine that never read the request — where before #319 it marked every native-Windows stop. The TUI surfaces the same pair: `x` hard-stops, `S` requests a graceful stop. -- `bmad-loop delete ` — delete a run directory and its out-of-tree control-plane dir (`--force` stops it first if live). -- `bmad-loop archive ` — compress a run into `.bmad-loop/archive` and remove it, control-plane dir included (`--force` stops it first if live). The tarball holds the run dir, so it carries no `events/`. It is staged through an exclusively created temp under a fresh unpredictable name per attempt, so a planted name is never followed or reused, the failure cleanup is provably its own, and a temp stranded by a kill cannot deny later attempts; the tarball is `fsync`ed before the publish — the run dir is removed immediately after, so it is the only remaining copy. A published archive lands at mode `0600` rather than a umask-derived one (#591). +- `bmad-loop delete ` — delete a run directory and its out-of-tree control-plane dir (`--force` stops it first if live). The destructive transaction re-checks liveness after acquiring the per-run lock, so force cannot remove a run a rival resume claimed after that stop; unknown liveness still warns and proceeds. +- `bmad-loop archive ` — compress a run into `.bmad-loop/archive` and remove it, control-plane dir included (`--force` stops it first if live). The destructive transaction re-checks liveness after acquiring the per-run lock, so force cannot archive a run a rival resume claimed after that stop; unknown liveness still warns and proceeds. The hold covers tar snapshot, durable publication, source removal, and control-plane cleanup. The tarball holds the run dir, so it carries no `events/`. It is staged through an exclusively created temp under a fresh unpredictable name per attempt, so a planted name is never followed or reused, the failure cleanup is provably its own, and a temp stranded by a kill cannot deny later attempts; the tarball is `fsync`ed before the publish — the run dir is removed immediately after, so it is the only remaining copy. A published archive lands at mode `0600` rather than a umask-derived one (#591). - Removal refuses while a matching agent session is live that the project cannot prove is another one's, even when the engine is dead: for an untagged session the run dir is the last ownership proof `cleanup` can read, so removing it would leak the session ([#419](https://github.com/bmad-code-org/bmad-loop/issues/419)). A session tagged to another project carries its own proof and never blocks. Run `cleanup` first, having confirmed the session is this project's (`attach`): for an _untagged_ session `cleanup` proves ownership by that same run dir, so two projects sharing a run id can prune each other's. Or pass `--force`, which removes anyway and kills nothing. `clean` leaves such a run untouched and reports it as protected. - `bmad-loop cleanup` — remove leftover tmux artifacts for finished/stopped runs. `--json` emits the sessions and ctl windows removed (or, with `--dry-run`, that would be) as a stable machine-readable document per the [contract below](#machine-readable-output---json). -- `bmad-loop clean` — reclaim disk from concluded runs per `[cleanup]`: tear down worktrees a mid-flight stop orphaned, trim heavy `worktrees/` from runs kept for history, archive/delete past the retention window, and sweep orphaned run control-plane dirs from the out-of-tree state root (`--dry-run`, `--keep`, `--retain N`, `--hard`). `--json` emits what was reclaimed (or would be) as a stable machine-readable document per the [contract below](#machine-readable-output---json), with `freed_bytes` a raw integer. +- `bmad-loop clean` — reclaim disk from concluded runs per `[cleanup]`: tear down worktrees a mid-flight stop orphaned, trim heavy `worktrees/` from runs kept for history, archive/delete past the retention window, and sweep orphaned run control-plane dirs from the out-of-tree state root (`--dry-run`, `--keep`, `--retain N`, `--hard`). A run that resumes before its final removal is classified as protected or trimmed according to work already completed, and unrelated candidates continue. `--json` emits what was reclaimed (or would be) as one stable machine-readable document per the [contract below](#machine-readable-output---json), with `freed_bytes` a raw integer. - `bmad-loop tui` — the interactive dashboard (`--low-frame-rate` for slow/SSH links). - `bmad-loop probe-adapter ` (`collect-adapter-data`) — collect + sanitize adapter-finalization data for a CLI profile; default zero-launch scan, opt-in `--probe` live capture. - Every command takes `--project ` (default: current directory). Any `` accepts a partial — the tail after the last `-`, shortened to any unique prefix. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 430658eb..13262931 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2811,6 +2811,12 @@ def _prepare_resume_locked(project: Path, run_dir: Path): def _resume_paused_run(project: Path, run_dir: Path) -> int: """Resume a paused/interrupted run without holding its lock across execution.""" with state_lock(run_dir): + # Cleanup removes the run under this same hold. A resume that resolved the + # path before cleanup won must not let Journal/save_state recreate it after + # its wait ends. + if not runs.is_run(run_dir): + print(f"no such run: {run_dir.name}", file=sys.stderr) + return 1 # Repeat the command's liveness decision after exclusion. A concurrent # resume publishes its pid under this same hold, so the waiter refuses # instead of reloading the predecessor's old paused state and double-driving. @@ -4073,6 +4079,9 @@ def cmd_delete(args: argparse.Namespace) -> int: return rc try: runs.delete_run(project, run_dir, force=args.force) + except runs.LiveEngineError as e: + print(str(e), file=sys.stderr) + return 1 except runs.LiveSessionError as e: print(f"{e} (or pass --force)", file=sys.stderr) return 1 @@ -4093,6 +4102,9 @@ def cmd_archive(args: argparse.Namespace) -> int: return rc try: dest = runs.archive_run(project, run_dir, force=args.force) + except runs.LiveEngineError as e: + print(str(e), file=sys.stderr) + return 1 except runs.LiveSessionError as e: print(f"{e} (or pass --force)", file=sys.stderr) return 1 @@ -4363,15 +4375,11 @@ def cmd_clean(args: argparse.Namespace) -> int: if not dry: runs.archive_run(project, run_dir) archived.append(run_dir.name) - except runs.LiveSessionError: - # A session appeared between the loop-top guard and here — a resume - # of a stopped run, racing this clean. The chokepoint refused the - # removal; record the run instead of letting one racing run abort - # the whole invocation. Correct the estimate down to what actually - # went. The wider race — every mutation in this loop against a - # concurrent resume — is older than this guard (`reclaimable` is - # sampled in the loop above and never re-read) and is tracked in - # issue #533. + except (runs.LiveEngineError, runs.LiveSessionError) as e: + # A session or engine appeared between the loop-top sample and the + # authoritative removal transaction. Record this run instead of + # letting one racer abort the whole invocation, then continue with + # its siblings. Correct the estimate down to what actually went. freed += heavy_bytes - run_bytes # Classify by what happened, not by what was intended: the steps # above may already have taken this run's worktree and artifacts, @@ -4380,8 +4388,13 @@ def cmd_clean(args: argparse.Namespace) -> int: # trimmed, which is exactly the state it ends in. (trimmed if run_worktrees or shrunk else protected).append(run_dir.name) if not args.json: + reason = ( + "agent session appeared mid-clean" + if isinstance(e, runs.LiveSessionError) + else "engine resumed mid-clean" + ) print( - f"run {run_dir.name}: agent session appeared mid-clean — not removed", + f"run {run_dir.name}: {reason} — not removed", file=sys.stderr, ) elif pol.cleanup.trim_artifacts: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index bae97e3b..fcc6125d 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -122,6 +122,16 @@ class LiveSessionError(Exception): message the CLI/TUI surface verbatim.""" +class LiveEngineError(Exception): + """A destructive run-lifecycle transaction found a provably live engine. + + Unlike :class:`LiveSessionError`, this refusal is authoritative even when the + operator requested ``force``: force may stop the engine before entering the + transaction, but it never licenses removing a run a rival resume claimed in + the meantime. ``str()`` is the operator-facing message surfaces report. + """ + + # How long stop_run waits for a signalled engine to exit before falling back to # marking the run stopped itself. _STOP_WAIT_S = 10.0 @@ -2583,10 +2593,24 @@ def _refuse_uncontained_run_dir(project: Path, run_dir: Path, action: str) -> No node = parent -def delete_run(project: Path, run_dir: Path, *, force: bool = False) -> None: - """Permanently remove a run directory. Callers enforce the engine-liveness - guard; the session guard is enforced here (see :func:`_refuse_live_session`), - which raises :class:`LiveSessionError` instead of removing. +def delete_run( + project: Path, + run_dir: Path, + *, + force: bool = False, + _expected_composer_pid: int | None = None, + _expected_composer_claim: os.stat_result | None = None, +) -> None: + """Permanently remove a run directory under one lifecycle transaction. + + Engine liveness is re-checked after acquiring the canonical per-run state + lock and a provably live engine raises :class:`LiveEngineError`. The private + ``_expected_composer_pid`` and ``_expected_composer_claim`` escape exists only + for ``runsetup``'s failed launch unwind: that composer may remove its own live + pid publication only while the freshly read pid and the directory it + exclusively created still match. It does not bypass the independent + live-session guard, and a rival pid publication or replacement directory + refuses the unwind. ``force`` is the operator's explicit override and skips that guard, accepting the leak on their own say-so. It deliberately does not kill the session @@ -2598,21 +2622,48 @@ def delete_run(project: Path, run_dir: Path, *, force: bool = False) -> None: the operator accepting a leaked session, never a licence to rmtree a path outside the runs dir.""" _refuse_uncontained_run_dir(project, run_dir, "delete") - if not force: - _refuse_live_session(project, run_dir.name, "delete") - shutil.rmtree(run_dir) - # after the run dir, never before: a raise above leaves the run whole, and a - # whole run keeps its control plane (see _discard_state_dir). - _discard_state_dir(project, run_dir.name) + with state_lock(run_dir): + if _expected_composer_claim is not None: + try: + current_claim = run_dir.stat(follow_symlinks=False) + except OSError as e: + raise LiveEngineError( + f"run {run_dir.name} changed directory ownership — refusing to delete it" + ) from e + if not os.path.samestat(_expected_composer_claim, current_claim): + raise LiveEngineError( + f"run {run_dir.name} changed directory ownership — refusing to delete it" + ) + published_pid = read_pid(run_dir) + if ( + _expected_composer_pid is not None + and published_pid is not None + and published_pid != _expected_composer_pid + ): + raise LiveEngineError( + f"run {run_dir.name} changed engine ownership — refusing to delete it" + ) + if _expected_composer_pid is None and engine_liveness(run_dir) == "alive": + raise LiveEngineError( + f"run {run_dir.name} is still live — refusing to delete it; stop it first" + ) + if not force: + _refuse_live_session(project, run_dir.name, "delete") + shutil.rmtree(run_dir) + # after the run dir, never before: a raise above leaves the run whole, and a + # whole run keeps its control plane (see _discard_state_dir). + _discard_state_dir(project, run_dir.name) def archive_run(project: Path, run_dir: Path, *, force: bool = False) -> Path: """Compress a run dir into .bmad-loop/archive/.tar.gz and remove the original. The tarball is written to a temp path then atomically replaced into - place so a partial archive never appears. Callers enforce the engine-liveness - guard; the session guard is enforced here (see :func:`_refuse_live_session`, - and :func:`delete_run` for ``force``) and runs before the tarball is written, - so a refusal leaves nothing behind. + place so a partial archive never appears. Engine liveness is re-checked under + the canonical per-run state lock; that exclusion remains held through archive + publication, source removal, and control-state discard. The session guard is + enforced here too (see :func:`_refuse_live_session` and :func:`delete_run` for + ``force``), before any archive path is created, so a refusal leaves nothing + behind. The tarball holds the run dir only, so since #494 an archive no longer carries the run's ``events/``: the channel moved out of the tree, and its files are @@ -2624,8 +2675,18 @@ def archive_run(project: Path, run_dir: Path, *, force: bool = False) -> Path: for the reason the session guard runs early: a refusal must leave no archive directory and no tarball behind.""" _refuse_uncontained_run_dir(project, run_dir, "archive") - if not force: - _refuse_live_session(project, run_dir.name, "archive") + with state_lock(run_dir): + if engine_liveness(run_dir) == "alive": + raise LiveEngineError( + f"run {run_dir.name} is still live — refusing to archive it; stop it first" + ) + if not force: + _refuse_live_session(project, run_dir.name, "archive") + return _archive_run_locked(project, run_dir) + + +def _archive_run_locked(project: Path, run_dir: Path) -> Path: + """Archive ``run_dir`` while its caller owns :func:`state_lock`.""" archive_dir = project / ARCHIVE_DIR archive_dir.mkdir(parents=True, exist_ok=True) dest = archive_dir / f"{run_dir.name}.tar.gz" diff --git a/src/bmad_loop/runsetup.py b/src/bmad_loop/runsetup.py index 4b4e258d..3e701fc0 100644 --- a/src/bmad_loop/runsetup.py +++ b/src/bmad_loop/runsetup.py @@ -32,6 +32,7 @@ import hashlib import json +import os import sys import time from contextlib import suppress @@ -830,7 +831,7 @@ class ComposedRun: journal: Journal -def _claim_run_dir(run_dir: Path) -> None: +def _claim_run_dir(run_dir: Path) -> os.stat_result: """Take exclusive ownership of a fresh run directory, refusing an id that already names a run. @@ -869,9 +870,23 @@ def _claim_run_dir(run_dir: Path) -> None: f"error: run {run_dir.name} already exists — refusing to compose over it. " "`--run-id` must name a run that does not exist yet." ) from e + try: + return run_dir.stat(follow_symlinks=False) + except BaseException: + # The directory is still empty and exclusively ours. If its identity + # cannot be captured, take the just-published claim back here because the + # outer composition unwind cannot safely identify it without the token. + with suppress(OSError): + run_dir.rmdir() + raise -def _unwind_composition(project: Path, run_dir: Path, journal: Journal | None) -> None: +def _unwind_composition( + project: Path, + run_dir: Path, + journal: Journal | None, + composer_claim: os.stat_result, +) -> None: """Remove the run a failed ``compose_*`` had already published, so a launch that aborts partway leaves nothing behind. @@ -929,7 +944,12 @@ def _unwind_composition(project: Path, run_dir: Path, journal: Journal | None) - effect: the operator reads the launch error, and nothing anywhere says the cleanup after it did not happen.""" try: - runs.delete_run(project, run_dir) + runs.delete_run( + project, + run_dir, + _expected_composer_pid=os.getpid(), + _expected_composer_claim=composer_claim, + ) except Exception as e: detail = f"{type(e).__name__}: {e}" print( @@ -999,7 +1019,7 @@ def compose_run( run_dir = project / RUNS_DIR / run_id # Outside the try below, and it must stay there: a collision refusal that # reached `_unwind_composition` would delete the run it exists to protect. - _claim_run_dir(run_dir) + composer_claim = _claim_run_dir(run_dir) # Composition is atomic from the first published artifact onward: everything # below either lands whole or is unwound (see :func:`_unwind_composition`, # which also states why the arm is `BaseException` and not `Exception`). @@ -1064,7 +1084,7 @@ def compose_run( else engine_cls(**common) # pyright: ignore[reportArgumentType] ) except BaseException: - _unwind_composition(project, run_dir, journal) + _unwind_composition(project, run_dir, journal, composer_claim) raise return ComposedRun(engine=engine, run_id=run_id, run_dir=run_dir, state=state, journal=journal) @@ -1147,7 +1167,7 @@ def compose_sweep( run_id = run_id or runs.new_run_id() run_dir = project / RUNS_DIR / run_id # Same claim, same reason, same placement outside the try as in `compose_run`. - _claim_run_dir(run_dir) + composer_claim = _claim_run_dir(run_dir) # Atomic from the first published artifact onward, exactly as in `compose_run` # — same reason, same opening on the statement after the claim, and one more # artifact to unwind (`sweep.json`). @@ -1204,7 +1224,7 @@ def compose_sweep( if on_started is not None: on_started() except BaseException: - _unwind_composition(project, run_dir, journal) + _unwind_composition(project, run_dir, journal, composer_claim) raise return ComposedRun(engine=engine, run_id=run_id, run_dir=run_dir, state=state, journal=journal) diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index d0fa8bc2..54a38b55 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1402,10 +1402,10 @@ def done(ok: bool | None) -> None: def _delete_run_worker(self, run_id: str, run_dir: Path) -> None: try: runs.delete_run(self.project, run_dir) - except (OSError, runs.LiveSessionError) as e: - # LiveSessionError is the #419 backstop: the confirm above gates on engine - # liveness, which an orphaned session passes. Surface it like any other - # failed removal rather than letting it kill the worker thread. + except (OSError, runs.StateRootError, runs.LiveEngineError, runs.LiveSessionError) as e: + # The modal's liveness sample is advisory. Surface authoritative + # lifecycle refusals and lock/removal failures here rather than letting + # them kill the worker thread or forgetting a run that still exists. self.call_from_thread(self.notify, f"delete failed: {e}", severity="error") return self.call_from_thread(self._dashboard.forget_run, run_id) @@ -1441,9 +1441,9 @@ def done(ok: bool | None) -> None: def _archive_run_worker(self, run_id: str, run_dir: Path) -> None: try: dest = runs.archive_run(self.project, run_dir) - except (OSError, runs.LiveSessionError) as e: - # see _delete_run_worker: the confirm's guard is engine-keyed, this one - # is session-keyed (#419). + except (OSError, runs.StateRootError, runs.LiveEngineError, runs.LiveSessionError) as e: + # Same worker boundary as delete: report the authoritative transaction, + # not the earlier modal sample. self.call_from_thread(self.notify, f"archive failed: {e}", severity="error") return self.call_from_thread(self._dashboard.forget_run, run_id) diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 553d8c1b..d36d913c 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -456,6 +456,67 @@ def racing(_project, run_id): assert doc["archived"] == [] and doc["deleted"] == [] +@pytest.mark.parametrize( + "hard, helper_name, result_key, other_key", + [ + (False, "archive_run", "archived", "deleted"), + (True, "delete_run", "deleted", "archived"), + ], +) +def test_cmd_clean_json_records_a_resumed_engine_and_continues_siblings( + project, monkeypatch, capsys, hard, helper_name, result_key, other_key +): + """A runs-layer lifecycle refusal is per-run data, never a partial JSON + document or an abort that prevents later candidates from being reclaimed.""" + install_bmad_config(project) + repo = project.project + racer = repo / ".bmad-loop" / "runs" / "20260101-000000-aaaa" + sibling = repo / ".bmad-loop" / "runs" / "20260101-000001-bbbb" + for run_dir in (racer, sibling): + save_state( + run_dir, + RunState(run_id=run_dir.name, project=str(repo), started_at="x", finished=True), + ) + real_cleanup = getattr(runs, helper_name) + + def racing_cleanup(project_path, run_dir): + if run_dir == racer: + raise runs.LiveEngineError("engine resumed") + return real_cleanup(project_path, run_dir) + + monkeypatch.setattr(runs, helper_name, racing_cleanup) + + extra = ("--hard",) if hard else () + doc = _clean_json(repo, capsys, "--retain", "0", *extra) + + assert doc["protected"] == [racer.name] + assert doc[result_key] == [sibling.name] + assert doc[other_key] == [] + assert racer.is_dir() and not sibling.exists() + + +def test_cmd_clean_text_identifies_a_resumed_engine(project, monkeypatch, capsys): + """Text mode distinguishes an engine resume from an agent-session race.""" + install_bmad_config(project) + repo = project.project + racer = repo / ".bmad-loop" / "runs" / "20260101-000000-aaaa" + save_state( + racer, + RunState(run_id=racer.name, project=str(repo), started_at="x", finished=True), + ) + + def racing_archive(_project, _run_dir): + raise runs.LiveEngineError("engine resumed") + + monkeypatch.setattr(runs, "archive_run", racing_archive) + + assert cli.cmd_clean(_clean_args(repo, retain=0)) == 0 + + _out, err = capsys.readouterr() + assert f"run {racer.name}: engine resumed mid-clean — not removed" in err + assert "agent session appeared mid-clean" not in err + + def test_cmd_clean_reclaims_past_a_session_proven_to_be_another_project_s( project, monkeypatch, capsys ): diff --git a/tests/test_cli.py b/tests/test_cli.py index 653623a5..10ea7ea9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2304,7 +2304,8 @@ def test_delete_force_stops_then_removes(tmp_path, monkeypatch, capsys): from bmad_loop import runs stopped = [] - monkeypatch.setattr(runs, "engine_liveness", lambda _rd: "alive") + samples = iter(("alive", "dead")) + monkeypatch.setattr(runs, "engine_liveness", lambda _rd: next(samples)) monkeypatch.setattr(runs, "stop_run", lambda rd: stopped.append(rd) or True) run_dir = _make_run_with_state(tmp_path, "r1") assert cli.main(["delete", "--project", str(tmp_path), "r1", "--force"]) == 0 @@ -2313,6 +2314,32 @@ def test_delete_force_stops_then_removes(tmp_path, monkeypatch, capsys): assert not run_dir.exists() +@pytest.mark.parametrize("command", ["delete", "archive"]) +def test_force_cannot_remove_a_run_that_resumes_after_the_stop( + tmp_path, monkeypatch, capsys, command +): + """The outer force stop is not an override for the authoritative in-lock + liveness refusal, and its error must not advise retrying with force. + + Ablation: gate the runs-layer probe on ``not force`` and the run disappears. + Verified. + """ + samples = iter(("alive", "alive")) + stopped: list[Path] = [] + monkeypatch.setattr(runs, "engine_liveness", lambda _rd: next(samples)) + monkeypatch.setattr(runs, "stop_run", lambda rd: stopped.append(rd) or True) + run_dir = _make_run_with_state(tmp_path, "r1") + + assert cli.main([command, "--project", str(tmp_path), "r1", "--force"]) == 1 + + err = capsys.readouterr().err + assert "still live" in err and "refusing to" in err + assert "pass --force" not in err + assert stopped == [run_dir] + assert run_dir.is_dir() + assert not (tmp_path / ".bmad-loop" / "archive").exists() + + def test_delete_force_stop_error_blocks(tmp_path, monkeypatch, capsys): # a failed --force stop must propagate, never fall through to deletion from bmad_loop import runs @@ -5155,6 +5182,7 @@ def recording_lock(_run_dir): yield monkeypatch.setattr(cli, "state_lock", recording_lock) + monkeypatch.setattr(cli.runs, "is_run", lambda _run_dir: True) def liveness(_run_dir): assert entered @@ -5199,6 +5227,7 @@ def prepare(_project, _run_dir): return 1 monkeypatch.setattr(cli, "state_lock", recording_lock) + monkeypatch.setattr(cli.runs, "is_run", lambda _run_dir: True) monkeypatch.setattr(cli.runs, "engine_liveness", liveness) monkeypatch.setattr(cli, "_prepare_resume_locked", prepare) @@ -5206,6 +5235,53 @@ def prepare(_project, _run_dir): assert acquisitions == 1 +@pytest.mark.parametrize("operation", [runs.delete_run, runs.archive_run]) +def test_resume_waiter_refuses_a_run_cleanup_removed_without_recreating_it( + tmp_path, monkeypatch, capsys, operation +): + """Cleanup-first ordering: after the waiter enters the lifecycle hold it + checks existence before liveness, Journal construction, adapters, or engine + drive can recreate anything. + + Ablation: remove or move the existence gate below preparation and the injected + preparation failure fires. Verified. + """ + import contextlib + + run_dir = _make_run_with_state(tmp_path, "r1") + + @contextlib.contextmanager + def cleanup_won(_run_dir): + operation(tmp_path, run_dir) + yield + + monkeypatch.setattr(cli, "state_lock", cleanup_won) + + def liveness(target): + if target.exists(): + return "dead" + pytest.fail("resume read liveness after cleanup removed the run") + + monkeypatch.setattr( + cli.runs, + "engine_liveness", + liveness, + ) + monkeypatch.setattr( + cli, "_prepare_resume_locked", lambda *_a: pytest.fail("resume recreated the run") + ) + monkeypatch.setattr( + cli.runsetup, "compose_resume", lambda **_k: pytest.fail("resume built an engine") + ) + + assert cli._resume_paused_run(tmp_path, run_dir) == 1 + + assert "no such run: r1" in capsys.readouterr().err + assert not run_dir.exists() + if operation is runs.archive_run: + assert (tmp_path / ".bmad-loop" / "archive" / "r1.tar.gz").is_file() + + def test_resume_retains_outer_lock_through_pid_publication(project, monkeypatch): run_dir = _paused_run_for_resume(project, monkeypatch) publications: list[str] = [] diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index c16e2afc..eb49ae63 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -216,6 +216,8 @@ ("runs.py", "rearm_escalation"), ("runs.py", "restamp_code_root"), ("runs.py", "_stop_run_once"), + ("runs.py", "archive_run"), + ("runs.py", "delete_run"), ("runsetup.py", "compose_run"), ("runsetup.py", "compose_sweep"), ("tui/app.py", "_do_rearm"), diff --git a/tests/test_runs.py b/tests/test_runs.py index 53eabd3a..edbba947 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2521,6 +2521,127 @@ def test_delete_run(tmp_path): assert not run_dir.exists() +def test_delete_run_refuses_a_live_engine_inside_the_lifecycle_hold(tmp_path, monkeypatch): + """Resume-first ordering: the decisive probe runs after lock acquisition and + leaves both the run and its control plane untouched. + + Ablation: remove the in-lock ``engine_liveness`` gate and the run disappears; + move it above ``state_lock`` and the lock assertion fails. Verified. + """ + run_dir = _make_state_run(tmp_path, "r1") + state_dir = _seed_state_dir(tmp_path, "r1") + + def live(target): + assert_run_state_lock_held(target) + return "alive" + + monkeypatch.setattr(runs, "engine_liveness", live) + monkeypatch.setattr( + runs, + "_refuse_live_session", + lambda *_args: pytest.fail("session guard ran before authoritative engine probe"), + ) + with pytest.raises(runs.LiveEngineError, match="refusing to delete"): + runs.delete_run(tmp_path, run_dir) + + assert run_dir.is_dir() + assert state_dir.is_dir() + + +def test_delete_run_holds_lifecycle_lock_through_removal_and_state_discard(tmp_path, monkeypatch): + """The authoritative probe, directory removal, and control-plane tail are one + uninterrupted transaction. Moving either mutation outside the hold reddens. + """ + run_dir = _make_state_run(tmp_path, "r1") + removed: list[str] = [] + real_rmtree = runs.shutil.rmtree + + def dead(target): + assert_run_state_lock_held(target) + return "dead" + + def checked_rmtree(target, *args, **kwargs): + assert_run_state_lock_held(run_dir) + removed.append("run") + return real_rmtree(target, *args, **kwargs) + + def checked_discard(_project, _run_id): + assert_run_state_lock_held(run_dir) + removed.append("state") + + monkeypatch.setattr(runs, "engine_liveness", dead) + monkeypatch.setattr(runs.shutil, "rmtree", checked_rmtree) + monkeypatch.setattr(runs, "_discard_state_dir", checked_discard) + + runs.delete_run(tmp_path, run_dir) + + assert removed == ["run", "state"] + + +def test_failed_composition_pid_bypasses_only_its_own_live_engine(tmp_path, monkeypatch): + """The narrow composer token keeps the independent session guard active.""" + run_dir = _make_state_run(tmp_path, "r1") + composer_claim = run_dir.stat(follow_symlinks=False) + runs.write_pid(run_dir) + checked: list[str] = [] + + def session_guard(_project, run_id, _action): + checked.append(run_id) + + monkeypatch.setattr(runs, "_refuse_live_session", session_guard) + runs.delete_run( + tmp_path, + run_dir, + _expected_composer_pid=os.getpid(), + _expected_composer_claim=composer_claim, + ) + + assert checked == ["r1"] + assert not run_dir.exists() + + +@pytest.mark.parametrize("operation", [runs.delete_run, runs.archive_run]) +def test_lifecycle_containment_is_checked_before_lock_acquisition(tmp_path, monkeypatch, operation): + """A hostile path is refused without deriving or taking any state lock.""" + project = tmp_path / "project" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "state.json").write_text("{}", encoding="utf-8") + + def unexpected_lock(_run_dir): + pytest.fail("containment must run before state-lock acquisition") + + monkeypatch.setattr(runs, "state_lock", unexpected_lock) + with pytest.raises(platform_util.UnconfinedWriteError): + operation(project, outside) + + assert outside.is_dir() + + +def test_delete_run_refuses_before_removal_when_state_lock_cannot_be_named(tmp_path, monkeypatch): + """The lifecycle lock is mandatory: without a state root cleanup cannot + rendezvous with resume, so failure leaves the run intact and surfaces.""" + run_dir = _make_state_run(tmp_path, "r1") + monkeypatch.setattr(runs, "state_root", _raising(runs.StateRootError("no root"))) + + with pytest.raises(runs.StateRootError, match="no root"): + runs.delete_run(tmp_path, run_dir) + + assert run_dir.is_dir() + + +def test_archive_run_refuses_before_staging_when_state_lock_cannot_be_named(tmp_path, monkeypatch): + """Archive cannot stage or remove anything when its mandatory lock fails.""" + run_dir = _make_state_run(tmp_path, "r1") + monkeypatch.setattr(runs, "state_root", _raising(runs.StateRootError("no root"))) + + with pytest.raises(runs.StateRootError, match="no root"): + runs.archive_run(tmp_path, run_dir) + + assert run_dir.is_dir() + assert not (tmp_path / ".bmad-loop" / "archive").exists() + + def test_delete_run_removes_the_out_of_tree_state_counterpart(tmp_path): """#494 moved the events channel out of the project tree, so removing the run dir stopped removing everything the run owns. Without this tail every delete @@ -2538,19 +2659,18 @@ def test_delete_run_removes_the_out_of_tree_state_counterpart(tmp_path): @pytest.mark.parametrize( "attr, exc", [ - ("state_root", runs.StateRootError("no root")), ("project_tag", OSError("cannot canonicalize")), ("project_tag", RuntimeError("Symlink loop from '/p'")), ], - ids=["no-derivable-state-root", "unresolvable-project", "symlink-loop-project"], + ids=["unresolvable-project", "symlink-loop-project"], ) def test_delete_run_survives_a_counterpart_it_cannot_name(tmp_path, monkeypatch, attr, exc): """The counterpart removal is a never-raise tail (#139 teardown doctrine). - Every row is the counterpart being *unnameable*, which is the only failure - that can escape: an environment with no derivable state root, and a project - the OS refuses to canonicalize (#552). Removal failures are absorbed - separately, by `ignore_errors`. + Every row is a project the OS refuses to canonicalize (#552) only after the + mandatory lifecycle lock was named. Removal failures are absorbed separately, + by `ignore_errors`. A missing state root now refuses before removal because + cleanup cannot safely rendezvous with resume without that lock. The `RuntimeError` row is not a hypothetical type: `project_tag` resolves before digesting, and below 3.13 `Path.resolve` reports a symlink loop as @@ -4074,6 +4194,80 @@ def test_archive_run(tmp_path): assert "20260611-100000-aaaa/journal.jsonl" in names +def test_archive_run_refuses_a_live_engine_before_staging(tmp_path, monkeypatch): + """Resume-first ordering leaves source, destination, and control state whole.""" + run_dir = _make_state_run(tmp_path, "20260611-100000-aaaa") + state_dir = _seed_state_dir(tmp_path, run_dir.name) + + def live(target): + assert_run_state_lock_held(target) + return "alive" + + monkeypatch.setattr(runs, "engine_liveness", live) + monkeypatch.setattr( + runs, + "_refuse_live_session", + lambda *_args: pytest.fail("session guard ran before authoritative engine probe"), + ) + with pytest.raises(runs.LiveEngineError, match="refusing to archive"): + runs.archive_run(tmp_path, run_dir) + + assert run_dir.is_dir() + assert state_dir.is_dir() + assert not (tmp_path / ".bmad-loop" / "archive").exists() + + +def test_archive_run_holds_one_lock_through_snapshot_publish_and_removal(tmp_path, monkeypatch): + """Snapshot, durable publication, source removal, and control cleanup all + remain inside the same lifecycle hold. + + Ablation: moving the liveness gate, tar add, replace, rmtree, or discard outside + the hold fails at that seam. Verified. + """ + run_dir = _make_state_run(tmp_path, "20260611-100000-aaaa") + (run_dir / "payload").write_text("data", encoding="utf-8") + order: list[str] = [] + real_add = runs.tarfile.TarFile.add + real_replace = runs.atomic_replace + real_rmtree = runs.shutil.rmtree + + def dead(target): + assert_run_state_lock_held(target) + order.append("probe") + return "dead" + + def checked_add(self, *args, **kwargs): + assert_run_state_lock_held(run_dir) + order.append("snapshot") + return real_add(self, *args, **kwargs) + + def checked_replace(src, dest): + assert_run_state_lock_held(run_dir) + order.append("publish") + return real_replace(src, dest) + + def checked_rmtree(target, *args, **kwargs): + assert_run_state_lock_held(run_dir) + order.append("remove") + return real_rmtree(target, *args, **kwargs) + + def checked_discard(_project, _run_id): + assert_run_state_lock_held(run_dir) + order.append("discard") + + monkeypatch.setattr(runs, "engine_liveness", dead) + monkeypatch.setattr(runs.tarfile.TarFile, "add", checked_add) + monkeypatch.setattr(runs, "atomic_replace", checked_replace) + monkeypatch.setattr(runs.shutil, "rmtree", checked_rmtree) + monkeypatch.setattr(runs, "_discard_state_dir", checked_discard) + + runs.archive_run(tmp_path, run_dir) + + assert order[0] == "probe" + assert order[-3:] == ["publish", "remove", "discard"] + assert order[1:-3] and set(order[1:-3]) == {"snapshot"} + + def test_archive_run_names_its_temp_after_the_destination(tmp_path, monkeypatch): """#363's filename half, and it needs its own test because NOTHING else grades it: on the happy path `atomic_replace` consumes the temp under either spelling, @@ -4142,6 +4336,9 @@ def boom(src, dst): assert (run_dir / "state.json").is_file() # the run survives a failed archive assert list((tmp_path / ".bmad-loop" / "archive").iterdir()) == [] # no temp left + sidecar = runs.lock_path_for(run_dir / "state.json", follow_final_symlink=False) + with platform_util.file_lock(sidecar, blocking=False): + pass # the original archive error released lifecycle exclusion def test_archive_run_temp_is_created_exclusively_at_0600(tmp_path, monkeypatch): diff --git a/tests/test_runsetup.py b/tests/test_runsetup.py index b1a37a79..0f143512 100644 --- a/tests/test_runsetup.py +++ b/tests/test_runsetup.py @@ -15,6 +15,7 @@ """ import dataclasses +import os import shutil import threading import types @@ -595,6 +596,85 @@ def test_compose_run_unwinds_the_run_when_the_adapters_abort(unwinding): _assert_unwound(unwinding) +def test_failed_composition_identifies_its_narrow_run_ownership(unwinding, monkeypatch): + """The composer may unwind its own live pid publication, but must opt into + that exception explicitly rather than borrowing operator ``force``.""" + real_delete = runs.delete_run + calls: list[tuple[bool, int | None]] = [] + + def checked_delete( + project, + run_dir, + *, + force=False, + _expected_composer_pid=None, + _expected_composer_claim=None, + ): + assert _expected_composer_claim is not None + assert os.path.samestat(_expected_composer_claim, run_dir.stat(follow_symlinks=False)) + calls.append((force, _expected_composer_pid)) + return real_delete( + project, + run_dir, + force=force, + _expected_composer_pid=_expected_composer_pid, + _expected_composer_claim=_expected_composer_claim, + ) + + monkeypatch.setattr(runs, "delete_run", checked_delete) + with pytest.raises(SystemExit, match="not usable on this host"): + _run_compose_sweep(unwinding.project, unwinding.make_adapters) + + assert calls == [(False, os.getpid())] + _assert_unwound(unwinding) + + +def test_failed_composition_refuses_to_unwind_a_rival_pid_publication(unwinding, capsys): + """A resume that replaces the composer's pid publication owns the run now. + + The launch error remains authoritative, while the refused unwind is reported + and leaves both run and control state available to the rival. + """ + + def rival_then_abort(project, run_dir, policy, *, profiles=None): + unwinding.published["run_dir"] = run_dir.is_dir() + unwinding.published["state"] = (run_dir / "state.json").is_file() + unwinding.published["state_dir"] = runs.state_dir_for(project, RUN_ID).is_dir() + (run_dir / runs.PID_FILE).write_text(str(os.getpid() + 1), encoding="utf-8") + raise SystemExit(BOOM) + + with pytest.raises(SystemExit, match="not usable on this host"): + _run_compose_sweep(unwinding.project, rival_then_abort) + + warning = capsys.readouterr().err + assert "changed engine ownership" in warning + assert runs.run_dir_for(unwinding.project, RUN_ID).is_dir() + assert runs.state_dir_for(unwinding.project, RUN_ID).is_dir() + + +def test_failed_composition_refuses_to_unwind_a_replacement_directory(unwinding, capsys): + """A missing pid does not prove the composer's original directory still exists. + + A cleanup can remove that directory after an unverifiable liveness probe and a + later creator can claim the same id before composition unwinds. The directory + identity captured by the original exclusive claim keeps the replacement whole. + """ + + def replace_then_abort(project, run_dir, policy, *, profiles=None): + shutil.rmtree(run_dir) + run_dir.mkdir() + (run_dir / "replacement").write_text("owned elsewhere", encoding="utf-8") + raise SystemExit(BOOM) + + with pytest.raises(SystemExit, match="not usable on this host"): + _run_compose_sweep(unwinding.project, replace_then_abort) + + warning = capsys.readouterr().err + assert "changed directory ownership" in warning + replacement = runs.run_dir_for(unwinding.project, RUN_ID) + assert (replacement / "replacement").read_text(encoding="utf-8") == "owned elsewhere" + + def test_compose_sweep_unwinds_the_run_when_the_adapters_abort(unwinding): """The sweep composer publishes the same artifacts (plus `sweep.json`) ahead of the same `make_adapters` call, so it owns its own unwind — separately, since a @@ -891,7 +971,14 @@ def test_a_failed_unwind_is_reported_and_does_not_replace_the_launch_error( operator is still `make_adapters`', not the cleanup's. A bare `pytest.raises` would pass just as happily for a cleanup failure that replaced it.""" - def boom(project, run_dir, *, force=False): + def boom( + project, + run_dir, + *, + force=False, + _expected_composer_pid=None, + _expected_composer_claim=None, + ): raise OSError(13, "Permission denied") monkeypatch.setattr(runs, "delete_run", boom) @@ -923,7 +1010,14 @@ def test_a_failed_unwind_still_reports_when_the_run_dir_is_already_gone( suppression around the journal write is load-bearing and gets its own test. The stderr report must still land, since it is now the only channel left.""" - def boom(project, run_dir, *, force=False): + def boom( + project, + run_dir, + *, + force=False, + _expected_composer_pid=None, + _expected_composer_claim=None, + ): shutil.rmtree(run_dir) raise RuntimeError("state dir removal failed") diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 331e5077..80b22974 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4359,6 +4359,54 @@ async def test_archive_live_run_refused_without_calling(project, monkeypatch): assert not isinstance(app.screen, ConfirmModal) +@pytest.mark.parametrize( + "key, helper, failure, expected", + [ + ("D", "delete_run", runs_mod.LiveEngineError("engine resumed"), "delete failed"), + ( + "D", + "delete_run", + runs_mod.StateRootError("no usable state root"), + "delete failed", + ), + ("A", "archive_run", runs_mod.LiveEngineError("engine resumed"), "archive failed"), + ( + "A", + "archive_run", + runs_mod.StateRootError("no usable state root"), + "archive failed", + ), + ], +) +async def test_lifecycle_workers_report_authoritative_failures_and_keep_the_run_visible( + project, monkeypatch, key, helper, failure, expected +): + """The modal's liveness sample is advisory. A later lifecycle or state-lock + refusal is toasted from the worker, and the dashboard forget happens only on + success. + + Ablation: omit either new exception type from the worker catch and the worker + dies without the expected notification. Verified. + """ + monkeypatch.setattr(data, "liveness", lambda _run_dir: "dead") + + def fail(*_args, **_kwargs): + raise failure + + monkeypatch.setattr(runs_mod, helper, fail) + run_dir = make_run(project.project, "20260611-100000-aaaa", finished=True) + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: dashboard(app).selected_run_id == run_dir.name) + await pilot.press(key) + await until(pilot, lambda: isinstance(app.screen, ConfirmModal)) + await pilot.click(await ready(pilot, "#ok")) + await until(pilot, lambda: any(expected in note for note in notifications(app))) + assert dashboard(app).selected_run_id == run_dir.name + + assert run_dir.is_dir() + + # ------------------------------------------------------------ graceful stop (S) From 96aa09a9612243118e04782e5925e9abdd7294a5 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 07:22:59 -0700 Subject: [PATCH 43/45] fix(resolve): refuse the #414 pair before the interactive session `cmd_resolve` checked `isolation = "worktree"` beside a `repo_root:` override only after the confirm, so an operator built adapters, conversed with a full agent and answered the re-arm prompt before being handed rc 1 for a pair that was knowable from config the whole time. `cmd_run` and `cmd_sweep` refuse it before provisioning anything. The check now also runs in the pre-session arm, and the `bmadconfig.load_paths` read moves above `_make_adapters` so it can precede the adapter build for the reason `cmd_run` puts it ahead of its queue and worktree-clean gates: this refusal says the configuration cannot run at all, so an adapter fault reported first sends the operator at the wrong problem. Ordering only -- `load_paths` is a read and its degrade arm is unchanged. The post-confirm refusal stays the authority: it re-reads the config after a conversation of unbounded length, and it is the only one that `--no-interactive` reaches. `restamp_code_root` now journals `rearm-code-root-restamped`. It is the gesture on which the code root actually moved, and it was the one leaving no durable trace: the re-stamp aligns the mirror `_resume_paused_run` later compares against config, so by the time `run-resume` computes `code_root_changed` the two necessarily agree and it records false. A stderr line and a TUI toast are not records. `rearm-spec-flip-skipped` carries `reaches_redrive`. `refused` is False for two disjoint reasons and a reader out of process cannot re-derive which, so the renderer asserted worktree behaviour -- "it mounts a fresh worktree and reads the COMMITTED spec" -- on a run that mounts nothing, telling the operator a failed flip was harmless at exactly the moment it is not. A record written before the field keeps the wording it was written under. `stale-restore-excluded` drops its completed-past-tense claim about a baseline that an abort never persists. Three of those fixes had landed with no test. Rows were added for each at the lowest layer that catches the regression, and `reaches_redrive` is declared in `JOURNAL_BENIGN_FIELDS`, which the routing guard had failed on. Every new negative assertion was ablated serially against a cp backup under PYTHONDONTWRITEBYTECODE=1 and graded on the named test reddening. Four documentation defects found by the same review are corrected. A `Changed` bullet claimed diagnose routes "by field name across every entry rather than by kind, so no existing run's dump changes shape" -- false in both halves, and it contradicted the `Security` entry beside it; the kind table does change pre-existing dumps, for `target` on the three merge kinds and `sentinel` on `sentinel-cleared`. Three `Fixed` entries repaired defects no released version had, and are folded into the `Added`/`Changed` entries that introduce them rather than deleted, since a release promotes `Unreleased` verbatim. The `Security` entry states that the branch-name leak pre-dates this work and that #640 names the work that found it rather than a reporting issue. README gains the resume hold and lists `resolve` among what the #414 pair refuses. --- CHANGELOG.md | 65 +++++++++++++++------------- README.md | 6 ++- src/bmad_loop/cli.py | 58 +++++++++++++++++++------ src/bmad_loop/runs.py | 69 +++++++++++++++++++++++++----- tests/test_cli.py | 54 +++++++++++++++++++++++- tests/test_diagnostics.py | 25 +++++++++-- tests/test_portability_guard.py | 1 + tests/test_resolve.py | 75 ++++++++++++++++++++++++++++++++- tests/test_runs.py | 24 ++++++++++- 9 files changed, 315 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b1ee62..7d8137e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,8 +48,13 @@ breaking changes may land in a minor release. - **`repo_root` in run `state.json`** (#716). A run records the git root its code work happens in, so an out-of-process reader — `bmad-loop resolve`'s re-arm — uses the tree the run measured - instead of re-deriving one. A `state.json` written before the field existed degrades to the - project directory, which is the pre-upgrade behavior. + instead of re-deriving one. `resume` re-stamps that mirror against the `repo_root` it re-reads + from `_bmad/bmm/config.yaml`, so an edit made while the run was paused cannot leave the engine + working in one tree while the out-of-process re-arm advances the attempt baseline in the other, + with no error on either side; a move is announced rather than silent, since the baselines, + preserve refs and branches already recorded name objects in the previous tree. A `state.json` + written before the field existed degrades to the project directory — the pre-upgrade behavior — + and migrates without being reported as a move. - **Atomic writers gain an opt-in `require_writable_target` refusal** (#597). Callers over operator-curated files can ask for the `PermissionError` a plain `Path.write_text` used to @@ -138,8 +143,12 @@ breaking changes may land in a minor release. - **`bmad-loop diagnose` routes the re-arm records by field name** (#640, #716). `spec_file` and `overwritten` are aliased, and `repo` is dropped — an absolute host path that correlates nothing. - Routing is by field name across every entry rather than by kind, so no existing run's dump changes - shape and `SCHEMA_VERSION` is unaffected. + All three arrive only on the re-arm kinds, so no run predating them renders differently on their + account. By-name routing is the default rather than the whole rule: a narrow kind-scoped table is + consulted FIRST, for the fields whose meaning depends on the kind carrying them, and that table + DOES change pre-existing dumps — `target` is aliased on `unit-merge-started`, `unit-merged` and + `resume-unit-merge` (see `### Security`) while the `board-advance-*` family keeps rendering it + verbatim, and `sentinel` is aliased on `sentinel-cleared`. `SCHEMA_VERSION` is unaffected. - **`bmad-loop diagnose`'s default report shows the split code root and the task generation** (#705, #716). Both fields reached `--json` but not the markdown renderer, which samples its fields @@ -153,12 +162,18 @@ breaking changes may land in a minor release. advanced the attempt baseline in the tree the run had left, while the engine that resumed measured in the new one, with no error anywhere. Both now re-stamp through one shared writer, after the confirm — so a cancelled resolve still leaves the divergence for `resume` to report — and each - warns that the run has changed repositories. A config this process cannot read degrades to the - root the run recorded, and says so. The #414 isolation refusal is hoisted alongside it, ahead of + warns that the run has changed repositories, and journals `rearm-code-root-restamped` so the + move leaves a durable record: this re-stamp is what makes resume's own `code_root_changed` + read `false` later in the same gesture, and a stderr line or a TUI toast is not a record. A + config this process cannot read degrades to the root the run recorded, and says so. The #414 isolation refusal is hoisted alongside it, ahead of both writes: under `isolation = "worktree"` beside a `repo_root` override, both surfaces used to re-stamp, advance the attempt baseline and report "re-armed" before resume refused the configuration — spending an escalation `resolve` could no longer re-run, since the story was no - longer escalated. + longer escalated. `resolve` refuses it a second time BEFORE the interactive session, ahead of + the adapter build: the pair is knowable from config, so the late refusal alone let an operator + converse with a full agent and answer the re-arm prompt only to be handed rc 1 for it. The + post-confirm refusal stays the authority — it re-reads the config after a conversation of + unbounded length, and is the only one `--no-interactive` reaches. - **Re-arm refuses a story spec it cannot re-open, instead of re-driving onto a status the session cannot route** (#640). A spec carrying no top-level `status:` failed the flip silently: the @@ -194,7 +209,12 @@ breaking changes may land in a minor release. HOLDS the resume both surfaces fold in behind the re-arm, since its advice is unactionable once the run has resumed, and `--resume` does not override the hold — the re-arm stands, and `bmad-loop resume ` picks the story up once the - fix is committed. + fix is committed. A spec in an artifact directory configured outside the project is exempt + entirely: `ProjectPaths.rebased` leaves such a directory where it is, shared across checkouts + rather than rebased onto each worktree, so the flip lands on the one file every re-drive reads and + there is nothing to commit — a remedy naming a file outside the repository. Containment is decided + on the canonical paths, so a spec spelled out of but resolving back into the worktree still warns, + as does one the host cannot canonicalize. - **The re-arm baseline records reach the TUI operator too** (#640). Each surface carried its own copy of the journal-kind → message routing and they had drifted: the TUI printed only @@ -317,7 +337,10 @@ breaking changes may land in a minor release. undoing the spec beneath it mirrors the same defect. An ORDINARY failure of the abort record's OWN journal write is suppressed whatever its type, so an observation that cannot be made never replaces the fault the operator is being told about — an interrupt still - leaves, since by then the rollback has already run and the operator asked to stop. + leaves, since by then the rollback has already run and the operator asked to stop. That + `finally` is also what makes the residue echo unconditional: the residue is journalled before + the re-stamp that can raise, so an abort would otherwise drop the notices for records already + on disk — the commits warning among them. - Stop an LLM-authored preference escalation from aborting the review leg. `_review_and_commit` splats a review session's own `result.json` escalation entries into `journal.append`, so a @@ -453,26 +476,6 @@ argument` and failed the story; a `ts` key did not raise and instead silently re then mounted a fresh worktree cut from git that could not see it. A change across the session is now reported on stderr. -- **`resume` re-stamps the run's recorded code root** (#716). Resume arms the engine against the - `repo_root` it re-reads from `_bmad/bmm/config.yaml` but left the `state.json` copy at its launch - value, so after an edit the engine worked in one tree while the out-of-process re-arm advanced the - attempt baseline in the other, with no error on either side. The mirror now follows the paths - resume adopts, and a move is announced rather than silent — the baselines, preserve refs and - branches already recorded name objects in the previous tree. A `state.json` from before the field - existed migrates without being reported as a move. - -- **The unreachable-spec-write warning no longer fires on a shared artifact directory** (#640). An - artifact directory configured outside the project is left where it is by `ProjectPaths.rebased`, - shared across checkouts instead of rebased onto each worktree — so the flip lands on the one file - every re-drive reads and there was never anything to commit, yet that layout took the warning on - every re-arm with a remedy naming a file outside the repository. Containment is decided on the - canonical paths, so a spec spelled out of but resolving back into the worktree still warns, as - does one the host cannot canonicalize. - -- **`bmad-loop resolve` still reports abandoned-restore residue when the re-arm aborts** (#640). - The residue is journalled before the re-stamp that can raise, so an abort discarded records - already written — including the commits warning. The echo now runs on both paths. - - **A YAML boolean in a spec's baseline key no longer refuses the attempt** (#716). `no`, `off`, `yes` and `on` parse as booleans, and the shared reader stringified them into `"False"`/`"True"` — non-empty, so they were judged as a claimed sha and outranked a `baseline_commit` naming the @@ -740,6 +743,10 @@ decisions` and the TUI decision modal now also catch the state-root failure that ### Security - **`bmad-loop diagnose` no longer ships a merge record's target branch verbatim** (#640). + The leak PRE-DATES the re-arm work this section is otherwise about: all three producers + and the by-name routing shipped in earlier releases, so any dump of a run that merged a + unit is affected, and the `#640` citation names the work that happened to find it rather + than an issue that reported it. The journal's `target` field carries a branch on `unit-merge-started`, `unit-merged` and `resume-unit-merge` but a sprint status on the `board-advance-*` family, and per-field routing is by field NAME — so the field was left unrouted and an identifier-shaped branch diff --git a/README.md b/README.md index 9a3c86fe..efbdb735 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ bmad-loop tui # …or drive everything from the dashboard | `bmad-loop run` | Drive the dev → review → verify → commit loop. `--epic N`, `--story KEY`, `--max-stories N`, `--dry-run`. `--spec ` forces **stories mode** (folder+id dispatch off `/stories.yaml`), overriding `[stories].source`; `--story` then filters by story id. | | `bmad-loop sweep` | Triage + execute open `deferred-work.md` entries. `--no-prompt`, `--decisions-only`, `--max-bundles N`, `--repeat`, `--max-cycles N`, `--dry-run`. `--archive [--before DATE]` instead moves closed ledger entries to `deferred-work-archive.md`, leaving id-preserving stubs. | | `bmad-loop resume ` | Continue a run paused at a gate, escalation, or interruption. The resume command rendezvouses with delete/archive on the run lifecycle lock; if cleanup removed the run while resume waited, resume reports it missing without recreating files or launching an engine. | -| `bmad-loop resolve ` | Resolve a CRITICAL escalation: open an interactive resolve agent to fix the frozen spec, then re-arm the story and resume. On an _intent gap_ the re-drive can resume review on the attempted change instead of re-implementing it. `--story KEY`, `--no-interactive`, `--restore-patch ` (intent-gap patch-restore), `--resume` / `--no-resume`, `--force` (proceed when engine liveness is unverifiable; a provably-live engine still blocks). | +| `bmad-loop resolve ` | Resolve a CRITICAL escalation: open an interactive resolve agent to fix the frozen spec, then re-arm the story and resume — the resume is held when the correction provably has not reached the tree the re-drive reads (see [Resolving a CRITICAL escalation](#how-a-story-flows)). On an _intent gap_ the re-drive can resume review on the attempted change instead of re-implementing it. `--story KEY`, `--no-interactive`, `--restore-patch ` (intent-gap patch-restore), `--resume` / `--no-resume`, `--force` (proceed when engine liveness is unverifiable; a provably-live engine still blocks). | | `bmad-loop decisions` | Answer deferred-work decisions earlier sweeps left unanswered (skipped by `--no-prompt`, or an abandoned interactive sweep). Recorded so the next sweep acts on them without re-asking. `--list` shows them without answering; `--json` emits them as a stable machine-readable document — id, question, context, recommendation, and every option's key/label/effect/intent/resolution/bundle-name with a derived `recommended` flag. It implies the listing and never prompts, so a script can select an option by policy instead of scraping the text. | | `bmad-loop confirm ` | Complete a story parked at `awaiting-operator` once you have carried out the external actions it owes (buy the domain, publish the DNS record). Acknowledges each action in turn, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair — nothing is re-driven. `--list` shows every parked story and what it owes; `--yes` skips the prompts; `--reverify` re-runs the project's `[verify]` commands first and blocks the confirmation if they fail; `--json` emits the parked set as a stable machine-readable document. Every write is checked and the spec is read back from disk, so a story is never declared done over a write that did not land; a confirmation interrupted before its board write is **finished** by re-running the command, with no second prompt and no second audit section. The index it reads is machine-local, so a park is confirmed on the machine that ran it. | | `bmad-loop list` (`ls`) | List every run/sweep with its short ref, type, and status — the handle you pass to the commands below. `--json` emits a stable machine-readable document instead — one entry per run, oldest first (short ref, run id, type, started-at, liveness-aware status, paused stage); an empty runs dir yields a valid empty document. | @@ -247,6 +247,8 @@ sprint-status.yaml: 1-2-account-mgmt: ready-for-dev **Resolving a CRITICAL escalation:** the escalated story is parked in a terminal `escalated` phase — `resume` skips it. To un-stick it, run `bmad-loop resolve ` (or press `R` in the TUI). That opens an interactive **resolve agent** seeded with the escalation and the frozen spec; you converse with it to disambiguate the spec, it records the resolution, and on your confirmation the orchestrator re-arms the story (`escalated → pending`, spec status reset to `ready-for-dev`) and resumes — a clean rebuild against the corrected spec, then on through the rest of the sprint. Already fixed the spec yourself? `bmad-loop resolve --no-interactive` skips straight to re-arm + resume. +**When the resume is held.** Under `[scm] isolation = "worktree"` the re-drive mounts a fresh worktree cut from the committed target branch, so a correction living only in your working tree never reaches it. Where the re-arm can _prove_ that — the committed spec does not carry the status the re-drive routes on, or, for a pre-planning sentinel, the ref it mounts from does not hold this checkout's `SPEC.md` / `stories.yaml` — the re-arm still stands but the resume stops there, `--resume` notwithstanding, and both surfaces name the branch to commit on. Commit the correction, then `bmad-loop resume `. Every other re-arm warning stays advisory and resumes in the one gesture as before. + **Intent-gap patch-restore.** When review halted on an **intent gap** — the implementation was sound but read the spec differently than intended — `bmad-build-auto` saves the attempted change as a patch before reverting ([BMAD-METHOD#2564](https://github.com/bmad-code-org/BMAD-METHOD/issues/2564)). If the attempted reading was in fact correct, `resolve` re-arms the spec to `in-review` and re-applies that patch onto baseline after every reset, so the re-driven session resumes **review** on the restored diff instead of re-implementing from scratch. The interactive agent supplies the patch automatically via `resolution.json`; on the hand-driven path pass `bmad-loop resolve --no-interactive --restore-patch `. A patch that fails to apply escalates rather than running on a half-restored tree, and deferred-work `sweep` bundles get the same recovery. ## Deferred-work sweeps @@ -525,7 +527,7 @@ Merge-back is always **serialized** — `max_parallel` is a validated knob clamp The settings editor with the [scm] section expanded: isolation, branch_per, merge_strategy, the seed-adapter-configs switch, and the extra-worktree-seed-files field.

-For a monorepo or any layout where the git root differs from the project dir, set an optional `repo_root` key in `_bmad/bmm/config.yaml` — it decouples where git/code work happens from where run state lives (defaults to the project dir). Your `[verify].commands` run there too — the code's root, not the BMAD project dir — while the orchestrator's own artifact reads stay project-rooted. It is **not compatible with `isolation = "worktree"`**: provisioning seeds a worktree from `repo_root` while the preflight probes `project`, so `validate` reports the pair and `run`/`sweep`/`resume` refuse to start. Use one or the other — plumbing both through provisioning is tracked as #443. +For a monorepo or any layout where the git root differs from the project dir, set an optional `repo_root` key in `_bmad/bmm/config.yaml` — it decouples where git/code work happens from where run state lives (defaults to the project dir). Your `[verify].commands` run there too — the code's root, not the BMAD project dir — while the orchestrator's own artifact reads stay project-rooted. It is **not compatible with `isolation = "worktree"`**: provisioning seeds a worktree from `repo_root` while the preflight probes `project`, so `validate` reports the pair and `run`, `sweep`, `resume` and `resolve` refuse to start — `resolve` before it opens the interactive agent, since its re-arm advances the attempt baseline against that same root. Use one or the other — plumbing both through provisioning is tracked as #443. ### Plugins diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 13262931..a6437e40 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -277,15 +277,22 @@ def _reject_isolation_conflict(paths: bmadconfig.ProjectPaths, pol) -> int | Non """Refuse `isolation = "worktree"` under a `repo_root` override (#414). Returns 1 to abort, None to proceed — the `_reject_bad_run_id` shape. - Called from the three :class:`~engine.Engine` construction sites that return an - rc to a human: `cmd_run`, `cmd_sweep`, and `_resume_paused_run` — the shared - helper behind both `resume` and `resolve`'s re-arm. The fourth such site, the - auto-triggered child sweep in `_sweep_factory`, shares the refusal but not this - disposition: it has no rc channel, so it raises (see the comment there). - Keyed on Engine construction rather than on "loads policy.toml", which is a - wider set that does not all provision — `_configure_mux` reads the file on - every command and builds nothing; `cmd_validate` and `cmd_clean` load it and - never mount a worktree. + Called from the four sites that return an rc to a human: `cmd_run`, `cmd_sweep`, + `_resume_paused_run` — the shared helper behind both `resume` and `resolve`'s + re-arm — and `cmd_resolve`, which calls it TWICE: once before the interactive + session and once after the config re-read that authorises the re-arm. A fifth + site, the auto-triggered child sweep in `_sweep_factory`, shares the refusal but + not this disposition: it has no rc channel, so it raises (see the comment there). + + Keyed on provisioning-or-arming a run against the config, NOT on Engine + construction: `cmd_resolve` constructs no Engine and delegates to + `_resume_paused_run` for that, but `runs.rearm_escalation` mutates persisted run + state — advancing the attempt baseline and re-stamping the spec — against the + same `repo_root` this refuses, and it does so BEFORE the delegate is reached. A + refusal keyed on Engine construction alone therefore arrives after the damage. + Both keyings exclude the same wider "loads policy.toml" set, which does not all + provision — `_configure_mux` reads the file on every command and builds nothing; + `cmd_validate` and `cmd_clean` load it and never mount a worktree. `validate` deliberately does not call this — it reports rather than aborts, so it renders the same message as a Finding and keeps running its other gates.""" @@ -300,8 +307,8 @@ def _reject_under_floor_git(project: Path) -> int | None: """Refuse to start against a git older than `verify.GIT_FLOOR`. Returns `ExitCode.FAILURE` to abort, None to proceed — the `_reject_bad_run_id` shape. - Called from the same four Engine-construction sites as - `_reject_isolation_conflict`, with the same split of dispositions: an rc to a + Called from the four Engine-construction sites, with the same split of + dispositions as `_reject_isolation_conflict`: an rc to a human from `cmd_run`, `cmd_sweep` and `_resume_paused_run`, and a raise from the auto-triggered child sweep in `_sweep_factory`, which has no rc channel. @@ -3140,18 +3147,43 @@ def cmd_resolve(args: argparse.Namespace) -> int: # gesture. resolution_recorded = False if args.interactive: - adapters = _make_adapters(project, run_dir, pol) - model = pol.adapter.resolved("dev").model # The interactive session uses the CURRENT CLI project as cwd. Its code root # must come from the CURRENT config too: both can have moved since state.json # was written. This is best-effort observation only; the mandatory config # re-read after the human conversation remains the authority for re-arm. + # + # Read BEFORE `_make_adapters` so the refusal below can precede it. Ordering + # only, no new failure mode: `load_paths` is a read, and the arm that cannot + # read degrades exactly as it did when it sat lower. try: pre_session_paths = bmadconfig.load_paths(project) except (bmadconfig.BmadConfigError, OSError): pre_session_code_root = state.code_root else: pre_session_code_root = pre_session_paths.repo_root + # Refuse the unsupported config BEFORE the interactive session, not only + # after it. Both inputs are already in hand here — `pol` was loaded at the + # top of this function and is being read for `isolation` two calls below — + # so the late refusal alone let an operator build adapters, converse with a + # full agent session and answer the re-arm prompt, only to be handed rc 1 + # for a configuration knowable before any of it. `cmd_run` and `cmd_sweep` + # refuse the same config before provisioning anything; this restores the + # parity, and honours the rule the restore latch states one screen down + # ("validate before the interactive resolve session, not after a whole + # agent conversation the abort would throw away"). Ahead of the adapter + # build for the same reason `cmd_run` puts it ahead of the queue and + # worktree-clean gates: this one says the configuration cannot run at all, + # so an adapter fault reported first would send the operator at the wrong + # problem — and would be refused again anyway. + # + # It does NOT replace the refusal after the confirm: that one re-reads the + # config, which is the authority for the re-arm and is the only check the + # `--no-interactive` path reaches. This is a strictly earlier exit on the + # same predicate, so an operator who declines still gets no config lecture. + if (rc := _reject_isolation_conflict(pre_session_paths, pol)) is not None: + return rc + adapters = _make_adapters(project, run_dir, pol) + model = pol.adapter.resolved("dev").model _ctx_path, withheld = resolve.build_context( state, run_dir, diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index fcc6125d..0bd1233c 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3933,15 +3933,28 @@ def restamp_code_root(run_dir: Path, repo_root: Path) -> str | None: moved = bool(state.repo_root) state.repo_root = new save_state(run_dir, state) - if not moved: - return None - return ( - f"run {run_dir.name}: the code root in _bmad/bmm/config.yaml has changed since " - "this run started — the re-drive works in the tree configured now, while the " - "baselines, preserve refs and branches this run already recorded name objects " - "in the previous one. Restore the previous `repo_root:` value if you did not " - "intend the move." - ) + if not moved: + return None + # Journalled OUTSIDE the lock, and under resume's own field name. This re-stamp + # aligns the mirror that `cli._resume_paused_run` later compares against config, + # so by the time `run-resume` computes `code_root_changed` the two necessarily + # agree and it records `false` — on the one gesture where the root DID move. The + # ephemeral stderr/toast the caller prints from the return value is not a record; + # without this line the move leaves no durable trace on the re-arm surfaces while + # plain `resume` still writes one. `repo` is dropped by the diagnose registry + # (`diagnostics._JOURNAL_DROP_FIELDS`), so the path never reaches a dump. + Journal(run_dir).append( + "rearm-code-root-restamped", + repo=new, + code_root_changed=True, + ) + return ( + f"run {run_dir.name}: the code root in _bmad/bmm/config.yaml has changed since " + "this run started — the re-drive works in the tree configured now, while the " + "baselines, preserve refs and branches this run already recorded name objects " + "in the previous one. Restore the previous `repo_root:` value if you did not " + "intend the move." + ) @dataclass(frozen=True) @@ -4447,12 +4460,21 @@ def _rearm_escalation_locked( # ("add a top-level `status:`") for a re-arm that COMPLETED sends # the human to repair a file nothing will read. refused = spec_path.is_file() and write_reaches_the_redrive + # `refused` is False for TWO disjoint reasons and the operator + # surfaces cannot re-derive which: the write would have reached + # the re-drive but the file is gone, or the file is there but the + # re-drive discards that copy. Carrying the second half of the + # conjunction is what lets the renderer stop asserting worktree + # behaviour on a run that has no worktree. Absent on records + # written before this field existed, where the renderer keeps its + # previous wording. journal.append( "rearm-spec-flip-skipped", story_key=key, spec_file=str(spec_path), status=target_status, refused=refused, + reaches_redrive=write_reaches_the_redrive, ) # ...and then ABORT — but only for a spec that IS a readable file # here AND is the copy the re-drive reads. The first half is the same @@ -4876,9 +4898,17 @@ def rearm_event_notice( kind = entry.get("kind", "") if kind == "stale-restore-excluded": files = ", ".join(str(f) for f in _journal_sequence(entry.get("files"))) + # "this re-arm computed" rather than a bare completed past tense, because + # `_stale_restore_residue` journals BEFORE the advance and the re-stamp, and + # `save_state` runs once at the very end. Both surfaces echo this from an abort + # path on purpose (the residue matters most there), and after an abort nothing + # was persisted: the task is still ESCALATED, `restore_patch` is still latched + # and `baseline_untracked` is unchanged. The sibling `stale-restore-commits` + # needs no such hedge — it reports where commits SIT, which stays true. return ( "note", - f"excluded the abandoned restore's new files from the re-drive baseline: {files}", + "excluded the abandoned restore's new files from the re-drive baseline this " + f"re-arm computed: {files}", "", ) if kind == "stale-restore-unparseable": @@ -5018,11 +5048,28 @@ def rearm_event_notice( "on the status it reads", "Add a top-level `status:` to the spec, then re-run resolve", ) + if entry.get("reaches_redrive"): + # The write DID address the copy the re-drive reads; the flip skipped + # because that path is not a readable file from this process — a spec moved + # or renamed by the resolve session, or an absolute path this `--project` + # invocation cannot see. Nothing is mounted and nothing is discarded, so the + # worktree wording below would tell the operator the failed flip is harmless + # at precisely the moment it is not. + return ( + "warning", + f"the recorded spec for this story ({spec}) could not be re-opened to " + f"`{status}` — it is not a readable file from here, and this run mounts " + "no worktree, so the re-drive reads that same path and will see the " + "escalated attempt's status", + "Check the recorded spec path before resuming", + ) # No next_step, and deliberately: on this leg there is nothing to do to THIS # file. Whether anything is left to do at all is decided by the committed spec, # and `rearm-spec-write-unreachable` — journalled from the same block, on # exactly the legs where the committed spec is not already at the target — - # carries that imperative, and holds the resume behind it. + # carries that imperative, and holds the resume behind it. Reached for a + # worktree-local copy the re-drive discards, and for a pre-`reaches_redrive` + # record, which keeps the wording it was written under. return ( "warning", f"the recorded spec for this story ({spec}) could not be re-opened to " diff --git a/tests/test_cli.py b/tests/test_cli.py index 10ea7ea9..f4c0a6de 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2824,6 +2824,57 @@ def test_resolve_refuses_worktree_isolation_before_it_mutates_anything( assert state.tasks["s1"].phase == Phase.ESCALATED # still armed for a corrected config +def test_resolve_refuses_worktree_isolation_before_the_interactive_session( + project, monkeypatch, capsys +): + """The sibling above passes `--no-interactive`, so it pins the refusal only against + the WRITES. Nothing pinned it against the agent conversation, and that is the half an + operator pays for: `cmd_run` and `cmd_sweep` refuse this config before doing any + work, while `resolve` built adapters, ran a full interactive session, and handed back + rc 1 for a pair knowable before any of it — throwing the conversation away. + + Graded on `run_session` never being reached, not on the exit code: the post-confirm + refusal returns the same 1 from the same predicate, so an rc assertion alone passes + with the hoist deleted. `_make_adapters` is failed rather than stubbed for the same + reason — it is the first thing the interactive arm does, so it fails EARLIER than + `run_session` if the refusal is merely moved down a few lines rather than removed. + + The post-confirm refusal stays the authority and is deliberately not disturbed: it + re-reads the config after a conversation of unbounded length, and it is the only one + the `--no-interactive` path reaches. + + Ablation: delete the `_reject_isolation_conflict` call from the `else:` branch of + `pre_session_paths` and this reddens on `_make_adapters` while the sibling row above + stays green. + """ + from bmad_loop import resolve, runs + from bmad_loop.journal import load_state + from bmad_loop.model import Phase + + run_dir, _moved, recorded = _resolve_run_with_a_moved_code_root(project, monkeypatch) + _write_policy(project.project, ISOLATION_WORKTREE_POLICY) + monkeypatch.setattr( + cli, "_make_adapters", lambda *a, **k: pytest.fail("built adapters for a refused config") + ) + monkeypatch.setattr( + resolve, "run_session", lambda *a, **k: pytest.fail("conversed under a refused config") + ) + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda *a, **k: pytest.fail("re-armed under a configuration the run refuses"), + ) + + # no --no-interactive: this is the path the sibling row cannot reach + argv = ["resolve", "--project", str(project.project), "r1", "--resume"] + assert cli.main(argv) == 1 + + assert REFUSAL in capsys.readouterr().err + state = load_state(run_dir) + assert state.repo_root == str(recorded) # nothing was written on the way out + assert state.tasks["s1"].phase == Phase.ESCALATED + + def test_resolve_degrades_when_the_config_cannot_name_the_code_root(tmp_path, monkeypatch, capsys): """Reading config.yaml to learn the tree is an OBSERVATION, so it degrades: without it this process cannot name the code root, and re-pointing the mirror at a guess is @@ -2900,7 +2951,8 @@ def fake_rearm( err = capsys.readouterr().err ordered_messages = ( - "excluded the abandoned restore's new files from the re-drive baseline: new.txt", + "excluded the abandoned restore's new files from the re-drive baseline this " + "re-arm computed: new.txt", "could not read the abandoned restore patch (b.patch)", "1 commit(s) sit below the re-drive's new baseline (ffffffffffff..)", ) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 701c4e0b..f91ab3bd 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -85,6 +85,7 @@ def _seed_run( extra_journal=None, sweeps_triggered=(), sweeps_refused=None, + repo_root="", ): """Build a run dir loaded with canaries in every readable sink. @@ -129,6 +130,7 @@ def _seed_run( state = RunState( run_id=run_id, project=f"{HOME_PATH}", + repo_root=repo_root, started_at="2026-06-27T12:00:00", run_type="story", target_branch=BRANCH, @@ -2387,8 +2389,20 @@ def test_diag_repo_root_diverges_is_false_for_the_ordinary_layout(project): Without this the assertion above passes for a hardcoded `True`, and the field stops carrying the one bit it exists to carry. + + Seeds `repo_root` EQUAL to `project`, which is what makes this the ordinary + layout rather than the legacy one. `repo_root_diverges` is + `bool(state.repo_root) and Path(state.repo_root) != Path(state.project)`, so a + run with no recorded root short-circuits on the first term and the equality arm + is never evaluated — the row would be named for a layout it does not build. + `runsetup` writes the field unconditionally on every run, equal to `project` + unless a `repo_root:` override exists, so this is the modal shape an operator's + dump carries. + + Ablation: drop the `repo_root=` argument and the row still passes, on the legacy + guard instead of the comparison. """ - run_dir = _seed_run(project.project) + run_dir = _seed_run(project.project, repo_root=f"{HOME_PATH}") diag, _pseudo, _combined = _render_all([run_dir]) (run,) = diag.runs @@ -2461,8 +2475,13 @@ def test_the_markdown_report_carries_the_split_root_and_the_generation(project): def test_the_markdown_report_says_no_for_the_ordinary_layout(project): - """The rendered line distinguishes; a hardcoded "yes" would pass the test above.""" - run_dir = _seed_run(project.project) + """The rendered line distinguishes; a hardcoded "yes" would pass the test above. + + Seeds `repo_root` equal to `project` for the same reason as its JSON twin: an + unset root answers `no` through the legacy guard without ever reaching the + comparison. + """ + run_dir = _seed_run(project.project, repo_root=f"{HOME_PATH}") pseudo = sanitize.Pseudonymizer() diag = diagnostics.collect([run_dir], pseudo=pseudo, project=ANY_PROJECT) md = diagnostics.render_markdown(diag, pseudo=pseudo) diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index eb49ae63..23e1e044 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -409,6 +409,7 @@ # turned out to be wrong. "rc", "re_review_capped", + "reaches_redrive", "rearmed", "record", "redrive", diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 9ed23bb2..9b26893f 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -4610,7 +4610,14 @@ def test_rearm_writes_the_project_rooted_spec_when_no_worktree_was_recorded(tmp_ @pytest.mark.parametrize( ("field", "value"), - [("files", 3), ("files", None), ("files", [1, 2]), ("commits", 3), ("commits", None)], + [ + ("files", 3), + ("files", None), + ("files", [1, 2]), + ("files", "new.txt"), + ("commits", 3), + ("commits", None), + ], ) def test_rearm_event_notice_survives_a_journal_shape_json_admits(field, value): """A malformed journal line must not raise out of either surface's `finally`. @@ -4639,6 +4646,32 @@ def test_rearm_event_notice_survives_a_journal_shape_json_admits(field, value): assert isinstance(message, str) +def test_rearm_event_notice_does_not_spell_a_bare_string_field_letter_by_letter(): + """The one shape `_journal_sequence`'s guard exists for, and the one its sibling + parametrization cannot grade. + + That row asserts only that no exception escapes, which a widened guard satisfies + too. `_journal_sequence`'s docstring gives the actual reason it refuses to iterate + a `str`: `", ".join("abc")` renders `"a, b, c"`, so a bare string would reach the + operator spelled out one character at a time. Nothing pinned that until here. + + Scoped honestly: no first-party producer can emit this. Both writers of these + fields pass lists (`verify.patch_new_files`, `verify.commits_above`), so the guard + is defensive against a hand-edited or third-party journal line — the same threat + model the sibling row's docstring invokes, `Journal.entries()` doing `json.loads` + with no shape filter. + + Ablation: widen the guard to `isinstance(value, (list, tuple, str))` and this + reddens on `n, e, w`; every row of the sibling parametrization stays green. + """ + notice = runs.rearm_event_notice({"kind": "stale-restore-excluded", "files": "new.txt"}) + + assert notice is not None + _severity, message, _next_step = notice + assert "new.txt" in message + assert "n, e, w" not in message + + def test_rearm_event_notice_splits_the_flip_skip_on_the_refusal(): """One kind, two outcomes — and the operator-facing halves must not be swapped. @@ -4671,6 +4704,46 @@ def test_rearm_event_notice_splits_the_flip_skip_on_the_refusal(): assert step == "" +def test_rearm_event_notice_does_not_promise_a_worktree_to_a_run_without_one(): + """`refused=False` is reached for TWO disjoint reasons, and the record's own + discriminator is what tells them apart out of process. + + `refused = spec_path.is_file() and write_reaches_the_redrive`. The sibling row above + feeds only the SECOND failure — a worktree-local copy the re-drive discards — and + pins "COMMITTED spec" as correct for it. On the first failure nothing is mounted and + nothing is discarded: the re-drive reads that same path, so telling the operator the + failed flip is harmless is wrong at exactly the moment it is not. The producer's own + row `test_rearm_journals_a_skip_when_the_recorded_spec_is_not_readable` builds that + state, with an empty `worktree_path` and a missing spec. + + A record written before `reaches_redrive` existed keeps the wording it was written + under — asserted, because the alternative is a reader silently re-classifying old + journals it cannot re-derive the answer for. + + Ablation: delete the `if entry.get("reaches_redrive")` branch and the first leg + reddens on the worktree sentence; return the new branch unconditionally and the + absent-field leg reddens instead. + """ + entry = { + "kind": "rearm-spec-flip-skipped", + "spec_file": "specs/s1.md", + "status": "ready-for-dev", + "refused": False, + } + + _, unreadable, unreadable_step = runs.rearm_event_notice({**entry, "reaches_redrive": True}) + assert "COMMITTED spec" not in unreadable + assert "mounts no worktree" in unreadable + assert unreadable_step # this leg HAS a remedy: the recorded path is wrong + + _, discarded, _ = runs.rearm_event_notice({**entry, "reaches_redrive": False}) + assert "COMMITTED spec" in discarded + + # a pre-`reaches_redrive` record is not re-classified + _, legacy, _ = runs.rearm_event_notice(entry) + assert "COMMITTED spec" in legacy + + def test_rearm_event_notice_splits_the_abort_three_ways_on_the_rollback(): """One kind, THREE renderings, and the split is by what the surface may CLAIM about the file — not by how the re-arm failed. diff --git a/tests/test_runs.py b/tests/test_runs.py index edbba947..2f7a9608 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2976,11 +2976,20 @@ def test_restamp_code_root_aims_the_mirror_the_rearm_reads(tmp_path, recorded): MISSING value, not a divergent one: it migrates silently, and calling it a move would fire the warning once on every pre-upgrade run. + The journal line is graded on the same three rows, because it is the DURABLE half + and the return value is not: the caller prints that string to stderr or a TUI toast + and it is gone. Worse, this re-stamp is what makes `cli._resume_paused_run`'s own + `code_root_changed` record read `false` later in the same gesture — both sides read + `bmadconfig.load_paths` on one project, so once the mirror is aimed the compare + NECESSARILY agrees. Without this line the one gesture where the root actually moved + is the one that leaves no trace, while plain `resume` still writes one. + Ablation: drop the `if not moved: return None` arm and `legacy` reddens on the message; return the message without the `save_state` and `moved` reddens on the - persisted root while the other two rows still pass. + persisted root while the other two rows still pass; delete the `journal.append` and + `moved` reddens on the record alone, with every message assertion still green. """ - from bmad_loop.journal import STATE_FILE + from bmad_loop.journal import STATE_FILE, Journal run = escalated_run(tmp_path, "r1", story_key="s1") now = tmp_path / "code" @@ -3009,6 +3018,17 @@ def test_restamp_code_root_aims_the_mirror_the_rearm_reads(tmp_path, recorded): else: assert message is None + # ...and the move is RECORDED, on exactly the row that moved + records = [ + e for e in Journal(run.run_dir).entries() if e["kind"] == "rearm-code-root-restamped" + ] + assert len(records) == (1 if recorded == "moved" else 0) + if records: + # `code_root_changed` is resume's own field name, so a reader correlates the two + # surfaces without knowing which one wrote the line + assert records[0]["code_root_changed"] is True + assert records[0]["repo"] == str(now) + def test_restamp_code_root_reloads_after_a_rival_writer(tmp_path, monkeypatch): """Ablation: move restamp_code_root's load above state_lock and the rival's From 8100c535d41be24551a7ebbded5d822030ceba41 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 08:34:24 -0700 Subject: [PATCH 44/45] test(guard): add journal-kind and refusal-site coverage gates Enumerate-vs-declare inventories for the two surfaces review iteration 6 kept re-finding by hand: 200 literal journal kinds (JOURNAL_KINDS, fed by a literal-kind emit that also sees kind-only and constructor-inline writes), nine _refuse_*/_reject_* helper defs (REFUSAL_HELPER_DEFS, Counter multiplicity), and the eleven #414-family isolation-refusal call sites (ISOLATION_CONFLICT_CALLERS, with multiplicity). Exact in both directions; every new detector arm carries must-flag and must-stay-silent probe rows. --- CHANGELOG.md | 11 + docs/testing.md | 25 +- tests/test_portability_guard.py | 756 +++++++++++++++++++++++++++++++- 3 files changed, 763 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8137e9..90df5b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ breaking changes may land in a minor release. ### Added +- **Journal-kind and refusal-site coverage gates.** `tests/test_portability_guard.py` gains + three enumerate-vs-declare inventories: the 200 literal journal kinds (`JOURNAL_KINDS`, + fed by a literal-kind emit that also sees kind-only and constructor-inline + `Journal(run_dir).append(...)` writes — a receiver spelling the journal scan was blind + to), the `_refuse_*`/`_reject_*` helper definitions counted with multiplicity + (`REFUSAL_HELPER_DEFS`), and the eleven #414-family isolation-refusal call sites counted + with multiplicity (`ISOLATION_CONFLICT_CALLERS`). A new kind, refusal helper, or refusal + call site reddens CI until its row lands; the row is the PR-time decision, and the + failure message demands the covering test land beside it. Each new detector arm carries + must-flag and must-stay-silent probe rows. + - **Interactive resolve context names both the BMAD project root and the run's code root.** `bmad-loop resolve` warns before a divergent-root session launches, while keeping the session project-rooted and directing code fixes and commits to the code diff --git a/docs/testing.md b/docs/testing.md index 511828ed..0ae02a80 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -158,18 +158,19 @@ test. A slice of the suite tests the **repo** rather than the product. The inventory: -| Guard | Where | Enforces | -| --------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Portability guard | `tests/test_portability_guard.py` | One shared AST scan over every `src/bmad_loop/**/*.py` (data scripts included), carrying thirteen guards: literal `["tmux", ...]` argvs only in the two backend files (the backends' own `[self._BINARY, ...]` spelling is deliberately unmatched, so this tripwire currently flags nothing — #549); sequence-form git argvs (list or tuple, literal or named constant) only as `_run_git`'s argv argument in `verify.py`, with string-form git spawns refused everywhere, `verify.py` included; no bare `/tmp`-class POSIX paths; no `signal.SIGKILL` attribute; `os.kill(pid, 0)` probes only in `process_host.py`; any `os.kill` at all only there too (a second, distinct guard); `start_new_session` only in the detach helpers; `shell=True` only in its two sanctioned files; `BMAD_LOOP_*` env reads only through the `envvars.py` registry, a plugin's own variable family, or the session-protocol vars the two stand-alone hook relays read back; a persisted `spec_file` / `dispatched_spec_file` resolved with a bare `Path(...)` only in the four files that run inside the tree the value was recorded against; `verify_commands_outcome` called only from `verify._verify_review_commands`; its classifier half `verify_command_results_outcome` called only from `verify.verify_commands_outcome` or `Engine._verify_commands_with_results` (a separate guard, because fencing the wrapper alone still lets a gate compose run+classify by hand and pick its own root — #695); plus a scanned-file-count floor so a broken scan root cannot pass vacuously | -| Settings-schema sync | `tests/test_settings_schema.py` | `src/bmad_loop/data/settings/core.toml` stays in lockstep with `policy.py` by reflection, in both directions: every spec maps to a live dataclass field with a matching default wherever one is baked in, every policy field is reachable from exactly one spec (or listed in the explicit `HIDDEN` set), and every `*Policy` dataclass is consciously classified | -| Exit-code allocation | `tests/test_entry_point.py` | `ExitCode` is pinned literally (OK=0, FAILURE=1, USAGE=2, INTERRUPTED=130) **and closed**: the enum's value set equals exactly those four, so codes 3–129/131+ cannot be allocated quietly | -| Extra-less core CLI | `tests/test_entry_point.py` | A fresh interpreter with `pyte`/`rich`/`textual`/`tomlkit` blocked at `find_spec` — the blocker **raises** rather than returning None, so the dev venv's installed copies cannot make it pass vacuously, and an `import pyte` floor proves it bites — imports `bmad_loop.cli` and `bmad_loop.settings_schema`, runs `list` to rc 0, and asserts `tui` degrades to the `bmad-loop[tui]` hint instead of a traceback. Every test job installs `--all-extras`, which is why #650 shipped broken for 23 releases; CI's isolated wheel `list` run is the same floor at install level | -| State-machine table | `tests/test_statemachine.py` | Every `Phase` has a transition row; `TERMINAL_PHASES` (model.py) equals the table's dead ends — a cross-module parity nothing else links; an N×N `parametrize` grid drives every pair (legal pairs land, illegal pairs raise and leave the phase untouched); the awaiting-operator reachability rule is additionally stated independently, because the N² grid reads its expectation out of the table under test | -| Check-id registry | `checks.py` + `tests/test_cli.py` | `ValidationReport.add` asserts its id is in `VALIDATE_CHECKS` at every **executed** call site, and an end-to-end test unions the ids a real passing **and** failing `validate --json` emit and asserts them registered. Both mechanisms are exercised-path enforcement — there is no static call-site scan, so an id on a branch neither reaches can still ship unregistered and raises `AssertionError` only when that branch first executes; a new check site therefore lands together with a test that reaches it | -| Skill-drift guard | `tests/test_module_skills_sync.py` | The seeded forks in `.claude/skills/` and `.agents/skills/` are byte-identical to canonical `src/bmad_loop/data/skills/`. **Documented limitation: CI-inert** — both trees are gitignored and absent in CI, so every parametrization skips there; the guard bites on dev boxes only. (The canonical-existence assertion runs before the skip and is CI-live.) | -| Schema-version parity | `tests/test_tui_app.py` | The TUI renderer's pinned validate schema version equals `documents.VALIDATE_SCHEMA_VERSION` — deliberate duplication, because an import would auto-follow a CLI bump and silently render a v2 document as v1 | -| Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | The hook relays' copies match their source: `test_hook_script.py` re-runs `install_into` and text-compares the project copy against the source; `test_probe_hook.py` compares the packaged resource — which only bites in a wheel-installed run, since an editable install resolves both sides to the same file | -| Version sync | `tests/test_release.py` + CI | `scripts/release.py check` runs as the `version-sync` job — `sync_version.check()` in-process, plus the CHANGELOG release contract (the canonical version's section exists; `## [Unreleased]` was reopened; its `compare/v...HEAD` link tracks the bump). `tests/test_release.py` covers the release helpers' pure logic **and** drives `cmd_check`/`cmd_prepare` over fixture changelogs; the version-field comparison itself is still CI-only | +| Guard | Where | Enforces | +| -------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Portability guard | `tests/test_portability_guard.py` | One shared AST scan over every `src/bmad_loop/**/*.py` (data scripts included), carrying thirteen guards: literal `["tmux", ...]` argvs only in the two backend files (the backends' own `[self._BINARY, ...]` spelling is deliberately unmatched, so this tripwire currently flags nothing — #549); sequence-form git argvs (list or tuple, literal or named constant) only as `_run_git`'s argv argument in `verify.py`, with string-form git spawns refused everywhere, `verify.py` included; no bare `/tmp`-class POSIX paths; no `signal.SIGKILL` attribute; `os.kill(pid, 0)` probes only in `process_host.py`; any `os.kill` at all only there too (a second, distinct guard); `start_new_session` only in the detach helpers; `shell=True` only in its two sanctioned files; `BMAD_LOOP_*` env reads only through the `envvars.py` registry, a plugin's own variable family, or the session-protocol vars the two stand-alone hook relays read back; a persisted `spec_file` / `dispatched_spec_file` resolved with a bare `Path(...)` only in the four files that run inside the tree the value was recorded against; `verify_commands_outcome` called only from `verify._verify_review_commands`; its classifier half `verify_command_results_outcome` called only from `verify.verify_commands_outcome` or `Engine._verify_commands_with_results` (a separate guard, because fencing the wrapper alone still lets a gate compose run+classify by hand and pick its own root — #695); plus a scanned-file-count floor so a broken scan root cannot pass vacuously | +| Kind & refusal inventories | `tests/test_portability_guard.py` | Enumerate-vs-declare gates riding the same AST scan, so a new entry on an enumerable surface is a PR-time decision instead of review-pass archaeology: every literal journal kind is a declared `JOURNAL_KINDS` row (kind-only writes included, via a dedicated literal-kind emit; dynamic kinds stay governed by the literalness test); every `_refuse_*`/`_reject_*` helper definition is a `REFUSAL_HELPER_DEFS` row; and the #414-family isolation-refusal call sites (`bmadconfig.worktree_isolation_conflict` plus its CLI wrapper `_reject_isolation_conflict`) match `ISOLATION_CONFLICT_CALLERS` with multiplicity, so `cmd_resolve`'s legitimate second call cannot absorb a third. All assertions are exact-set or exact-Counter in both directions — a removed or renamed entry reddens its stale row too — and each new detector emit carries must-flag and must-stay-silent probe rows through `_scan_source` | +| Settings-schema sync | `tests/test_settings_schema.py` | `src/bmad_loop/data/settings/core.toml` stays in lockstep with `policy.py` by reflection, in both directions: every spec maps to a live dataclass field with a matching default wherever one is baked in, every policy field is reachable from exactly one spec (or listed in the explicit `HIDDEN` set), and every `*Policy` dataclass is consciously classified | +| Exit-code allocation | `tests/test_entry_point.py` | `ExitCode` is pinned literally (OK=0, FAILURE=1, USAGE=2, INTERRUPTED=130) **and closed**: the enum's value set equals exactly those four, so codes 3–129/131+ cannot be allocated quietly | +| Extra-less core CLI | `tests/test_entry_point.py` | A fresh interpreter with `pyte`/`rich`/`textual`/`tomlkit` blocked at `find_spec` — the blocker **raises** rather than returning None, so the dev venv's installed copies cannot make it pass vacuously, and an `import pyte` floor proves it bites — imports `bmad_loop.cli` and `bmad_loop.settings_schema`, runs `list` to rc 0, and asserts `tui` degrades to the `bmad-loop[tui]` hint instead of a traceback. Every test job installs `--all-extras`, which is why #650 shipped broken for 23 releases; CI's isolated wheel `list` run is the same floor at install level | +| State-machine table | `tests/test_statemachine.py` | Every `Phase` has a transition row; `TERMINAL_PHASES` (model.py) equals the table's dead ends — a cross-module parity nothing else links; an N×N `parametrize` grid drives every pair (legal pairs land, illegal pairs raise and leave the phase untouched); the awaiting-operator reachability rule is additionally stated independently, because the N² grid reads its expectation out of the table under test | +| Check-id registry | `checks.py` + `tests/test_cli.py` | `ValidationReport.add` asserts its id is in `VALIDATE_CHECKS` at every **executed** call site, and an end-to-end test unions the ids a real passing **and** failing `validate --json` emit and asserts them registered. Both mechanisms are exercised-path enforcement — there is no static call-site scan, so an id on a branch neither reaches can still ship unregistered and raises `AssertionError` only when that branch first executes; a new check site therefore lands together with a test that reaches it | +| Skill-drift guard | `tests/test_module_skills_sync.py` | The seeded forks in `.claude/skills/` and `.agents/skills/` are byte-identical to canonical `src/bmad_loop/data/skills/`. **Documented limitation: CI-inert** — both trees are gitignored and absent in CI, so every parametrization skips there; the guard bites on dev boxes only. (The canonical-existence assertion runs before the skip and is CI-live.) | +| Schema-version parity | `tests/test_tui_app.py` | The TUI renderer's pinned validate schema version equals `documents.VALIDATE_SCHEMA_VERSION` — deliberate duplication, because an import would auto-follow a CLI bump and silently render a v2 document as v1 | +| Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | The hook relays' copies match their source: `test_hook_script.py` re-runs `install_into` and text-compares the project copy against the source; `test_probe_hook.py` compares the packaged resource — which only bites in a wheel-installed run, since an editable install resolves both sides to the same file | +| Version sync | `tests/test_release.py` + CI | `scripts/release.py check` runs as the `version-sync` job — `sync_version.check()` in-process, plus the CHANGELOG release contract (the canonical version's section exists; `## [Unreleased]` was reopened; its `compare/v...HEAD` link tracks the bump). `tests/test_release.py` covers the release helpers' pure logic **and** drives `cmd_check`/`cmd_prepare` over fixture changelogs; the version-field comparison itself is still CI-only | Rules for adding or touching a guard: diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 23e1e044..3e695d20 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -14,8 +14,8 @@ calls go through the ``_run_git`` chokepoint in ``verify.py``" — see ``test_no_git_invocation_outside_verify``. -Three later invariants ride the same machinery, each one previously held by -docstring prose alone: +Later invariants ride the same machinery, each one previously held by docstring +prose alone (or by nothing): * the task-directory artifact names are ``journal.TASK_CYCLE_ARTIFACTS`` and not a literal repeated per reader/writer — ``test_task_cycle_artifacts_named_only_through_the_constant`` @@ -29,6 +29,12 @@ the two names ``Journal.append`` mints itself, which no call site spells. * ``runs.rearm_escalation`` is called from exactly two places, each of which consults liveness first — ``test_rearm_escalation_called_only_behind_a_liveness_gate``. +* every literal journal KIND is a declared ``JOURNAL_KINDS`` row — + ``test_journal_kind_inventory_is_complete``. +* every ``_refuse_*``/``_reject_*`` helper definition and every #414-family + isolation-refusal call site is enumerated — + ``test_refusal_helper_inventory_is_complete`` and + ``test_isolation_conflict_refusal_sites_are_enumerated``. If this test flags something unexpected, fix the source (route it through the seam / a platform helper) rather than widening an allowlist. @@ -223,6 +229,51 @@ ("tui/app.py", "_do_rearm"), } +# The two refusal surfaces review iteration 6 kept re-finding by hand, enumerated so +# a NEW one reddens CI until its row lands — the row being the PR-time decision +# whose failure message demands the covering test land beside it (the journal-kind +# inventory below is the third surface of that shape). +# +# Every `_refuse_*` / `_reject_*` helper DEFINITION in the tree, as +# `(file, def name)`. The prefix pair is the tree's whole refusal-helper naming +# convention today; a helper named outside it is invisible here — a stated bound, +# not coverage. The guard forces the decision only on names that claim to be +# refusals, and deliberately adds no runtime abstraction (no RefusalError, no +# registry): the inventory is the test file's, not the product's. +REFUSAL_HELPER_DEFS = { + ("cli.py", "_reject_bad_run_id"), + ("cli.py", "_reject_isolation_conflict"), + ("cli.py", "_reject_under_floor_git"), + ("engine.py", "_refuse_gated_story"), + ("platform_util.py", "_refuse_unwritable_target"), + ("platform_util.py", "_refuse_unwritable_target_at"), + ("resolve.py", "_reject_json_constant"), + ("runs.py", "_refuse_live_session"), + ("runs.py", "_refuse_uncontained_run_dir"), +} + +# Every #414-family call site — `bmadconfig.worktree_isolation_conflict`, sole +# producer of the isolation-under-repo-root refusal text, plus its rc-returning CLI +# wrapper `_reject_isolation_conflict` — as `(file, enclosing function) -> count`. +# The `REARM_ESCALATION_CALLERS` idiom WITH multiplicity, because `cmd_resolve` +# legitimately calls the wrapper twice: post-confirm is the authority, and the +# pre-session arm spares the operator a full interactive session on a pair knowable +# from config — the `96aa09a9` fix, which landed with no structural gate naming it. +# Accepted cost (human decision 2026-09-02): every future caller of the predicate +# touches a row here in the same PR. +ISOLATION_CONFLICT_CALLERS = { + ("cli.py", "_reject_isolation_conflict"): 1, # the wrapper's own predicate call + ("cli.py", "cmd_run"): 1, + ("cli.py", "cmd_sweep"): 1, + ("cli.py", "cmd_resolve"): 2, # pre-session + post-confirm re-read + ("cli.py", "cmd_validate"): 1, # reports a Finding rather than aborting + ("cli.py", "_prepare_resume_locked"): 1, # behind both `resume` and the re-arm + ("cli.py", "_warn_preflight_would_abort"): 1, # the dry-run honesty banner + ("cli.py", "factory"): 1, # `_sweep_factory`'s closure: raises — no rc channel + ("tui/app.py", "_guarded"): 1, # the pre-launch toast guard + ("tui/app.py", "_do_rearm"): 1, +} + # What counts as consulting liveness, matched as a substring of the callee's name # because the two sites legitimately spell it differently and neither spelling is more # correct: the CLI calls ``runs.engine_liveness`` directly, the TUI goes through @@ -354,6 +405,7 @@ "errors", "expired_clock", "failed", + "fallback", "field", "finished", "fired_at", @@ -396,6 +448,7 @@ "original", "owed_after_implement", "phase", + "pid", "platform", "plugin", "plugins", @@ -610,16 +663,262 @@ ("plugins/bus.py", "_log"), } -# The receivers a ``.append(...)`` call must hang off to be a journal write. Matched -# on the trailing name so `self.journal`, a bare `journal` parameter and -# `self._journal` (the plugin bus's optional handle) all resolve — the three -# spellings in the tree. +# Every literal journal KIND written today: a declared inventory, not a per-kind +# audit — `JOURNAL_BENIGN_FIELDS`' claim, made for the kind axis. Kind #201 cannot +# appear without someone deciding, in the same PR, what covers the record it +# introduces: a routing row in `diagnostics` if any field carries an identifier, a +# path or free text, and a test row asserting the record at the layer that reads it +# — the decision review iteration 6 kept discovering had been skipped. +# +# Generated from the scan, hand-reviewed, grouped by producer module; a kind two +# modules write sits under a shared heading. A deleted or renamed kind reddens the +# staleness direction too — PROVIDED no other producer still writes it: the +# staleness arm sees the union of producers, so removing ONE writer of a shared +# kind (the shared headings below, `run-stop` included) reddens nothing by itself. +# +# ⚠️ STATED BOUNDS. Dynamic kinds — the f-string family in +# `recovery_flow.prune_preserve_refs`, the parameter defaults in +# `engine._skip_review_and_commit`, the chosen kinds of +# `sweep._close_bundle_ledger_when_spec_status` — are NOT rows here: their +# POSITIONS are declared in `JOURNAL_DYNAMIC_KIND_ALLOW` and governed by the +# literalness test, so the kinds they mint (e.g. `attempt-preserve-pruned`, +# `review-skipped`) never enter this inventory. And a locally aliased journal +# handle — a named one, or one bound from `Journal(run_dir)` — is invisible to the +# whole journal scan (`JOURNAL_RECEIVERS`' bound), this emit included. +JOURNAL_KINDS = frozenset( + { + # cli.py + "run-resume", + # engine.py + "board-advance-carried", + "board-advance-carry-failed", + "board-advance-carry-foreign-dirt", + "board-advance-carry-uncommitted", + "console-ctrl-ignored", + "defer-ledger-restore-diverged", + "deferred-artifacts-stashed", + "deferred-close-duplicate-id", + "deferred-close-external-ledger", + "deferred-close-ledger-unavailable", + "deferred-close-malformed", + "deferred-close-reopen-unmatched", + "deferred-close-rollback-failed", + "deferred-close-rolled-back", + "deferred-close-skipped-out-of-tree", + "deferred-close-unmatched", + "dev-decision", + "epic-boundary", + "fix-decision", + "fix-harvest-failed", + "harvest-carried", + "harvest-carry-uncommitted", + "isolation-flip-orphaned-worktree", + "ledger-baseline-probe-failed", + "ledger-restore-failed", + "ledger-restore-skipped-diverged", + "ledger-scope-probe-failed", + "ledger-snapshot-missing", + "ledger-tracked-probe-failed", + "legacy-ledger-attribution-failed", + "max-stories-reached", + "notify-desktop-unavailable", + "operator-index-failed", + "park-proof-of-work-skipped", + "park-record-rollback-failed", + "plugin-veto", + "plugins-active", + "preference-escalation", + "resume-defer", + "resume-ledger-carry", + "resume-review", + "resume-unit-merge", + "resume-verify", + "review-budget-committed", + "review-followup-damped", + "review-not-recommended", + "review-result", + "review-retry", + "review-timeout-salvage", + "review-timeout-salvage-failed", + "review-verify-failed", + "run-complete", + "run-crash", + "run-paused", + "run-stop-finalize-error", + "session-end", + "session-rescued-post-kill", + "session-start", + "session-synthesized-from-frontmatter", + "spec-deferrals-harvested", + "spec-deferrals-malformed", + "spec-deferrals-skipped-out-of-tree", + "spec-marker-repair-failed", + "spec-marker-repair-skipped", + "spec-marker-repaired", + "spec-read-failed", + "spec-reconcile-skipped-out-of-tree", + "spec-status-reconciled", + "sprint-status-unknown-keys", + "stop-request-discarded", + "story-awaiting-operator", + "story-deferred", + "story-deferred-close-carried", + "story-deferred-close-carry-uncommitted", + "story-deferred-closed", + "story-done", + "story-gate-unreadable", + "story-gated", + "story-skipped", + "story-start", + "sweep-auto-failed", + "sweep-auto-finished", + "sweep-auto-not-started", + "sweep-auto-skipped-dirty", + "sweep-auto-suppressed", + "sweep-auto-trigger", + "token-budget-exceeded", + "verify-command-result", + "workflow-end", + "workflow-start", + # engine.py + runs.py (runs.py's writer is the constructor-inline spelling) + "run-stop", + # engine.py + sweep.py + "resume-commit", + "resume-restart", + # engine.py + worktree_flow.py + "story-escalated", + # plugins/bus.py + "plugin-hook", + "plugin-hook-error", + # plugins/bus.py + plugins/registry.py + "plugin-error", + # plugins/loader.py + "plugin-skipped", + # plugins/registry.py + "plugin-loaded", + "plugin-untrusted", + # recovery_flow.py + "attempt-commits-preserved", + "attempt-preserve-enumerate-failed", + "attempt-preserve-failed", + "attempt-restore-failed", + "attempt-restored", + "attempt-worktree-preserve-failed", + "attempt-worktree-preserved", + "rollback-auto", + "rollback-dirty-check-failed", + "rollback-manual-required", + "rollback-owned-spec-baseline-read-failed", + "rollback-owned-spec-baseline-status-failed", + "rollback-owned-spec-manual-required", + "rollback-owned-spec-normalized", + "rollback-owned-spec-restored", + "rollback-owned-spec-snapshot-missing", + "rollback-owned-spec-unavailable", + "rollback-owned-spec-unpreservable", + "rollback-owned-spec-unreadable", + "rollback-reset-failed", + "rollback-skipped-clean", + # runs.py + "rearm-aborted", + "rearm-baseline-advance-failed", + "rearm-baseline-restamp-skipped", + "rearm-baseline-restamped", + "rearm-code-root-restamped", + "rearm-commits-probe-failed", + "rearm-spec-flip-skipped", + "rearm-spec-write-unreachable", + "rearm-upstream-write-unreachable", + "run-stop-undelivered", + "sentinel-cleared", + "stale-restore-commits", + "stale-restore-excluded", + "stale-restore-unparseable", + "story-escalation-resolved", + # runsetup.py + "composition-unwind-failed", + "run-start", + # stories_engine.py + "checkpoint-pause", + "checkpoint-resume", + "checkpoint-skip-last", + "deferred-close-declaration-unreadable", + "plan-halt", + "plan-halt-proof-of-work-skipped", + "sentinel-detected", + "stories-escalation-unresolved", + "stories-manifest-unreadable", + "stories-selector-unknown", + "stories-validated", + "stories-wedged", + # sweep.py + "bundle-start", + "decision-answered", + "decision-pending", + "decision-preanswered", + "decision-preanswers-pruned", + "decision-skipped-unattended", + "migrate-decision", + "migrate-duplicate-ids", + "sweep-bundle-close-carried", + "sweep-bundle-close-carry-uncommitted", + "sweep-bundle-name-discarded", + "sweep-bundle-name-normalized", + "sweep-bundle-reopened", + "sweep-bundle-skipped", + "sweep-bundles-truncated", + "sweep-cycle", + "sweep-decisions-only", + "sweep-inflight-redrive", + "sweep-inflight-stranded", + "sweep-intent-regenerated", + "sweep-ledger-commit", + "sweep-migrated", + "sweep-migration-restore-diverged", + "sweep-nothing-open", + "sweep-repeat-done", + "sweep-resolved-closed", + "sweep-return-no-client", + "sweep-returned-after-decisions", + "sweep-triage-reload-failed", + "sweep-triage-result", + "triage-decision", + # worktree_flow.py + "merge-preflight-refused", + "merge-target-cleaned", + "merge-target-tolerated", + "scm-failed-diff-unlimited", + "target-branch", + "target-branch-checkout", + "target-branch-created", + "unit-closed", + "unit-merge-started", + "unit-merged", + "worktree-exclude-degraded", + "worktree-kept", + "worktree-module-skills-dropped", + "worktree-open-failed", + "worktree-opened", + "worktree-seed-dropped", + "worktree-seed-skipped", + "worktree-teardown-degraded", + } +) + +# The NAMED-HANDLE receivers a ``.append(...)`` call must hang off to be a journal +# write. Matched on the trailing name so `self.journal`, a bare `journal` parameter +# and `self._journal` (the plugin bus's optional handle) all resolve. The tree's +# fourth spelling — the constructor-inline `Journal(run_dir).append(...)` that +# runs.py's stop/restamp records use — is a Call receiver, not a name, and is +# matched structurally in `_is_journal_write` rather than through this set. # # ⚠️ STATED BOUND: a LOCALLY ALIASED handle is invisible. `j = self.journal` followed # by `j.append(kind, customer_email=x)` produces no finding (verified by running it -# through `_scan_source`). No such site exists in the tree today, and resolving the -# binding would be `_call_aliases`' shape rather than a new idea — but the -# guard does not do it, and a reader must not assume it does. +# through `_scan_source`), and a handle bound from the constructor — +# `j = Journal(run_dir)` then `j.append(...)` — is the same shape. No such site +# exists in the tree today, and resolving the binding would be `_call_aliases`' +# shape rather than a new idea — but the guard does not do it, and a reader must +# not assume it does. JOURNAL_RECEIVERS = {"journal", "_journal"} # Files that may name a bare POSIX path, each on a line carrying a `# portability:` @@ -1111,8 +1410,9 @@ def _mint_candidates(node: ast.expr, depth: int = 0): def _is_journal_write(node: ast.AST, rel: str) -> bool: """Whether this node writes a journal entry — a ``.append(...)`` call in - each of the three receiver spellings the tree uses (see ``JOURNAL_RECEIVERS``), - or a call to one of this file's declared ``JOURNAL_FORWARDERS``. + each of the four receiver spellings the tree uses: the three named handles (see + ``JOURNAL_RECEIVERS``) and the constructor-inline ``Journal(run_dir).append(...)`` + — or a call to one of this file's declared ``JOURNAL_FORWARDERS``. The forwarder half is not a convenience. ``plugins/bus.py::_log`` takes its own ``**fields`` and hands them to ``self._journal.append``, so its four call sites @@ -1130,11 +1430,17 @@ def _is_journal_write(node: ast.AST, rel: str) -> bool: return False if (rel, name) in JOURNAL_FORWARDERS: return True - return ( - isinstance(node.func, ast.Attribute) - and name == "append" - and _called_name(node.func.value) in JOURNAL_RECEIVERS - ) + if not (isinstance(node.func, ast.Attribute) and name == "append"): + return False + receiver = node.func.value + if _called_name(receiver) in JOURNAL_RECEIVERS: + return True + # The constructor-inline spelling: `Journal(run_dir).append(...)`. The receiver + # is an ast.Call, so the named-handle match above can never see it — runs.py's + # stop/restamp records (and their kinds and fields) went unscanned exactly this + # way. Name-anchored on `Journal` like the handle arm, so a lookalike + # constructor stays silent. + return isinstance(receiver, ast.Call) and _called_name(receiver.func) == "Journal" def _dict_literal_keys(value: ast.expr) -> set[str] | None: @@ -1268,6 +1574,23 @@ def _names_rearm_escalation(func: ast.expr, aliases: frozenset[str] = frozenset( return _names_guarded_verify_call(func, "rearm_escalation", aliases) +def _names_isolation_refusal( + func: ast.expr, + predicate_aliases: frozenset[str] = frozenset(), + wrapper_aliases: frozenset[str] = frozenset(), +) -> bool: + """True when ``func`` spells a #414-family refusal entry point: the + ``bmadconfig.worktree_isolation_conflict`` predicate or its rc-returning CLI + wrapper ``_reject_isolation_conflict``. Both names are guarded because a new + surface can reach the refusal through either — the ``96aa09a9`` site did so + through the wrapper — and each resolves its own alias set. Same reach and + computed-name bound as the sibling detectors, and the same trade: an unrelated + ``x.worktree_isolation_conflict(...)`` is a review prompt, not a miss.""" + return _names_guarded_verify_call( + func, "worktree_isolation_conflict", predicate_aliases + ) or _names_guarded_verify_call(func, "_reject_isolation_conflict", wrapper_aliases) + + def _block_exits(body: list[ast.stmt]) -> bool: """Whether this simple guard body cannot fall through to the re-arm below it.""" return bool(body) and isinstance(body[-1], (ast.Return, ast.Raise)) @@ -1346,6 +1669,8 @@ def _scan_source(src: str, rel: str): verify_command_aliases = _call_aliases(tree, "verify_commands_outcome") verify_classifier_aliases = _call_aliases(tree, "verify_command_results_outcome") rearm_aliases = _call_aliases(tree, "rearm_escalation") + isolation_aliases = _call_aliases(tree, "worktree_isolation_conflict") + isolation_wrapper_aliases = _call_aliases(tree, "_reject_isolation_conflict") # First positional args of `_run_git(...)` calls — the one position where a # git argv literal feeds the chokepoint instead of bypassing it. Collected up @@ -1595,6 +1920,14 @@ def line_at(lineno: int) -> str: ) if kind is None: findings.append(("journalkind", rel, node.lineno, line_at(node.lineno), fn_name)) + else: + # The literal-kind twin, and the KIND inventory's only feed. NOT + # derivable from the `journalfield` rows below, although each of + # those carries the kind: a kind-only write (`run-complete` and + # three siblings) has no keyword row to ride on. + findings.append( + ("journalkindliteral", rel, node.lineno, line_at(node.lineno), kind) + ) for kw in node.keywords: if kw.arg is not None: findings.append( @@ -1847,6 +2180,29 @@ def record_mint(value: ast.expr, *, bare_at_depth: bool) -> None: ) ) + # Every refusal-helper DEFINITION (`_refuse_*` / `_reject_*`) and every + # #414-family isolation-refusal CALL — the two surfaces `REFUSAL_HELPER_DEFS` + # and `ISOLATION_CONFLICT_CALLERS` enumerate. The def side needs no alias + # resolution (a definition IS its name); the call side resolves both guarded + # names through `_call_aliases`, exactly like the re-arm detector above. + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith( + ("_refuse_", "_reject_") + ): + findings.append(("refusaldef", rel, node.lineno, line_at(node.lineno), node.name)) + if isinstance(node, ast.Call) and _names_isolation_refusal( + node.func, isolation_aliases, isolation_wrapper_aliases + ): + findings.append( + ( + "isolationcall", + rel, + node.lineno, + line_at(node.lineno), + enclosing_names.get(id(node)), + ) + ) + return findings @@ -2294,6 +2650,85 @@ def test_run_state_writer_and_transaction_inventory_is_complete(): assert _production_call_sites("state_lock") == RUN_STATE_TRANSACTIONS +def test_refusal_helper_inventory_is_complete(): + """Every `_refuse_*`/`_reject_*` helper definition in the tree has a declared + row, in both directions. Review iteration 6 (and four bot rounds after it) kept + finding one shape by hand — a refusal landed with no test row, caught only by a + later review pass — and the journal-FIELD inventory beside this one is the gate + that caught `reaches_redrive`; this is the same gate for the refusal surface. A + row here is the PR-time decision, not the test itself: the helper's refusal + behavior still needs its own test landed with the row. + + Anti-vacuity is structural, the exact-inventory property the rearm Counter + relies on: the declared side is non-empty, so a scan that stops finding + definitions reddens the staleness direction instead of passing green. + + Graded as a Counter WITH multiplicity, not a set of names — + `ISOLATION_CONFLICT_CALLERS`' rationale, applied to definitions: a SECOND def + of a declared name in the same file (a platform-conditional twin, say) is a new + refusal body the emit reports twice, and a set comparison absorbed it silently + (measured). Every declared row's count is 1 today, which `Counter` over the set + encodes. + + Ablation: delete the `refusaldef` emit and this reddens with all nine declared + rows stale; add `def _refuse_nothing()` to `runs.py` and this reddens naming + it; add a platform-conditional TWIN def of `_refuse_live_session` to `runs.py` + and the count comparison reddens with the set of names unchanged.""" + findings = _of("refusaldef") + scanned = Counter((rel, name) for _, rel, _, _, name in findings) + declared = Counter(REFUSAL_HELPER_DEFS) + changed = {key for key in set(scanned) | set(declared) if scanned[key] != declared[key]} + detail = [ + f" {rel}:{ln}: {name} — {txt.strip()}" + for _, rel, ln, txt, name in findings + if (rel, name) in changed + ] + assert scanned == declared, ( + "the `_refuse_*`/`_reject_*` helper definitions moved. A NEW helper — a " + "second same-named def in one file included — lands WITH its (file, name) " + "row and the test asserting what it refuses in the same PR; a helper no " + "module defines any more loses its row, which otherwise stands as a " + "pre-approval for the next helper that reuses the name:\n" + f" scanned: {sorted(scanned.elements())}\n" + f" declared: {sorted(declared.elements())}\n" + "\n".join(detail) + ) + + +def test_isolation_conflict_refusal_sites_are_enumerated(): + """The #414 refusal is reached from exactly the declared call sites, counted + WITH multiplicity — both the `bmadconfig.worktree_isolation_conflict` predicate + and its CLI wrapper `_reject_isolation_conflict`, so a new surface reaching the + pair through either spelling reddens this row. That is the `96aa09a9` shape: + `cmd_resolve`'s pre-session refusal landed as a wrapper call with no test row, + and nothing structural named the omission until a review pass did. + + Multiplicity is load-bearing on today's tree — `cmd_resolve` legitimately calls + the wrapper twice, so a set of keys would absorb a third call there silently + (`test_isolation_callsite_count_does_not_hide_a_second_call_in_one_function` + pins the counting itself). + + Ablation: delete the `isolationcall` emit and this reddens (eleven declared, + zero scanned); add a third `_reject_isolation_conflict` call inside + `cmd_resolve` and the count comparison reddens naming the site.""" + findings = _of("isolationcall") + sites = _isolation_callsite_counts(findings) + declared = Counter(ISOLATION_CONFLICT_CALLERS) + changed = {key for key in set(sites) | set(declared) if sites[key] != declared[key]} + detail = [ + f" {rel}:{ln}: in {fn or ''} — {txt.strip()}" + for _, rel, ln, txt, fn in findings + if (rel, fn) in changed + ] + assert sites == declared, ( + "the #414-family refusal call sites moved (worktree_isolation_conflict / " + "_reject_isolation_conflict). A new surface refusing the pair lands WITH " + "its own refusal test in the same PR; a removed one deletes its row — " + "update ISOLATION_CONFLICT_CALLERS only alongside that decision:\n" + f" scanned: {sorted(sites.elements())}\n" + f" declared: {sorted(declared.elements())}\n" + "\n".join(detail) + ) + + def _journal_field_offenders(findings) -> list[tuple[str, int, str, str]]: """The routing invariant as a filter, in the two directions a finding can fail: a field name that neither ``diagnostics`` nor the benign inventory accounts for, @@ -2417,6 +2852,56 @@ def test_journal_kinds_are_literal_or_the_position_is_declared(): ) +def test_journal_kind_inventory_is_complete(): + """Every literal journal kind a producer writes is a declared `JOURNAL_KINDS` + row, in both directions — the enumerate-vs-declare gate for the kind axis. The + ~196 literal kinds had no inventory at all: a new record kind could land, with + or without a test row, and only a later review pass would ask what covers it. + Now the question is asked by CI, at PR time, on the diff that introduces the + kind. + + Both directions matter. A NEW kind fails the undeclared arm naming its file, + line and kind; a RENAME fails both arms at once — the new spelling undeclared, + the old row stale — so the old row cannot survive as a pre-approval for the + next kind that reuses it. The staleness arm's bound is stated on + `JOURNAL_KINDS`: it sees the union of producers, so one writer of a SHARED + kind can drop it without reddening anything while another still writes it. + + Dynamic kinds are deliberately absent (`JOURNAL_KINDS`' stated bound): a + non-literal kind emits no `journalkindliteral` finding, and the sibling + literalness test above governs whether its POSITION may be dynamic at all. + Consumer-side kind parity — readers matching kinds by literal — stays DW-82's, + out of scope here. + + Anti-vacuity is structural: the declared set is non-empty, so deleting the + `journalkindliteral` emit reddens the staleness arm with the entire inventory + rather than passing green. + + Ablation: delete the `journalkindliteral` emit and this reddens with all 200 + rows stale; duplicate engine.py's epic-boundary write under the kind + `"guard-ablation-probe"` and ONLY this test reddens, naming the kind and + site.""" + findings = _of("journalkindliteral") + scanned = {kind for _, _, _, _, kind in findings} + undeclared = [ + (rel, ln, txt, kind) for _, rel, ln, txt, kind in findings if kind not in JOURNAL_KINDS + ] + assert undeclared == [], ( + "a journal producer writes a literal kind that JOURNAL_KINDS does not " + "declare — add the kind's row IN THE SAME PR as what covers its record: a " + "diagnostics routing row if any field carries an identifier, a path or " + "free text, and the test asserting the record at the layer that reads it; " + "or drop the write:\n" + + "\n".join(f" {rel}:{ln}: {kind!r} — {txt.strip()}" for rel, ln, txt, kind in undeclared) + ) + stale = JOURNAL_KINDS - scanned + assert stale == set(), ( + "JOURNAL_KINDS declares kinds no producer writes any more — a stale row " + "pre-approves the next record that reuses the name; delete these rows and " + f"retire their routing/test rows deliberately: {sorted(stale)}" + ) + + def test_journal_field_guard_actually_saw_the_producers(): """The sibling assertion is an ABSENCE, so it is green both when every field is accounted for and when the scan stopped finding journal writes at all. This is @@ -3803,15 +4288,196 @@ def test_rearm_call_detector_stays_silent_on_non_calls(label, source): assert not found, f"the {label!r} shape produced a `rearmcall` finding:\n{source}" +# The refusal-helper detector's matrix: `(label, source, expected def names)`. The +# surface is DEFINITIONS — a new `_refuse_*`/`_reject_*` helper is a new refusal +# behavior that must land with an inventory row and its own test — so calls, +# lookalike prefixes and prose must all stay silent or the inventory fills with +# noise it cannot force a decision about. +REFUSAL_DEF_PROBES = [ + ( + "plain-def", + "def _refuse_live_session(project, run_id, verb):\n return None\n", + {"_refuse_live_session"}, + ), + ( + "reject-spelling", + "def _reject_bad_run_id(run_id):\n return None\n", + {"_reject_bad_run_id"}, + ), + ( + "async-def", + "async def _refuse_slow_probe(target):\n return None\n", + {"_refuse_slow_probe"}, + ), + # A method is a definition too — `engine.Engine._refuse_gated_story` is one of + # the nine rows the real tree declares. + ( + "method-def", + "class Engine:\n def _refuse_gated_story(self, story_key):\n return None\n", + {"_refuse_gated_story"}, + ), +] +REFUSAL_DEF_NON_PROBES = [ + # A CALL is not a definition: call sites belong to each helper's own tests, and + # flagging them would report every use as a new refusal behavior. + ("call-not-a-def", 'def f():\n _refuse_live_session(project, run_id, "stop")\n'), + # The prefix is `_refuse_`/`_reject_` WITH the trailing underscore: a name that + # merely starts `_refus` is not claiming to be a refusal helper. + ("similar-prefix", "def _refusal_note(story_key):\n return None\n"), + # …and a public spelling makes no `_refuse_*` claim either. + ("public-spelling", "def refuse_everything():\n return None\n"), + # Prose naming a helper is a Constant, not a def. + ("prose", 'def f():\n """Calls _refuse_live_session first."""\n return 1\n'), +] + + +@pytest.mark.parametrize( + ("label", "source", "expected"), REFUSAL_DEF_PROBES, ids=[p[0] for p in REFUSAL_DEF_PROBES] +) +def test_refusal_def_detector_flags_every_definition_shape(label, source, expected): + """Each definition shape is found and reported by name. `runs.py` is passed + because nothing in this detector is file-scoped — the enumeration lives in the + tree-wide inventory test, not here. + + Ablation: delete the `refusaldef` emit and every row here reddens.""" + found = {f[4] for f in _scan_source(source, "runs.py") if f[0] == "refusaldef"} + assert found == expected, f"the {label!r} shape resolved to {sorted(found)}:\n{source}" + + +@pytest.mark.parametrize( + ("label", "source"), REFUSAL_DEF_NON_PROBES, ids=[p[0] for p in REFUSAL_DEF_NON_PROBES] +) +def test_refusal_def_detector_stays_silent_on_lookalikes(label, source): + """A call, a lookalike prefix, a public spelling and prose are not refusal-helper + definitions. The inventory is an equality assertion, so a false positive fails as + loudly as a miss. + + Ablation: widen the emit's prefix match to `_refus` and the similar-prefix row + reddens.""" + found = [f for f in _scan_source(source, "runs.py") if f[0] == "refusaldef"] + assert not found, f"the {label!r} shape produced a `refusaldef` finding:\n{source}" + + +# The #414-family call detector's matrix: `(label, source, expected enclosing +# function)`. Both spellings of the refusal are probed — the `bmadconfig` predicate +# and the rc-returning CLI wrapper — because a new surface can reach the pair +# through either, and the `96aa09a9` site (cmd_resolve's pre-session arm) arrived +# through the wrapper. +ISOLATION_CALL_PROBES = [ + ( + "qualified-predicate-call", + "def cmd_validate(args):\n" + " conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation)\n", + "cmd_validate", + ), + ( + "bare-wrapper-call", + "def cmd_run(args):\n" + " if (rc := _reject_isolation_conflict(paths, pol)) is not None:\n" + " return rc\n", + "cmd_run", + ), + # A rename-on-import and an assignment alias are just as callable — the + # `_call_aliases` shapes, one per guarded name. + ( + "renamed-predicate-import", + "from .bmadconfig import worktree_isolation_conflict as conflict_for\n" + "def f(args):\n" + " conflict_for(paths, isolation)\n", + "f", + ), + ( + "assigned-wrapper-alias", + "check = _reject_isolation_conflict\ndef f(args):\n check(paths, pol)\n", + "f", + ), +] +ISOLATION_CALL_NON_PROBES = [ + # The definitions are not calls. The wrapper's own predicate call is a real + # finding on today's tree — `("cli.py", "_reject_isolation_conflict")` is a row + # of the declared Counter — so the bodies here are stubs on purpose. + ( + "predicate-definition", + "def worktree_isolation_conflict(paths, isolation):\n return None\n", + ), + ("wrapper-definition", "def _reject_isolation_conflict(paths, pol):\n return None\n"), + # A different function whose name merely embeds the guarded one. + ("similar-name", "def f():\n worktree_isolation_conflicts(paths)\n"), + ("reference-not-a-call", "def f():\n handler = bmadconfig.worktree_isolation_conflict\n"), + ( + "prose", + 'def f():\n """bmadconfig.worktree_isolation_conflict(paths, mode) decides."""\n' + " return 1\n", + ), +] + + +@pytest.mark.parametrize( + ("label", "source", "fn"), ISOLATION_CALL_PROBES, ids=[p[0] for p in ISOLATION_CALL_PROBES] +) +def test_isolation_call_detector_reports_the_site(label, source, fn): + """Each call shape is found and attributed to its enclosing function — the key + the declared Counter is built on. `cli.py` is passed because nothing in this + detector is file-scoped. + + Ablation: delete the `isolationcall` emit and every row here reddens.""" + found = [f for f in _scan_source(source, "cli.py") if f[0] == "isolationcall"] + assert len(found) == 1, f"the {label!r} shape produced {len(found)} findings:\n{source}" + assert found[0][4] == fn, f"the {label!r} shape attributed to {found[0][4]!r}" + + +@pytest.mark.parametrize( + ("label", "source"), ISOLATION_CALL_NON_PROBES, ids=[p[0] for p in ISOLATION_CALL_NON_PROBES] +) +def test_isolation_call_detector_stays_silent_on_non_calls(label, source): + """Definitions, a similarly-named neighbour, a bare reference and prose are not + call sites. The tree-wide assertion is a Counter equality, so a false positive + fails as loudly as a miss. + + Ablation: relax `_names_guarded_verify_call`'s name equality to a substring + match and the similar-name row reddens.""" + found = [f for f in _scan_source(source, "cli.py") if f[0] == "isolationcall"] + assert not found, f"the {label!r} shape produced an `isolationcall` finding:\n{source}" + + +def _isolation_callsite_counts(findings) -> Counter: + """Call-site multiplicity, not just distinct enclosing functions — the + `_rearm_callsite_counts` idiom, and load-bearing on the real tree: + `cli.cmd_resolve` legitimately calls the wrapper twice.""" + return Counter((rel, fn) for _, rel, _, _, fn in findings) + + +def test_isolation_callsite_count_does_not_hide_a_second_call_in_one_function(): + """Ablation: collapse `_isolation_callsite_counts` to a set of keys and this + reddens — `cmd_resolve` would then absorb a third call silently.""" + source = ( + "def cmd_resolve(args):\n" + " if (rc := _reject_isolation_conflict(paths, pol)) is not None:\n" + " return rc\n" + " if (rc := _reject_isolation_conflict(paths, pol)) is not None:\n" + " return rc\n" + ) + found = [f for f in _scan_source(source, "cli.py") if f[0] == "isolationcall"] + assert _isolation_callsite_counts(found) == Counter({("cli.py", "cmd_resolve"): 2}) + + # The journal detector's probe matrix, as `(label, source, expected)` where # `expected` is the exact set of field names the scan must extract — `None` standing # for an unresolvable splat. Asserting the SET rather than "something was found" is # what makes a partial splat resolution fail here instead of quietly under-reporting. JOURNAL_FIELD_PROBES = [ - # The three receiver spellings in the tree. + # The four receiver spellings in the tree. ("self-journal", 'self.journal.append("k", story_key=s, patch=p)\n', {"story_key", "patch"}), ("bare-journal", 'journal.append("k", branch=b)\n', {"branch"}), ("private-journal", "self._journal.append(kind, plugin=name)\n", {"plugin"}), + # The constructor-inline spelling `Journal(run_dir).append(...)` — three live + # sites in runs.py use it, and the receiver is an ast.Call, so the named-handle + # match alone left them (and their kinds and fields) entirely unscanned. + ( + "constructor-inline-receiver", + 'def f(run_dir):\n Journal(run_dir).append("k", pid=1)\n', + {"pid"}, + ), # A splat resolved through the literal stores that build it, in both store # shapes and across the conditional-dict form `engine._run_inner` uses. ( @@ -3936,6 +4602,10 @@ def test_journal_forwarder_calls_enter_the_inventory(label, rel, source, expecte # the most common in the language, so anchoring on the receiver is load-bearing. ("list-append", "results.append(SessionResult(status=s, stop_seen=True))\n"), ("attribute-list-append", "self.entries.append(dict(kind=k, story_key=s))\n"), + # A constructor that merely ends in a `.append` is not a journal write unless + # the constructed thing IS a Journal — the constructor arm is name-anchored + # exactly like the handle arm. + ("constructor-lookalike", 'NotAJournal(run_dir).append("k", pid=1)\n'), # A journal write with no fields at all produces nothing to route. ("kind-only", 'self.journal.append("run-start")\n'), # Prose naming the call is a Constant, not a Call. @@ -4093,6 +4763,58 @@ def test_journal_kind_probes_flag_a_non_literal_kind(): assert not [f for f in _scan_source(source, "sweep.py") if f[0] == "journalkind"], source +def test_journal_kind_literal_probes_extract_the_kind(): + """The kind inventory's detector half: a journal write whose kind IS a string + literal emits that kind — including a kind-only write (`run-complete` and three + siblings), which the FIELD detector never reports because there is no keyword + to carry it, and a declared forwarder's call site, whose kind would otherwise + stop at `plugins/bus.py::_log`'s wall. + + Ablation: delete the `journalkindliteral` emit and every row here reddens.""" + for source, rel, kind in ( + ( + 'def f(self):\n self.journal.append("run-start", story_key=s)\n', + "sweep.py", + "run-start", + ), + # The kind-only shape: no keywords, so no `journalfield` finding exists to + # derive the kind from — this emit is the only reader. + ('def f(self):\n journal.append("run-complete")\n', "engine.py", "run-complete"), + ( + 'def f(self):\n self._journal.append("plugin-loaded", plugin=name)\n', + "plugins/registry.py", + "plugin-loaded", + ), + ('def f(self):\n self._log("plugin-hook", rc=rc)\n', "plugins/bus.py", "plugin-hook"), + ): + found = [f[4] for f in _scan_source(source, rel) if f[0] == "journalkindliteral"] + assert found == [kind], f"extracted {found} from:\n{source}" + + +def test_journal_kind_literal_probes_stay_silent_on_lookalikes(): + """The complement: a non-literal kind (the literalness test's territory), an + `.append` on a non-journal receiver, a forwarder NAME outside its declared + file, and prose are all silent — the inventory must not fill itself with + strings that never reach `Journal.append`. + + Ablation: drop `_is_journal_write`'s receiver anchor (accept any `.append`) and + the list-append row reddens.""" + for source, rel in ( + ("def f(self):\n self.journal.append(kind, story_key=s)\n", "sweep.py"), + ( + 'def f(self):\n self.journal.append(f"{family}-pruned", count=n)\n', + "recovery_flow.py", + ), + ('def f(self):\n results.append("done")\n', "sweep.py"), + ('def f(self):\n self._log("plugin-hook", rc=rc)\n', "stories_engine.py"), + ( + 'def f():\n """journal.append("prose-kind") is described here."""\n return 1\n', + "sweep.py", + ), + ): + assert not [f for f in _scan_source(source, rel) if f[0] == "journalkindliteral"], source + + def test_journal_routing_tables_are_read_from_diagnostics(): """`JOURNAL_ROUTED_FIELDS` and `JOURNAL_KIND_ROUTED_FIELDS` are built from the live `diagnostics` tables, not copied, so the guard cannot drift from the module From 7899f0b5a8d586c74ae3b995980d7e1ff96b97c2 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 09:34:44 -0700 Subject: [PATCH 45/45] test(guard): inventory forwarder-kind literals; grade both kind arms at once Review pass 2 follow-ups to 8100c535: - A second journalkindliteral arm reads the literal kind= a caller hands a declared dynamic-kind position, and that position's kind parameter default, so review-skipped, review-skipped-awaiting-operator, sweep-bundle-closed and sweep-bundle-reclosed join JOURNAL_KINDS (200 -> 204). Only the f-string family stays outside the inventory; header, docstring, testing.md and CHANGELOG now say so. Must-flag rows landed failing-first. - test_journal_kind_inventory_is_complete grades undeclared and stale from one scan in one assertion (_journal_kind_inventory_drift), so a rename's single failure names both the new spelling and the stale row; a synthetic-findings probe pins the helper. - State the constructor arm's bounds: a Journal subclass constructed inline, super().append inside _RearmJournal's override, and an import-aliased constructor are invisible to the journal scan. - docs/testing.md: the "four detectors carry probe matrices" bullet rewritten to the current family list. --- CHANGELOG.md | 7 +- docs/testing.md | 8 +- tests/test_portability_guard.py | 237 ++++++++++++++++++++++++++------ 3 files changed, 206 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90df5b78..cb8a13cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,11 @@ breaking changes may land in a minor release. ### Added - **Journal-kind and refusal-site coverage gates.** `tests/test_portability_guard.py` gains - three enumerate-vs-declare inventories: the 200 literal journal kinds (`JOURNAL_KINDS`, - fed by a literal-kind emit that also sees kind-only and constructor-inline + three enumerate-vs-declare inventories: the 204 literal journal kinds (`JOURNAL_KINDS`, + fed by a literal-kind emit that also sees kind-only writes, constructor-inline `Journal(run_dir).append(...)` writes — a receiver spelling the journal scan was blind - to), the `_refuse_*`/`_reject_*` helper definitions counted with multiplicity + to — and the `kind=` literals and parameter defaults that reach a declared dynamic-kind + position), the `_refuse_*`/`_reject_*` helper definitions counted with multiplicity (`REFUSAL_HELPER_DEFS`), and the eleven #414-family isolation-refusal call sites counted with multiplicity (`ISOLATION_CONFLICT_CALLERS`). A new kind, refusal helper, or refusal call site reddens CI until its row lands; the row is the PR-time decision, and the diff --git a/docs/testing.md b/docs/testing.md index 0ae02a80..e791f0ff 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -161,7 +161,7 @@ A slice of the suite tests the **repo** rather than the product. The inventory: | Guard | Where | Enforces | | -------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Portability guard | `tests/test_portability_guard.py` | One shared AST scan over every `src/bmad_loop/**/*.py` (data scripts included), carrying thirteen guards: literal `["tmux", ...]` argvs only in the two backend files (the backends' own `[self._BINARY, ...]` spelling is deliberately unmatched, so this tripwire currently flags nothing — #549); sequence-form git argvs (list or tuple, literal or named constant) only as `_run_git`'s argv argument in `verify.py`, with string-form git spawns refused everywhere, `verify.py` included; no bare `/tmp`-class POSIX paths; no `signal.SIGKILL` attribute; `os.kill(pid, 0)` probes only in `process_host.py`; any `os.kill` at all only there too (a second, distinct guard); `start_new_session` only in the detach helpers; `shell=True` only in its two sanctioned files; `BMAD_LOOP_*` env reads only through the `envvars.py` registry, a plugin's own variable family, or the session-protocol vars the two stand-alone hook relays read back; a persisted `spec_file` / `dispatched_spec_file` resolved with a bare `Path(...)` only in the four files that run inside the tree the value was recorded against; `verify_commands_outcome` called only from `verify._verify_review_commands`; its classifier half `verify_command_results_outcome` called only from `verify.verify_commands_outcome` or `Engine._verify_commands_with_results` (a separate guard, because fencing the wrapper alone still lets a gate compose run+classify by hand and pick its own root — #695); plus a scanned-file-count floor so a broken scan root cannot pass vacuously | -| Kind & refusal inventories | `tests/test_portability_guard.py` | Enumerate-vs-declare gates riding the same AST scan, so a new entry on an enumerable surface is a PR-time decision instead of review-pass archaeology: every literal journal kind is a declared `JOURNAL_KINDS` row (kind-only writes included, via a dedicated literal-kind emit; dynamic kinds stay governed by the literalness test); every `_refuse_*`/`_reject_*` helper definition is a `REFUSAL_HELPER_DEFS` row; and the #414-family isolation-refusal call sites (`bmadconfig.worktree_isolation_conflict` plus its CLI wrapper `_reject_isolation_conflict`) match `ISOLATION_CONFLICT_CALLERS` with multiplicity, so `cmd_resolve`'s legitimate second call cannot absorb a third. All assertions are exact-set or exact-Counter in both directions — a removed or renamed entry reddens its stale row too — and each new detector emit carries must-flag and must-stay-silent probe rows through `_scan_source` | +| Kind & refusal inventories | `tests/test_portability_guard.py` | Enumerate-vs-declare gates riding the same AST scan, so a new entry on an enumerable surface is a PR-time decision instead of review-pass archaeology: every literal journal kind is a declared `JOURNAL_KINDS` row (kind-only writes and the `kind=` literals or parameter defaults that reach a declared dynamic-kind position included, via a dedicated literal-kind emit; only the f-string family stays out, governed by the literalness test); every `_refuse_*`/`_reject_*` helper definition is a `REFUSAL_HELPER_DEFS` row; and the #414-family isolation-refusal call sites (`bmadconfig.worktree_isolation_conflict` plus its CLI wrapper `_reject_isolation_conflict`) match `ISOLATION_CONFLICT_CALLERS` with multiplicity, so `cmd_resolve`'s legitimate second call cannot absorb a third. All assertions are exact-set or exact-Counter in both directions — a removed or renamed entry reddens its stale row too — and each new detector emit carries must-flag and must-stay-silent probe rows through `_scan_source` | | Settings-schema sync | `tests/test_settings_schema.py` | `src/bmad_loop/data/settings/core.toml` stays in lockstep with `policy.py` by reflection, in both directions: every spec maps to a live dataclass field with a matching default wherever one is baked in, every policy field is reachable from exactly one spec (or listed in the explicit `HIDDEN` set), and every `*Policy` dataclass is consciously classified | | Exit-code allocation | `tests/test_entry_point.py` | `ExitCode` is pinned literally (OK=0, FAILURE=1, USAGE=2, INTERRUPTED=130) **and closed**: the enum's value set equals exactly those four, so codes 3–129/131+ cannot be allocated quietly | | Extra-less core CLI | `tests/test_entry_point.py` | A fresh interpreter with `pyte`/`rich`/`textual`/`tomlkit` blocked at `find_spec` — the blocker **raises** rather than returning None, so the dev venv's installed copies cannot make it pass vacuously, and an `import pyte` floor proves it bites — imports `bmad_loop.cli` and `bmad_loop.settings_schema`, runs `list` to rc 0, and asserts `tui` degrades to the `bmad-loop[tui]` hint instead of a traceback. Every test job installs `--all-extras`, which is why #650 shipped broken for 23 releases; CI's isolated wheel `list` run is the same floor at install level | @@ -182,8 +182,10 @@ Rules for adding or touching a guard: `os.kill`), and `verify.py`'s git exemption is narrowed further, to the `_run_git` argv position. Env-read exemptions are scoped **by variable name or family, never by file** — a file-wide pass would let a hook read a core knob unnoticed. -- **The detector itself gets executable coverage.** Four detectors — env-read, git-argv, and the - two verify-composition ones — carry probe matrices: the scan is split (`_scan_source`) so probe fixtures run +- **The detector itself gets executable coverage.** Every detector added since this bar was set + carries a probe matrix — env-read, git-argv, the two verify-composition ones, spec-anchor, + task-artifact, session-task-id, the re-arm call, the journal field / kind / kind-literal + family, refusal-def and isolation-call: the scan is split (`_scan_source`) so probe fixtures run the same code path as the real scan, with a must-flag row per claimed access form and a must-stay-silent row per lookalike. When a new form turns up, add the failing probe row first, then fix the detector. The green "no findings today" assertion cannot grade a diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 3e695d20..017e42fb 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -664,7 +664,7 @@ } # Every literal journal KIND written today: a declared inventory, not a per-kind -# audit — `JOURNAL_BENIGN_FIELDS`' claim, made for the kind axis. Kind #201 cannot +# audit — `JOURNAL_BENIGN_FIELDS`' claim, made for the kind axis. Kind #205 cannot # appear without someone deciding, in the same PR, what covers the record it # introduces: a routing row in `diagnostics` if any field carries an identifier, a # path or free text, and a test row asserting the record at the layer that reads it @@ -676,15 +676,24 @@ # staleness arm sees the union of producers, so removing ONE writer of a shared # kind (the shared headings below, `run-stop` included) reddens nothing by itself. # -# ⚠️ STATED BOUNDS. Dynamic kinds — the f-string family in -# `recovery_flow.prune_preserve_refs`, the parameter defaults in -# `engine._skip_review_and_commit`, the chosen kinds of -# `sweep._close_bundle_ledger_when_spec_status` — are NOT rows here: their -# POSITIONS are declared in `JOURNAL_DYNAMIC_KIND_ALLOW` and governed by the -# literalness test, so the kinds they mint (e.g. `attempt-preserve-pruned`, -# `review-skipped`) never enter this inventory. And a locally aliased journal -# handle — a named one, or one bound from `Journal(run_dir)` — is invisible to the -# whole journal scan (`JOURNAL_RECEIVERS`' bound), this emit included. +# A declared dynamic-kind position (`JOURNAL_DYNAMIC_KIND_ALLOW`) writes a +# parameter, not a literal, so its kinds enter here through the literals that reach +# it from outside: the `kind="..."` a caller hands `engine._skip_review_and_commit` +# or `sweep._close_bundle_ledger_when_spec_status`, and each one's parameter +# default (`review-skipped`, `sweep-bundle-closed`) — a second `journalkindliteral` +# arm reads both, keyed by the same `(file, name)` as the position. +# +# ⚠️ STATED BOUNDS. Truly dynamic kinds — the f-string family in +# `recovery_flow.prune_preserve_refs` — are NOT rows here: the position is declared +# and governed by the literalness test, and the kinds it mints (e.g. +# `attempt-preserve-pruned`) never enter this inventory. And every receiver shape +# the journal scan cannot see is a hole in this emit too (`JOURNAL_RECEIVERS`' +# bound): a locally aliased handle, a `Journal` SUBCLASS constructed inline +# (`_RearmJournal(run_dir).append(...)`), the `super().append(...)` inside such a +# subclass's override, and a constructor reached through an import alias. On +# today's tree the only subclass instance is bound to the `journal` name and its +# override forwards its parameter kind, so no literal is missed — but the bound is +# the scan's, not the tree's. JOURNAL_KINDS = frozenset( { # cli.py @@ -738,6 +747,8 @@ "review-not-recommended", "review-result", "review-retry", + "review-skipped", + "review-skipped-awaiting-operator", "review-timeout-salvage", "review-timeout-salvage-failed", "review-verify-failed", @@ -862,8 +873,10 @@ "migrate-duplicate-ids", "sweep-bundle-close-carried", "sweep-bundle-close-carry-uncommitted", + "sweep-bundle-closed", "sweep-bundle-name-discarded", "sweep-bundle-name-normalized", + "sweep-bundle-reclosed", "sweep-bundle-reopened", "sweep-bundle-skipped", "sweep-bundles-truncated", @@ -915,10 +928,15 @@ # ⚠️ STATED BOUND: a LOCALLY ALIASED handle is invisible. `j = self.journal` followed # by `j.append(kind, customer_email=x)` produces no finding (verified by running it # through `_scan_source`), and a handle bound from the constructor — -# `j = Journal(run_dir)` then `j.append(...)` — is the same shape. No such site -# exists in the tree today, and resolving the binding would be `_call_aliases`' -# shape rather than a new idea — but the guard does not do it, and a reader must -# not assume it does. +# `j = Journal(run_dir)` then `j.append(...)` — is the same shape. So is anything +# the constructor arm's bare-name anchor does not spell: a SUBCLASS constructed +# inline (`_RearmJournal(run_dir).append(...)` — `runs._RearmJournal(Journal)` +# exists), the `super().append(kind, **fields)` inside that subclass's override +# (runs.py's fifth receiver spelling, a `super` Call), and `Journal` reached +# through an import alias. None of these carries a literal the tree misses today +# (the subclass's one instance is bound to `journal`; its override forwards a +# parameter kind), and resolving them would be `_call_aliases`' shape rather than a +# new idea — but the guard does not do it, and a reader must not assume it does. JOURNAL_RECEIVERS = {"journal", "_journal"} # Files that may name a bare POSIX path, each on a line carrying a `# portability:` @@ -1408,11 +1426,31 @@ def _mint_candidates(node: ast.expr, depth: int = 0): yield from _mint_candidates(arg, depth + 1) +def _kind_param_default(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None: + """The string-literal default of ``fn``'s ``kind`` parameter — positional-or- + keyword or keyword-only — or None when there is no such parameter or its default + is not a string literal. The declared dynamic-kind positions mint their fallback + kind here (``review-skipped``, ``sweep-bundle-closed``), and nothing else in + the scan reads a parameter default.""" + args = fn.args + positional = args.posonlyargs + args.args + padded: list[ast.expr | None] = [None] * (len(positional) - len(args.defaults)) + padded.extend(args.defaults) + for arg, default in [*zip(positional, padded), *zip(args.kwonlyargs, args.kw_defaults)]: + if arg.arg == "kind": + if isinstance(default, ast.Constant) and isinstance(default.value, str): + return default.value + return None + return None + + def _is_journal_write(node: ast.AST, rel: str) -> bool: """Whether this node writes a journal entry — a ``.append(...)`` call in - each of the four receiver spellings the tree uses: the three named handles (see + each of the four receiver spellings the scan reads: the three named handles (see ``JOURNAL_RECEIVERS``) and the constructor-inline ``Journal(run_dir).append(...)`` - — or a call to one of this file's declared ``JOURNAL_FORWARDERS``. + — or a call to one of this file's declared ``JOURNAL_FORWARDERS``. The tree's + fifth spelling, ``super().append(...)`` inside ``runs._RearmJournal``'s override, + is a stated bound (``JOURNAL_RECEIVERS``), not a receiver. The forwarder half is not a convenience. ``plugins/bus.py::_log`` takes its own ``**fields`` and hands them to ``self._journal.append``, so its four call sites @@ -1439,7 +1477,8 @@ def _is_journal_write(node: ast.AST, rel: str) -> bool: # is an ast.Call, so the named-handle match above can never see it — runs.py's # stop/restamp records (and their kinds and fields) went unscanned exactly this # way. Name-anchored on `Journal` like the handle arm, so a lookalike - # constructor stays silent. + # constructor stays silent — and so, by the same anchor, does a subclass + # constructor or an import alias (the stated bound on `JOURNAL_RECEIVERS`). return isinstance(receiver, ast.Call) and _called_name(receiver.func) == "Journal" @@ -2180,6 +2219,46 @@ def record_mint(value: ast.expr, *, bare_at_depth: bool) -> None: ) ) + # The literal kinds that reach a declared dynamic-kind POSITION from outside it: + # a `kind="..."` keyword at a call to one of this file's + # `JOURNAL_DYNAMIC_KIND_ALLOW` functions, and that function's own `kind` + # parameter default. The write inside such a position spells a parameter, so + # the journal-write arm above reports it as `journalkind` and nothing more — + # which is how `review-skipped-awaiting-operator` and its three siblings + # reached the journal with no inventory row anyone had to decide on (review + # pass 2). Same `journalkindliteral` finding, same inventory; keyed `(file, + # name)` exactly like the position it serves, so a same-named callee in a file + # that declares no such position stays silent. + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and (rel, _called_name(node.func)) in JOURNAL_DYNAMIC_KIND_ALLOW + ): + for kw in node.keywords: + if ( + kw.arg == "kind" + and isinstance(kw.value, ast.Constant) + and isinstance(kw.value.value, str) + ): + findings.append( + ( + "journalkindliteral", + rel, + node.lineno, + line_at(node.lineno), + kw.value.value, + ) + ) + elif ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and (rel, node.name) in JOURNAL_DYNAMIC_KIND_ALLOW + ): + default = _kind_param_default(node) + if default is not None: + findings.append( + ("journalkindliteral", rel, node.lineno, line_at(node.lineno), default) + ) + # Every refusal-helper DEFINITION (`_refuse_*` / `_reject_*`) and every # #414-family isolation-refusal CALL — the two surfaces `REFUSAL_HELPER_DEFS` # and `ISOLATION_CONFLICT_CALLERS` enumerate. The def side needs no alias @@ -2867,39 +2946,84 @@ def test_journal_kind_inventory_is_complete(): `JOURNAL_KINDS`: it sees the union of producers, so one writer of a SHARED kind can drop it without reddening anything while another still writes it. - Dynamic kinds are deliberately absent (`JOURNAL_KINDS`' stated bound): a - non-literal kind emits no `journalkindliteral` finding, and the sibling - literalness test above governs whether its POSITION may be dynamic at all. - Consumer-side kind parity — readers matching kinds by literal — stays DW-82's, - out of scope here. + A declared dynamic-kind position writes a parameter, so its kinds are read + where a literal reaches it — a caller's `kind="..."` keyword, or the parameter + default — by the emit's second arm (`JOURNAL_KINDS`' header); only the f-string + family is absent, by `JOURNAL_KINDS`' stated bound, and the sibling literalness + test above governs whether a POSITION may be dynamic at all. Consumer-side kind + parity — readers matching kinds by literal — stays DW-82's, out of scope here. Anti-vacuity is structural: the declared set is non-empty, so deleting the `journalkindliteral` emit reddens the staleness arm with the entire inventory rather than passing green. - Ablation: delete the `journalkindliteral` emit and this reddens with all 200 + Both arms are graded from ONE scan in ONE assertion + (`_journal_kind_inventory_drift`): as two sequential asserts a rename reported + only the undeclared spelling, and the stale row surfaced a run later, after the + new row had landed (review pass 2). + + Ablation: delete the `journalkindliteral` emit and this reddens with all 204 rows stale; duplicate engine.py's epic-boundary write under the kind - `"guard-ablation-probe"` and ONLY this test reddens, naming the kind and - site.""" - findings = _of("journalkindliteral") + `"guard-ablation-probe"` and ONLY this test reddens, naming the kind and site; + add `self._skip_review_and_commit(task, kind="guard-ablation-probe")` to + engine.py and ONLY this test reddens, naming the call; rename engine.py's + `epic-boundary` write and the ONE failure names both the new spelling and the + stale row.""" + undeclared, stale = _journal_kind_inventory_drift(_of("journalkindliteral")) + assert (undeclared, stale) == ([], set()), ( + "the literal journal kinds and JOURNAL_KINDS disagree. A kind a producer " + "writes but no row declares — add its row IN THE SAME PR as what covers its " + "record: a diagnostics routing row if any field carries an identifier, a " + "path or free text, and the test asserting the record at the layer that " + "reads it; or drop the write. A row no producer writes any more — delete it " + "and retire its routing/test rows deliberately, because a stale row " + "pre-approves the next record that reuses the name:\n" + + "\n".join( + f" undeclared {rel}:{ln}: {kind!r} — {txt.strip()}" + for rel, ln, txt, kind in undeclared + ) + + ("\n" if undeclared and stale else "") + + "\n".join(f" stale row: {kind!r}" for kind in sorted(stale)) + ) + + +def _journal_kind_inventory_drift( + findings, +) -> tuple[list[tuple[str, int, str, str]], set[str]]: + """Both arms of the kind inventory from one set of `journalkindliteral` + findings: the literal kinds written but undeclared (with their sites), and the + declared rows nothing writes any more. Returned together so the inventory test + can grade them in one assertion — a rename is one defect with two faces.""" scanned = {kind for _, _, _, _, kind in findings} undeclared = [ (rel, ln, txt, kind) for _, rel, ln, txt, kind in findings if kind not in JOURNAL_KINDS ] - assert undeclared == [], ( - "a journal producer writes a literal kind that JOURNAL_KINDS does not " - "declare — add the kind's row IN THE SAME PR as what covers its record: a " - "diagnostics routing row if any field carries an identifier, a path or " - "free text, and the test asserting the record at the layer that reads it; " - "or drop the write:\n" - + "\n".join(f" {rel}:{ln}: {kind!r} — {txt.strip()}" for rel, ln, txt, kind in undeclared) - ) - stale = JOURNAL_KINDS - scanned - assert stale == set(), ( - "JOURNAL_KINDS declares kinds no producer writes any more — a stale row " - "pre-approves the next record that reuses the name; delete these rows and " - f"retire their routing/test rows deliberately: {sorted(stale)}" - ) + return undeclared, JOURNAL_KINDS - scanned + + +def test_journal_kind_inventory_drift_reports_a_rename_on_both_arms(): + """A rename is one undeclared spelling AND one stale row, from the same findings. + + Ablation: make `_journal_kind_inventory_drift` return the stale arm only when + the undeclared arm is empty (the sequential-assert shape) and this reddens.""" + synthetic = [ + ("journalkindliteral", "engine.py", 1, f'journal.append("{kind}")', kind) + for kind in sorted(JOURNAL_KINDS) + if kind != "epic-boundary" + ] + [ + ( + "journalkindliteral", + "engine.py", + 7728, + 'self.journal.append("epic-boundary-renamed", epic=e)', + "epic-boundary-renamed", + ) + ] + undeclared, stale = _journal_kind_inventory_drift(synthetic) + assert [(rel, ln, kind) for rel, ln, _, kind in undeclared] == [ + ("engine.py", 7728, "epic-boundary-renamed") + ] + assert stale == {"epic-boundary"} def test_journal_field_guard_actually_saw_the_producers(): @@ -4786,6 +4910,28 @@ def test_journal_kind_literal_probes_extract_the_kind(): "plugin-loaded", ), ('def f(self):\n self._log("plugin-hook", rc=rc)\n', "plugins/bus.py", "plugin-hook"), + # The kinds a declared dynamic-kind POSITION receives from outside it: the + # literal `kind=` a caller hands it, and the position's own parameter + # default — keyword-only (`engine._skip_review_and_commit`) or + # positional-or-keyword (`sweep._close_bundle_ledger_when_spec_status`). + # The write inside spells a parameter, so nothing else reads these. + ( + 'def f(self):\n self._skip_review_and_commit(task, kind="review-skipped-awaiting-operator")\n', + "engine.py", + "review-skipped-awaiting-operator", + ), + ( + 'def _skip_review_and_commit(self, task, *, kind="review-skipped"):\n' + " self.journal.append(kind, story_key=s)\n", + "engine.py", + "review-skipped", + ), + ( + "def _close_bundle_ledger_when_spec_status(self, task, spec_file, status, " + 'kind="sweep-bundle-closed"):\n return None\n', + "sweep.py", + "sweep-bundle-closed", + ), ): found = [f[4] for f in _scan_source(source, rel) if f[0] == "journalkindliteral"] assert found == [kind], f"extracted {found} from:\n{source}" @@ -4811,6 +4957,17 @@ def test_journal_kind_literal_probes_stay_silent_on_lookalikes(): 'def f():\n """journal.append("prose-kind") is described here."""\n return 1\n', "sweep.py", ), + # The forwarder-kind arm is keyed `(file, name)` like the position it + # serves: the same call in a file that declares no such position, a + # `kind=` keyword on an undeclared callee, a non-literal `kind=` at the + # declared position, and a non-string default on its def are all silent. + ( + 'def f(self):\n self._skip_review_and_commit(task, kind="review-skipped")\n', + "sweep.py", + ), + ('def f(self):\n self.emit(kind="review-skipped")\n', "engine.py"), + ("def f(self):\n self._skip_review_and_commit(task, kind=chosen)\n", "engine.py"), + ("def _skip_review_and_commit(self, task, *, kind=None):\n return None\n", "engine.py"), ): assert not [f for f in _scan_source(source, rel) if f[0] == "journalkindliteral"], source