diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b1be41..f15494af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,92 @@ breaking changes may land in a minor release. ### Fixed +- 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 + real spec kept its terminal status, so the next dispatch did not re-plan. +- Anchor a spec's confinement root on a tree that can actually contain it, so the status + flip, the result strip and the baseline re-stamp no longer fall back to an unguarded + write, and the re-arm's undo no longer fails outright. +- Re-anchor spec ownership and the attempt baseline before a discarded worktree is dropped, + in both the engine and sweep. A later resume could otherwise probe the main checkout — + deleting untracked files the operator already had, or restoring a dead attempt's spec + over their own copy. +- Release spec ownership when a half-built worktree is discarded for a restart, so the + replacement mount can bind the spec. The attempt's binding is cleared and the accepted + spec returns to its mount-relative spelling; left absolute into the deleted tree it + resolved to nothing, and the restarted attempt ran unbound with the repair prompt naming + a path that no longer existed. +- Release a mount's state when a resumed run stops treating it as isolated. Flipping + `[scm] isolation` to `none` left the re-anchored spec absolute inside a mount the resume + neither reopens nor discards, so the in-place attempt ran unbound; worse, it carried the + unit's `baseline_commit`/`baseline_untracked` into an in-place rollback of the main + checkout, where a unit's empty untracked snapshot marks every untracked file in the + operator's own tree as attempt debris — deleted outright under an auto-recovering cause. + The task also stops CLAIMING the mount: `worktree_path` doubles as the record of which + tree owns a task's persisted state, so keeping it set made the spec and stories-root + helpers answer for the unit while execution used the project checkout. Every + non-isolated leg releases, not only the restart: the spec-approval, recorded-result and + commit-finalizer continuations each finish their work and return without ever reaching + it, so they went on consuming a spec absolutized into the orphan. The directory itself is + left standing and the orphan is journaled, and a later flip back to `worktree` now + reclaims it — the mount path is deterministic, so the leftover checkout made that second + flip fail outright. The shared run branch is spared by the reclaim, keeping the commits + earlier units landed on it. +- Fall back to the project when a task's recorded worktree is gone. Successful integration + retires a task without clearing `worktree_path`, so the TUI's story-checkpoint card + looked for `stories.yaml` under a deleted mount and lost the committed story's title and + description. +- Tell the resolve session where an unreachable correction has to land. `context.json` now + carries `redrive_base_ref` beside the reachability verdict, and the skill spells out that + committing from the main checkout cannot include a file in a linked worktree and that the + unit's own branch is not what the replacement mount is cut from. +- Locate the stories folder from the workspace root rather than from the spec's confinement + root, so one modal can no longer read its spec from the run's tree and its sentinel from + the project. +- Anchor pause notifications and the `checkpoint-pause` journal on the run's tree, sprint + mode included. The dev-session prompt keeps the raw path — that session runs in the mount. +- Keep the dashboard up on a spec that is absent or undecodable: an absent spec reads as an + explicit read failure rather than an empty body, and a bad byte degrades in place instead + of losing the document. +- Refuse `Re-arm & resume` on a spec that could not be read, and report its blocking + condition as unknown rather than absent. `Resolve` stays offered — it writes nothing and + is what repairs a bad anchor. +- Report in `context.json` whether an edit to the spec survives to the re-drive, and teach + the resolve skill to act on it: under isolation the mount is discarded first, so an edit + to a worktree-local spec is lost unless it is committed. +- Decide whether the re-drive will run isolated from live policy instead of from the mount + the escalated attempt recorded. `resolve` builds `context.json` in a separate process + before the resume, so editing `[scm] isolation` while a story sat escalated made its + advice wrong in opposite directions: switched to `none`, the session was told to commit + the correction on the run's pinned branch, which an in-place re-drive never reads; + switched to `worktree`, it was told a working-tree edit was safe when the replacement + mount reads only committed content. The re-arm's unreachable-write record now says which + of the two remedies applies, and the resolve skill spells out that a `HEAD` base means + re-applying the correction in the main checkout rather than committing it anywhere. The + TUI's re-arm refuses when `policy.toml` cannot be read rather than guessing a mode — a + re-arm consumes the escalation, so a wrong guess is unrecoverable. +- Hold a re-armed SENTINEL until its upstream correction reaches the re-drive. A + pre-planning sentinel is cleared by deletion, so that arm dropped the spec and returned + before the reachability gate the status-flip arm runs — no gate was ever computed for it. + The correction that stops the sentinel recurring is upstream (`SPEC.md` / `stories.yaml`, + where the resolve skill sends the agent instead of the sentinel), and an isolated re-drive + re-plans from the committed tree of a fresh mount, so an uncommitted upstream edit was + invisible and the re-plan minted the same sentinel again, spending the escalation. The + re-arm now records `rearm-upstream-write-unreachable`, names the folder and the branch to + commit on, and stops the re-arm-and-resume gesture. Narrowed by proof rather than by + configuration: the record fires only while the ref 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 as before. An in-place re-drive never records — it reads the + main checkout, which is where the resolve session runs. +- Re-read `[scm] isolation` after the interactive resolve session, before the re-arm. + `resolve` loaded policy, then blocked on a human conversation of unbounded length, then + keyed the re-arm on that stale answer while the engine it arms re-read policy for itself. + Editing isolation while the agent was open therefore split the two readers: `none` to + `worktree` re-armed treating the main-checkout correction as reachable, emitted no hold, + 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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f46c7f33..d8a28a94 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -78,9 +78,18 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se spec and task never silently agree on a stale sha (#640). 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 re-arm re-anchors it on - that worktree before writing — resolved against the process cwd it named the main checkout's copy - of the same story spec, and both writes landed on a file the run never used. A spec re-arm still + longer report. `spec_file` is persisted relative to a worktree for an isolated task, so every out-of-process reader + re-anchors it on the tree the run owns before reading or writing — resolved against the process cwd it named the main checkout's copy + of the same story spec, and both writes landed on a file the run never used. The same + anchor backs the dashboard's review modals and their replan write, `context.json`'s + `spec_file`, and the paths the pause notifications print. The fields beside it (the sentinel + indicator, the stories block) take a DIFFERENT root by design: they resolve against the + workspace stories root, not the spec's confinement root, whose out-of-mount arm falls back to + the project so a `confine_root` can always contain the path it validates — borrowing that + answer for a READ would look the stories folder up in the main checkout while the dev session + answered the worktree. Each names the tree the run owns, so a single surface cannot describe + two trees. The dev session's own prompt keeps the relative spelling, + because that session runs inside the mount. A spec re-arm still cannot read has its baseline re-stamp skipped rather than silently no-oped (`rearm-baseline-restamp-skipped`), and a status flip that quietly changed nothing is reported too (`rearm-spec-flip-skipped`) — though not when the spec was simply already at the target status, @@ -108,6 +117,15 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w escalation on a session that halts blocked. They now stop after the re-arm — the story stays armed, `bmad-loop resume ` picks it up once the fix is committed, and `--resume` does not override it, since the record is written on proof rather than suspicion. The advisory warnings do not hold. + A pre-planning **sentinel** gets the same treatment on its own artifacts. It is cleared by + deletion rather than a status flip, so there is no spec write to measure — but the correction that + stops it recurring is upstream (`SPEC.md` / `stories.yaml`, where the resolve skill sends the agent + instead of the sentinel), and an isolated re-drive re-plans from the committed tree of a fresh + mount. Re-arm now says so (`rearm-upstream-write-unreachable`), names the folder and the branch, + and holds the resume the same way. Narrowed on the same principle: it fires only while the branch + 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. 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/docs/tui-guide.md b/docs/tui-guide.md index 864120d4..3eb83955 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -470,7 +470,11 @@ artifacts the engine already wrote. - **Plan checkpoint** (`spec_checkpoint`, stories mode) — a read-only viewer of the planned `ready-for-dev` spec at its id-keyed path (shown prominently, with a - copy-path action). **Approve & resume** resumes straight to implementation; + copy-path action). Under worktree isolation the path is anchored on the tree the + run owns, not on the directory the dashboard was launched from, so the viewer and + the replan write both act on the run's own copy rather than the main checkout's + twin. A spec that cannot be read at that path says so explicitly and its actions + are disabled — an unreviewable gate is not approvable. **Approve & resume** resumes straight to implementation; **Request replan** resets the spec to `draft` and strips its Auto Run Result (via the same `devcontract` primitives the engine's repair path uses), then resumes so the next dispatch re-plans. Edit the markdown in your own editor — the @@ -482,13 +486,24 @@ artifacts the engine already wrote. - **Escalation** — the escalation view enriched with story context: the story entry's title/description (from `stories.yaml`), the blocking condition parsed from the spec's `## Auto Run Result`, and a sentinel indicator when the matched - spec is a fixed-slug pre-planning-halt sentinel. **Resolve** launches the same - interactive agent as `R`; **Re-arm & resume** (offered once the resolve agent has - recorded a resolution) re-arms and resumes — deleting a sentinel with a preserved - copy for a clean re-dispatch. Both refuse a still-live engine. + spec is a fixed-slug pre-planning-halt sentinel. Both of those answer from the + tree the run owns: under isolation the spec text and the sentinel live in the + unit's mount, and reading either from the launch directory let one modal + contradict itself. A spec that cannot be read reports its blocking condition as + unknown rather than absent — the two are otherwise indistinguishable — and + refuses **Re-arm & resume**, which would flip the spec's frontmatter, strip its + result and re-stamp the baseline on evidence nobody could read. **Resolve** stays + offered: it neither re-arms nor rewrites the spec — it writes the resolver's + `context.json` and starts the repair session, which is what repairs a bad anchor — and + gating it would have left `close` as the modal's only action while the `R` binding + reached the same agent anyway. **Resolve** launches the same interactive agent as `R`; + **Re-arm & resume** (offered once the resolve agent has recorded a resolution) + re-arms and resumes — deleting a sentinel with a preserved copy for a clean + re-dispatch. Both refuse a still-live engine. - **Spec-approval / epic / story gate** — a spec-approval gate reuses the spec viewer (view the finalized spec, then **Approve & resume**), so the pre-existing sprint-mode - gate inherits the same richer surface. Story-gate and epic-boundary pauses have no + gate inherits the same richer surface — including the anchored read and the refusal + of **Approve & resume** on a spec that cannot be read. Story-gate and epic-boundary pauses have no spec to show — a story gate fires before the story is recorded, an epic boundary has no story at all — so they open a compact pause-reason viewer instead: the reason names the blocking entries and the remedy, and **Resume** re-picks the story and re-asks the diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 96a33a8a..3bd3fade 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2917,6 +2917,28 @@ def cmd_resolve(args: argparse.Namespace) -> int: f"no resolution recorded for {story_key} (agent did not write resolution.json)", file=sys.stderr, ) + # `pol` was read BEFORE a session that blocks on a human conversation of + # arbitrary length, and everything below keys the re-arm on its isolation mode + # while `_resume_paused_run` at the bottom of this function re-reads policy for + # the engine. An edit made while the agent was open would therefore re-arm under + # the old answer and re-drive under the new one — `none -> worktree` re-arms + # treating the main-checkout edit as reachable, emits no hold, and then mounts a + # fresh worktree cut from git that cannot see it: the escalation is spent and the + # story re-wedges. Re-read so the re-arm and the engine agree, which is also what + # lets the reachability gate below fire against the mode actually in force. + # Unguarded, exactly like the first load above: nothing has been mutated yet, so + # an unreadable policy aborts before the re-arm rather than guessing a mode — and + # `resolution.json` is already on disk, so `--no-interactive` resumes the work. + isolation_before_session = pol.scm.isolation + pol = policy_mod.load(_policy_path(project)) + if pol.scm.isolation != isolation_before_session: + print( + f"warning: [scm] isolation changed " + f"{isolation_before_session} -> {pol.scm.isolation} during the resolve " + "session; re-arming against the new mode (the agent was told where the " + "correction had to land under the old one)", + file=sys.stderr, + ) # resolution.json restore latch: only exists after the session ran, so this # arm of the validation cannot be hoisted above it. @@ -2989,7 +3011,12 @@ def cmd_resolve(args: argparse.Namespace) -> int: before_entries = runs.journal_entries_or_none(run_dir) hold_resume = False try: - runs.rearm_escalation(run_dir, story_key, restore_patch=restore_patch) + runs.rearm_escalation( + run_dir, + story_key, + restore_patch=restore_patch, + isolated_redrive=pol.scm.isolation == "worktree", + ) except runs.RearmError as e: print(f"error: {e}", file=sys.stderr) return 1 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 d13536df..f6e5353f 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -32,6 +32,8 @@ These environment variables are set: "story_key": "6-4-cli-list-command", "run_id": "20260613-111429-6a14", "spec_file": "/abs/path/to/_bmad-output/implementation-artifacts/spec-.md", + "spec_reaches_the_redrive": true, + "redrive_base_ref": "", "baseline_commit": "", "paused_reason": "CRITICAL escalation from review session: ...", "escalations": [ @@ -45,6 +47,46 @@ These environment variables are set: } ``` +**`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. + +**`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. + +Do not skip the edit when it is `false` — the corrected spec is what gets carried +over, and it is the clearest statement of what you and the human agreed. Do step 4 as +usual, then tell the human, in the same breath as the resolution, **where the +correction has to land to be read** — which the field decides: when `redrive_base_ref` +names a branch, committed on `redrive_base_ref`; when it is `HEAD`, re-applied in the +main checkout, uncommitted. The two paragraphs below carry each arm. + +Be precise about this, because the two obvious moves both fail silently: + +- Committing from the **main checkout** cannot include the file you edited — it lives + in a linked unit worktree, which is a separate working tree. +- Committing on the **unit's own branch** does not reach the re-drive either. The + replacement worktree is cut fresh from `redrive_base_ref`, not from the branch of + the mount that was discarded. + +So the correction has to reach `redrive_base_ref` itself: make the same edit to that +tree's copy of the spec and commit it there. The orchestrator names the same ref when +it re-arms; say it here so they hear it before they close the session rather than +after. + +**When `redrive_base_ref` is `HEAD`, do not tell them to commit anything.** That means +the re-drive runs in the **main checkout** and reads its WORKING TREE, so an edit there +is read as-is. `spec_reaches_the_redrive: false` beside a `HEAD` base is the opposite +problem from the one above: `spec_file` points into a worktree this run has STOPPED +using, because its isolation policy changed while the story sat escalated. The remedy +is to make the same edit to the main checkout's copy of the spec — no commit, no +branch. Telling them to commit here sends them to a tree the re-drive does not read, +which is the same lost work in the other direction. + In **stories mode** (folder+id dispatch) the context also carries a `stories` block — the manifest intent for this story, so you can see WHAT it is meant to do without hunting for it: @@ -105,7 +147,13 @@ case below — omit it entirely for an ordinary resolution. `` block and any affected acceptance criteria / test matrix rows so a fresh dev session has exactly one correct reading. Make the smallest change that removes the ambiguity. You MAY use the `bmad-spec` or - `bmad-correct-course` skills if a larger spec change is warranted. + `bmad-correct-course` skills if a larger spec change is warranted. **If + `spec_reaches_the_redrive` is `false`, make the same edit and then say plainly + that this copy is not the one the re-drive reads, and name the remedy that + `redrive_base_ref` selects: on a branch, the correction must be + committed on `redrive_base_ref`; on `HEAD`, it must be re-applied in the main + checkout, uncommitted** — an unflagged edit here is lost work, and a commit in the + wrong tree or on the wrong branch is lost work that looks done. 5. **Write the resolution marker** at `resolution_path` (schema above), then tell the human the resolution is recorded and they can exit this session — the orchestrator will offer to **re-arm the story and resume the run** (a clean @@ -170,7 +218,11 @@ entirely: the orchestrator re-drives from scratch against the corrected intent. field — the orchestrator deterministically re-arms the spec status on resume. Edit spec **content** only. - **Do NOT** implement the story, write feature code, run tests, or commit. Your - job ends at a corrected spec + the resolution marker. + job ends at a corrected spec + the resolution marker. That holds when + `spec_reaches_the_redrive` is `false` too: landing the corrected spec where the + re-drive reads it is the HUMAN's step — committing it on `redrive_base_ref` when that + names a branch, re-applying it in the main checkout when it is `HEAD`. Tell them it + is required, and which one; do not do it yourself. - **Do NOT** widen scope. Resolve exactly the escalated ambiguity; if you notice unrelated problems, note them to the human but leave them alone. @@ -191,6 +243,16 @@ block inside it. So for a sentinel: planning pass can succeed: usually that means clarifying `SPEC.md` (the epic spec) or this story's entry in `stories.yaml` — the `title` / `description` / `invoke_dev_with` the planner reads — with the human. +- **`redrive_base_ref` decides where that upstream edit has to land, exactly as it + does for a spec.** `spec_reaches_the_redrive` does not answer this — it is about + `spec_file`, which for a sentinel is the file being deleted. The artifacts you + actually edit are `SPEC.md` / `stories.yaml`, and they face the same question: when + `redrive_base_ref` names a **branch**, the re-drive mounts a fresh worktree and + re-plans from that branch's COMMITTED tree, so an uncommitted edit is invisible and + the re-plan mints the same sentinel again — tell the human it has to be committed + there. When it is `HEAD`, the re-drive re-plans in the main checkout's working tree + and the edit is read as-is — do not tell them to commit. The orchestrator re-arms on + the same rule and will hold the resume until the branch carries it. - On **re-arm** the orchestrator does NOT flip the sentinel to `ready-for-dev` (there is no plan to route to). It **preserves a copy** of the sentinel under `{run}/sentinels/-.md` as a breadcrumb, **deletes** the sentinel, and diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 0ed34a58..73be7f2d 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -139,16 +139,16 @@ # `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: - # `spec_file` is now always ABSOLUTE, because all four kinds journal - # `str(_task_spec_path(...))`, whose anchors (`task.worktree_path`, `state.project`) - # are absolute in every production path; while `spec` is NOT uniformly absolute — - # engine's reconcile and marker-repair kinds journal an absolute `str(spec_path)`, - # but `stories_engine`'s `checkpoint-pause` journals the raw persisted - # `task.spec_file`, which is worktree-relative for a task that ran under isolation. - # Same value, same hazard, same namespace. Do NOT read this as "one field is - # already normalized, so the basename step is dead": `_JOURNAL_BASENAME_NAMESPACES` - # keys on the NAMESPACE rather than the field precisely so both spellings reduce to - # one alias whichever shape either happens to carry. + # 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 + # `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 + # normalized, so the basename step is dead" — it is not a guarantee this module + # holds. `_operator_spec_path` still answers a bare STORY KEY for a spec-less task, + # nothing stops a future producer from journaling a raw `task.spec_file`, and + # `_JOURNAL_BASENAME_NAMESPACES` keys on the NAMESPACE rather than the field + # precisely so every spelling reduces to one alias whichever shape it carries. "spec_file": "spec", } # Kind-scoped routing, consulted BEFORE the by-name table above and losing to @@ -186,9 +186,12 @@ # 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 # reconcile and marker-repair kinds journal `str(spec_path)` (absolute — -# `verify.resolve_spec_path` returns an absolute path), while stories_engine's -# `checkpoint-pause` journals `task.spec_file`, which `StoryTask` persists -# worktree-relative (a bare basename for a spec at the worktree root). Aliasing +# `verify.resolve_spec_path` returns an absolute path). Every producer is absolute +# TODAY — `checkpoint-pause` moved to `_operator_spec_path` — but the reduction is +# keyed on the NAMESPACE rather than on any producer precisely so that stays a +# property this module does not have to trust: `_operator_spec_path` still answers a +# bare STORY KEY for a spec-less task, and nothing stops a future producer journaling +# a raw `task.spec_file`. Aliasing # the raw string would give ONE spec TWO aliases in a single dump, defeating the # correlation these fields are aliased rather than dropped to preserve, and would # park an absolute home path in the local `--legend` file (before `spec` was @@ -247,6 +250,16 @@ # free-text rule above (it is a `GitError` string quoting git's own stderr), # not this identifier-shape argument — same set, different rationale. "repo", + # An absolute host path naming the folder a sentinel's upstream correction has + # to land in (`rearm-upstream-write-unreachable`). Dropped for `repo`'s reason, + # not aliased for `spec_file`'s: it is a DIRECTORY, journalled by one kind, and + # one run has one spec folder — so it correlates nothing across events, while a + # `spec` alias would additionally be wrong, since that namespace reduces to a + # basename and every run we author would collapse onto the same `stories`-ish + # tail. `scrub_json` already redacts any real path (`_IDENTIFIER_RE` forbids + # `/`, `\` and `:`), so — exactly as for `repo` — only an assertion on the + # field's ABSENCE can grade this, and the canary sweep cannot. + "stories_root", } ) # Journal fields whose value is a LIST of story keys (sprint unknown-keys). diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 0dee3579..9a0810aa 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -76,6 +76,7 @@ read_stop_request_mode, reset_owner_run_dir, set_owner_run_dir, + task_spec_path, ) from .sprintstatus import ACTIONABLE_STATUSES, STATUS_ORDER, SprintStatusError from .sprintstatus import advance as sprint_advance @@ -1355,6 +1356,134 @@ def _protected_relpaths(self) -> tuple[str, ...]: def _rollback_or_pause(self, task: StoryTask, *, cause: str = "stopped") -> None: self._recovery_flow.rollback_or_pause(task, cause=cause) + def _discard_unit_for_restart(self, task: StoryTask) -> None: + """Drop a half-built unit worktree and the four fields that LOCATE it. + + Scoped deliberately: `worktree_path`, `branch`, `baseline_commit` and + `baseline_untracked` all name the mount or a measurement taken inside it, and + each is wrong the moment it is gone. + + Spec ownership is released through `task.release_spec_paths_from_mount()`, + which clears the attempt-owned pair and returns `spec_file` to the + mount-relative spelling. It runs BEFORE `worktree_path` is cleared, because + the relativization is measured against it. + + An earlier version left that pair alone, reasoning that + `_bind_dispatched_spec_for_attempt` rebinds on the next attempt before any + reader acts on it. The rebind does run — and returns None. The caller + re-anchors both fields immediately above, so by the time the mount is deleted + `spec_file` is an ABSOLUTE path into it; `_dispatched_spec_for_attempt` + resolves that `strict=True`, raises, and leaves the fresh attempt unbound on + a story whose spec is sitting in the replacement mount at the same relative + place. Nothing downstream repairs it — `_record_dev_spec` no-ops while + `spec_file` is set — so the repair prompt goes on naming the deleted path. + The relative spelling is what `verify.resolve_spec_path` re-probes against + the live workspace, which is how this bound correctly before the re-anchor + existed. + + `baseline_ledger_digest` and the `pre_harvest_ledger` pair are measured in the + mount too (`_ledger_digest` reads `workspace.paths.deferred_work`, which is + rebased onto the unit under isolation) and are deliberately NOT cleared. The + criterion is not "measured in the mount" but "read before anything re-measures + it": `baseline_commit` has the `elif task.baseline_commit:` arm below, which + fires on a later resume that finds `worktree_path` empty, while every path out + of here forces `Phase.PENDING` and saves — so `_resumable_session` (which + answers only for `*_RUNNING`/`*_VERIFY`) returns None and `_dev_phase` always + re-enters with `resume_result is None`, re-stamping the digest at its own + fresh-entry block. Clearing the ledger pair would be actively wrong: it is the + crash-replay attribution `_disarm_ledger_snapshot` exists to preserve. + + `worktree_path`/`branch` name the mount; `baseline_commit`/`baseline_untracked` + were MEASURED in it (`_dev_phase` stamps both from `self.workspace.root`, which + is the unit under isolation). Clearing only the first pair leaves the second + describing a tree that no longer exists, and the restart arm's own + `elif task.baseline_commit:` hands them to `recovery_flow.rollback_or_pause` + against the MAIN checkout on any later resume that finds `worktree_path` empty. + + That state is durable and reachable without a host death: the caller saves + right after this, and `worktree_flow.run_isolated` assigns `task.worktree_path` + only AFTER `open_unit_workspace` returns, so a `GitSpawnError` there pauses the + run with the cleared value already persisted. + + Neither operand fails loud there. Linked worktrees share the main repo's object + database, so force-deleting the unit branch does NOT make the baseline + unresolvable -- a `git reset --hard` onto it succeeds from the main checkout. + And a fresh worktree is a tracked-only checkout, so `baseline_untracked` is + effectively empty; `verify._rollback_cleanup_plan` computes + `untracked_files(repo) - set(baseline_untracked)` as its DELETION list, so every + untracked file in the operator's own checkout reads as this attempt's debris. + Under `scm.rollback_on_failure` that unlinks them; with the default off it + pauses on a dirtiness no operator action can clear, which is the exact + non-termination `rollback_or_pause`'s docstring promises against. + + `_dev_phase` re-stamps both from the replacement mount, so clearing costs the + restart nothing -- it turns the `elif` into a correct no-op rather than a probe + of the wrong tree. `None` (not `[]`) for the untracked half is the value + `attempt_dirty` and `_rollback_cleanup_plan` both read as "nothing here is this + attempt's to remove", and the same one `sweep`'s migration refusal already uses. + """ + discard_worktree( + self.paths.repo_root, task.worktree_path, task.branch, 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`. + """ + if not task.worktree_path: + return + orphan = task.worktree_path + # before the clears: the relativization is measured against this field + task.release_mount_owned_state() + task.worktree_path = "" + task.branch = "" + self.journal.append( + "isolation-flip-orphaned-worktree", + story_key=task.story_key, + worktree=orphan, + ) + def _safe_reset(self, task: StoryTask, *, preserve: tuple[str, ...] = ()) -> None: self._recovery_flow.safe_reset(task, preserve=preserve) @@ -1497,6 +1626,22 @@ def _finish_inflight(self) -> None: for task in list(self.state.tasks.values()): 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. + 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: # _defer records its reason before carrying harvested findings. @@ -1528,6 +1673,7 @@ def _finish_inflight(self) -> None: self.workspace = prev self._integrate_unit(task, unit) else: + self._release_orphaned_mount(task) self._resume_after_dev_verify(task) elif (resumable := self._resumable_session(task)) is not None: # the host died inside the post-session window: the session @@ -1558,6 +1704,7 @@ def _finish_inflight(self) -> None: self.workspace = prev self._integrate_unit(task, unit) else: + self._release_orphaned_mount(task) continuation() elif task.phase == Phase.COMMITTING: # the host died in the commit window: the gate+advance save @@ -1579,6 +1726,7 @@ def _finish_inflight(self) -> None: self.workspace = prev self._integrate_unit(task, unit) else: + self._release_orphaned_mount(task) self._finalize_commit_phase(task) else: # This arm is the one that does not finish work: it discards the @@ -1610,12 +1758,15 @@ def _finish_inflight(self) -> None: ) if isolated: # drop the half-built worktree; _run_story mounts a fresh one - discard_worktree( - self.paths.repo_root, task.worktree_path, task.branch, run_dir=self.run_dir - ) - task.worktree_path = "" - task.branch = "" - elif task.baseline_commit: + self._discard_unit_for_restart(task) + else: + # A mount live policy no longer treats as isolated. Released HERE, + # below `_refuse_gated_story` — that gate can raise `RunPaused`, and + # until an arm commits to acting the anchored spelling is what makes + # recovery refuse loudly instead of rewriting the main checkout. + self._release_orphaned_mount(task) + + if not 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 @@ -1908,6 +2059,30 @@ def _run_story(self, task: StoryTask) -> None: self._drive_story(task) self._emit("post_story", task) + def _operator_spec_path(self, task: StoryTask) -> str: + """The task's spec spelled the way an operator can actually open it. + + Every pause that hands a human a path and tells them to review it goes through + here, and the journal records the same string. `task.spec_file` is persisted + RELATIVE to the mounted worktree under isolation + (`model._serialized_worktree_path`), so the raw value resolves against whatever + directory the operator happens to be in — the main checkout, which carries the + same layout and answers with the wrong tree's copy. That is the identical defect + the TUI's `_paused_spec` carries a docstring about; this is the surface the + operator meets FIRST, before any dashboard. + + Defined on `Engine` rather than on `StoriesEngine`, where it started, because + sprint mode pauses for spec approval too (`_drive_story` below) and it is + isolation-capable through `_run_isolated` — so the same relative spelling + reached the same operator from the sibling engine. + + Falls back to the story key on a spec-less task, matching `spec_ref` in + stories mode rather than raising out of a notification path. + """ + if not task.spec_file: + return task.story_key + return str(task_spec_path(task, self.state)) + def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) -> None: if not self._dev_phase(task, resume_result=dev_resume): return @@ -1916,7 +2091,8 @@ def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) self.policy, self.run_dir, f"spec ready for approval: {task.story_key}", - f"review {task.spec_file}, then `bmad-loop resume {self.state.run_id}`", + f"review {self._operator_spec_path(task)}, then " + f"`bmad-loop resume {self.state.run_id}`", ) raise RunPaused( f"awaiting spec approval for {task.story_key}", diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 71d2308c..c9f784c2 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -174,6 +174,20 @@ def from_dict(cls, d: dict[str, Any]) -> "SessionRecord": ) +def _rebased_on(path: str | None, root: Path) -> str | None: + """One persisted spec path, re-anchored on `root`; absolute values pass through. + + Split out so `StoryTask.rebase_spec_paths_on` states the rule once per field + without repeating the guard, and so the guard itself is unmissable: the + is-absolute test is what keeps an out-of-mount spec (persisted verbatim by + `_serialized_worktree_path`) from being joined onto a root that does not + contain it. + """ + if not path or Path(path).is_absolute(): + return path + return str(root / path) + + @dataclass class StoryTask: story_key: str @@ -462,6 +476,92 @@ def _serialized_worktree_path(self, path: str | None) -> str | None: except ValueError: return path # spec lives outside the worktree; keep absolute + def release_spec_paths_from_mount(self) -> None: + """Give up the spec ownership a mount being DISCARDED carried. + + The counterpart to :meth:`rebase_spec_paths_on`, and deliberately not its + exact inverse — the two fields part company here because their roles do: + + * `dispatched_spec_file` / `dispatched_spec_snapshot` are the ATTEMPT's + binding, the pair `recovery_flow` restores bytes through. The attempt died + with its tree, so the binding has nothing left to name; clearing both + together keeps the authority pair whole (a path without its snapshot is the + one shape `_bind_dispatched_spec_for_attempt` never persists). + * `spec_file` is the ACCEPTED artifact and outlives the attempt. The + replacement mount will carry the same story's spec at the same + mount-relative place, so the relative spelling is the one that re-resolves + onto it — `verify.resolve_spec_path` probes a relative value against the + live workspace and passes an absolute one through untouched. + + Leaving `spec_file` absolute into the deleted mount is what made the fresh + attempt start UNBOUND: `_dispatched_spec_for_attempt` resolves it + `strict=True`, the dead path raises, and the miss is silent because an + unbound attempt is a legal state. `_record_dev_spec` cannot repair it either + — it no-ops while `spec_file` is set. + + Uses the same relativization as `to_dict`, so the discarded-mount spelling + and the persisted one cannot drift, which also means a spec OUTSIDE the mount + stays verbatim: it was never the mount's to give up. MUST be called while + `worktree_path` still names the mount. + """ + self.dispatched_spec_file = None + self.dispatched_spec_snapshot = None + self.spec_file = self._serialized_worktree_path(self.spec_file) + + def release_mount_owned_state(self) -> None: + """Give up EVERYTHING a mount owned: its spec ownership and the measurements + taken inside it. + + One method because the two callers that stop using a mount — the restart + discard and the isolation-flip arm — must give up the same set, and the second + was written releasing only the spec half. That half-release is not a smaller + version of the same thing, it is a different bug: `baseline_commit` and + `baseline_untracked` are stamped from `self.workspace.root` (the unit under + isolation), so leaving them set hands unit-mount operands to + `recovery_flow.rollback_or_pause` running against the MAIN checkout. Neither + fails loud there — linked worktrees share the object database, so the baseline + still resolves and a reset onto it succeeds, while a fresh worktree is a + tracked-only checkout whose empty untracked snapshot makes + `verify._rollback_cleanup_plan` compute `untracked_files(repo) - + baseline_untracked` as every untracked file in the operator's own checkout. + Under an auto-recovering cause those are DELETED. + + Costs the re-run nothing: `_dev_phase` re-stamps both from whatever workspace + it re-enters with, so clearing turns the `baseline_commit` leg into a correct + no-op instead of a probe of the wrong tree. + + MUST be called while `worktree_path` still names the mount — the spec + relativization is measured against it. + """ + self.release_spec_paths_from_mount() + self.baseline_commit = None + self.baseline_untracked = None + + def rebase_spec_paths_on(self, root: Path) -> None: + """Re-absolutize both spec-ownership paths against the tree that owns them. + + The read-side inverse of :meth:`_serialized_worktree_path`, and the single + implementation of that rule: `to_dict` persists a worktree-local spec + RELATIVE to the mount and `from_dict` reads it back raw, so a consumer that + resolves the raw value against anything else names the wrong tree. The main + checkout carries the same `_bmad-output/...` layout, so that wrong tree + answers `is_file()` and passes containment — the failure is silent, not an + error. + + Both fields move together because they are one asymmetry: `spec_file` is the + accepted/result artifact and `dispatched_spec_file` the attempt-owned input, + and a caller re-anchoring one and not the other leaves a task naming two + trees at once. + + Idempotent: an absolute value is already anchored (a spec outside the mount + is persisted verbatim) and passes through untouched, so re-running this + against the same root cannot double-join. `root` is the tree the values were + persisted relative to — `task.worktree_path` — never the caller's cwd or + project. + """ + self.spec_file = _rebased_on(self.spec_file, root) + self.dispatched_spec_file = _rebased_on(self.dispatched_spec_file, root) + @classmethod def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot = d.get("dispatched_spec_snapshot") diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index ac62008a..34a71c78 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -23,7 +23,13 @@ from .adapters.base import SessionSpec from .model import RunState from .platform_util import safe_segment -from .runs import validate_restore_latch +from .runs import ( + redrive_base_ref, + spec_reaches_the_redrive, + task_spec_path, + task_stories_root, + validate_restore_latch, +) RESOLVE_DIR = "resolve" @@ -94,9 +100,20 @@ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[ return 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) -> 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 — + `restore_supported`, `spec_reaches_the_redrive`, `redrive_base_ref` — are claims + about a re-drive that has NOT happened yet, and run state cannot answer them. The + mode is re-read at every resume and a mid-run change is journalled, never refused, + so the recorded `task.worktree_path` says only how the escalated attempt RAN. A + defaulted mode would hand the agent an in-place answer for a run that mounts (or the + reverse) with nothing to signal it — the defect being fixed, re-introduced at the + seam that reports it.""" task = state.tasks.get(story_key) + isolated_redrive = isolation == "worktree" # 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, @@ -104,13 +121,33 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # `restore_patch` after the session; told to the agent up front so it never # negotiates a restore it can't honor. restore_supported = task is not None and ( - validate_restore_latch(state, task, story_key, worktree_isolation=isolation == "worktree") - is None + 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) context = { "story_key": story_key, "run_id": state.run_id, - "spec_file": task.spec_file if task else None, + # 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. + # 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` + # persists the relative form with `.as_posix()`. It also normalizes the + # 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), "baseline_commit": task.baseline_commit if task else None, "paused_reason": state.paused_reason, "escalations": _gather_escalations(run_dir, state, story_key), @@ -118,13 +155,49 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # path is consumed by the agent, and Python/tools accept '/' on Windows). "resolution_path": resolution_path(run_dir, story_key).as_posix(), "restore_supported": restore_supported, + # Whether an edit to `spec_file` survives to the re-drive that reads it. Under + # isolation the mount is discarded by `engine._finish_inflight`, so the agent + # can otherwise spend a whole session editing a file with no future and see + # 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. + "spec_reaches_the_redrive": ( + spec_reaches_the_redrive(task, state, isolated_redrive=isolated_redrive) + if task and task.spec_file + else None + ), + # WHERE a correction has to land to be read. Emitted beside the verdict + # because on its own `spec_reaches_the_redrive: false` states a problem with + # no remedy: the session is told the edit is doomed, and the obvious repair + # (commit it) is wrong in two different ways for an isolated unit. Committing + # from the main checkout cannot include a file that lives in the linked unit + # worktree, and committing on the unit's own branch does not put it on the ref + # the replacement worktree is cut from. That ref is this one — the run's + # PINNED `target_branch` when the re-drive will MOUNT, `HEAD` otherwise — and + # it is the same value `rearm_escalation`'s unreachable-write record names, so + # the session and the orchestrator quote one answer. + # + # Keyed on the LIVE isolation mode, not on the recorded mount. This file is + # written by a SEPARATE process, before the resume runs, so it is the one + # consumer no amount of resume-time bookkeeping on `task.worktree_path` could + # reach: reading the mount here sent the session to commit on the pinned branch + # for a run whose policy had since flipped to `none`, where the re-drive reads + # `HEAD` in the main checkout and never looks — and answered `HEAD` for the + # mirror flip, where it mounts and never reads a working tree at all. + "redrive_base_ref": ( + redrive_base_ref(state, isolated_redrive=isolated_redrive) if task else None + ), } # Stories mode: hand the resolver the manifest intent (the story entry) and a # 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_ctx = _stories_context(state, story_key, stories_root) if stories_ctx: context["stories"] = stories_ctx path = context_path(run_dir, story_key) @@ -133,7 +206,7 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: return path -def _stories_context(state: RunState, story_key: str) -> dict[str, Any]: +def _stories_context(state: RunState, story_key: str, root: Path) -> 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 @@ -141,8 +214,13 @@ def _stories_context(state: RunState, story_key: str) -> dict[str, Any]: an unreadable manifest just yields the folder (resolve still runs).""" from . import stories - project = Path(state.project) - folder = stories.resolve_spec_folder(project, state.spec_folder) + # `root`, not `Path(state.project)`: the caller resolved it with + # `task_stories_root`, so this block reads the manifest and sentinel out of the tree + # the RUN owns. One `context.json` that names two trees is worse than one that names + # the wrong one — `sentinel.path` and `blocking_condition` would otherwise describe a + # file the re-arm will never touch, or vanish entirely because the main checkout has + # no sentinel while the mount does. + folder = stories.resolve_spec_folder(root, state.spec_folder) ctx: dict[str, Any] = {"spec_folder": state.spec_folder} try: entry = stories.load_stories(folder).get(story_key) diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index c4b7f1b8..ead38653 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2175,7 +2175,7 @@ def validate_restore_latch( return None -def _task_spec_path(task: StoryTask, state: RunState) -> Path: +def task_spec_path(task: StoryTask, state: RunState) -> Path: """The recorded spec path, re-anchored on the tree it was persisted relative to. `StoryTask._serialized_worktree_path` (`model.py`) persists a worktree-local spec @@ -2189,18 +2189,25 @@ def _task_spec_path(task: StoryTask, state: RunState) -> Path: left on the escalated attempt's sha. Absolute paths pass through: a spec outside the worktree is persisted verbatim. + + Raises `ValueError` on an empty `task.spec_file` rather than documenting a + precondition nothing enforces: `Path("")` is `.`, so `root / raw` would answer the + ROOT DIRECTORY — a write target, not a spec. Every caller already guards; this is + public now, so the next one gets an exception instead of a silent tree root. """ - raw = Path(task.spec_file or "") + if not task.spec_file: + raise ValueError("task_spec_path requires a non-empty task.spec_file") + raw = Path(task.spec_file) if raw.is_absolute(): return raw - return _task_spec_root(task, state) / raw + return task_spec_root(task, state) / raw -def _task_spec_root(task: StoryTask, state: RunState) -> Path: - """The tree a relative `task.spec_file` is anchored on — and confined to. +def task_spec_root(task: StoryTask, state: RunState) -> Path: + """The tree a `task.spec_file` is anchored on — and confined to. One definition backs both halves because they must not disagree: the root - `_task_spec_path` resolves against and the `confine_root` the writers validate the + `task_spec_path` resolves against and the `confine_root` the writers validate the result against are the same claim about which tree owns this spec. Passing `state.project` while resolving against the worktree does not REFUSE the mismatch — `set_frontmatter_status`, `devcontract.strip_auto_run_result` and @@ -2214,13 +2221,125 @@ def _task_spec_root(task: StoryTask, state: RunState) -> Path: `.resolve()`d path: a symlinked `.bmad-loop`, `runs` or `worktrees` lands the spec outside `project`, and before this anchor moved that silently degraded all three writes. + + A worktree that CANNOT confine the anchored path yields the project instead. An + absolute `spec_file` beside a set `worktree_path` is precisely the out-of-mount + 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 + 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 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` + picks its arm on a LEXICAL `is_relative_to`, but the confined arm it picks then + walks the components below the root and refuses a redirect (`open_dir_confined` on + POSIX, `path_is_confined` on win32). A spec that is lexically under the project but + reached THROUGH a symlinked component — a symlinked `_bmad-output`, say — therefore + moves from a succeeding plain no-follow write to `UnconfinedWriteError`, which + `rearm_escalation` re-raises as `RearmError`. That is a re-arm which used to + complete and now aborts, so this arm is not the pure improvement an earlier draft of + this docstring claimed. + + It is kept anyway, because the alternative is worse. Predicting the walk here (gate + the arm on `path_is_confined` and fall back to the worktree) makes the ROOT depend + on filesystem state: `path_is_confined` answers False for a component it cannot + probe, so a spec whose parent does not exist yet would anchor on the worktree and + the same spec would anchor on the project once the directory appeared. A confine + root that moves under a `mkdir` is not a definition. The refusal is also the correct + posture on its own terms — #593 exists to refuse writes through a link on a path + that came from a session-driven scan — so this trades a narrow, LOUD failure for a + deterministic rule, and the failure names the path in its message. + + The test is the same lexical `is_relative_to` the writer gates on, so the root and + the writer's ARM SELECTION agree by construction; only the walk below can still + refuse. Deliberately not canonicalized: `_spec_is_shared_with_the_redrive` answers a + DIFFERENT question (is this spec reachable by the re-drive) and canonicalizes for + it, but matching that here would diverge from the gate this value is measured + against and change writes that are correct today. + """ + worktree = task.worktree_path + if not worktree: + return Path(state.project) + raw = Path(task.spec_file or "") + if raw.is_absolute() and not raw.is_relative_to(worktree): + return Path(state.project) + return Path(worktree) + + +def task_stories_root(task: StoryTask | None, state: RunState) -> Path: + """The tree this run's STORIES FOLDER lives in — the workspace root, not a + confinement root. + + Deliberately NOT `task_spec_root`, which the sentinel and stories-block readers + used to borrow. That function answers "which tree can CONFINE a write to + `task.spec_file`", and its out-of-mount arm falls back to the project precisely so + a `confine_root` can never fail to contain the anchored path. Reusing that answer + here imported a write-confinement decision into a READ of a different file: for an + isolated run whose `spec_file` is absolute and lexically outside the mount — the + shape `model._serialized_worktree_path` persists verbatim, reachable whenever a + symlinked component makes a spec that physically lives in the mount look outside + it, since `verify.resolve_spec_path` deliberately does not `.resolve()` — the + stories folder would be looked up in the MAIN CHECKOUT while + `stories_engine._stories_folder` answers the worktree for the same task. One + surface would then describe two trees, which is the exact defect the spec anchor + exists to close. + + So this mirrors `_stories_folder`'s own rule instead: the mount whenever the task + holds one, the project otherwise. `spec_file` does not enter into it — the stories + folder is located by `state.spec_folder` relative to the workspace root, and a + task's spec being elsewhere says nothing about where its story manifest lives. + + A mount that is GONE degrades to the project. `worktree_path` is cleared at + exactly one site in the engine — the restart discard — so a task that reached a + terminal phase through successful integration keeps naming the unit worktree its + teardown already removed. The `done_checkpoint` pause is raised in precisely that + window, and the TUI reads this for the checkpoint card's title and description, so + trusting the stale field lost the committed story's manifest to a deleted + directory while the merged copy sat in the project checkout. + + Answering on filesystem state is right HERE and would be wrong in + `task_spec_root`: that one is a write-confinement root, where a value that moves + under a `mkdir` is not a definition. This is a READ locator, and observation + degrades rather than raising — a probe that cannot answer falls back to the tree + that always exists. + + Accepts `None` so the two call sites do not each re-spell the no-task fallback. """ - return Path(task.worktree_path or state.project) + if task is None or not task.worktree_path: + return Path(state.project) + mount = Path(task.worktree_path) + try: + if not mount.is_dir(): + return Path(state.project) + except OSError: + return Path(state.project) + return mount def _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: - """True when an isolated unit's recorded spec lives outside BOTH checkouts, so the - re-arm's status flip survives the worktree's disposal and the re-drive reads it. + """True when the recorded spec lives outside BOTH checkouts, so the re-arm's status + flip survives a mount's disposal and the ISOLATED re-drive reads it. + + Asked only of a re-drive that will mount (`spec_reaches_the_redrive`'s isolated + arm), and deliberately not of a task that HAS a mount: those are two different + questions, and a policy flip separates them. A run switched from `isolation = "none"` + to `"worktree"` while an escalation is paused re-drives isolated with no mount + recorded at all, and the recorded spec is then measured against the project alone — + which is the whole point, since the fresh worktree is cut from git and reads no + working tree. The case: artifact dirs configured outside the project tree. `ProjectPaths.rebased` leaves those exactly where they are ("configured outside the project tree; doesn't @@ -2245,14 +2364,15 @@ def _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: `.bmad-loop` puts the mount outside the project.) The recorded spelling opens the question but does not answer it. - `StoryTask._serialized_worktree_path` persists a spec RELATIVE whenever it sits - under the mounted worktree and verbatim (absolute) otherwise, so an absolute value - on a task that HAS a worktree is the only shape that can be shared — but that - relativize is a LEXICAL `relative_to` against the same `worktree_path` read here, so - all an absolute value proves is that the two spellings did not share a prefix. A - spec reported through a symlink or a `..` segment sits inside the worktree and is - persisted absolute all the same, and answering "shared" for it would suppress the - warning on a spec that really is destroyed with the worktree. + `StoryTask._serialized_worktree_path` persists a spec RELATIVE whenever it sits under + the mounted worktree (and, with no mount, whenever the run recorded it relative to + the project), and verbatim (absolute) otherwise — so an absolute value is the only + shape that can be shared. But that relativize is a LEXICAL `relative_to` against the + same `worktree_path` read here, so all an absolute value proves is that the two + spellings did not share a prefix. A spec reported through a symlink or a `..` segment + sits inside the worktree and is persisted absolute all the same, and answering + "shared" for it would suppress the warning on a spec that really is destroyed with + the worktree. So containment is decided on the CANONICAL paths, and a host that cannot canonicalize one of them answers "not shared". That degrade is the safe direction and the reason @@ -2260,22 +2380,62 @@ def _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: fold `..`, so a spec spelled through either checkout would come back looking external and go silent — trading a wrong warning for no warning at all.""" raw = Path(task.spec_file or "") - if not task.worktree_path or not raw.is_absolute(): + if not raw.is_absolute(): return False try: # the house pair — `resolve()` raises RuntimeError, not OSError, for a symlink # loop on the 3.11/3.12 floor real = raw.resolve() - return not real.is_relative_to(Path(task.worktree_path).resolve()) and not ( - real.is_relative_to(Path(state.project).resolve()) - ) + if real.is_relative_to(Path(state.project).resolve()): + return False + if task.worktree_path and real.is_relative_to(Path(task.worktree_path).resolve()): + return False + return True except (OSError, RuntimeError): return False -def _redrive_base_ref(state: RunState, task: StoryTask) -> str: +def _spec_is_inside_the_mount(task: StoryTask) -> bool: + """True when the file `task_spec_path` names sits INSIDE the mount this task + recorded — so a write to it cannot reach an IN-PLACE re-drive, which reads the main + checkout. + + The mirror of `_spec_is_shared_with_the_redrive`, for the other arm of + `spec_reaches_the_redrive`. Reachable only through a policy flip: a run switched + from `isolation = "worktree"` to `"none"` while an escalation is paused still + carries the escalated attempt's `worktree_path`, so `task_spec_path` re-anchors the + edit on that mount while `engine._run_story` re-runs the story in the main checkout. + `_finish_inflight` releases the mount-owned spelling at RESUME, which is after + `bmad-loop resolve` has already written the context and re-armed — this is what the + human and the agent are told in the meantime. + + Unlike the shared test, containment inside the PROJECT is not disqualifying: an + in-place re-drive reads the main checkout's working tree, so a spec anywhere the + project can see it reaches. Only the mount is out of reach. + + A relative spelling beside a recorded mount is inside it BY CONSTRUCTION — + `_serialized_worktree_path` relativizes exactly when `relative_to(worktree_path)` + succeeds — so it needs no filesystem probe and gets none. Absolute spellings are + canonicalized for the same reason the shared test does it (a `..` segment or a + symlinked component puts a physically-inside path outside lexically), and a host + that cannot canonicalize degrades to "inside": the safe direction here is the one + that WARNS, matching the shared test's own degrade. + """ + if not task.worktree_path: + return False + raw = Path(task.spec_file or "") + if not raw.is_absolute(): + return True + try: + return raw.resolve().is_relative_to(Path(task.worktree_path).resolve()) + except (OSError, RuntimeError): + return True + + +def redrive_base_ref(state: RunState, *, isolated_redrive: bool) -> str: """The ref whose committed tree the re-drive will actually read this unit's spec - from: the run's PINNED `target_branch` for an isolated unit, ``HEAD`` otherwise. + from: the run's PINNED `target_branch` when the re-drive will MOUNT, ``HEAD`` + otherwise. Not `HEAD` in both cases, because the isolated re-drive never reads the main checkout's working ref. `engine._finish_inflight` discards the escalated worktree @@ -2294,21 +2454,219 @@ def _redrive_base_ref(state: RunState, task: StoryTask) -> str: terminal status, or holds a resume whose work is already committed where the re-drive will find it. - The two guards are the same proxy the caller already uses. `task.worktree_path` is - how this file recognizes an isolated unit at all (`_task_spec_root`, - `_spec_is_shared_with_the_redrive`) — every isolated escalation carries a mounted - one. An empty `target_branch` beside it is a MISSING value, not a divergent one: - `ensure_target_branch` pins the field before any worktree mounts, so only a - state.json predating it can reach here, and that shape degrades to exactly the ref - it read before — the same migration `restamp_code_root` gives an unrecorded root. - Answering ``""`` instead would hold the resume on a per-configuration constant, the - failure the record's narrowing exists to avoid. + `isolated_redrive` is the LIVE policy's isolation mode, injected by the caller, and + the task drops out of the signature entirely. It used to be inferred from + `task.worktree_path` — a recorded mount — and that is the retrospective fact, not + this one. `engine._run_story` selects the mode from `self._isolated` alone, and an + isolation change mid-run is journalled, never refused, so the recorded mount and the + next re-drive part company in BOTH directions: a run flipped to `"none"` still + carries the escalated attempt's mount and would name the pinned branch for an + in-place re-drive that reads `HEAD`, and one flipped to `"worktree"` carries no + mount at all and would name `HEAD` for a re-drive that mounts. Both send a + correction to a tree the run does not read. The same injection is how + `validate_restore_latch` already learns this fact. + + That the caller must supply it is the point: `bmad-loop resolve` computes this + context in a SEPARATE process, before the resume ever runs, so no amount of + resume-time bookkeeping on `task.worktree_path` could have reached it. The fact + enters the pure core as a parameter and nothing here reads policy. + + An empty `target_branch` beside an isolated re-drive is a MISSING value, not a + divergent one: `ensure_target_branch` pins the field before any worktree mounts, so + only a state.json predating it can reach here, and that shape degrades to exactly + the ref it read before — the same migration `restamp_code_root` gives an unrecorded + root. Answering ``""`` instead would hold the resume on a per-configuration + constant, the failure the record's narrowing exists to avoid. """ - if task.worktree_path and state.target_branch: + if isolated_redrive and state.target_branch: return state.target_branch return "HEAD" +def spec_reaches_the_redrive(task: StoryTask, state: RunState, *, isolated_redrive: bool) -> bool: + """Whether an edit to this task's spec survives to the re-drive that reads it. + + The other half of `task_spec_path`'s answer, and the two ask different questions of + different sources. That one is RETROSPECTIVE — which tree owns the state this task + already persisted — and reads the recorded mount, correctly. This one is + PROSPECTIVE, so it reads `isolated_redrive`: the live policy's mode, injected by the + caller exactly as `redrive_base_ref` and `validate_restore_latch` take it. + + Both arms are about the same gap between where the edit LANDS (`task_spec_path`) and + where the re-drive READS: + + - the re-drive will MOUNT: it reads the COMMITTED tree of a fresh worktree, so only + a spec outside both checkouts is one file they share + (`_spec_is_shared_with_the_redrive` carries that argument in full). True whether + or not a mount is recorded — a run flipped to `isolation = "worktree"` mid-pause + has none, and its working-tree edit vanishes just as silently. + - the re-drive runs IN PLACE: it reads the main checkout's working tree, so the edit + reaches unless it landed inside a recorded mount (`_spec_is_inside_the_mount`) — + the flip in the other direction. + + Public because `resolve.build_context` needs it for the same reason + `rearm_escalation` does: the context hands a human and an agent a `spec_file` to + edit, and an edit to a doomed copy is worse than no edit — it looks like it landed. + """ + if isolated_redrive: + return _spec_is_shared_with_the_redrive(state, task) + return not _spec_is_inside_the_mount(task) + + +def _upstream_artifacts_folder(state: RunState) -> Path: + """The folder holding the UPSTREAM stories artifacts a sentinel's correction goes + into — anchored on the project, never on a mount. + + Deliberately NOT `task_stories_root`, which answers "which tree does this RUN read + its manifest out of" and is the mount whenever the task holds one. This answers + "which folder does the CORRECTION land in", and `resolve.run_session` settles that + independently of the mount: the agent runs with `cwd=project` and the artifacts are + named by a project-relative `state.spec_folder`, so the writes go to the main + checkout even for a task that recorded a worktree. An absolute `spec_folder` — the + external-artifact-dir layout `[stories] source` allows — is left where it is, which + is what `resolve_spec_folder` already does and what makes it shared across + checkouts. + + One locator for all three consumers (the gate, the proof, and the journal record) + so a record can never name a folder its own gate did not measure. + """ + from .stories import resolve_spec_folder + + return resolve_spec_folder(Path(state.project), state.spec_folder) + + +def stories_reach_the_redrive(task: StoryTask, state: RunState, *, isolated_redrive: bool) -> bool: + """Whether an edit to this run's UPSTREAM stories artifacts survives to the re-drive. + + `spec_reaches_the_redrive` asked of `SPEC.md` / `stories.yaml` instead of the frozen + spec, for the one wedge where the spec is not the artifact being corrected: a + fixed-slug pre-planning-halt SENTINEL. A sentinel is cleared by DELETION, so the + re-arm drops `task.spec_file` and there is no spec write whose reachability that + helper could measure — which is why its arm is an `else` this path never entered, + and why no hold ever fired for a sentinel. But the correction that stops the + sentinel RECURRING is upstream, in the artifacts `bmad-loop-resolve/SKILL.md` sends + the agent to instead of the sentinel, and it faces the identical gap: an isolated + re-drive mounts fresh from `redrive_base_ref` and re-plans from a COMMITTED tree, so + an uncommitted upstream edit is invisible and the re-plan mints the sentinel again. + + The two arms are NOT the spec question's, and the difference is where the write + lands. `task_spec_path` re-anchors a spec write ON the recorded mount, so a policy + flip separates writer from reader in BOTH directions. The upstream artifacts are + named by a project-relative `state.spec_folder` and `resolve.run_session` runs the + agent with `cwd=project`, so the correction lands in the MAIN CHECKOUT whichever way + the flip went. That collapses one arm: + + - the re-drive runs IN PLACE: it reads the main checkout's working tree — + `stories_engine._stories_folder` anchors a relative folder on the live workspace + root, which is the project under `isolation = "none"`. Writer and reader are the + same tree, so the edit reaches. The recorded mount does not enter into it; a run + flipped `"worktree" -> "none"` mid-pause still carries one, and it is not where + the correction went. + - the re-drive will MOUNT: the fresh worktree is cut from git and checks out TRACKED + files, so no working-tree write reaches it — with the single exception + `_spec_is_shared_with_the_redrive` carries in full, an artifact dir configured + OUTSIDE the project tree, which `ProjectPaths.rebased` leaves exactly where it is + and every worktree therefore reads through the same absolute path. True whether or + not a mount is recorded: a run flipped `"none" -> "worktree"` has none, and its + working-tree edit vanishes just as silently. + + Both roots are tested on the mounting arm for the same reason that helper tests + both: worktrees normally sit under `/.bmad-loop/runs/`, but + `workspace.open_unit_workspace` stores a `.resolve()`d path, so a symlinked + `.bmad-loop` puts the mount outside the project and "outside the project" alone + would not be "shared". + + Canonicalized because a `..` segment or a symlinked component puts a + physically-inside path outside lexically, and a host that cannot canonicalize + degrades to UNREACHABLE — the direction that warns, matching the degrade both spec + helpers already chose. + """ + if not isolated_redrive: + return True + try: + real = _upstream_artifacts_folder(state).resolve() + if real.is_relative_to(Path(state.project).resolve()): + return False + if task.worktree_path and real.is_relative_to(Path(task.worktree_path).resolve()): + return False + return True + except (OSError, RuntimeError): + return False + + +# The two upstream artifacts `bmad-loop-resolve/SKILL.md` names for a sentinel wedge: +# the epic spec and the story manifest the planner reads. Fixed names, discovered as +# siblings in the spec folder (`stories.STORIES_FILENAME`'s own docstring says so), so +# the proof below can name them without parsing anything. +_UPSTREAM_ARTIFACTS = ("SPEC.md", "stories.yaml") + + +def _redrive_reads_the_upstream_artifacts(state: RunState) -> bool: + """PROOF that the tree the re-drive re-plans from already carries this checkout's + upstream artifacts byte-for-byte. ``False`` on every uncertainty. + + `_redrive_spec_status`'s counterpart for the sentinel path, and it exists for the + same reason: without it the record its caller writes is a per-configuration + CONSTANT. Every isolated stories run resolves its spec folder inside the project, + so `stories_reach_the_redrive` answers "unreachable" for 100% of sentinel re-arms + under `isolation = "worktree"` — and that record now HOLDS THE RESUME + (`rearm_holds_the_resume`), so an unnarrowed gate would not merely train the + operator to scroll past a warning, it would turn every one of those re-arms into a + two-command gesture for an outcome nothing decided. That is the exact failure the + spec arm's own narrowing exists to avoid, and it is worse here. + + There is no status to read for a sentinel — it is cleared by deletion and the + re-plan routes on nothing — so the proof is byte equality instead: if the ref the + fresh worktree is cut from already holds what this checkout holds, the re-drive + re-plans from exactly the tree the operator is looking at and there is nothing left + to commit. If it does not, the operator has upstream work the re-drive will not read. + + Read at `redrive_base_ref` and NOT at the code root's `HEAD`, for the reason that + function documents: an operator who checks out another branch while the escalation + is paused moves `HEAD` off the tree the re-drive reads, in either direction. It is + asked for the MOUNTING mode unconditionally, and takes no `isolated_redrive` to say + so, because there is exactly one reachable caller and it has already established + that: `stories_reach_the_redrive` answers "reaches" for every in-place re-drive, so + the `and` short-circuits before this runs. Carrying a second in-place arm here would + not be defence in depth — it would SHADOW that one, leaving the reachability arm + ungraded by any test and a wrong answer there invisible. + + Every uncertainty answers ``False`` so the record fires and the resume holds: a + folder outside the code root (which includes the external artifact dir, already + exempted one gate earlier as SHARED), an unreadable working-tree file, an untracked + or non-blob path at that ref (the read answers ``None``, which no byte string + equals), or any `GitError` — including the project simply not being a repository. Suppression requires proof that the work is already done. + + The blob is materialized through `worktree_file_bytes_at_revision`, not read raw: + that function exists for precisely this comparison — a live checkout file against + its committed counterpart — because Git's smudge, EOL and working-tree-encoding + filters mean a byte-exact LF blob is legitimately a CRLF file on disk under + `core.autocrlf=true`. Comparing raw blob bytes would mismatch every artifact on a + Windows checkout and re-create, on one platform, the constant this narrowing exists + to prevent. + """ + base = _upstream_artifacts_folder(state) + code_root = state.code_root + ref = redrive_base_ref(state, isolated_redrive=True) + for name in _UPSTREAM_ARTIFACTS: + live = base / name + try: + rel = live.relative_to(code_root).as_posix() + except ValueError: + return False + try: + committed = verify.worktree_file_bytes_at_revision(code_root, ref, rel) + except verify.GitError: + return False + try: + working = live.read_bytes() + except OSError: + return False + if committed != working: + return False + return True + + def _restore_rearmed_spec( spec_path: Path, original: bytes | None, task: StoryTask, state: RunState ) -> None: @@ -2359,7 +2717,7 @@ def _restore_rearmed_spec( except OSError: return try: - atomic_write_bytes_confined(spec_path, original, confine_root=_task_spec_root(task, state)) + 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 " @@ -2369,16 +2727,32 @@ def _restore_rearmed_spec( ) from e -def _committed_spec_status(state: RunState, task: StoryTask) -> str: - """The spec's status as COMMITTED in the tree the re-drive reads, or ``""`` when - unprovable. +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. + + The proof that decides whether the operator still has anything to do, so it has to + read the same file the caller's remedy names — otherwise the record holds a resume + over work that is already done, or clears on work that is not. + + Two sources, because the two re-drive modes read two different things: + + * MOUNTING: the fresh worktree is cut from git and checks out TRACKED files only, so + it reads the COMMITTED spec and never a working-tree write. Anchored on + `state.code_root` — the same tree the baseline advance reads — at the ref + `redrive_base_ref` names, the run's pinned `target_branch` rather than that tree's + current `HEAD`. + * IN PLACE: the story re-runs in the main checkout, which reads its WORKING TREE. A + commit is neither required nor sufficient there, so measuring the committed tree + would hold the resume until the operator committed a correction the re-drive would + have read uncommitted — and `rearm_event_notice`'s in-place remedy tells them to + do exactly that (re-apply it in the main checkout, no commit), so a committed-only + proof would make the record's own instruction unable to clear it. - Under isolation the re-drive reads the committed spec and never a working-tree - write (see `rearm_escalation`'s note on `rearm-spec-write-unreachable`), so this is - the value that decides whether the operator still has anything to do. Anchored on - `state.code_root` — the same tree the baseline advance reads — at the ref - `_redrive_base_ref` names, which is the run's pinned `target_branch` for an isolated - unit rather than that tree's current `HEAD`. + Reached only when the write does NOT reach the re-drive, so the in-place arm is + always the isolation-flip shape: the flip's write landed in the mount the escalated + attempt recorded while the re-drive reads `state.project`. That is the tree + `task_spec_root` answers for a task with no mount, which is what the resume makes + this task once `release_mount_owned_state` runs. Degrades to ``""`` on every uncertainty: a spec recorded absolute (nothing names its position in the tree), an absent or non-blob path at that ref, a non-UTF-8 blob, @@ -2388,18 +2762,28 @@ def _committed_spec_status(state: RunState, task: StoryTask) -> str: that the work is already done, and the non-repo case stays non-fatal, as the story's Boundaries require. - The absolute arm is narrower than it looks: the caller has already answered the one + Degrades to ``""`` on every uncertainty in BOTH arms, including a spec recorded + absolute. That arm is narrower than it looks: the caller has already answered the one absolute shape whose write the re-drive DOES read — the shared external spec — with `_spec_is_shared_with_the_redrive`. What still reaches here is an absolute spelling - of a path inside one of the two checkouts, which is genuinely unreachable and - genuinely unprovable, so degrading it to a warning is the right answer, not a gap. + of a path inside one of the two checkouts, which is genuinely unreachable, and whose + position in the re-drive's tree nothing here can name, so degrading it to a warning + is the right answer rather than a gap. """ raw = Path(task.spec_file or "") if not task.spec_file or raw.is_absolute(): return "" + if not isolated_redrive: + try: + text = (Path(state.project) / raw).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + return status_of(parse_frontmatter(text)) try: blob = verify.file_bytes_at_revision( - state.code_root, _redrive_base_ref(state, task), raw.as_posix() + state.code_root, + redrive_base_ref(state, isolated_redrive=isolated_redrive), + raw.as_posix(), ) except verify.GitError: return "" @@ -2455,7 +2839,11 @@ def restamp_code_root(run_dir: Path, repo_root: Path) -> str | None: def rearm_escalation( - run_dir: Path, story_key: str | None = None, *, restore_patch: str | None = None + run_dir: Path, + story_key: str | None = None, + *, + restore_patch: str | None = None, + isolated_redrive: bool, ) -> str: """Re-arm an escalation-paused story so the next resume re-drives it. @@ -2511,6 +2899,17 @@ def rearm_escalation( with the blocking condition, and delete it, so the re-dispatch resolves to a clean PENDING and re-plans from scratch (leg 1 again for a spec_checkpoint id). + `isolated_redrive` is the LIVE policy's isolation mode (`scm.isolation == + "worktree"`), which run state cannot carry: the mode is re-read at every resume and + a mid-run change is journalled, never refused, so the recorded `task.worktree_path` + says how the escalated attempt RAN and only policy says how the re-drive WILL run. + Keyword-only and required, because every consumer of it here is an answer a human + acts on — which ref to commit the corrected spec on, whether the working-tree flip + reaches the re-drive at all, whether a restore latch can be honored — and a + defaulted mode would answer all three for the wrong tree in silence, which is the + defect this parameter exists to close. Both callers (`cli.cmd_resolve`, + `tui.TuiApp._do_rearm`) hold a loaded policy already. + 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 — @@ -2536,7 +2935,7 @@ def rearm_escalation( # of the interactive session; this call is what makes a programmatic caller # (TUI restore parity, scripts) unable to bypass it. if restore_patch: - err = validate_restore_latch(state, task, key) + err = validate_restore_latch(state, task, key, worktree_isolation=isolated_redrive) if err is not None: raise RearmError(err) @@ -2571,7 +2970,7 @@ def rearm_escalation( # `if task.spec_file:` block, past the advance it depends on. spec_before: bytes | None = None if task.spec_file: - spec_path = _task_spec_path(task, state) + 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 @@ -2589,9 +2988,47 @@ def rearm_escalation( _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. + # (`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 @@ -2628,13 +3065,13 @@ def rearm_escalation( # 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 `_committed_spec_status` had + # 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 = not task.worktree_path or _spec_is_shared_with_the_redrive( - state, task + 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 @@ -2648,7 +3085,7 @@ def rearm_escalation( # 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 + # `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. # @@ -2656,8 +3093,20 @@ def rearm_escalation( # 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. + # `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` @@ -2673,14 +3122,16 @@ def rearm_escalation( # to `branch` would pseudonymize statuses as branches. if ( not write_reaches_the_redrive - and _committed_spec_status(state, task) != target_status + 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, + 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 @@ -2692,7 +3143,7 @@ def rearm_escalation( spec_before = None try: flipped = verify.set_frontmatter_status( - spec_path, target_status, confine_root=_task_spec_root(task, state) + 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, @@ -2764,14 +3215,14 @@ def rearm_escalation( # 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 + # 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: `_committed_spec_status` had just PROVEN the + # 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"), @@ -2799,7 +3250,7 @@ def rearm_escalation( # 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) + 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 @@ -2963,7 +3414,7 @@ def rearm_escalation( # `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 + # `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 @@ -2975,7 +3426,7 @@ def rearm_escalation( # 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) + 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 @@ -3010,7 +3461,7 @@ def rearm_escalation( spec_path, "baseline_revision", task.baseline_commit, - confine_root=_task_spec_root(task, state), + confine_root=task_spec_root(task, state), ) except (OSError, UnicodeDecodeError, verify.FrontmatterWriteError) as e: # FrontmatterWriteError joins the tuple rather than getting its own @@ -3142,7 +3593,7 @@ def rearm_event_notice( files = ", ".join(str(f) for f in _journal_sequence(entry.get("files"))) return ( "note", - f"excluded the abandoned restore's new files from the re-drive " f"baseline: {files}", + f"excluded the abandoned restore's new files from the re-drive baseline: {files}", "", ) if kind == "stale-restore-unparseable": @@ -3171,6 +3622,28 @@ def rearm_event_notice( "Check the baseline before resuming", ) if kind == "rearm-spec-write-unreachable": + # ONE kind, TWO remedies, told apart by the `redrive` field its producer writes + # — the live isolation mode of the re-drive, which this reader runs too late and + # in the wrong process to determine for itself. A record predating the field is + # an ISOLATED one: that was the only shape the producer could journal before the + # in-place arm existed, so the absent field is a known value, not an unknown. + spec = entry.get("spec_file", "?") + if str(entry.get("redrive", "isolated") or "isolated") == "in-place": + # The mirror shape: `isolation` was edited to `"none"` while the escalation + # was paused, so the writes went into the mount the escalated attempt + # recorded and the re-drive reads the main checkout instead. Committing is + # not the remedy here and naming a branch would be actively wrong — the + # in-place re-drive reads a WORKING TREE, so the edit simply has to be made + # in the checkout the run resumes into. + return ( + "warning", + f"this run's isolation policy changed to `none` while the story was " + f"escalated, so the re-arm's spec writes ({spec}) landed in the " + "escalated attempt's worktree while the re-drive now runs in the main " + "checkout — re-apply the correction to the main checkout's copy of the " + "spec or the story re-wedges on the escalated attempt's status", + "Correct the spec in the main checkout before resuming", + ) # The branch is the half an operator cannot infer: the re-drive cuts its fresh # worktree from the run's PINNED target branch, so a correction committed on # whatever the main checkout happens to have checked out is not the one it @@ -3180,12 +3653,35 @@ def rearm_event_notice( where = f" on `{base}`" if base else "" return ( "warning", - f"this story ran under worktree isolation, so the re-arm's spec writes " - f"({entry.get('spec_file', '?')}) land in a worktree the re-drive discards " - "— the re-driven session reads the COMMITTED spec, so commit the corrected " + f"the re-drive of this story will mount a fresh worktree, so the re-arm's " + f"spec writes ({spec}) land in a tree it discards — the re-driven session " + "reads the COMMITTED spec, so commit the corrected " f"spec{where} or the story re-wedges on the escalated attempt's status", f"Commit the corrected spec{where} before resuming", ) + if kind == "rearm-upstream-write-unreachable": + # The sentinel counterpart, and ONE remedy rather than the two above: the + # producer only reaches this record on the mounting leg, because an in-place + # re-drive reads the very checkout `resolve.run_session` ran the agent in. So + # there is no `redrive` discriminator to read and no in-place arm to get wrong. + # + # It names the FOLDER, not a file, because the correction is not one file: the + # skill sends the agent to `SPEC.md` or to this story's entry in `stories.yaml`, + # and which of the two moved is the agent's choice, not something a journal + # reader can recover. Naming both and the folder they sit in is what makes the + # remedy actionable without claiming more than the record proves. + root = str(entry.get("stories_root", "?")) + base = str(entry.get("target_branch", "") or "") + where = f" on `{base}`" if base else "" + return ( + "warning", + f"the sentinel was cleared, but the re-drive of this story will mount a " + f"fresh worktree and re-plan from the COMMITTED tree — the upstream " + f"correction in {root} (`SPEC.md` / `stories.yaml`) is uncommitted there, " + f"so the re-plan reads the same intent that wedged and mints the sentinel " + "again", + f"Commit the corrected SPEC.md / stories.yaml{where} before resuming", + ) if kind == "rearm-spec-flip-skipped": # ONE kind, TWO outcomes, told apart by the flag the producer writes rather # than by anything readable from here: `rearm_escalation` raises `RearmError` @@ -3258,8 +3754,8 @@ def rearm_holds_the_resume(entry: dict[str, Any]) -> bool: tree — so a surface that re-arms and resumes in ONE gesture must stop after the re-arm and leave `bmad-loop resume` to the operator. - Exactly one kind qualifies, and the discriminator is PROOF, not urgency. - `rearm-spec-write-unreachable` is written only once `_committed_spec_status` has + TWO kinds qualify, and the discriminator is PROOF, not urgency. + `rearm-spec-write-unreachable` is written only once `_redrive_spec_status` has established that the committed spec does NOT carry the status the re-drive routes on, and only for a spec the working-tree flip cannot reach. Resuming on it is not risky, it is futile: the re-drive discards the worktree, mounts a fresh one from @@ -3270,6 +3766,15 @@ def rearm_holds_the_resume(entry: dict[str, Any]) -> bool: the moment it rendered. The interactive resolve agent cannot close that gap either — its skill forbids it from committing. + `rearm-upstream-write-unreachable` earns it the same way on the sentinel path, + where there is no spec write to measure at all: the sentinel is cleared by + deletion, and the correction that stops it recurring sits upstream in `SPEC.md` / + `stories.yaml`. Its proof is `_redrive_reads_the_upstream_artifacts`, which fires + the record only while the ref the re-drive mounts from does NOT already hold this + checkout's copy of those two files — so, exactly as above, resuming is not risky + 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 @@ -3280,7 +3785,10 @@ def rearm_holds_the_resume(entry: dict[str, Any]) -> bool: asked of the same entry: that table answers "what do I tell the operator", this answers "may this gesture still resume". Both surfaces ask both, in one walk. """ - return isinstance(entry, dict) and entry.get("kind") == "rearm-spec-write-unreachable" + return isinstance(entry, dict) and entry.get("kind") in ( + "rearm-spec-write-unreachable", + "rearm-upstream-write-unreachable", + ) def _stale_restore_residue( diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 0d6db7d3..99f45b3d 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -598,7 +598,8 @@ def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) self.policy, self.run_dir, f"spec ready for approval: {task.story_key}", - f"review {task.spec_file}, then `bmad-loop resume {self.state.run_id}`", + f"review {self._operator_spec_path(task)}, then " + f"`bmad-loop resume {self.state.run_id}`", ) raise RunPaused( f"awaiting spec approval for {task.story_key}", @@ -614,13 +615,16 @@ def _pause_plan_checkpoint(self, task: StoryTask) -> None: :meth:`_resume_after_dev_verify` for the implement leg. Always raises.""" task.plan_review_owed = False # discharged: we are pausing for the review now self.journal.append( - "checkpoint-pause", story_key=task.story_key, checkpoint="plan", spec=task.spec_file + "checkpoint-pause", + story_key=task.story_key, + checkpoint="plan", + spec=self._operator_spec_path(task), ) gates.notify( self.policy, self.run_dir, f"plan ready for review: {task.story_key}", - f"review the planned spec {task.spec_file}, then " + f"review the planned spec {self._operator_spec_path(task)}, then " f"`bmad-loop resume {self.state.run_id}`", ) self._save() @@ -647,7 +651,7 @@ def _pause_plan_review_owed(self, task: StoryTask) -> None: "checkpoint-pause", story_key=task.story_key, checkpoint="plan", - spec=task.spec_file, + spec=self._operator_spec_path(task), owed_after_implement=True, ) gates.notify( @@ -655,7 +659,7 @@ def _pause_plan_review_owed(self, task: StoryTask) -> None: self.run_dir, f"plan review owed (already implemented): {task.story_key}", f"the story was implemented before its plan checkpoint fired — review " - f"{task.spec_file}, then `bmad-loop resume {self.state.run_id}`", + f"{self._operator_spec_path(task)}, then `bmad-loop resume {self.state.run_id}`", ) self._save() raise RunPaused( diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 22dff5f5..302f828d 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -29,7 +29,6 @@ ) from .runs import _project_of_run_dir from .statemachine import advance -from .workspace import discard_worktree def _read_json(path: Path) -> Any: @@ -805,6 +804,23 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: result. Lifting that is a resume-fidelity change of its own. The 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. + task.rebase_spec_paths_on(Path(task.worktree_path)) isolated = self._isolated and task.worktree_path if task.phase == Phase.COMMITTING: # the gate+advance save landed pre-death; finish the commit @@ -844,11 +860,7 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: return True if isolated: # drop the half-built worktree; _run_story mounts a fresh one - discard_worktree( - self.paths.repo_root, task.worktree_path, task.branch, run_dir=self.run_dir - ) - task.worktree_path = "" - task.branch = "" + self._discard_unit_for_restart(task) elif task.baseline_commit: # latch resolved_redrive so the corrected spec + restored diff stay # protected through every reset of this re-drive, not just this diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 4dd933cc..5cdaef01 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -34,6 +34,7 @@ PAUSE_STORY_CHECKPOINT, PAUSE_STORY_GATE, RunState, + StoryTask, ) from ..platform_util import resolve_or_lexical from ..policy import POLICY_FILE @@ -605,12 +606,13 @@ def _paused_selection(self) -> tuple[str, Path, RunState] | None: return run_id, run_dir, state def _review_plan_checkpoint(self, run_id: str, run_dir: Path, state: RunState) -> None: - spec_path, spec_text = self._paused_spec(state) + spec_path, spec_text, readable = self._paused_spec(state) modal = SpecReviewModal( title="plan checkpoint — review the planned spec before implementation", subtitle=self._story_subtitle(state), spec_path=spec_path, spec_text=spec_text, + unreadable=not readable, actions=[ ("approve", "Approve & resume", "primary"), ("replan", "Request replan", "warning"), @@ -624,13 +626,13 @@ def done(verb: str | None) -> None: if spec_path is None: self.notify("no spec file to reset for replan", severity="error") return - self._do_replan(run_id, spec_path) + self._do_replan(run_id, spec_path, self._paused_spec_root(state)) self.push_screen(modal, done) def _review_gate(self, run_id: str, run_dir: Path, state: RunState) -> None: label = widgets.pause_label(state.paused_stage or "")[0] or "gate" - spec_path, spec_text = self._paused_spec(state) + spec_path, spec_text, readable = self._paused_spec(state) def done(verb: str | None) -> None: if verb == "resume": @@ -659,6 +661,7 @@ def done(verb: str | None) -> None: subtitle=self._story_subtitle(state), spec_path=spec_path, spec_text=spec_text, + unreadable=not readable, actions=[("resume", "Approve & resume", "primary")], ) self.push_screen(modal, done) @@ -715,14 +718,22 @@ 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 "?" - spec_path, spec_text = self._paused_spec(state) + 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) modal = EscalationModal( story_key=story_key, title=title, description=description, + # `_blocking_condition` reduces the read-failure body to "" like any + # other text without a halt block, so an unreadable spec would render + # "(no blocking condition recorded)" — indistinguishable from a spec that + # was read fine and simply halted without one. The verdict has to be + # carried in, and it also REFUSES both verbs: re-arm flips the spec's + # frontmatter, strips its result and re-stamps the baseline, which is not + # an action to take on evidence nobody could read. blocking=self._blocking_condition(spec_text), + unreadable=not readable, sentinel_kind=self._sentinel_kind(state, story_key), resolution_ready=resolve.resolution_path(run_dir, story_key).is_file(), engine_live=_engine_possibly_live(run_dir), @@ -785,10 +796,18 @@ def _do_resume(self, run_id: str) -> None: ) self.notify(f"resume of {run_id} launched (control session {launch.CTL_SESSION})") - def _do_replan(self, run_id: str, spec_path: Path) -> None: + def _do_replan(self, run_id: str, spec_path: Path, confine_root: Path) -> None: """Request-replan: reset the planned spec to draft + strip its Auto Run Result, then resume — the next dispatch re-enters step-02 planning. Uses - the same devcontract primitives the engine's repair path uses.""" + the same devcontract primitives the engine's repair path uses. + + `confine_root` arrives from the caller (`_paused_spec_root`) rather than being + `self.project` here: this method has no task in scope, and the root these two + writers validate against must be the SAME claim about which tree owns the spec + that `_paused_spec` anchored the path on. `runs.task_spec_root`'s docstring + carries the rationale — a `confine_root` that disagrees with the anchor is not + REFUSED, it silently drops both writes to the plain no-follow arm and loses the + confined arm's O_NOFOLLOW walk (#593) with no signal at all.""" # Guard a possibly-live engine BEFORE mutating the spec — a draft-reset + # strip under a still-running session would race its writes (the rearm path # already checks liveness first; match it so replan can't corrupt a live @@ -796,16 +815,31 @@ def _do_replan(self, run_id: str, spec_path: Path) -> None: run_dir = self.project / RUNS_DIR / run_id if self._resolve_blocked_by_liveness(run_id, run_dir): return + if not spec_path.is_file(): + # `reset_spec_status` returns False for an ABSENT spec and for one with no + # frontmatter status alike, and the shared notice below blamed the + # frontmatter for both. Now that the path is re-anchored on the run's own + # tree, an absent spec is the signal that the ANCHORING is wrong, so it + # earns its own message naming the path actually consulted. + 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=self.project) - devcontract.strip_auto_run_result(spec_path, confine_root=self.project) - except (OSError, verify.FrontmatterWriteError) as e: + reset = devcontract.reset_spec_status(spec_path, "draft", confine_root=confine_root) + devcontract.strip_auto_run_result(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 # lands in the same notice as a permissions failure because it has the # same shape for the operator — the replan did not happen and the run # is not resumed — and because an uncaught raise inside a Textual # worker takes the dashboard down instead of saying so. + # + # UnicodeDecodeError is a ValueError, so neither sibling arm caught it and + # `reset_spec_status` decodes STRICTLY (`read_bytes().decode("utf-8")`). + # That raise became reachable when `_paused_spec` started degrading a + # non-UTF-8 spec in place instead of raising at render: the operator can now + # open the modal on one and press replan, which is precisely the event-loop + # crash the read-side fix exists to prevent. self.notify(f"replan failed: {e}", severity="error") return if not reset: @@ -813,8 +847,7 @@ def _do_replan(self, run_id: str, spec_path: Path) -> None: # status, or is already draft), so the next dispatch would NOT re-enter # planning. Surface it instead of a misleading "reset" notice + resume. self.notify( - "replan: could not reset the plan to draft (no frontmatter status?) " - "— not resuming", + "replan: could not reset the plan to draft (no frontmatter status?) — not resuming", severity="error", ) return @@ -859,6 +892,30 @@ def _do_rearm( path (rearm_escalation handles sentinel auto-delete-with-preservation).""" if self._resolve_blocked_by_liveness(run_id, run_dir): return + # The LIVE isolation mode, read once and used twice below. `runs.rearm_escalation` + # requires it: how the re-drive WILL run is a policy question, and the recorded + # `task.worktree_path` answers only how the escalated attempt ran — the two part + # company on exactly the mid-run policy edit the conflict check below is also + # about. + # + # Unreadable REFUSES here, unlike the launch guard above and unlike this block's + # own previous disposition. That fall-through was correct while the policy fed + # one optional CHECK: "no conflict" and "could not look" are different answers + # and neither blocks a launch the detached CLI will re-read the same file for. + # It is not correct for an INPUT to a repair write. Without the mode this + # gesture cannot say which ref the re-drive reads, so it would flip the spec and + # then tell the operator to put the correction in a tree picked by a default — + # silently, and unrecoverably, since a re-arm consumes the escalation. + # `cli.cmd_resolve` raises on the same unreadable file before it re-arms. + try: + isolation = policy.load(self.project / POLICY_FILE).scm.isolation + except (policy.PolicyError, OSError) as e: + self.notify( + f"cannot read policy.toml to determine the re-drive's isolation mode " + f"({e}) — fix it, then re-arm; the story is still escalated", + severity="error", + ) + return # Same seam as `cli.cmd_resolve`, for the same reason and at the same moment: # `runs.rearm_escalation` reads the persisted code root back out of the run # state, and only a process that has just read config.yaml can tell whether a @@ -881,17 +938,10 @@ def _do_rearm( # against it. The operator saw "re-armed " and then a pane that # refused, with the story no longer escalated for `resolve` to correct. # - # An unreadable policy falls THROUGH to the re-arm rather than blocking, - # matching this surface's launch guard above: the check cannot tell "no - # conflict" from "could not look", and the detached CLI reads the same file - # and fails loudly on it. `paths` is already in hand, so only the policy - # read is guarded here. - try: - conflict = bmadconfig.worktree_isolation_conflict( - paths, policy.load(self.project / POLICY_FILE).scm.isolation - ) - except (policy.PolicyError, OSError): - conflict = None + # Reads the mode hoisted above rather than loading policy.toml a second + # time: two reads of one file in one gesture can disagree under a concurrent + # edit, and the refusal must be about the same mode the re-arm is told. + conflict = bmadconfig.worktree_isolation_conflict(paths, isolation) if conflict is not None: self.notify(conflict, severity="error") return @@ -900,7 +950,7 @@ def _do_rearm( before_entries = runs.journal_entries_or_none(run_dir) hold_resume = False try: - runs.rearm_escalation(run_dir, story_key) + 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 @@ -944,17 +994,75 @@ def _resolve_blocked_by_liveness(self, run_id: str, run_dir: Path) -> bool: # ---------------------------------------------------- pause-context readers - def _paused_spec(self, state: RunState) -> tuple[Path | None, str]: - """(spec path, spec text) for the paused story, or (None, "") when the - task has no spec file (e.g. an ambiguous-match escalation).""" - task = state.tasks.get(state.paused_story_key) if state.paused_story_key else None + def _paused_task(self, state: RunState) -> StoryTask | None: + """The paused story's task, or None when nothing is paused. + + One lookup for both `_paused_spec` (which anchors the READ) and + `_paused_spec_root` (which supplies the destructive write's `confine_root`). + The whole point of routing both through `runs.task_spec_path`/`task_spec_root` + is that the anchor and the root must name one tree; two copies of the lookup + would let them drift on the very state that decides it.""" + return state.tasks.get(state.paused_story_key) if state.paused_story_key else None + + def _paused_spec(self, state: RunState) -> tuple[Path | None, str, bool]: + """(spec path, spec text, readable) for the paused story, or (None, "", True) + when the task has no spec file (e.g. an ambiguous-match escalation). + + `readable` is False only when the spec could not be READ at the anchored path, + which is the signal that the anchoring is wrong. It is returned rather than left + for the renderer to infer, because the alternative is sniffing the body for the + failure sentence — the failure text and a spec that merely opens with the same + words are not distinguishable after the fact, and one of them must not disable + an operator's approve button. + + The path is re-anchored through `runs.task_spec_path`, never `Path(...)` on the + 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.""" + task = self._paused_task(state) if task is None or not task.spec_file: - return None, "" - path = Path(task.spec_file) + return None, "", True + path = runs.task_spec_path(task, state) try: - return path, path.read_text(encoding="utf-8") - except OSError: - return path, "" + # `errors="replace"` for the same reason `_commit_subject` uses it: a story + # spec is agent- or human-authored, so an odd byte is a fact about the file, + # not a reason to withhold it. Decoding strictly here cost the reviewer the + # WHOLE document at a gate whose only purpose is reading it — and, because + # every review surface calls this from the Textual event loop, an escaping + # UnicodeDecodeError (a ValueError, so no OSError arm catches it) took the + # dashboard down rather than rendering the fault. + return path, path.read_bytes().decode("utf-8", errors="replace"), True + except OSError as e: + # An absent spec at the ANCHORED path is the signal that the anchoring is + # wrong, so it must not reduce to "" — SpecReviewModal renders that as + # "(empty spec)", which is also what a present-but-blank spec renders as. + # Report the failure as the body so the two cases read differently, and + # keep this arm to ABSENCE now that a decode fault degrades in place. + return path, f"(spec could not be read — {e})", False + + def _paused_spec_root(self, state: RunState) -> Path: + """The tree the paused story's spec is anchored on — and confined to. + + The mirror of `_paused_spec`'s anchor, kept as a sibling so the three read-only + consumers keep the untouched two-value read. `_do_replan` WRITES the path + `_paused_spec` returned, and `runs.task_spec_root` is the single definition + backing both halves: an anchor and a `confine_root` that name different trees do + not refuse, they silently degrade the write (#593). + + The no-task arm is `Path(state.project)`, NOT `self.project`, so both arms make + one claim: the delegate answers from the state the run persisted at launch, + while `self.project` is the constructor's `resolve_or_lexical` of the operator's + argument, and the two can differ. That arm is currently unreachable from the + write path — `_review_plan_checkpoint`'s `done()` refuses a `None` `spec_path` + before calling `_do_replan`, and `_paused_spec` returns `None` on BOTH of its + arms (no task, and a task carrying no `spec_file`) — so this is about not + leaving a second claim lying around for a future caller, not a live bug. The + no-task arm is the only one reachable here: a task with an empty `spec_file` + still answers from `task_spec_root`, which needs no spec to name a tree.""" + task = self._paused_task(state) + return runs.task_spec_root(task, state) if task else Path(state.project) def _story_subtitle(self, state: RunState) -> Text: key = state.paused_story_key or "?" @@ -968,8 +1076,17 @@ def _story_context(self, state: RunState, key: str) -> tuple[str, str]: """(title, description) from stories.yaml in stories mode, else ("", "").""" if state.source != "stories" or not state.spec_folder: return "", "" + # `task_stories_root`, not `self.project`, for the reason `_sentinel_kind` + # states below: BOTH feed one `EscalationModal` — this supplies its title and + # description, that its sentinel indicator — so a manifest read from the main + # checkout beside a sentinel read from the mount is the same one-surface-two-trees + # defect the anchor exists to close. `self.project` is also the wrong VALUE for + # the no-task arm: it is the constructor's `resolve_or_lexical` of the operator's + # argument, while every other anchored read here answers from `state.project`, + # the path the run persisted at launch. + root = runs.task_stories_root(state.tasks.get(key), state) try: - folder = stories.resolve_spec_folder(self.project, state.spec_folder) + folder = stories.resolve_spec_folder(root, state.spec_folder) entry = stories.load_stories(folder).get(key) except stories.StoriesError: return "", "" @@ -978,11 +1095,26 @@ def _story_context(self, state: RunState, key: str) -> tuple[str, str]: def _sentinel_kind(self, state: RunState, key: str) -> str: if state.source != "stories" or not state.spec_folder: return "" + # Anchored on the tree the RUN owns, for the same reason `_paused_spec` is: the + # sentinel the engine wrote lives in the unit's mount under isolation + # (`stories_engine._stories_folder` IS the worktree during a driven story), + # while the main checkout carries the same layout and holds a stale twin or + # nothing. Both values feed ONE `EscalationModal` — the spec text through + # `_blocking_condition`, this through `sentinel_kind` — so anchoring them on + # different trees let a single modal disagree with itself and rendered a + # pre-planning sentinel wedge as an ordinary escalation. + # + # `task_stories_root`, not `task_spec_root`: the folder is located from the + # workspace root, and the latter's out-of-mount arm answers a confinement + # question about `spec_file` that would send this read to the main checkout + # while `_stories_folder` stayed on the mount. It also takes `None`, so the + # no-task fallback is not re-spelled here. + root = runs.task_stories_root(state.tasks.get(key), state) # resolve_story_spec globs + reads frontmatter; a file removed mid-scan (a # re-arm clearing the sentinel while the viewer refreshes) can raise OSError. # Degrade to "" rather than let a race-window read crash the render. try: - folder = stories.resolve_spec_folder(self.project, state.spec_folder) + folder = stories.resolve_spec_folder(root, state.spec_folder) st = stories.resolve_story_spec(folder, key) except OSError: return "" diff --git a/src/bmad_loop/tui/screens/modals.py b/src/bmad_loop/tui/screens/modals.py index 86f74c85..88004545 100644 --- a/src/bmad_loop/tui/screens/modals.py +++ b/src/bmad_loop/tui/screens/modals.py @@ -535,6 +535,7 @@ def __init__( spec_path: Path | None, spec_text: str, actions: list[tuple[str, str, str]], + unreadable: bool = False, ): super().__init__() self._title = title @@ -542,6 +543,7 @@ def __init__( self._spec_path = spec_path self._spec_text = spec_text self._actions = actions + self._unreadable = unreadable def compose(self) -> ComposeResult: with Vertical(id="dialog"): @@ -555,12 +557,28 @@ def compose(self) -> ComposeResult: yield Static(path_line, classes="path") with VerticalScroll(id="spec"): body = self._spec_text.strip() - yield Static(Text(body) if body else Text("(empty spec)", style="dim")) + if self._unreadable: + # Dimmed like "(empty spec)", never as plain body text: this string + # is THIS modal's report of a failed read, and rendering it in the + # style reserved for the spec's own words invites it to be read as + # spec content that happens to open with that sentence. + yield Static(Text(body, style="dim")) + else: + yield Static(Text(body) if body else Text("(empty spec)", style="dim")) with Horizontal(classes="buttons"): if self._spec_path is not None: yield Button("copy path", id="copy-path") for verb, label, variant in self._actions: - yield Button(label, variant=variant, id=f"act-{verb}") # type: ignore[arg-type] + # Every verb this modal offers acts ON the spec — approve resumes the + # run past the gate, replan rewrites the file. A spec nobody could + # read is one nobody reviewed, so the actions are refused at the + # source rather than left to fail (or worse, succeed) downstream. + yield Button( + label, + variant=variant, # type: ignore[arg-type] + id=f"act-{verb}", + disabled=self._unreadable, + ) yield Button("close", id="cancel") def on_button_pressed(self, event: Button.Pressed) -> None: @@ -704,6 +722,7 @@ def __init__( resolution_ready: bool, engine_live: bool, restore_recorded: bool = False, + unreadable: bool = False, ): super().__init__() self._story_key = story_key @@ -714,6 +733,7 @@ def __init__( self._resolution_ready = resolution_ready self._engine_live = engine_live self._restore_recorded = restore_recorded + self._unreadable = unreadable def compose(self) -> ComposeResult: head = Text() @@ -735,11 +755,31 @@ def compose(self) -> ComposeResult: ) with Vertical(id="blocking"): body = self._blocking.strip() - yield Static( - Text(body) - if body - else Text("(no blocking condition recorded)", style="dim") - ) + if self._unreadable: + # The spec could not be READ at the anchored path, so there is + # no blocking condition to parse and "(no blocking condition + # recorded)" would be a lie indistinguishable from a readable + # spec that simply halted without one. `_blocking_condition` + # reduces the read-failure body to "" like any other non-halt + # text, so this arm cannot be inferred downstream — it has to + # be carried in. + # + # REPLACES the body rather than prefixing it: `body` is always + # "" on this arm (the failure sentence carries no halt block), + # so an `if` that fell through still rendered the very sentence + # the paragraph above calls a lie, directly under the warning + # denying it. + yield Static( + Text( + "⚠ the spec could not be read at the anchored path — " + "the blocking condition is unknown, not absent", + style="red", + ) + ) + elif body: + yield Static(Text(body)) + else: + yield Static(Text("(no blocking condition recorded)", style="dim")) if self._engine_live: yield Static( Text("engine may still be live — stop it before resolving", style="yellow") @@ -747,7 +787,18 @@ def compose(self) -> ComposeResult: # The restore-discard branch below gates an enabled Re-arm, so the hint # is docked outside #body (never scrolled off) — directly above the buttons. hint = Text() - if self._restore_recorded: + if self._unreadable: + # Precedence over both branches below: they explain when Re-arm + # unlocks, and neither is true while the evidence cannot be read. + hint.append( + "re-arm is refused while the spec is unreadable — it flips the " + "frontmatter, strips the result and re-stamps the baseline on " + "evidence nobody could read. Resolve stays OPEN: it is the " + "non-destructive remedy, and a bad anchor is exactly what it " + "repairs — `bmad-loop resolve` does the same from the CLI", + style="red", + ) + elif self._restore_recorded: # honoring the latch from here would be unsafe (a stale marker is # indistinguishable from a fresh one), so Re-arm stays a plain # from-scratch re-drive — but never a silent drop of the decision. @@ -767,14 +818,24 @@ def compose(self) -> ComposeResult: ) yield Static(hint, id="hint") with Horizontal(classes="buttons"): + # NOT gated on `_unreadable`. Resolve opens an interactive agent to + # repair the frozen spec and writes nothing itself, so it is the one + # verb an unreadable spec is a REASON to offer — refusing it left the + # modal with `close` as its only action, on the very failure the + # resolve agent exists to fix. It also kept the modal out of step with + # `action_resolve_run` (the `R` binding), which has no readability + # check, so the refusal was advisory rather than enforced. yield Button( - "Resolve", variant="primary", id="act-resolve", disabled=self._engine_live + "Resolve", + variant="primary", + id="act-resolve", + disabled=self._engine_live, ) yield Button( "Re-arm & resume", variant="warning", id="act-rearm", - disabled=not self._resolution_ready or self._engine_live, + disabled=not self._resolution_ready or self._engine_live or self._unreadable, ) yield Button("close", id="cancel") diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index 6712c743..338868bf 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -126,6 +126,25 @@ def open_unit_workspace( f"cannot resolve worktree mount path for {unit_key} ({unresolved_wt}): {e}" ) from e 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 + # re-mount targets the exact path a previous mount used — and `worktree_add` + # refuses a target that exists or a branch checked out elsewhere, which makes a + # leftover registration a hard `GitError` rather than a recoverable state. + # `engine._finish_inflight` reaches that shape by design: when live policy leaves + # isolation it releases the mount's state and clears the task's claim but + # deliberately LEAVES the directory standing (the journal names it), so a later + # flip back to `worktree` re-derives this same path and used to be unrecoverable + # through the normal run flow. Reclaiming here rather than deleting at the flip + # 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. + discard_worktree(repo_root, str(wt), "", run_dir=run_dir) if verify.branch_exists(repo_root, branch): verify.worktree_add(repo_root, wt, branch, create=False) else: diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index d4535427..ba2fa42f 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -2288,11 +2288,10 @@ def reopen_unit(self, task: StoryTask) -> UnitWorkspace: # Spec paths are persisted relative to the worktree (model.to_dict) so # state stays portable; re-absolutize both accepted/result ownership and # the current/last attempt's dispatch ownership against the reopened tree. - # Absolute outside-worktree paths pass through unchanged. - for field_name in ("spec_file", "dispatched_spec_file"): - value = getattr(task, field_name) - if value and not Path(value).is_absolute(): - setattr(task, field_name, str(wt / value)) + # Absolute outside-worktree paths pass through unchanged. The rule itself + # lives on the class that creates the relative spelling, so this and + # `Engine._finish_inflight`'s pre-discard re-anchor cannot drift apart. + task.rebase_spec_paths_on(wt) return UnitWorkspace( workspace=Workspace(root=wt, paths=self.paths.rebased(wt)), repo_root=self.paths.repo_root, diff --git a/tests/test_cli.py b/tests/test_cli.py index bcc03ec1..6771053a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2609,7 +2609,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): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): seen.append(load_state(rd).code_root) return key @@ -2733,7 +2733,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): + 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") @@ -2773,7 +2773,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): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): journal = Journal(rd) journal.append( "rearm-baseline-advance-failed", @@ -2824,7 +2824,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): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): Journal(rd).append( "rearm-baseline-restamped", story_key=key, @@ -2878,7 +2878,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): + 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 @@ -2914,7 +2914,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): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): Journal(rd).append( "rearm-baseline-restamp-skipped", story_key=key, @@ -2962,7 +2962,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): + 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"] @@ -2984,7 +2984,11 @@ def fake_rearm(rd, key, *, restore_patch=None): def test_resolve_holds_the_resume_when_the_correction_cannot_reach_the_redrive( tmp_path, monkeypatch, capsys ): - """The one record that PROVES a wedge breaks the re-arm+resume gesture. + """A record that PROVES a wedge breaks the re-arm+resume gesture. + + Two kinds qualify (`rearm-spec-write-unreachable` here, and the sentinel path's + `rearm-upstream-write-unreachable`); this grades the gesture, which is shared, so it + drives the spec one and leaves the sentinel producer to tests/test_resolve.py. `rearm-spec-write-unreachable` fires only once the re-arm has established that the committed spec does not carry the status the re-drive routes on, and its own @@ -3013,7 +3017,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): + def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): Journal(rd).append(kind, story_key=key, **fields) return key @@ -3080,7 +3084,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): + 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", @@ -3568,6 +3572,74 @@ def fake_session(adapter, project, rd, story_key, *, model=""): assert "in-review" in spec.read_text() +@pytest.mark.parametrize("flipped_mid_session", [False, True]) +def test_resolve_rereads_isolation_after_the_agent_session( + tmp_path, monkeypatch, capsys, flipped_mid_session +): + """`pol` is read BEFORE a session that blocks on a human conversation of arbitrary + length, and the re-arm below it is keyed on that stale answer. + + Everything after `resolve.run_session` returns — the restore-patch latch, the + isolation-conflict refusal, and `rearm_escalation`'s `isolated_redrive` — used the + mode as it stood when the operator TYPED the command, while `_resume_paused_run` at + the bottom of the same function re-reads policy for the engine it arms. So a + `none -> worktree` edit made while the agent was open split the two readers in the + one window nobody can bound: the re-arm treated the main-checkout correction as + reachable and emitted no hold, then the engine mounted a fresh worktree cut from git + that could not see it. The escalation was spent and the story re-wedged, with no + error anywhere. + + The window is real precisely because this session is INTERACTIVE by contract — a + human is present, the skill is allowed to ask, and `run_session` blocks on + `subprocess.run` until they exit. Editing policy while a resolve agent is open is + not exotic; deciding the story needs isolation is one of the things a resolve + conversation concludes. + + The control row is what makes this a re-read rather than a hardcode: an untouched + policy must still re-arm on its own answer and print nothing. + + Ablation: delete the second `pol = policy_mod.load(...)` in `cmd_resolve` and the + flipped row reddens on `assert False is True` — the re-arm goes back to the mode + read before the conversation. Drop the warning `print` and the row reddens on + stderr alone, with the isolation assertion still passing. + """ + from bmad_loop import resolve, runs + + spec = tmp_path / "spec.md" + spec.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + _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=""): + # 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: + _write_policy(tmp_path, '[scm]\nisolation = "worktree"\n') + marker = resolve.resolution_path(rd, story_key) + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}", encoding="utf-8") + return True + + seen: list[bool] = [] + + 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) + monkeypatch.setattr(resolve, "run_session", fake_session) + monkeypatch.setattr(runs, "rearm_escalation", recording_rearm) + monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0) + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--resume"]) == 0 + + # the re-arm keyed on the mode in force when it ran, not the one read before the + # conversation started + assert seen == [flipped_mid_session] + err = capsys.readouterr().err + assert ("[scm] isolation changed none -> worktree" in err) is flipped_mid_session + + def test_resolve_corrupt_resolution_json_aborts_loudly(tmp_path, monkeypatch, capsys): """A present-but-unparseable resolution.json may carry the agent's recorded restore decision, and a re-arm consumes the escalation — so corruption must diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a0e207bd..291213a7 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -630,6 +630,56 @@ def test_rearm_records_leak_neither_the_code_root_nor_a_spec_name(): 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. + + Routed like `repo` and NOT like `spec_file`, and the two precedents genuinely + disagree: a spec filename is the customer's feature name and correlates one spec + across four kinds, so it is ALIASED. This is a DIRECTORY, journalled by one kind, + and one run has one spec folder — it correlates nothing, and a `spec` alias would + additionally be wrong, since that namespace reduces to a basename and every run we + author would collapse onto the same `epic-*` tail. + + Graded on the field's ABSENCE, because the canary sweep below is a false green on + its own: `_IDENTIFIER_RE` forbids `/`, so `scrub_json` already collapses any real + path to `` and the home path never appears whether the field is routed + or not. That is precisely the argument `repo`'s own row makes, and the reason both + are asserted the same way. `target_branch` beside it is the control: identifier- + shaped by design, so it must come back ALIASED rather than dropped, and it does leak + through the sweep when unrouted. + + Ablation: drop `stories_root` from `_JOURNAL_DROP_FIELDS` and the presence assertion + reddens while the canary sweep stays green; drop `target_branch` from + `_JOURNAL_ALIAS_FIELDS` and the branch assertions redden on BOTH. + """ + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + scrubbed = diagnostics._scrub_entry( + { + "ts": 2.0, + "kind": "rearm-upstream-write-unreachable", + "story_key": STORY_KEY, + "stories_root": f"{HOME_PATH}/_bmad-output/epic-6", + "target_branch": REARM_BRANCH, + }, + pseudo, + {}, + 1.0, + ) + + assert "stories_root" not in scrubbed and scrubbed["stories_root_present"] is True + branch_alias = next( + a for ns, orig, a in pseudo.entries() if ns == "branch" and orig == REARM_BRANCH + ) + assert scrubbed["target_branch"] == branch_alias != REARM_BRANCH + # the folder never entered the legend either — dropped means dropped, not aliased + assert not [orig for ns, orig, _a in pseudo.entries() if ns == "spec"] + + rendered = json.dumps(scrubbed) + for canary in (HOME_PATH, REARM_BRANCH, PROPRIETARY, *CANARIES): + assert canary not in rendered, f"LEAK: {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_engine.py b/tests/test_engine.py index 88743b9c..04592e56 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -4444,7 +4444,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) + rearm_escalation(engine.run_dir, isolated_redrive=False) resumed, _ = resume_engine( project, @@ -8101,7 +8101,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) # 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, @@ -8148,7 +8148,7 @@ def escalate_dirty(spec): summary = engine.run() assert summary.paused and summary.escalated == 1 - rearm_escalation(engine.run_dir) # 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, @@ -8236,7 +8236,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) + 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" @@ -8807,7 +8807,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) # 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 @@ -9042,7 +9042,9 @@ def test_intent_gap_restore_redrive_applies_patch_and_lands_done(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)) # human confirmed the reading + rearm_escalation( + 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 assert load_state(engine.run_dir).tasks["1-1-a"].restore_patch == str(patch) @@ -9070,7 +9072,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)) + 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)] @@ -9091,7 +9093,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)) + rearm_escalation(engine.run_dir, restore_patch=str(patch), isolated_redrive=False) seen: list[str] = [] resumed, _ = resume_engine( @@ -9126,7 +9128,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)) + 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)]) @@ -9513,7 +9515,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) # 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"])}) @@ -9917,7 +9919,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) + rearm_escalation(engine.run_dir, isolated_redrive=False) assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 @@ -11105,7 +11107,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) # 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, @@ -11167,7 +11169,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) # 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( @@ -15582,10 +15584,7 @@ def test_a_park_record_rollback_refused_as_unconfined_is_journaled(project): outside = project.project.parent / "outside-operator" hook = project.project / ".git" / "hooks" / "pre-commit" hook.write_text( - "#!/bin/sh\n" - f'mv "{operator_dir}" "{outside}"\n' - f'ln -s "{outside}" "{operator_dir}"\n' - "exit 1\n", + f'#!/bin/sh\nmv "{operator_dir}" "{outside}"\nln -s "{outside}" "{operator_dir}"\nexit 1\n', encoding="utf-8", ) hook.chmod(0o755) diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 9679233f..3a3bf153 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -26,6 +26,7 @@ install_build_auto_skill, refuse_to_resolve, set_sprint, + write_gated_ledger, write_ledger, write_spec, write_sprint, @@ -2100,7 +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") == "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() @@ -2258,6 +2259,446 @@ def test_worktree_reopen_reabsolutizes_both_spec_ownership_paths(project, tmp_pa assert task.dispatched_spec_file == outside_dispatched +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. + + `_finish_inflight`'s restart arm is the one arm that never calls `reopen_unit`: + it discards the worktree, clears `task.worktree_path` and saves. Both spec paths + are persisted RELATIVE to that mount (`model._serialized_worktree_path`), so + without a re-anchor the save leaves a worktree-relative spelling beside an empty + `worktree_path`, and the next resume resolves it against the MAIN checkout — which + carries the same layout, so `recovery_flow._attempt_owned_spec` finds exactly one + candidate and `spec_within_roots` accepts it. The snapshot restore then rewrites + the operator's own copy. Anchored on the mount instead, the binding names a tree + that no longer exists and recovery refuses it loudly. + + Graded at the discard rather than after it: the ordering is the whole property, and + `_run_story` rebinds the field moments later, so a post-hoc assertion would pass + with the re-anchor deleted. + + Ablation: drop `task.rebase_spec_paths_on(...)` from `_finish_inflight` and both + assertions fail with the bare relative spellings. + """ + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(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 + ) + task = StoryTask("1-1-a", 1, phase=Phase.DEV_RUNNING) + task.worktree_path = str(unit.path) + task.branch = unit.branch + task.spec_file = "_bmad-output/accepted.md" + task.dispatched_spec_file = "_bmad-output/dispatched.md" + engine.state.tasks["1-1-a"] = task + + seen: dict[str, str | None] = {} + + class _StopAtDiscard(Exception): + pass + + def _spy(*_args, **_kwargs): + seen["spec_file"] = task.spec_file + seen["dispatched_spec_file"] = task.dispatched_spec_file + raise _StopAtDiscard + + monkeypatch.setattr("bmad_loop.engine.discard_worktree", _spy) + + with pytest.raises(_StopAtDiscard): + engine._finish_inflight() + + assert seen["spec_file"] == str(unit.path / "_bmad-output/accepted.md") + assert seen["dispatched_spec_file"] == str(unit.path / "_bmad-output/dispatched.md") + + +def test_restart_arm_clears_the_baseline_it_measured_in_the_discarded_mount(project, monkeypatch): + """`baseline_commit`/`baseline_untracked` describe the mount and must die with it. + + `_dev_phase` stamps both from `self.workspace.root` — the unit worktree under + isolation. The restart arm discards that mount and saves, so leaving them set + persists two operands that describe a tree which no longer exists. Any later + resume finding `worktree_path` empty takes the `elif task.baseline_commit:` leg + into `recovery_flow.rollback_or_pause` against the MAIN checkout, and neither + operand fails loud there: linked worktrees share the object database, so the + baseline still resolves and a reset onto it succeeds, while a fresh worktree's + empty untracked snapshot makes `verify._rollback_cleanup_plan` treat every + untracked file in the operator's checkout as this attempt's debris. + + Asserted on the DURABLE state, not the in-memory task: state.json is what the + next resume reads, and the save happens between the discard and the re-run. + + Ablation: drop the two `= None` clears from `_discard_unit_for_restart` and the + baseline assertions fail while the `worktree_path` one stays green — which is + precisely the split that made this reachable. + """ + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(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 + ) + task = StoryTask("1-1-a", 1, phase=Phase.DEV_RUNNING) + task.worktree_path = str(unit.path) + task.branch = unit.branch + task.baseline_commit = rev_parse_head(unit.path) + task.baseline_untracked = [] # a fresh mount is a tracked-only checkout + engine.state.tasks["1-1-a"] = task + + class _StopBeforeRerun(Exception): + pass + + def _stop(*_args, **_kwargs): + raise _StopBeforeRerun + + monkeypatch.setattr(engine, "_run_story", _stop) + + with pytest.raises(_StopBeforeRerun): + engine._finish_inflight() + + saved = load_state(engine.run_dir).tasks["1-1-a"] + assert saved.worktree_path == "" + assert saved.branch == "" + assert saved.baseline_commit is None + assert saved.baseline_untracked is None + + +def test_restart_arm_leaves_a_spec_the_replacement_mount_can_bind(project, monkeypatch): + """The property the re-anchor broke: after the discard, the fresh mount BINDS. + + `_finish_inflight` re-anchors `spec_file` onto the mount (correct — recovery must + not resolve it against the main checkout), and the restart arm then DELETES that + mount. Left absolute, the value names a tree that no longer exists: + `verify.resolve_spec_path` passes an absolute path through untouched, + `_dispatched_spec_for_attempt` resolves it `strict=True` and swallows the + `FileNotFoundError`, and the fresh attempt starts unbound on a story whose spec is + sitting in the replacement mount at the same relative place. Nothing downstream + repairs it — `_record_dev_spec` no-ops while `spec_file` is set — so the repair + prompt keeps naming the deleted path. + + Graded on the DURABLE state and then on the resolution itself, because the + spelling is only a proxy: what matters is that the replacement mount answers with + ITS copy, and neither the dead path nor the main checkout's identical layout. + + Ablation: drop `task.release_spec_paths_from_mount()` from + `_discard_unit_for_restart` and this reddens on the durable-spelling assertion — + the saved `spec_file` comes back absolute into the deleted mount, which is the + state that shipped. That assertion fires before the binding one, so the binding + assertion is not what the ablation proves; it is what states the CONSEQUENCE, and + it holds the row to the replacement mount's copy rather than merely to some + resolvable path (the main checkout carries the identical layout and would answer + a relative value too, from the wrong tree). + """ + from bmad_loop import verify + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, []) + + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + rel = "_bmad-output/implementation-artifacts/spec-1-1-a.md" + (unit.path / rel).parent.mkdir(parents=True, exist_ok=True) + (unit.path / rel).write_text("---\nstatus: ready-for-dev\n---\n", encoding="utf-8") + + task = StoryTask("1-1-a", 1, phase=Phase.DEV_RUNNING) + task.worktree_path = str(unit.path) + task.branch = unit.branch + # persisted RELATIVE, exactly as `_serialized_worktree_path` writes it — the + # re-anchor inside `_finish_inflight` is what makes it absolute + task.spec_file = rel + task.dispatched_spec_file = rel + task.dispatched_spec_snapshot = b"pre-launch bytes" + engine.state.tasks["1-1-a"] = task + + class _StopBeforeRerun(Exception): + pass + + monkeypatch.setattr( + engine, "_run_story", lambda *a, **k: (_ for _ in ()).throw(_StopBeforeRerun()) + ) + + with pytest.raises(_StopBeforeRerun): + engine._finish_inflight() + + saved = load_state(engine.run_dir).tasks["1-1-a"] + assert saved.worktree_path == "" + assert saved.spec_file == rel # relative again, not absolute into the deleted tree + assert saved.dispatched_spec_file is None # the attempt died with its tree + assert saved.dispatched_spec_snapshot is None + + # the replacement mount `_run_story` would have opened, carrying the same spec + replacement = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + (replacement.path / rel).parent.mkdir(parents=True, exist_ok=True) + (replacement.path / rel).write_text("---\nstatus: ready-for-dev\n---\n", encoding="utf-8") + + # the binding `_dispatched_spec_for_attempt` makes, against the live workspace + bound = verify.resolve_spec_path(saved.spec_file, replacement.workspace.paths).resolve( + strict=True + ) + assert bound == (replacement.path / rel).resolve() + + +def test_finish_inflight_anchors_on_the_persisted_mount_not_the_live_isolation_policy(project): + """The relative spelling is persisted state; `isolated` is re-read policy. + + `model._serialized_worktree_path` relativizes whenever `task.worktree_path` is + set, but `_finish_inflight` gates its `reopen_unit` arms on + `self._isolated and task.worktree_path` — and `self._isolated` comes from a policy + file re-read on every resume, where an `isolation` change is journaled and never + refused. Flip `[scm] isolation` to "none" between a crash and a resume and every + arm runs without `reopen_unit` on a task whose paths are still mount-relative. + + The story gate stops the restart arm before it mutates anything, so what is graded + is the re-anchor alone — and it must have happened despite `isolated` being false. + + Ablation: move `task.rebase_spec_paths_on(...)` inside the `if isolated:` arm (or + delete it) and both assertions fail with the bare relative spellings. Note the + sibling test above stays GREEN under that first ablation, which is why this row + exists separately. + """ + 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) + assert not engine._isolated # the premise: live policy says in-place + + mount = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1-1-a" + task = StoryTask("1-1-a", 1, phase=Phase.DEV_RUNNING) + task.worktree_path = str(mount) # ...but the persisted task still carries one + task.spec_file = "_bmad-output/accepted.md" + task.dispatched_spec_file = "_bmad-output/dispatched.md" + engine.state.tasks["1-1-a"] = task + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + with pytest.raises(RunPaused): + engine._finish_inflight() + + assert task.spec_file == str(mount / "_bmad-output/accepted.md") + assert task.dispatched_spec_file == str(mount / "_bmad-output/dispatched.md") + + +def test_isolation_flip_releases_the_units_baseline_before_the_in_place_rollback( + project, monkeypatch +): + """The mount-measured operands must not reach a rollback of the MAIN checkout. + + `[scm] isolation` is re-read on every resume and a change is journaled, never + refused, so `worktree -> none` reaches the restart arm with a mount still recorded. + That arm re-runs in place, and the leg below it hands `baseline_commit` / + `baseline_untracked` to `recovery_flow.rollback_or_pause` — but both were stamped + from `self.workspace.root`, the UNIT, and the workspace is now the main checkout. + + Neither operand fails loud there. Linked worktrees share the object database, so + the unit baseline still resolves and a reset onto it succeeds; and a fresh + worktree is a tracked-only checkout, so its empty `baseline_untracked` makes + `verify._rollback_cleanup_plan` compute `untracked_files(repo) - + baseline_untracked` as EVERY untracked file in the operator's own checkout. Under + an auto-recovering cause those are deleted outright — the operator's own files, + for a story that merely changed isolation mode. + + Graded on the rollback leg not being entered at all, rather than only on the + cleared fields: the fields are the mechanism, the un-entered leg is the property. + + The mount's CLAIM and the mount's DIRECTORY are separated, and both are asserted. + `worktree_path` names a directory and is also how `runs` answers the RETROSPECTIVE + question — which tree owns the state this task already persisted (`task_spec_root`, + `task_stories_root`) — so a task that keeps the field set while executing in the + main checkout makes those readers answer for a tree the run has left. The directory + itself stays: this arm did not build it, and a policy change is not an instruction + to delete the operator's tree. The orphan is journaled so that is not silent. + + The PROSPECTIVE readers are deliberately absent from that list and from the + assertions below. `redrive_base_ref` and `spec_reaches_the_redrive` describe the + re-drive that has not happened yet, and they take the live isolation mode as a + parameter rather than inferring it here — because `bmad-loop resolve` asks them in + a separate process BEFORE this resume runs, where no amount of claim-clearing is + visible. Asserting them here would grade the argument this test passes them, not + the field it is about; `test_redrive_base_ref_reads_live_policy_not_the_recorded_mount` + (tests/test_runs.py) carries that direction against both flips. + + Ablation: narrow the arm back to `release_spec_paths_from_mount()` and this + reddens on the spy — the leg fires with the unit's operands, which is the state + that would have deleted them. Separately, drop the `worktree_path = ""` and the + claim assertions redden while the spy stays green, which is why both are here. + """ + 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) + assert not engine._isolated # the premise: live policy says in-place + + mount = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1-1-a" + (mount / "_bmad-output").mkdir(parents=True, exist_ok=True) + (mount / "_bmad-output" / "accepted.md").write_text("# spec\n", encoding="utf-8") + + task = StoryTask("1-1-a", 1, phase=Phase.DEV_RUNNING) + task.worktree_path = str(mount) # the persisted mount the live policy ignores + task.spec_file = "_bmad-output/accepted.md" + task.dispatched_spec_file = "_bmad-output/accepted.md" + task.dispatched_spec_snapshot = b"pre-launch bytes" + # measured INSIDE the unit by `_dev_phase`; a fresh mount is tracked-only, which + # is what makes the untracked half so destructive against another tree + task.baseline_commit = rev_parse_head(project.project) + task.baseline_untracked = [] + engine.state.tasks["1-1-a"] = task + + rolled: list[str] = [] + monkeypatch.setattr(engine, "_rollback_or_pause", lambda t, cause: rolled.append(cause)) + + class _StopBeforeRerun(Exception): + pass + + def _stop(*_a, **_k): + raise _StopBeforeRerun + + monkeypatch.setattr(engine, "_run_story", _stop) + + with pytest.raises(_StopBeforeRerun): + engine._finish_inflight() + + assert rolled == [] # the leg never ran, so the unit operands never travelled + + saved = load_state(engine.run_dir).tasks["1-1-a"] + assert saved.baseline_commit is None + assert saved.baseline_untracked is None + assert saved.spec_file == "_bmad-output/accepted.md" # relative again + assert saved.dispatched_spec_file is None + assert saved.dispatched_spec_snapshot is None + + # the CLAIM is dropped: the retrospective readers must now answer the main checkout + assert saved.worktree_path == "" + assert saved.branch == "" + assert runs.task_stories_root(saved, engine.state) == project.project + assert runs.task_spec_root(saved, engine.state) == project.project + + # ...but the DIRECTORY is not deleted, and the orphan is on the record + assert mount.is_dir() # left standing: this arm did not build it + 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. + + `_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. + + 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. + """ + 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) + assert not engine._isolated # the premise: live policy says in-place + + mount = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1-1-a" + 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.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_untracked = [] + engine.state.tasks["1-1-a"] = task + + seen: dict[str, object] = {} + monkeypatch.setattr( + engine, + "_resume_after_dev_verify", + lambda t: seen.update( + spec_file=t.spec_file, + dispatched=t.dispatched_spec_file, + worktree_path=t.worktree_path, + baseline_commit=t.baseline_commit, + ), + ) + + 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 + + +def test_open_unit_workspace_reclaims_the_orphan_holding_its_mount_path(project): + """A flip back to `worktree` is not blocked by the orphan the flip left behind. + + `unit_branch_name` and the mount path are both DETERMINISTIC in + (run_id, unit_key, run_dir), so a re-mount targets the exact directory a previous + mount used. `engine._release_orphaned_mount` deliberately leaves that directory + standing when live policy drops isolation — a policy change is not an instruction + to delete the tree — so a later flip BACK re-derives the same path and met a + `git worktree add` that refuses both an existing target and a branch checked out + elsewhere, deferring the task instead of resuming it. + + The BRANCH is deliberately spared by the reclaim: under `branch_per=run` this name + is the SHARED run branch carrying commits earlier units already landed, so a + force-delete would drop real work. The reclaim drops only the worktree, and the + `branch_exists` fork re-mounts the branch from its own HEAD — which is what the + committed file below grades. + + Ablation: delete the `discard_worktree(...)` call in `open_unit_workspace` and the + second mount raises `GitError`; swap its `""` back to `branch` and `landed.txt` is + gone because the shared branch was force-deleted. + """ + 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", "run", run_dir) + + first = open_unit_workspace(*args) + (first.path / "landed.txt").write_text("earlier unit\n", encoding="utf-8") + git(first.path, "add", "landed.txt") + git(first.path, "commit", "-m", "landed on the shared run branch") + + # the orphan: nothing tore this down, exactly as the isolation flip leaves it + assert first.path.is_dir() + + second = open_unit_workspace(*args) + + assert second.path == first.path # the same deterministic mount point + assert second.branch == first.branch + # the branch was NOT force-deleted: the earlier unit's commit is still on it + assert (second.path / "landed.txt").read_text(encoding="utf-8") == "earlier unit\n" + + def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) gated = Policy( @@ -3706,7 +4147,7 @@ def refuse_merge(*a, **kw): ), ( lambda: verify.GitError( - "git merge --no-ff feat failed in /repo: fatal: some state no probe " "measured" + "git merge --no-ff feat failed in /repo: fatal: some state no probe measured" ), "was not classified", ), @@ -5321,7 +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") == "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 6bc1850d..3bb4fca2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -239,6 +239,52 @@ 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(): + """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 + re-anchor that moved only one would leave a task naming two trees. Absolute + 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") + task = StoryTask( + story_key="1-1-a", + epic=1, + spec_file="_out/accepted.md", + dispatched_spec_file="_out/dispatched.md", + ) + + task.rebase_spec_paths_on(mount) + + assert task.spec_file == str(mount / "_out/accepted.md") + assert task.dispatched_spec_file == str(mount / "_out/dispatched.md") + + # idempotent: a second pass finds both absolute and leaves them alone + task.rebase_spec_paths_on(mount) + assert task.spec_file == str(mount / "_out/accepted.md") + assert task.dispatched_spec_file == str(mount / "_out/dispatched.md") + + +def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): + """An out-of-mount spec and an unbound field are both already correct. + + `_serialized_worktree_path` keeps a path verbatim exactly when + `relative_to(worktree_path)` raises, so an absolute value beside a set + `worktree_path` is the out-of-mount shape — joining it onto the mount would + invent a path no tree contains. `None` must survive as `None` rather than + 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")) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=outside) + + task.rebase_spec_paths_on(Path("/repo/wt")) + + assert task.spec_file == outside + assert task.dispatched_spec_file is None + + 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) @@ -602,3 +648,66 @@ def test_cache_read_weight_defaults_when_limits_not_a_dict(): def test_cache_read_weight_defaults_when_value_not_a_number(): state = _state(policy_snapshot={"limits": {"cache_read_weight": "high"}}) assert state.cache_read_weight() == 0.1 + + +def test_release_spec_paths_from_mount_relativizes_the_accepted_spec(): + """The accepted spec goes back to the spelling the REPLACEMENT mount re-resolves. + + `_discard_unit_for_restart` deletes the mount and the next attempt mounts a fresh + one carrying the same story's spec at the same relative place. An absolute path + into the deleted tree is what `verify.resolve_spec_path` passes through untouched, + so `_dispatched_spec_for_attempt` resolves it `strict=True` and the fresh attempt + starts unbound; the relative spelling is re-probed against the live workspace and + binds. `spec_file` outlives the attempt, so it is relativized rather than cleared. + + Ablation: drop the `_serialized_worktree_path` call from + `release_spec_paths_from_mount` and this reddens on the absolute spelling. + """ + task = StoryTask("1-1-a", 1) + task.worktree_path = "/runs/r1/worktrees/1" + task.spec_file = "/runs/r1/worktrees/1/_bmad-output/spec.md" + + task.release_spec_paths_from_mount() + + assert task.spec_file == "_bmad-output/spec.md" + + +def test_release_spec_paths_from_mount_clears_the_attempt_binding(): + """The attempt-owned pair died with its tree, and both halves go together. + + `dispatched_spec_file`/`dispatched_spec_snapshot` are the authority pair + `recovery_flow` restores bytes through. A path without its snapshot is a shape + `_bind_dispatched_spec_for_attempt` never persists, so clearing one and not the + other would invent it. + + Ablation: drop either `= None` and this reddens on that half. + """ + task = StoryTask("1-1-a", 1) + task.worktree_path = "/runs/r1/worktrees/1" + task.dispatched_spec_file = "/runs/r1/worktrees/1/_bmad-output/spec.md" + task.dispatched_spec_snapshot = b"frozen bytes" + + task.release_spec_paths_from_mount() + + assert task.dispatched_spec_file is None + assert task.dispatched_spec_snapshot is None + + +def test_release_spec_paths_from_mount_keeps_an_out_of_mount_spec_verbatim(): + """A spec outside the mount was never the mount's to give up. + + `_serialized_worktree_path` keeps such a path verbatim exactly when + `relative_to` raises — the shared-artifact-dir shape that survives the re-drive. + Relativizing it would be meaningless, and reusing that one helper is what makes + the discarded-mount spelling and the persisted one agree by construction. + + Ablation: replace the helper call with an unconditional `relative_to`/join and + this reddens (or raises) while the in-mount row above stays green. + """ + task = StoryTask("1-1-a", 1) + task.worktree_path = "/runs/r1/worktrees/1" + task.spec_file = "/shared-artifacts/spec.md" + + task.release_spec_paths_from_mount() + + assert task.spec_file == "/shared-artifacts/spec.md" diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 4ae1bc44..be482291 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -55,6 +55,28 @@ # TUI checkpoint modal, and a probe ignoring `limits.git_timeout_s`. GIT_CHOKEPOINT = {"verify.py"} +# 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 +# `recovery_flow.py` are in-process consumers driving a live run, where the field is +# still the absolute path the engine stamped and no reload has round-tripped it +# through `StoryTask.to_dict`. +# +# 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 +# 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. +# Nothing enforced the rule, which is why each round only ever found the next one. +# +# Adding a file here is a claim that its cwd IS the run's tree. If it is not, route +# the read through `runs.task_spec_path` (or `StoryTask.rebase_spec_paths_on` when +# re-anchoring persisted state) instead. +SPEC_ANCHOR_CHOKEPOINT = {"runs.py", "engine.py", "verify.py", "recovery_flow.py"} +SPEC_PATH_FIELDS = {"spec_file", "dispatched_spec_file"} + # 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 @@ -601,6 +623,22 @@ def line_at(lineno: int) -> str: # source line — a read spelled through a constant does not carry it. findings.append(("envread", rel, node.lineno, line_at(node.lineno), env_key)) + # A raw `Path(x.spec_file)` / `Path(x.dispatched_spec_file)`: the persisted value + # may be worktree-RELATIVE, so this resolves against the reader's cwd rather than + # the tree the run owns. Detected as the call shape rather than by name, so an + # alias (`Path(t.spec_file)`, `Path(self._task.dispatched_spec_file)`) is caught + # too; the enclosing `if x.spec_file else` ternary does not hide it. + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "Path" + and node.args + and isinstance(node.args[0], ast.Attribute) + and node.args[0].attr in SPEC_PATH_FIELDS + ): + findings.append(("specanchor", rel, node.lineno, line_at(node.lineno))) + return findings @@ -648,6 +686,62 @@ def test_no_git_invocation_outside_verify(): ) +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. + + ``StoryTask._serialized_worktree_path`` persists an isolated unit's spec RELATIVE + to its mounted worktree and ``from_dict`` reads it back raw, so every reader that + 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 + 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. + + This is the guard's whole point — the same defect was found and fixed one surface + at a time over four review rounds, each round discovering the next unanchored + reader, because nothing made the rule checkable. + + Ablation: revert ``_paused_spec``'s ``runs.task_spec_path(task, state)`` to + ``Path(task.spec_file)`` and this reddens naming ``tui/app.py``.""" + offenders = [ + (rel, ln, txt) for _, rel, ln, txt in _of("specanchor") if rel not in SPEC_ANCHOR_CHOKEPOINT + ] + assert not offenders, ( + "a persisted spec path resolved against the reader's cwd — route it through " + "runs.task_spec_path (or StoryTask.rebase_spec_paths_on) so the anchor names " + "the tree the run owns:\n" + + "\n".join(f" {rel}:{ln}: {txt.strip()}" for rel, ln, txt in offenders) + ) + + +def test_spec_anchor_detector_flags_the_shipped_defect(): + """The guard above asserts an ABSENCE, so it passes for every reason a match could + be missing. Feed it the exact line the defect shipped as, through the same + ``_scan_source`` the real scan uses.""" + found = _scan_source("from pathlib import Path\npath = Path(task.spec_file)\n", "tui/app.py") + assert [f[0] for f in found if f[0] == "specanchor"] == ["specanchor"] + # and the dispatched twin, which carries the identical serialization hazard + found = _scan_source( + "from pathlib import Path\np = Path(self._task.dispatched_spec_file)\n", "tui/app.py" + ) + assert [f[0] for f in found if f[0] == "specanchor"] == ["specanchor"] + + +def test_spec_anchor_detector_stays_silent_on_the_anchored_form(): + """The sanctioned spellings must not trip it, or the guard becomes noise that + gets allowlisted away.""" + for src in ( + "p = runs.task_spec_path(task, state)\n", + "task.rebase_spec_paths_on(wt)\n", + "from pathlib import Path\np = Path(state.project)\n", + ): + assert not [f for f in _scan_source(src, "tui/app.py") if f[0] == "specanchor"] + + 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. diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 78d86ab2..064e70c6 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2,6 +2,7 @@ import json import sys +from pathlib import Path import pytest import yaml @@ -44,6 +45,7 @@ def _escalated_run( restore_patch=None, repo_root=None, target_branch=None, + spec_folder=None, ): """conftest's builder with this module's shape: a review-cycle-1 task carrying a completed review session (what `build_context` reads), returning the full triple. @@ -77,11 +79,13 @@ def _escalated_run( worktree_path=worktree_path, restore_patch=restore_patch, ) - if repo_root is not None or target_branch is not None: + if repo_root is not None or target_branch is not None or spec_folder is not None: if repo_root is not None: run.state.repo_root = str(repo_root) if target_branch is not None: run.state.target_branch = target_branch + if spec_folder is not None: + run.state.spec_folder = spec_folder save_state(run.run_dir, run.state) return run.run_dir, run.state, run.task @@ -505,7 +509,13 @@ def test_set_frontmatter_field_refuses_a_readonly_spec(tmp_path): def test_build_context_gathers_critical_escalations(tmp_path): - run_dir, state, task = _escalated_run(tmp_path, spec_file="/abs/spec.md") + # An absolute `spec_file` passes through `runs.task_spec_path` verbatim. The literal + # has to be OS-absolute, not merely rooted: on Windows "/abs/spec.md" is DRIVE-relative + # (`Path.is_absolute()` is False), so it takes the anchoring arm instead and pathlib's + # `/` keeps the root's drive while discarding its path — yielding "D:/abs/spec.md", a + # shape no real run persists. `tmp_path` is absolute on every OS. + spec = tmp_path / "abs" / "spec.md" + run_dir, state, task = _escalated_run(tmp_path, spec_file=str(spec)) task_dir = run_dir / "tasks" / "6-4-cli-list-command-review-1" task_dir.mkdir(parents=True) (task_dir / "result.json").write_text( @@ -519,10 +529,10 @@ def test_build_context_gathers_critical_escalations(tmp_path): ), encoding="utf-8", ) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command") + 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"] == "/abs/spec.md" + assert ctx["spec_file"] == spec.as_posix() assert ctx["baseline_commit"] == "abc123" details = [e["detail"] for e in ctx["escalations"]] assert "names not unique" in details @@ -533,9 +543,90 @@ def test_build_context_gathers_critical_escalations(tmp_path): assert "\\" not in ctx["resolution_path"] +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. + + `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 + 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 + through, which is also the absolute shape `bmad-loop-resolve/SKILL.md` documents. + + The EQUALITY is what grades this row, and it is the whole instrument: + `is_absolute()` alone is satisfied by a PROJECT-anchored resolve, which is the + same bug wearing an absolute path. Two things here are deliberately NOT load- + bearing, so nobody reads them as proof they are not: `build_context` never opens + the spec, so both on-disk copies are inert scenery, and the `chdir` cannot change + an emitted value computed by pure path arithmetic. They are kept because a future + `abspath`/`resolve()`-shaped resolver WOULD consult the cwd, and pinning it to + the tree the agent really runs from keeps this row honest under that change. + + Shape, not just value: `.as_posix()` matches `resolution_path`'s contract two + fields below — one string on every OS — so the assertion compares posix + spellings and re-uses that field's no-backslash check. `str()` here would have + regressed Windows, where the value it replaced was already posix + (`_serialized_worktree_path` persists the relative form with `.as_posix()`). + Both halves of that check are INERT on POSIX, where `str()` and `.as_posix()` + agree — exactly as the sibling `resolution_path` assertion is. Windows CI is + where they grade; do not read a green run here as having exercised them. + + Ablation: revert `build_context`'s field to `task.spec_file if task else None` and + this reddens on `is_absolute()` — the emitted value is the bare relpath. + """ + rel = "_bmad-output/specs/6-4-cli-list-command.md" + wt = tmp_path / "wt" + for root in (wt, tmp_path): # the run's own copy, and the main checkout's twin + spec = root / rel + spec.parent.mkdir(parents=True, exist_ok=True) + spec.write_text(SPEC, encoding="utf-8") + + 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") + 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 + # also the contract shape (no backslashes leak in on Windows). + assert ctx["spec_file"] == (wt / rel).as_posix() + assert "\\" not in ctx["spec_file"] + + +def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): + """The re-anchor must not manufacture a path out of nothing. `Path("")` is `.`, + so an unguarded `root / raw` would emit the worktree root itself — a real + directory — as the story's spec. Both empty legs stay `None`, which is what the + agent reads as "there is no frozen spec to edit" (a spec-less escalation, or a + key with no task at all). + + Ablation: drop the `and task.spec_file` guard from `build_context`'s field and the + spec-less leg reddens with the worktree root in place of `None`.""" + wt = tmp_path / "wt" + 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") + ) + 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" + ) + ) + assert ctx["spec_file"] is None # no task at all + + 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") + 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") @@ -550,25 +641,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) + path = resolve.build_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") 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) + 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 = resolve.build_context(state, run_dir, key) + 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 = resolve.build_context(state, run_dir, key) + path = resolve.build_context(state, run_dir, key, isolation="") assert json.loads(path.read_text(encoding="utf-8"))["restore_supported"] is False @@ -579,7 +670,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) + 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 @@ -593,7 +684,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) + 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] @@ -617,7 +708,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) + 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" @@ -628,7 +719,7 @@ def test_rearm_strips_stale_terminal_section(tmp_path): def test_rearm_warns_when_an_isolated_tasks_spec_writes_cannot_reach_the_redrive(tmp_path): """A worktree-isolated task's spec writes land in a directory the re-drive destroys. - `_task_spec_path` re-anchors the recorded spec on `task.worktree_path`, but a + `task_spec_path` re-anchors the recorded spec on `task.worktree_path`, but a re-armed task falls to `engine._finish_inflight`'s final arm, which calls `discard_worktree` and lets `_run_story` mount a fresh one — and the re-driven session resolves its spec against THAT worktree @@ -660,7 +751,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) + 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" @@ -682,7 +773,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -750,9 +841,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) else: - runs.rearm_escalation(run_dir) + 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": @@ -783,7 +874,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) journal = (run_dir / "journal.jsonl").read_text(encoding="utf-8") assert "story-escalation-resolved" in journal @@ -802,7 +893,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) + 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 @@ -821,7 +912,7 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir) + 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 @@ -831,7 +922,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.baseline_commit == "abc123" @@ -851,7 +942,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) + 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" @@ -879,7 +970,7 @@ def boom(repo): monkeypatch.setattr(runs.verify, "untracked_files", boom) with pytest.raises(MemoryError): - runs.rearm_escalation(run_dir) + runs.rearm_escalation(run_dir, isolated_redrive=False) @pytest.mark.parametrize("restore", [None, "artifacts/attempt.patch"]) @@ -903,7 +994,7 @@ def boom(repo): raise verify.GitError("simulated failure") monkeypatch.setattr(runs.verify, "untracked_files", boom) - runs.rearm_escalation(run_dir, restore_patch=restore) + 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 @@ -928,7 +1019,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.generation == 1 @@ -936,7 +1027,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert load_state(run_dir).tasks["6-4-cli-list-command"].generation == 2 @@ -961,7 +1052,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) + 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 @@ -1019,7 +1110,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) # 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 @@ -1060,7 +1151,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert load_state(run_dir).tasks["6-4-cli-list-command"].baseline_commit == head @@ -1077,7 +1168,7 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, 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. - `_task_spec_path` now anchors a relative path on `task.worktree_path` (falling back + `task_spec_path` now anchors a relative path on `task.worktree_path` (falling back to `state.project`) and passes an absolute one through. The cwd is set EXPLICITLY: pytest's own cwd is not this sandbox, so without the @@ -1086,7 +1177,7 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, carry distinguishable `baseline_revision` claims for the same reason — "the right file was written" has to be checkable against "the other one was not". - Ablation: revert `_task_spec_path`'s body to `return Path(task.spec_file or "")` + Ablation: revert `task_spec_path`'s body to `return Path(task.spec_file or "")` and this reddens on the worktree copy with `AssertionError: assert 'blocked' == 'ready-for-dev'` — the flip went to the main checkout — and the byte-identity assertion on the main copy reddens behind it. @@ -1107,7 +1198,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) + 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 @@ -1143,7 +1234,9 @@ def test_rearm_journals_a_skip_when_the_recorded_spec_is_not_readable(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) # must not raise: the flip's no-op is not a refusal + runs.rearm_escalation( + run_dir, isolated_redrive=False + ) # must not raise: the flip's no-op is not a refusal kinds = _kinds(run_dir) (skipped,) = [e for e in kinds if e["kind"] == "rearm-baseline-restamp-skipped"] @@ -1182,7 +1275,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) + 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"] @@ -1210,7 +1303,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) + 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"] == [] @@ -1239,7 +1332,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) + returned = runs.rearm_escalation(run_dir, isolated_redrive=False) assert returned == key # sentinel deleted from disk, a copy preserved under the run dir @@ -1279,7 +1372,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) + 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 @@ -1299,7 +1392,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) + 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 @@ -1316,7 +1409,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) + 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 @@ -1342,7 +1435,9 @@ 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") + runs.rearm_escalation( + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + ) assert sentinel.is_file() # nothing deleted, copy NOT preserved — no clear happened task = load_state(run_dir).tasks[key] @@ -1362,14 +1457,18 @@ def test_rearm_rejects_restore_patch_without_a_spec_file(tmp_path): run_dir, _, _ = _escalated_run(tmp_path, spec_file=None) with pytest.raises(runs.RearmError, match="no recorded spec file"): - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch") + runs.rearm_escalation( + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + ) task = load_state(run_dir).tasks["6-4-cli-list-command"] assert task.phase == Phase.ESCALATED # not re-armed; the escalation stays armed assert task.restore_patch is None # no latch persisted assert not (run_dir / "journal.jsonl").exists() # nothing journaled - runs.rearm_escalation(run_dir) # a from-scratch re-arm remains available + runs.rearm_escalation( + 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 @@ -1385,13 +1484,15 @@ 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") + runs.rearm_escalation( + 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) == "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): @@ -1418,7 +1519,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") + 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" @@ -1458,7 +1559,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") + 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 @@ -1508,7 +1609,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) # no restore + runs.rearm_escalation(run_dir, isolated_redrive=False) # no restore fm = verify.read_frontmatter(spec) assert fm["baseline_revision"] == new_head @@ -1559,7 +1660,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) + 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 @@ -1589,7 +1690,10 @@ def test_rearm_restores_the_spec_when_the_result_strip_faults(tmp_path, monkeypa 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. + 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. """ _resolve_repo(tmp_path) spec = tmp_path / "spec.md" @@ -1607,12 +1711,58 @@ 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) + 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 +def test_rearm_restores_an_isolated_tasks_spec_that_sits_outside_the_worktree( + tmp_path, monkeypatch +): + """The undo must still land when the mount cannot confine the spec. + + 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. + """ + _resolve_repo(tmp_path) + wt = tmp_path / ".bmad-loop" / "runs" / "wt-mount" # the mount, which holds no spec + wt.mkdir(parents=True, exist_ok=True) + spec = tmp_path / "spec.md" # in the project, outside the mount + spec.write_text( + "---\nstatus: blocked\n---\n\n## Intent\n\nx\n\n## Auto Run Result\n\nterminal verdict\n", + encoding="utf-8", + ) + before = spec.read_bytes() + run_dir, _, _ = _escalated_run(tmp_path, spec_file=str(spec), worktree_path=str(wt)) + + def boom(spec_path, *, confine_root): + raise OSError(28, "No space left on device") + + 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) + + 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 + + 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 @@ -1626,7 +1776,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) + 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 @@ -1635,7 +1785,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) assert len([e for e in _kinds(run_dir) if e["kind"] == "rearm-baseline-restamped"]) == 1 @@ -1661,7 +1811,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) + 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 @@ -1697,7 +1847,7 @@ def test_rearm_reports_a_claim_the_advanced_head_would_have_masked(tmp_path): encoding="utf-8", ) - runs.rearm_escalation(run_dir) + 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 @@ -1714,7 +1864,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) + 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 @@ -1750,7 +1900,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) # 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 @@ -1765,7 +1915,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) # 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 @@ -1786,7 +1936,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) + 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] @@ -1806,7 +1956,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) == 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 @@ -1839,7 +1989,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) def test_rearm_rejects_unescalated_story(tmp_path): @@ -1847,7 +1997,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) # ----------------------------------------------------------- run_session @@ -1866,7 +2016,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") + 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 @@ -1879,7 +2029,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") + 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 @@ -1890,7 +2040,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") + 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") @@ -1928,7 +2078,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(resolve.build_context(state, run_dir, key).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" @@ -1952,7 +2104,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(resolve.build_context(state, run_dir, key).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"] @@ -1962,11 +2116,200 @@ 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").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 +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. + + An absolute `spec_file` beside a set `worktree_path` is the out-of-mount shape + (`_serialized_worktree_path` keeps a path verbatim exactly when + `relative_to(worktree_path)` raises). `task_spec_path` passes it through untouched, + so `context.json` must show it unchanged rather than re-anchored onto either tree. + The row exists here because the `runs` and TUI layers grade the resolver while this + is the surface that hands the value to the resolve agent. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "20260613-111429-6a14" / "worktrees" / "1" + spec = tmp_path / "shared-artifacts" / "6-4.md" + spec.parent.mkdir(parents=True) + spec.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + 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") + ) + assert ctx["spec_file"] == spec.as_posix() + + +def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): + """One `context.json` must not name two different trees for one story. + + `spec_file` is anchored on the tree the run owns, but `_stories_context` resolved + its folder from `Path(state.project)` — so under isolation the sentinel indicator + and its recorded blocking condition described the MAIN CHECKOUT while `spec_file` + named the mount. The agent was then told there is no frozen spec to edit (or handed + a stale twin's blocking condition) for a story whose real sentinel sits in the + worktree the re-arm actually writes to. + + The decoy is load-bearing: the project carries a sentinel at the same relpath with a + DIFFERENT blocking condition, so "read the right tree" is checkable against "did not + read the other one". + + Ablation: revert `_stories_context` to `stories.resolve_spec_folder(Path(state.project), + ...)` and this reddens on the blocking condition — it reports the decoy'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")): + folder = root / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "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", + ) + + 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) + ) + state.spec_folder = "epic-1" + + ctx = json.loads( + 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"] + 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 same tree spec_file named + + +def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec(tmp_path): + """The consumer half of the divergent shape, at the surface that motivated the split. + + `test_build_context_stories_block_names_the_same_tree_as_spec_file` above builds the + isolated case with a RELATIVE `spec_file`, where `task_spec_root` and + `task_stories_root` return the same tree — so it passes with either resolver wired in + and cannot grade the choice. Here `spec_file` is ABSOLUTE and lexically outside the + mount (the shape `_serialized_worktree_path` persists verbatim, reachable whenever a + symlinked component makes a spec that physically lives in the mount look outside it, + since `verify.resolve_spec_path` deliberately does not `.resolve()`). + + 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. + + 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.""" + 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")): + folder = root / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "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", + ) + + # absolute and outside the mount: the two resolvers now answer different trees + outside = tmp_path / "shared-artifacts" / f"{key}.md" + outside.parent.mkdir(parents=True, exist_ok=True) + outside.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + + run_dir, state, _ = _escalated_run( + tmp_path, run_id, spec_file=str(outside), source="stories", worktree_path=str(wt) + ) + state.spec_folder = "epic-1" + + ctx = json.loads( + 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"] + 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 + + +def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): + """The agent is told when the file it is being sent to edit has no future. + + Under isolation `engine._finish_inflight` discards the mount, so an edit to a + worktree-local spec succeeds and then vanishes before the re-drive reads anything. + `rearm_escalation` already journals `rearm-spec-write-unreachable` on this verdict; + emitting it here is what lets the session act on it rather than discover it after. + + Ablation: hardcode the field to `True` and the isolated leg reddens. + """ + 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") + ) + assert ctx["spec_reaches_the_redrive"] is False + + plain_dir, plain_state, _ = _escalated_run( + 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") + ) + assert plain["spec_reaches_the_redrive"] is True + + +def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_path): + """`spec_reaches_the_redrive: false` on its own states a problem with no remedy. + + The session is told its edit is doomed, and both obvious repairs fail silently for + an isolated unit: committing from the main checkout cannot include a file that + lives in the linked unit worktree, and committing on the unit's own branch does + not put it on the ref the replacement worktree is cut from. That ref is the run's + PINNED `target_branch`, which is what this field carries — the same value + `rearm_escalation`'s unreachable-write record names, so the session and the + orchestrator quote one answer instead of two. + + Ablation: return `"HEAD"` unconditionally and the isolated leg reddens; drop the + key and the skill-contract guard reddens too, since the schema documents it. + """ + 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)) + 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") + ) + # the paired claim: the edit has no future, and THIS is the tree that does + assert ctx["spec_reaches_the_redrive"] is False + assert ctx["redrive_base_ref"] == "feat/the-pinned-one" + + # in-place: no mount, so the re-drive reads the working ref + plain_dir, plain_state, _ = _escalated_run( + tmp_path, "20260613-111429-6a15", spec_file=str(tmp_path / "specs" / "6-4.md") + ) + 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") + ) + assert plain["redrive_base_ref"] == "HEAD" + + @pytest.mark.parametrize( ("committed_status", "warns"), [("ready-for-dev", False), ("blocked", True)], @@ -2009,7 +2352,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) + 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 @@ -2048,7 +2391,7 @@ def test_rearm_reads_the_committed_spec_from_the_redrive_base_not_the_current_he The worktree copy is held at `blocked` on both rows, so the only moving part is which committed ref carries the correction. - Ablation: restore the revision argument in `_committed_spec_status` to a literal + Ablation: restore the revision argument in `_redrive_spec_status` to a literal `"HEAD"` and BOTH rows redden (`assert False is True` / `assert True is False`) — the pair is the discriminator; either row alone also passes for the anchor it is meant to reject. @@ -2081,7 +2424,7 @@ def _commit(status, message): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir) + 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 @@ -2095,13 +2438,428 @@ def _commit(status, message): assert "`main`" in runs.rearm_event_notice(unreachable[0])[2] +SPEC_FOLDER = "_bmad-output/epic-6" +WEDGED_INTENT = "# Epic 6\n\nDo the thing, somehow.\n" +CORRECTED_INTENT = "# Epic 6\n\nDo the thing by rotating the vault key first.\n" +MANIFEST = "- id: 6-4-cli-list-command\n title: List command\n description: list them\n" + + +def _sentinel_run( + tmp_path, + *, + committed_intent, + working_intent, + spec_folder=SPEC_FOLDER, + target_branch="main", +): + """A stories-mode run wedged on a pre-planning SENTINEL, in a real repo. + + The upstream artifacts (`SPEC.md` + `stories.yaml`) are COMMITTED holding + ``committed_intent`` and then left in the working tree holding + ``working_intent`` — the two halves the re-arm's proof compares. Equal strings + mean "the operator's correction is already on the branch the re-drive mounts + from"; different ones mean it is still only in this checkout. + + A `worktree_path` is always recorded, because that is what an escalated unit + under `isolation = "worktree"` really carries (`worktree_flow.escalate_unit` + never clears it) and because it is the field that must NOT move the answer: the + correction lands in the main checkout either way, since `resolve.run_session` + runs the agent with `cwd=project`. + """ + key = "6-4-cli-list-command" + _resolve_repo(tmp_path) + folder = Path(spec_folder) + folder = folder if folder.is_absolute() else tmp_path / folder + folder.mkdir(parents=True, exist_ok=True) + spec_md, manifest = folder / "SPEC.md", folder / "stories.yaml" + spec_md.write_text(committed_intent, encoding="utf-8") + manifest.write_text(MANIFEST, encoding="utf-8") + if folder.is_relative_to(tmp_path): + git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-q", "-m", "upstream artifacts") + # an external artifact dir is outside the repo entirely — there is nothing to commit, + # which is the whole point of the row that uses one + spec_md.write_text(working_intent, encoding="utf-8") + + 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", + encoding="utf-8", + ) + mount = tmp_path / "wt" + mount.mkdir(exist_ok=True) + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + worktree_path=str(mount), + spec_folder=spec_folder, + target_branch=target_branch, + ) + return run_dir, state, sentinel + + +def _upstream_records(run_dir): + return [e for e in _kinds(run_dir) if e["kind"] == "rearm-upstream-write-unreachable"] + + +@pytest.mark.parametrize( + ("isolated", "committed_matches", "warns"), + [(True, False, True), (True, True, False), (False, False, False)], +) +def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redrive( + tmp_path, monkeypatch, isolated, committed_matches, warns +): + """The sentinel arm used to fall through the reachability gate entirely. + + A sentinel is cleared by DELETION, so the arm drops `task.spec_file` and returns — + and `spec_reaches_the_redrive` / `rearm-spec-write-unreachable` live wholly in the + `else`. The consequence is not a missing warning but a spent escalation: the + correction that stops the sentinel RECURRING is upstream (`SPEC.md` / + `stories.yaml`, where `bmad-loop-resolve/SKILL.md` sends the agent instead of the + sentinel), an isolated re-drive mounts fresh from `redrive_base_ref` and re-plans + from a COMMITTED tree, and the re-plan mints the same sentinel again. + + Three rows, because two of them are the narrowing and the third is the axis: + + - isolated + the correction only in this checkout: the record fires and HOLDS the + resume. This is the P1. + - isolated + the same bytes already committed on the target branch: SILENT. Without + this proof the record is a per-configuration CONSTANT — every isolated stories run + resolves its spec folder inside the project, so `stories_reach_the_redrive` says + "unreachable" for 100% of sentinel re-arms under `isolation = "worktree"`, and + since the kind holds the resume that would turn every one of them into a + two-command gesture for an outcome nothing decided. + - IN PLACE, same uncommitted tree: SILENT. The re-drive reads the main checkout's + working tree, which is exactly where `cwd=project` put the correction. The + recorded mount is identical on all three rows precisely so it cannot be what + separates them. + + Ablation: drop the `_redrive_reads_the_upstream_artifacts` conjunct from the gate + and row 2 reddens on `assert True is False`; make `stories_reach_the_redrive` return + False for the in-place leg and row 3 reddens; delete the whole `if` and row 1 + reddens on `assert [] != []`. Every row alone is satisfied by a wrong fix. + """ + intent = CORRECTED_INTENT if committed_matches else WEDGED_INTENT + run_dir, _, sentinel = _sentinel_run( + tmp_path, committed_intent=intent, working_intent=CORRECTED_INTENT + ) + monkeypatch.chdir(tmp_path) + + 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) + assert bool(records) is warns + if not warns: + return + (rec,) = records + # the FOLDER the correction lands in — the main checkout's, not `task_stories_root`'s + # mount, because the resolve agent runs with `cwd=project` + assert rec["stories_root"] == str(tmp_path / SPEC_FOLDER) + assert rec["target_branch"] == "main" + # a resume in the same gesture would re-plan from the tree that wedged + assert runs.rearm_holds_the_resume(rec) is True + severity, message, next_step = runs.rearm_event_notice(rec) + 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" + + +@pytest.mark.parametrize( + ("corrected_on_target", "warns"), + [(True, False), (False, True)], +) +def test_rearm_reads_the_upstream_artifacts_at_the_redrive_base_not_the_current_head( + tmp_path, monkeypatch, corrected_on_target, warns +): + """The proof is read at the run's PINNED target branch, not at the code root's HEAD. + + The same seam `test_rearm_reads_the_committed_spec_from_the_redrive_base_not_the_current_head` + pins for the spec record, asked of the sentinel path: an isolated re-drive never + reads the main checkout's working ref, because `worktree_flow.run_isolated` cuts the + replacement worktree from `state.target_branch`. An operator who checks out another + branch while the escalation is parked — a wholly ordinary thing to do with a run + waiting on a human — moves `HEAD` off the tree the re-drive reads. + + Both rows leave the checkout on `side` and put the two candidate refs in + DISAGREEMENT, so neither can pass by reading the other: + + - the target branch already carries what this checkout holds: reading `main` + suppresses (nothing left to commit); reading `HEAD` sees `side`'s wedged blob and + holds a resume whose work is already where the re-drive looks for it. + - the correction was committed on `side` and `main` still holds the wedged intent: + reading `main` fires; reading `HEAD` sees the correction and SUPPRESSES — which, + because this kind holds the resume, is the default resolve flow resuming straight + into the wedge it was meant to clear. + + Ablation: pass a literal `"HEAD"` instead of `redrive_base_ref` in + `_redrive_reads_the_upstream_artifacts` and BOTH rows redden. Either row alone also + passes for the anchor it exists to reject. + """ + key = "6-4-cli-list-command" + _resolve_repo(tmp_path) + folder = tmp_path / SPEC_FOLDER + folder.mkdir(parents=True) + spec_md, manifest = folder / "SPEC.md", folder / "stories.yaml" + manifest.write_text(MANIFEST, encoding="utf-8") + + def _commit(intent, message): + spec_md.write_text(intent, encoding="utf-8") + git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-q", "-m", message) + + if corrected_on_target: + _commit(CORRECTED_INTENT, "corrected on the target branch") + git(tmp_path, "checkout", "-q", "-b", "side") + _commit(WEDGED_INTENT, "side went its own way") + # the operator's checkout still shows the correction the target branch carries + spec_md.write_text(CORRECTED_INTENT, encoding="utf-8") + else: + _commit(WEDGED_INTENT, "the intent that wedged") + git(tmp_path, "checkout", "-q", "-b", "side") + _commit(CORRECTED_INTENT, "corrected on the wrong branch") + + sentinel = folder / f"{key}-unresolved.md" + sentinel.write_text("---\nstatus: blocked\n---\n\n## Auto Run Result\n\nx\n", "utf-8") + mount = tmp_path / "wt" + mount.mkdir() + run_dir, _, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + worktree_path=str(mount), + spec_folder=SPEC_FOLDER, + target_branch="main", + ) + monkeypatch.chdir(tmp_path) + + runs.rearm_escalation(run_dir, isolated_redrive=True) + + records = _upstream_records(run_dir) + assert bool(records) is warns + if warns: + assert records[0]["target_branch"] == "main" + + +@pytest.mark.parametrize("external", [False, True]) +def test_rearm_exempts_a_stories_folder_configured_outside_the_project( + tmp_path, monkeypatch, external +): + """An artifact dir configured OUTSIDE the project tree is the one folder an isolated + re-drive still reads through the working tree. + + `ProjectPaths.rebased` leaves such a dir exactly where it is rather than rebasing it + onto the mount, so every worktree opens the same absolute path — the identical carve + -out `_spec_is_shared_with_the_redrive` makes for a spec, asked of the stories + folder. Nothing is committed on either row, so the ONLY moving part is where the + folder lives; an in-project folder must fire and an external one must not. + + Ablation: delete the `is_relative_to(project)` arm of `stories_reach_the_redrive` and + the in-project row reddens; make the isolated arm answer False unconditionally and + the external row reddens. + """ + outside = tmp_path.parent / f"{tmp_path.name}-artifacts" / "epic-6" + spec_folder = str(outside) if external else SPEC_FOLDER + run_dir, _, _ = _sentinel_run( + tmp_path, + committed_intent=WEDGED_INTENT, + working_intent=CORRECTED_INTENT, + spec_folder=spec_folder, + ) + monkeypatch.chdir(tmp_path) + + runs.rearm_escalation(run_dir, isolated_redrive=True) + + assert bool(_upstream_records(run_dir)) is not external + + +def test_rearm_of_a_sentinel_survives_a_project_that_is_not_a_repository(tmp_path, monkeypatch): + """The proof reads git, and a non-repo project must still re-arm. + + `_redrive_spec_status` already carries this requirement for the spec record ("the + non-repo case stays non-fatal, as the story's Boundaries require"); the sentinel arm + now runs git too, so it inherits the same obligation on a path that did not read git + at all before. A `GitError` escaping here would abort a re-arm that has ALREADY + deleted the sentinel and preserved its copy — the escalation spent on a traceback. + + The degrade direction is the warning one: no proof means the record fires and the + resume holds, so an operator on a non-repo project is told to commit rather than + quietly resumed into a re-plan nothing verified. + + Deliberately not `_sentinel_run`, which git-inits: this row's whole premise is the + absence of a repository. + + Ablation: drop the `except verify.GitError` arm in + `_redrive_reads_the_upstream_artifacts` and this reddens with the GitError escaping + `rearm_escalation` — the sentinel already gone from disk. + """ + key = "6-4-cli-list-command" + folder = tmp_path / SPEC_FOLDER + folder.mkdir(parents=True) + (folder / "SPEC.md").write_text(CORRECTED_INTENT, encoding="utf-8") + (folder / "stories.yaml").write_text(MANIFEST, encoding="utf-8") + sentinel = folder / f"{key}-unresolved.md" + sentinel.write_text("---\nstatus: blocked\n---\n\n## Auto Run Result\n\nvague\n", "utf-8") + run_dir, _, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + spec_folder=SPEC_FOLDER, + target_branch="main", + ) + monkeypatch.chdir(tmp_path) + + 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) + assert runs.rearm_holds_the_resume(rec) is True + + +def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_path, monkeypatch): + """The mirror of the isolated warning, and a DIFFERENT remedy — which is why the + record carries a discriminator rather than leaving the reader to guess. + + Setup is a `worktree` -> `none` flip: the task still carries the escalated + attempt's mount (nothing clears it until the resume runs `_finish_inflight`), so + `task_spec_path` anchors the re-arm's status flip inside that mount — while + `_run_story` will re-run the story in the main checkout, which never reads it. The + write is unreachable, exactly as under isolation, and for the opposite reason. + + So the remedy inverts. Under isolation the correction must be COMMITTED, because + the replacement worktree is cut from git and reads no working tree. Here the + re-drive reads the main checkout's WORKING tree, so the correction has to be + re-applied there and a commit is beside the point — and naming `target_branch` + would send the operator to the one place this re-drive does not look. Both fields + move: `redrive` says which shape it is, and `target_branch` goes empty. + + `rearm_event_notice` renders out of process from the journal line alone, so it + cannot re-read the policy that produced the record; the discriminator has to be ON + the line or the reader falls back to the isolated wording it cannot verify. + + Ablation: drop the `redrive` field from the `journal.append` and the notice falls + to its isolated arm — this reddens on `next_step`, which tells the operator to + commit onto a branch this re-drive never reads. + """ + rel = "_bmad-output/specs/6-4-cli-list-command.md" + _resolve_repo(tmp_path) + spec = tmp_path / rel + spec.parent.mkdir(parents=True, exist_ok=True) + # the MAIN CHECKOUT's copy — the tree the in-place re-drive reads — left terminal, + # so `_redrive_spec_status` cannot suppress the record + spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") + git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-q", "-m", "escalated spec") + + wt_spec = tmp_path / "wt" / rel + wt_spec.parent.mkdir(parents=True, exist_ok=True) + wt_spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") + + run_dir, _, _ = _escalated_run( + tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt"), target_branch="main" + ) + 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) + + (rec,) = [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] + assert rec["redrive"] == "in-place" + # the pin survives the flip on the state, but must not reach a record whose reader + # would turn it into "commit here" + assert rec["target_branch"] == "" + + severity, message, next_step = runs.rearm_event_notice(rec) + assert severity == "warning" + assert "main checkout" in next_step and "commit" not in next_step.lower() + assert "isolation policy changed" in message + # and the flip still holds the resume: the remedy is upstream of the re-drive's read + assert runs.rearm_holds_the_resume(rec) + + +def test_rearm_in_place_proof_reads_the_working_tree_not_the_commit(tmp_path, monkeypatch): + """The in-place remedy has to be able to CLEAR the record it is printed on. + + `rearm-spec-write-unreachable` holds the resume (`rearm_holds_the_resume`), and its + in-place notice tells the operator to re-apply the correction in the main checkout — + no commit, because an in-place re-drive reads that tree's WORKING copy. So the proof + that suppresses it has to read the working copy too. Measuring the committed tree + instead would demand a commit the re-drive never needs, and the operator who did + exactly what the notice said would re-run resolve and be held again, forever. + + Both rows leave the COMMITTED spec terminal, so only the working tree moves: + + - working tree corrected -> the re-drive routes, and the record is suppressed. + - working tree still terminal -> it re-wedges, and the record fires. + + Ablation: delete the `if not isolated_redrive:` arm from `_redrive_spec_status` so + both rows fall through to the committed read, and the first row reddens — the + correction the re-drive will read is reported as absent. + """ + rel = "_bmad-output/specs/6-4-cli-list-command.md" + for corrected, warns in ((True, False), (False, True)): + root = tmp_path / f"row-{corrected}" + root.mkdir() + _resolve_repo(root) + spec = root / rel + spec.parent.mkdir(parents=True, exist_ok=True) + # committed terminal on BOTH rows: a committed read can never suppress here + spec.write_text("---\nstatus: blocked\n---\n\n## Intent\n\nx\n", encoding="utf-8") + git(root, "add", "-A") + git(root, "commit", "-q", "-m", "escalated spec") + if corrected: # uncommitted, exactly as the in-place notice instructs + spec.write_text("---\nstatus: ready-for-dev\n---\n\n## Intent\n\nx\n", encoding="utf-8") + + mount = root / "wt" + (mount / rel).parent.mkdir(parents=True, exist_ok=True) + (mount / rel).write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + + run_dir, _, _ = _escalated_run( + root, spec_file=rel, worktree_path=str(mount), target_branch="main" + ) + monkeypatch.chdir(root) + 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}" + + +def test_rearm_event_notice_reads_a_pre_discriminator_record_as_isolated(): + """A record written before the `redrive` field existed is an ISOLATED one — that + was the only shape the producer could journal — so the absent field is a KNOWN + value, not an unknown, and must not degrade to the in-place wording. + + A journal is append-only and read back by later versions (`_echo_rearm_events` + walks lines this process did not write), so the migration shape is reachable in a + plain upgrade, not just in a contrived fixture. + + Ablation: default the `redrive` read to `"in-place"` and this reddens — the legacy + record renders the working-tree remedy for a re-drive that reads only committed + trees. + """ + legacy = { + "kind": "rearm-spec-write-unreachable", + "story_key": "s1", + "spec_file": "wt/specs/s1.md", + "status": "ready-for-dev", + "target_branch": "main", + } + _, message, next_step = runs.rearm_event_notice(legacy) + assert "Commit the corrected spec on `main`" in next_step + assert "mount a fresh worktree" in message + + def test_rearm_base_ref_degrades_to_head_for_a_run_that_pinned_no_target(tmp_path, monkeypatch): """An unrecorded `target_branch` is a MISSING value, not a divergent one. `ensure_target_branch` pins the field before any worktree can mount, so only a state.json predating it reaches here with a `worktree_path` and no target — and it must degrade to the ref it read before the fix rather than to `""`. Answering `""` - would make `_committed_spec_status` unprovable for every re-arm of such a run and + would make `_redrive_spec_status` unprovable for every re-arm of such a run and hold the resume on a per-configuration constant, the exact failure the record's narrowing exists to avoid. @@ -2125,7 +2883,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) + runs.rearm_escalation(run_dir, isolated_redrive=True) assert [e for e in _kinds(run_dir) if e["kind"] == "rearm-spec-write-unreachable"] == [] @@ -2148,7 +2906,7 @@ def test_rearm_does_not_refuse_a_flip_the_redrive_never_reads( in what git has committed, which is what makes this a reachability claim rather than a readability one: - - `ready-for-dev` — `_committed_spec_status` has already PROVEN the re-drive reads + - `ready-for-dev` — `_redrive_spec_status` has already PROVEN the re-drive reads the status it routes on. Refusing blocked an otherwise-complete re-arm over an obsolete copy, with nothing for the operator to do at all. - `blocked` — the correction really is outstanding, and the remedy is @@ -2177,7 +2935,9 @@ def test_rearm_does_not_refuse_a_flip_the_redrive_never_reads( run_dir, _, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(tmp_path / "wt")) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir) # must not raise: this flip cannot reach the re-drive + runs.rearm_escalation( + run_dir, isolated_redrive=True + ) # must not raise: this flip cannot reach the re-drive kinds = _kinds(run_dir) (skipped,) = [e for e in kinds if e["kind"] == "rearm-spec-flip-skipped"] @@ -2191,13 +2951,13 @@ def test_rearm_does_not_refuse_a_flip_the_redrive_never_reads( def test_rearm_suppresses_the_unreachable_warning_only_on_proof(tmp_path, monkeypatch): """A project that is not a repo must still WARN, not fall silent. - `_committed_spec_status` degrades to `""` on every uncertainty — a `GitError` + `_redrive_spec_status` degrades to `""` on every uncertainty — a `GitError` (which covers "not a repository"), an absent blob, a non-UTF-8 blob. `""` never equals a target status, so the record fires. This is the direction the narrowing must fail in: suppressing a warning demands proof the work is done, and the re-arm advance stays non-fatal outside a repo, as the story's Boundaries require. - Ablation: make `_committed_spec_status` return `target_status` on `GitError` + Ablation: make `_redrive_spec_status` return `target_status` on `GitError` instead of `""` and this reddens on the missing record — the re-arm still "succeeds", which is exactly the silence #640(b) exists to end. """ @@ -2209,7 +2969,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) + 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"] @@ -2251,7 +3011,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) + 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 @@ -2295,7 +3055,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) + 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" @@ -2306,7 +3066,7 @@ def test_rearm_warns_when_the_spec_cannot_be_placed_against_the_worktree(tmp_pat `_spec_is_shared_with_the_redrive` decides on `resolve()`, which raises on the hosts #552 is about (a registered-but-not-serving WSL UNC provider) — and an uncertain - answer must not buy silence, the same direction `_committed_spec_status` degrades + answer must not buy silence, the same direction `_redrive_spec_status` degrades in. This row is the shared-artifact-dir row above with resolution taken away, so it is the fault, not the layout, that flips the outcome. @@ -2334,14 +3094,14 @@ def _refuse(self, *a, **kw): ) monkeypatch.chdir(tmp_path) - runs.rearm_escalation(run_dir) + 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" def test_rearm_writes_the_project_rooted_spec_when_no_worktree_was_recorded(tmp_path, monkeypatch): - """The `state.project` half of `_task_spec_path` — graded, not merely reachable. + """The `state.project` half of `task_spec_path` — graded, not merely reachable. `engine._finish_inflight` clears `task.worktree_path` while leaving `spec_file` relative, and `model._serialized_worktree_path` returns it unchanged when @@ -2354,7 +3114,7 @@ def test_rearm_writes_the_project_rooted_spec_when_no_worktree_was_recorded(tmp_ directory this process actually runs from, so a cwd-anchored resolve has somewhere plausible to land. - Ablation: change `_task_spec_root` to `Path(task.worktree_path or "")` and this + Ablation: change `task_spec_root` to `Path(task.worktree_path or "")` and this reddens twice over — the project spec keeps `status: blocked`, and the decoy's byte-identity assertion fails behind it. """ @@ -2372,7 +3132,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) + 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 @@ -2446,7 +3206,7 @@ def test_rearm_event_notice_splits_the_flip_skip_on_the_refusal(): 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. - `rearm-spec-write-unreachable` is written only once `_committed_spec_status` has + `rearm-spec-write-unreachable` is written only once `_redrive_spec_status` has established that the committed spec does not carry the status the re-drive routes on, so resuming on it is futile rather than risky: step-01 halts blocked on `unrecognized status in existing story file` and the escalation is spent. Its diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py new file mode 100644 index 00000000..396fee47 --- /dev/null +++ b/tests/test_resolve_skill_contract.py @@ -0,0 +1,155 @@ +"""Contract guards for the shipped `bmad-loop-resolve` skill. + +`resolve.build_context` writes `context.json` and the skill is its ONLY consumer, so +a key added to one side and not the other is inert by construction: the orchestrator +computes a verdict, the agent never reads it, and the session it was meant to steer +proceeds exactly as before. That is not hypothetical — `spec_reaches_the_redrive` +shipped that way, emitted beside `spec_file` while the skill's schema, its step 4 and +its commit prohibition all stayed silent, so the agent edited the worktree-local copy +the re-drive discards and recorded a successful resolution over lost work. +""" + +import ast +import inspect + +import pytest + +SKILL_DIR = "bmad-loop-resolve" + + +@pytest.fixture(scope="module") +def skill_md(): + from importlib import resources + + return ( + resources.files("bmad_loop.data") + .joinpath("skills") + .joinpath(SKILL_DIR) + .joinpath("SKILL.md") + .read_text(encoding="utf-8") + ) + + +def _emitted_context_keys() -> set[str]: + """The top-level keys `build_context` writes into `context.json`. + + Read from the SOURCE rather than by calling it, so the set is complete without + having to build a fixture that takes every optional arm (`stories` is only + attached in stories mode). A key that is added to the literal is therefore in + scope for the guard the moment it is written. + """ + from bmad_loop import resolve + + tree = ast.parse(inspect.getsource(resolve)) + fn = next( + n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "build_context" + ) + keys: set[str] = set() + for node in ast.walk(fn): + # `context = {...}` — the literal + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict): + for k in node.value.keys: + if isinstance(k, ast.Constant) and isinstance(k.value, str): + keys.add(k.value) + # `context["stories"] = ...` — the conditional arm + if isinstance(node, ast.Assign): + for tgt in node.targets: + if ( + isinstance(tgt, ast.Subscript) + and isinstance(tgt.slice, ast.Constant) + and isinstance(tgt.slice.value, str) + ): + keys.add(tgt.slice.value) + return keys + + +def test_every_emitted_context_key_is_documented(skill_md): + """Each key `build_context` emits is named in the skill the agent reads. + + Either spelling counts: `"key"` inside a schema block, or `` `key` `` in prose — + `restore_supported` is documented the second way (a whole section turns on it) + and is no less binding for it. What the guard refuses is a key documented + NEITHER way, which is a signal with no reader. + + Ablation: drop `"spec_reaches_the_redrive"` from SKILL.md and this reddens + naming it. + """ + undocumented = sorted( + k for k in _emitted_context_keys() if f'"{k}"' not in skill_md and f"`{k}`" not in skill_md + ) + assert not undocumented, ( + "context.json emits keys the resolve skill never mentions, so the agent " + "cannot act on them — document each in SKILL.md's schema block or prose: " + f"{undocumented}" + ) + + +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. + + A `redrive_base_ref` of `HEAD` means the re-drive runs in the main checkout and + reads its WORKING tree — reachable when the run's isolation policy is edited to + `none` while the story sits escalated, which leaves `spec_file` pointing into a + mount the run has stopped using. The skill's own remedy text was written for the + isolated case alone, and applied verbatim it sends the human to commit onto a + branch this run never reads while the file the re-drive DOES read stays wrong. So + the skill has to name `HEAD` and say the remedy is different there, not merely + document the field. + + Ablation: delete the "When `redrive_base_ref` is `HEAD`" paragraph from SKILL.md + and this reddens on the first assertion; restore the instruction sentence to its + unconditional "**where the correction has to land to be read**: committed on + `redrive_base_ref`." and it reddens on the three-site assertion instead. + """ + normalized = " ".join(skill_md.split()) + + assert "When `redrive_base_ref` is `HEAD`, do not tell them to commit anything." in normalized + # ...and it says WHERE instead, in the tree the in-place re-drive actually reads + assert "make the same edit to the main checkout's copy of the spec" in normalized + # THREE sites carry the fork, not two. The instruction sentence, step 4 and the + # prohibition each tell the human where the correction lands, and any one of them + # left unconditional is a blanket "commit it" the agent can act on before reaching + # the paragraph that carved the exception. This row first enumerated only the + # latter two; the instruction sentence — the one that most directly says "tell the + # human" — kept the absolute spelling for four commits because nothing graded it. + assert ( + "which the field decides: when `redrive_base_ref` names a branch, committed on " + "`redrive_base_ref`; when it is `HEAD`, re-applied in the main checkout, uncommitted." + ) in normalized + assert "re-applied in the main checkout, uncommitted" in normalized + assert "re-applying it in the main checkout when it is `HEAD`" in normalized + + +def test_context_key_scan_is_not_vacuous(): + """The guard above asserts an ABSENCE, so it passes for every reason the key set + could come back empty — a renamed function, a refactor to a builder, an `ast` + walk that silently matches nothing. Pin the shape it depends on.""" + keys = _emitted_context_keys() + assert {"spec_file", "spec_reaches_the_redrive", "stories", "resolution_path"} <= keys + assert len(keys) >= 8 + + +def test_skill_branches_on_spec_reachability(skill_md): + """Documenting the field is not enough — the skill has to TELL the agent what to + do differently when it is false, or the lost-work scenario it was added for + plays out unchanged. + + The three sites that must agree: the schema (so it is expected), step 4 (so the + edit is flagged rather than silently doomed), and the commit prohibition (which + otherwise reads as forbidding the very remedy step 4 now demands). + """ + normalized = " ".join(skill_md.split()) + + assert '"spec_reaches_the_redrive": true,' in skill_md # schema block + # the edit still happens — skipping it would leave nothing to carry over + assert "make the same edit and then say plainly" in normalized + # and it names WHERE the correction has to land. Without this the field states a + # problem with no remedy, and both obvious moves fail silently: the main checkout + # cannot commit a file living in a linked worktree, and the unit's own branch is + # not what the replacement mount is cut from. + assert "committed on `redrive_base_ref`" in normalized + assert "cannot include the file you edited" in normalized + 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 diff --git a/tests/test_runs.py b/tests/test_runs.py index 3b1135fa..26249029 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2766,7 +2766,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") + 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 @@ -2784,7 +2784,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) # 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 @@ -2814,7 +2814,9 @@ def test_rearm_aborts_when_the_spec_status_cannot_be_reopened(tmp_path): assert verify.status_of(verify.read_frontmatter(spec)) == "blocked" # the reader is fine with pytest.raises(runs.RearmError, match="re-open story spec"): - runs.rearm_escalation(run_dir, restore_patch="artifacts/attempt.patch") + runs.rearm_escalation( + run_dir, restore_patch="artifacts/attempt.patch", isolated_redrive=False + ) assert spec.read_text(encoding="utf-8") == spec_text # byte-identical task = load_state(run_dir).tasks["1-1-a"] @@ -2833,7 +2835,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) + runs.rearm_escalation(run_dir, isolated_redrive=False) task = load_state(run_dir).tasks["1-1-a"] assert task.followup_reviews_spent == 0 @@ -2878,7 +2880,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) # 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 @@ -2895,7 +2897,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") + 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" @@ -2913,7 +2915,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) # 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 @@ -2929,7 +2931,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") + 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) == [] @@ -2945,7 +2947,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) + 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 @@ -3833,3 +3835,409 @@ def test_project_of_a_real_run_dir_is_the_project_root(tmp_path): run_dir = _make_state_run(tmp_path, "r1") assert runs._project_of_run_dir(run_dir) == tmp_path + + +# ---- task_spec_root: the root must be able to CONFINE the path task_spec_path returns + + +def test_task_spec_root_yields_the_project_when_the_worktree_cannot_confine_the_spec(tmp_path): + """An absolute `spec_file` beside a set `worktree_path` is the OUT-OF-MOUNT shape. + + `model._serialized_worktree_path` keeps a path verbatim exactly when + `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. + + 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 + graded by `test_task_spec_root_refuses_a_spec_the_project_cannot_reach` rather than + asserted here — a lexically-contained spec reached through a symlinked component + moves from a succeeding plain write to a refused confined one. + + Ablation: revert the body to `Path(task.worktree_path or state.project)` and this + reddens — the root is the worktree, which cannot confine the spec. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + spec = tmp_path / "_bmad-output" / "specs" / "6-4.md" # in the project, not the mount + run = escalated_run(tmp_path, "r1", spec_file=str(spec), worktree_path=str(wt)) + + assert runs.task_spec_root(run.task, run.state) == tmp_path + # the anchored path is confinable by the root, which is the whole point + assert runs.task_spec_path(run.task, run.state).is_relative_to( + runs.task_spec_root(run.task, run.state) + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_task_spec_root_refuses_a_spec_the_project_cannot_reach(tmp_path): + """The out-of-mount arm's one REGRESSION, pinned so it is graded rather than assumed. + + `task_spec_root`'s docstring used to claim this arm "only ever trades a skipped or + refused confined write for a taken one". That is false for a spec which is lexically + under the project but reached THROUGH a symlinked component: `_atomic_write_spec` + selects its arm on the lexical `is_relative_to` — which passes — and the confined + arm it selects then walks the components below the root and refuses the redirect. So + a write that previously took the plain no-follow arm and SUCCEEDED now raises + `UnconfinedWriteError`. Graded here through `devcontract.reset_spec_status`, one of + the three `_atomic_write_spec` writers, so the arm SELECTION is what reaches the + refusal rather than being assumed. `rearm_escalation` converting it to `RearmError` + is its own arm and is graded by the re-arm rows, not by this one. + + Kept as behavior rather than fixed, because the fix is worse: gating the arm on + `path_is_confined` makes the root depend on filesystem state (that predicate answers + False for a component it cannot probe, so an absent parent directory would anchor on + the worktree and the same spec would anchor on the project once it existed). A + confine root that moves under a `mkdir` is not a definition. This row exists so the + trade is visible and a future reader does not rediscover it as a surprise. + + Ablation: make `task_spec_root` return the worktree for the out-of-mount shape and + this reddens on the root assertion — and the refusal disappears with it, because the + writer would take the plain arm instead. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + real = tmp_path / "elsewhere" + real.mkdir() + (tmp_path / "_bmad-output").mkdir() + link = tmp_path / "_bmad-output" / "specs" + link.symlink_to(real, target_is_directory=True) # a REDIRECT below the project root + spec = link / "6-4.md" + spec.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + run = escalated_run(tmp_path, "r1", spec_file=str(spec), worktree_path=str(wt)) + + root = runs.task_spec_root(run.task, run.state) + assert root == tmp_path # lexically contained, so the confined arm is selected + assert spec.is_relative_to(root) + # Driven through `_atomic_write_spec`, NOT through the primitive: calling + # `atomic_write_bytes_confined` directly proves only that the primitive refuses a + # symlinked component, which was never in doubt. The claim is that the WRITER's + # lexical arm selection reaches that refusal for this root — so the real writer has + # to be the thing that raises. + from bmad_loop import devcontract + + with pytest.raises(platform_util.UnconfinedWriteError): + devcontract.reset_spec_status(spec, "draft", confine_root=root) + # and the spec is untouched by the aborted write + assert spec.read_text(encoding="utf-8") == "---\nstatus: blocked\n---\n" + + +def test_task_spec_path_refuses_an_empty_spec_file(tmp_path): + """`Path("")` is `.`, so an empty `spec_file` would answer the ROOT DIRECTORY. + + The helper is public now, so the precondition is enforced instead of documented: a + caller that skips the guard every current call site has gets an exception rather + than a write target pointing at the tree root. + + Ablation: restore `raw = Path(task.spec_file or "")` and drop the raise — this + reddens, and `task_spec_path` answers the worktree itself. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + run = escalated_run(tmp_path, "r1", spec_file="", worktree_path=str(wt)) + + with pytest.raises(ValueError, match="non-empty"): + runs.task_spec_path(run.task, run.state) + # the ROOT still answers, because naming a tree needs no spec + assert runs.task_spec_root(run.task, run.state) == wt + + +def test_spec_reaches_the_redrive_is_false_for_a_worktree_local_spec(tmp_path): + """The verdict `build_context` publishes so the resolve agent is not lied to. + + A worktree-local spec is destroyed with the mount by `engine._finish_inflight` + before the re-drive reads anything, so an edit to it succeeds and then vanishes. + `rearm_escalation` already journals `rearm-spec-write-unreachable` on this same + verdict; promoting it is what lets the context carry it too. + + The two no-flip rows only. `isolated_redrive` agrees with the recorded mount on + both, which is what makes them the rows a `task.worktree_path` proxy also passed — + the rows that grade the SOURCE are in + `test_spec_reaches_the_redrive_reads_live_policy_not_the_recorded_mount`. + + Ablation: return a bare `True` from `spec_reaches_the_redrive` and this reddens on + the isolated leg. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + isolated = escalated_run(tmp_path, "r1", spec_file="specs/6-4.md", worktree_path=str(wt)) + assert ( + runs.spec_reaches_the_redrive(isolated.task, isolated.state, isolated_redrive=True) is False + ) + + plain = escalated_run(tmp_path, "r2", spec_file=str(tmp_path / "specs" / "6-4.md")) + assert runs.spec_reaches_the_redrive(plain.task, plain.state, isolated_redrive=False) is True + + +def test_spec_reaches_the_redrive_reads_live_policy_not_the_recorded_mount(tmp_path): + """The two rows where the recorded mount and the live policy DISAGREE — which is + the whole reason this takes a parameter instead of reading `task.worktree_path`. + + `scm.isolation` is re-read at every resume and a mid-run change is journalled, + never refused (`engine._finish_inflight`), so an operator who edits policy.toml + while a story sits escalated makes the recorded mount describe the attempt that + RAN and nothing about the re-drive that WILL run. `bmad-loop resolve` builds + context.json in a separate process BEFORE that resume, so no resume-time + bookkeeping on the recorded mount can reach it: the fact has to arrive as an + argument or not at all. + + - `worktree` -> `none`: the mount is still recorded and the writes still land in + it (`task_spec_path` anchors there), but `_run_story` now re-runs the story in + the main checkout, which never reads that tree. False. + - `none` -> `worktree`: no mount was ever recorded, and the fresh one is cut from + git — so the working-tree edit the agent was sent to make is not in it. False, + and this is the row the `task.worktree_path` proxy answered TRUE for: the agent + was told its edit was safe while it silently vanished. + + Ablation: restore `not task.worktree_path or _spec_is_shared_with_the_redrive(...)` + as the body and the `none -> worktree` row reddens (True for a doomed edit). The + `worktree -> none` row does NOT redden under that ablation — it agreed by accident, + which is why `test_redrive_base_ref_reads_live_policy_not_the_recorded_mount` + carries that direction. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + + flipped_off = escalated_run(tmp_path, "r1", spec_file="specs/6-4.md", worktree_path=str(wt)) + assert ( + runs.spec_reaches_the_redrive(flipped_off.task, flipped_off.state, isolated_redrive=False) + is False + ) + + flipped_on = escalated_run(tmp_path, "r2", spec_file="specs/6-4.md") + assert not flipped_on.task.worktree_path # the premise: nothing recorded to gate on + assert ( + runs.spec_reaches_the_redrive(flipped_on.task, flipped_on.state, isolated_redrive=True) + is False + ) + + +def test_spec_reaches_the_redrive_in_place_measures_the_mount_not_its_presence(tmp_path): + """The in-place arm asks WHERE the edit lands, not WHETHER a mount was recorded. + + After a `worktree` -> `none` flip the recorded mount is still set on every row here, + so `bool(task.worktree_path)` cannot tell them apart — but the re-drive reads the + main checkout's working tree, and only a spec inside the mount is out of its reach: + + - relative: `_serialized_worktree_path` relativizes exactly when the spec sits under + the mount, so a relative spelling IS inside it, by construction and with no probe. + - absolute, under the mount: the same file spelled the other way. Also unreachable. + - absolute, outside the mount but under the PROJECT: the main checkout's own copy — + which is precisely what an in-place re-drive reads. Reachable, and the row that + separates this from the isolated arm, where the same shape is unreachable because + a fresh worktree measures it against worktree-local roots. + + Ablation: return `bool(task.worktree_path)` from `_spec_is_inside_the_mount` and the + third row reddens — a spec the re-drive reads is reported as doomed. + """ + mount = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + mount.mkdir(parents=True) + + relative = escalated_run(tmp_path, "r1", spec_file="specs/6-4.md", worktree_path=str(mount)) + assert ( + runs.spec_reaches_the_redrive(relative.task, relative.state, isolated_redrive=False) + is False + ) + + inside = escalated_run( + tmp_path, "r2", spec_file=str(mount / "specs" / "6-4.md"), worktree_path=str(mount) + ) + assert runs.spec_reaches_the_redrive(inside.task, inside.state, isolated_redrive=False) is False + + outside = escalated_run( + tmp_path, "r3", spec_file=str(tmp_path / "specs" / "6-4.md"), worktree_path=str(mount) + ) + assert ( + runs.spec_reaches_the_redrive(outside.task, outside.state, isolated_redrive=False) is True + ) + # ...and the SAME task under isolation is unreachable: the fresh mount measures the + # main checkout's copy against worktree-local roots and rejects it + assert ( + runs.spec_reaches_the_redrive(outside.task, outside.state, isolated_redrive=True) is False + ) + + +def test_spec_reaches_the_redrive_keeps_a_shared_external_spec_without_a_mount(tmp_path): + """`_spec_is_shared_with_the_redrive` had to lose its `not task.worktree_path` + early return, or generalizing the isolated arm would have traded one wrong answer + for another. + + An artifact dir configured outside the project tree is SHARED across checkouts + (`ProjectPaths.rebased` leaves it where it is), so a spec that lands there is one + file every worktree sees — reachable whether or not a mount is recorded. Without + the generalization the `none -> worktree` flip above would warn on every such run: + wrong-but-loud rather than silent, but still a doom notice on a spec that is fine, + and the operator trained to scroll past it is the failure the record's narrowing + exists to avoid. + + Ablation: restore `if not task.worktree_path or not raw.is_absolute(): return False` + and the no-mount leg reddens. + """ + shared = tmp_path / "outside" / "artifacts" / "6-4.md" + shared.parent.mkdir(parents=True) + shared.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir() + + no_mount = escalated_run(project, "r1", spec_file=str(shared)) + assert ( + runs.spec_reaches_the_redrive(no_mount.task, no_mount.state, isolated_redrive=True) is True + ) + + wt = project / ".bmad-loop" / "runs" / "r2" / "worktrees" / "1" + mounted = escalated_run(project, "r2", spec_file=str(shared), worktree_path=str(wt)) + assert runs.spec_reaches_the_redrive(mounted.task, mounted.state, isolated_redrive=True) is True + + +def test_redrive_base_ref_reads_live_policy_not_the_recorded_mount(tmp_path): + """Where a correction has to be committed to be read — answered from the mode the + re-drive will RUN in, not from the mount the escalated attempt left behind. + + The pinned `target_branch` is only the right answer when the re-drive mounts: + `workspace.open_unit_workspace` cuts the replacement worktree from it. An in-place + re-drive reads the main checkout's working ref instead, and naming a branch there + sends the resolve session to commit where this run never looks — the reported + defect, and unreachable by any resume-time fix because `bmad-loop resolve` computes + this in another process first. + + Four rows: both no-flip rows, and both flips. `task` is gone from the signature, so + the recorded mount cannot influence any of them — the flip rows are what prove it. + + Ablation: restore `if task.worktree_path and state.target_branch` (re-adding the + parameter) and BOTH flip rows redden — `worktree -> none` answers the pinned branch + for a re-drive reading `HEAD`, and `none -> worktree` answers `HEAD` for one that + reads the branch. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + mounted = escalated_run(tmp_path, "r1", spec_file="specs/6-4.md", worktree_path=str(wt)) + mounted.state.target_branch = "feat/the-pinned-one" + + # no flip + assert runs.redrive_base_ref(mounted.state, isolated_redrive=True) == "feat/the-pinned-one" + # `worktree` -> `none`: the mount is still recorded, and it must not decide this + assert runs.redrive_base_ref(mounted.state, isolated_redrive=False) == "HEAD" + + unmounted = escalated_run(tmp_path, "r2", spec_file="specs/6-4.md") + unmounted.state.target_branch = "feat/the-pinned-one" + assert not unmounted.task.worktree_path # the premise: nothing recorded to gate on + + # no flip + assert runs.redrive_base_ref(unmounted.state, isolated_redrive=False) == "HEAD" + # `none` -> `worktree`: the re-drive mounts from the pin, with no mount on record + assert runs.redrive_base_ref(unmounted.state, isolated_redrive=True) == "feat/the-pinned-one" + + +def test_redrive_base_ref_degrades_to_head_without_a_pinned_target(tmp_path): + """The migration shape, kept from the version that read `task.worktree_path`: + `ensure_target_branch` pins the field before any worktree mounts, so an empty + `target_branch` beside an isolated re-drive is a state.json predating the field — + a MISSING value, not a divergent one. It degrades to exactly the ref it read + before, rather than to `""`, which would hold the resume on a per-configuration + constant. + + Ablation: drop the `and state.target_branch` conjunct and this reddens with `""`. + """ + run = escalated_run(tmp_path, "r1", spec_file="specs/6-4.md") + assert run.state.target_branch == "" + assert runs.redrive_base_ref(run.state, isolated_redrive=True) == "HEAD" + + +def test_task_spec_root_stays_on_the_worktree_for_specs_it_can_confine(tmp_path): + """The guard against an over-broad fix: only the out-of-mount shape moves. + + Two shapes must keep answering the worktree — the RELATIVE spec (the common + isolated case, which `task_spec_path` resolves against this very root), and an + ABSOLUTE spec that does sit under the mount. A fix that returned the project + whenever `worktree_path` was set would re-break the defect the anchor exists to + fix, sending the isolated read and write back to the main checkout's twin. + + Ablation: drop the `raw.is_absolute() and` conjunct so the arm keys on containment + alone, and the relative row reddens (`Path("_bmad-output/...")` is not relative to + the mount); return `Path(state.project)` whenever a worktree is set and both redden. + """ + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + + run = escalated_run( + tmp_path, "r1", spec_file="_bmad-output/specs/6-4.md", worktree_path=str(wt) + ) + assert runs.task_spec_root(run.task, run.state) == wt # relative: the common case + + inside = wt / "_bmad-output" / "specs" / "6-4.md" + run = escalated_run(tmp_path, "r2", spec_file=str(inside), worktree_path=str(wt)) + assert runs.task_spec_root(run.task, run.state) == wt # absolute, but under the mount + + +def test_task_spec_root_without_a_worktree_is_the_project(tmp_path): + """The no-worktree fallback is untouched by the confinement arm: an absolute spec + that the project cannot confine still answers the project, because there is no + second candidate to choose and the pre-existing behavior is the contract. + + Ablation: return `Path(state.project)` only when the project confines the spec and + this reddens — an out-of-project spec has nowhere else to go.""" + run = escalated_run(tmp_path, "r1", spec_file="/elsewhere/6-4.md") + assert runs.task_spec_root(run.task, run.state) == tmp_path + + +def test_task_stories_root_stays_on_the_mount_for_an_out_of_mount_spec(tmp_path): + """The ONE shape where `task_stories_root` and `task_spec_root` disagree — which is + the entire reason the second function exists. + + `task_spec_root` answers "which tree can CONFINE a write to `task.spec_file`", so its + out-of-mount arm falls back to the project precisely so a `confine_root` can never + fail to contain the anchored path. The stories FOLDER is a different question: it is + located from the workspace root by `state.spec_folder`, and a task's spec being + elsewhere says nothing about where its manifest lives. `stories_engine._stories_folder` + answers the mount for this task, so borrowing the confinement answer made one surface + describe two trees. + + Every other row builds the isolated shape with a RELATIVE `spec_file`, where the two + resolvers agree by construction and cannot tell each other apart — which is why + collapsing `task_stories_root` back into `task_spec_root` left the whole suite green. + + Ablation: make `task_stories_root` delegate to `task_spec_root` and this reddens on + the first assertion; the second pins that the two genuinely diverge here, so a future + change that made them agree could not satisfy both.""" + wt = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" + # OS-absolute, not merely rooted. `task_spec_root`'s out-of-mount arm gates on + # `Path.is_absolute()`, and on Windows "/elsewhere/6-4.md" is DRIVE-relative — so the + # arm never fired there, the fallback returned the worktree, and the row graded the + # divergence it exists to pin on POSIX only. What the arm needs is a spec outside the + # MOUNT, not outside the project, so anchoring on `tmp_path` keeps the shape while + # being absolute on every OS. + outside = tmp_path / "elsewhere" / "6-4.md" + # the mount has to EXIST: a live isolated unit's does, and `task_stories_root` + # degrades to the project for one that is gone (see the row below), so a + # never-created path would grade that fallback instead of this divergence. + wt.mkdir(parents=True, exist_ok=True) + run = escalated_run(tmp_path, "r1", spec_file=str(outside), worktree_path=str(wt)) + + assert runs.task_stories_root(run.task, run.state) == wt + assert runs.task_spec_root(run.task, run.state) == tmp_path # deliberately different + + +def test_task_stories_root_without_a_worktree_is_the_project(tmp_path): + """The no-worktree and no-task arms, which the two call sites rely on rather than + re-spelling the fallback.""" + run = escalated_run(tmp_path, "r1", spec_file="epic-1/stories/6-4.md") + assert runs.task_stories_root(run.task, run.state) == tmp_path + assert runs.task_stories_root(None, run.state) == tmp_path + + +def test_task_stories_root_falls_back_when_the_mount_is_gone(tmp_path): + """A terminal task keeps naming the worktree its own teardown removed. + + `worktree_path` is cleared at exactly ONE site in the engine — the restart + discard — so successful integration retires a task with the field still set while + the mount is deleted. The `done_checkpoint` pause is raised in that window and the + TUI reads this root for the checkpoint card's title and description, so trusting + the stale field looked for `stories.yaml` under a deleted directory and dropped + the committed story's manifest, which by then is merged into the project. + + Ablation: drop the `is_dir()` guard from `task_stories_root` and this reddens with + the deleted mount; the sibling row above (whose mount exists) stays green, so the + guard cannot be satisfied by collapsing the function to the project. + """ + gone = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1" # never created + run = escalated_run(tmp_path, "r1", spec_file="epic-1/stories/6-4.md", worktree_path=str(gone)) + + assert not gone.exists() + assert runs.task_stories_root(run.task, run.state) == tmp_path diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 2777a3c5..94720d20 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -964,6 +964,82 @@ def test_plan_checkpoint_pause_then_resume_implements(project): assert "BMAD_LOOP_PLAN_HALT" not in leg2.env +def test_operator_spec_path_anchors_an_isolated_units_spec(project): + """The pause notice is the FIRST surface an operator meets — before any dashboard. + + Every pause here hands a human a path and says "review it, then resume", and the + `checkpoint-pause` journal records the same string. `task.spec_file` is persisted + RELATIVE to the mounted worktree (`model._serialized_worktree_path`), so the raw + value resolved against whatever directory the operator happened to be in — the main + checkout, which carries the same `epic-1/stories/...` layout and answers with the + wrong tree's copy. The TUI's `_paused_spec` carries a docstring about exactly this; + the notification reaches the operator earlier and had none. + + NOT applied to the dev-session prompt at `spec_ref`: that session's cwd IS the + mount, so the relative spelling is the correct one there. The anchor belongs to the + consumer, not to the field. + + Ablation: change the helper's body to return `task.spec_file` and this reddens. + NOTE this row grades the HELPER only — reverting a CALL SITE to a bare + `task.spec_file` leaves it green, which is why + `test_plan_checkpoint_pause_journals_the_mount_anchored_spec` below pins the + journalled value that an actual pause emits. + """ + engine, _adapter = make_engine(project, []) + wt = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1" + rel = "epic-1/stories/1-slug.md" + task = StoryTask("1", 0, spec_file=rel) + task.worktree_path = str(wt) + + assert engine._operator_spec_path(task) == str(wt / rel) + # a spec-less task falls back to the story key rather than raising out of a notice + assert engine._operator_spec_path(StoryTask("1", 0)) == "1" + + +def test_plan_checkpoint_pause_journals_the_mount_anchored_spec(project): + """The CALL SITE, not the helper — the two can regress independently. + + `test_operator_spec_path_anchors_an_isolated_units_spec` calls the helper directly, + so every one of the five notification/journal sites could be reverted to a bare + `task.spec_file` with the whole suite still green: the stories engine builds its + policy with `notify=QUIET`, so no row observes a notification body, and every other + `checkpoint-pause` assertion reads `checkpoint` and never `spec`. + + This pins the value an actual pause emits, which is also the premise + `diagnostics.py` now records for the `spec` alias field. + + Ablation: revert `_pause_plan_checkpoint`'s `spec=` to `task.spec_file` and this + reddens on the bare relative spelling. + """ + from bmad_loop.engine import RunPaused + + engine, _adapter = make_engine(project, []) + wt = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1" + rel = "epic-1/stories/1-slug.md" + task = StoryTask("1", 0, spec_file=rel) + task.worktree_path = str(wt) + engine.state.tasks["1"] = task + + with pytest.raises(RunPaused): + engine._pause_plan_checkpoint(task) + + record = _kinds(engine.journal, "checkpoint-pause")[-1] + assert record["spec"] == str(wt / rel) + assert record["checkpoint"] == "plan" + + # The NOTIFY body, not only the journal: they are separate call sites that regress + # independently, and this one is the surface the operator actually reads. `QUIET` is + # `NotifyPolicy(desktop=False, file=True)`, so the ATTENTION file is written. Before + # 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. + 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 + + # -------- MAJOR-B: a spec_checkpoint story can never commit without a plan review @@ -1253,7 +1329,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") + 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 @@ -1293,7 +1369,9 @@ def test_resolved_wedge_is_still_gated_on_redispatch(project): wedged = load_state(engine.run_dir).tasks["1"] assert wedged.phase == Phase.ESCALATED and wedged.attempt == 0 and not wedged.sessions - runs.rearm_escalation(engine.run_dir, "1") # human fixed the frozen spec + runs.rearm_escalation( + 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 write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) @@ -1327,7 +1405,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") + 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 c6e57479..eadb6ee8 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -3334,7 +3334,9 @@ def test_sweep_bundle_restore_redrive_reaches_done_and_clears_latch(project, mon 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)) + runs.rearm_escalation( + engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + ) resumed, adapter = resume_sweep( project, @@ -3373,7 +3375,9 @@ def test_sweep_restore_redrive_exhaustion_pauses_not_defers(project, monkeypatch patch = project.implementation_artifacts / "attempt-dw-fix.patch" 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)) + runs.rearm_escalation( + engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + ) resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) summary = resumed.run() @@ -3393,7 +3397,9 @@ def test_sweep_from_scratch_redrive_exhaustion_pauses_not_defers(project): limits=LimitsPolicy(max_dev_attempts=1), ) engine = _run_to_dev_escalation(project, policy=policy) - runs.rearm_escalation(engine.run_dir, "dw-fix") # from-scratch, no restore + runs.rearm_escalation( + engine.run_dir, "dw-fix", isolated_redrive=False + ) # from-scratch, no restore resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) summary = resumed.run() @@ -4532,7 +4538,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") + 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)) @@ -4554,7 +4560,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") + runs.rearm_escalation(engine.run_dir, "dw-fix", isolated_redrive=False) _lose_triage(engine.run_dir, corruption) fresh = triage_result( @@ -4591,7 +4597,9 @@ def test_restore_patch_latch_honored_when_triage_json_lost(project, monkeypatch) patch = project.implementation_artifacts / "attempt-dw-fix.patch" 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)) + runs.rearm_escalation( + engine.run_dir, "dw-fix", restore_patch=str(patch), isolated_redrive=False + ) _lose_triage(engine.run_dir) resumed, adapter = resume_sweep(project, engine, _redrive_script(project)) @@ -4715,7 +4723,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") + 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() @@ -5519,3 +5527,61 @@ def atomic_save_effect(spec): assert "changed underneath the failed migration attempt" not in engine.state.paused_reason assert target.read_text(encoding="utf-8") == LEGACY_LEDGER assert len(adapter.sessions) == 2 + + +def test_bundle_restart_arm_anchors_spec_ownership_before_it_discards_the_mount( + project, monkeypatch +): + """Sweep's restart arm is the engine's, and it needed the same re-anchor. + + `SweepEngine` replaces `_loop` wholesale and `Engine._loop` is the ONLY caller of + `_finish_inflight`, so the re-anchor that method makes never runs here — while + `_recover_inflight_bundle` reaches the very same shared + `Engine._discard_unit_for_restart`. The baseline half of that helper was therefore + inherited by sweep and the spec-ownership half was not. + + Both spec paths are persisted RELATIVE to the mount + (`model._serialized_worktree_path`), and the restart arm discards the worktree and + clears `task.worktree_path` before the caller saves — so without the re-anchor the + save strands a worktree-relative spelling beside an EMPTY `worktree_path`, and the + next resume resolves it against the main checkout, which carries the same layout. + `recovery_flow._attempt_owned_spec` then finds exactly one candidate, + `spec_within_roots` accepts it, and the snapshot restore rewrites the operator's own + copy. + + Graded at the discard, like its engine sibling: the ordering is the property, and a + later rebind would let a post-hoc assertion pass with the re-anchor deleted. + + Ablation: drop `task.rebase_spec_paths_on(...)` from `_recover_inflight_bundle` and + both assertions fail with the bare relative spellings. + """ + from bmad_loop.workspace import open_unit_workspace + + engine, _ = make_sweep(project, [], policy=isolated_policy()) + unit = open_unit_workspace( + project.project, project, "sweep-run", "dw-fix", "main", "bundle", engine.run_dir + ) + task = StoryTask("dw-fix", 1, phase=Phase.DEV_RUNNING) + task.worktree_path = str(unit.path) + task.branch = unit.branch + task.spec_file = "_bmad-output/accepted.md" + task.dispatched_spec_file = "_bmad-output/dispatched.md" + engine.state.tasks["dw-fix"] = task + + seen: dict[str, str | None] = {} + + class _StopAtDiscard(Exception): + pass + + def _spy(*_args, **_kwargs): + seen["spec_file"] = task.spec_file + seen["dispatched_spec_file"] = task.dispatched_spec_file + raise _StopAtDiscard + + monkeypatch.setattr("bmad_loop.engine.discard_worktree", _spy) + + with pytest.raises(_StopAtDiscard): + engine._recover_inflight_bundle(task) + + assert seen["spec_file"] == str(unit.path / "_bmad-output/accepted.md") + assert seen["dispatched_spec_file"] == str(unit.path / "_bmad-output/dispatched.md") diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 37809066..4a3a863e 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3335,11 +3335,31 @@ def _stories_paused_run( review_cycle: int = 0, blocked_result: str = "", sentinel: bool = False, + worktree_path: str = "", + spec_outside_worktree: bool = False, ) -> tuple[Path, Path]: """A stories-mode run paused at `stage`, with the id-keyed story spec on disk - and a StoryTask pointing at it. Returns (run_dir, spec_path).""" + and a StoryTask pointing at it. Returns (run_dir, spec_path). + + `worktree_path` expresses the worktree-isolation shape: the run's own copy of the + spec is written under that tree while the main checkout keeps a TWIN at the same + relative path, and `task.spec_file` is the absolute worktree path — which + `StoryTask.to_dict` persists RELATIVE to the mount, so `load_state` hands the app + back the bare relpath production actually stores. The returned spec path is then + the worktree's copy; the twin is the decoy a cwd-anchored resolve lands on. + + `spec_outside_worktree` keeps the mount but leaves the spec at the main-checkout + path — the shape a shared artifact dir produces, where + `_serialized_worktree_path`'s `relative_to` raises and the ABSOLUTE path is + persisted verbatim beside a set `worktree_path`.""" import yaml + # The two parameters are one shape, not two: "outside the worktree" is meaningless + # without a worktree, and the combination silently built a non-isolated run that + # graded nothing while reading like an isolated row. + if spec_outside_worktree and not worktree_path: + raise ValueError("spec_outside_worktree requires worktree_path") + folder = root / "epic-1" (folder / "stories").mkdir(parents=True, exist_ok=True) (folder / "SPEC.md").write_text("# Epic 1\n", encoding="utf-8") @@ -3366,6 +3386,17 @@ def _stories_paused_run( spec.write_text(body, encoding="utf-8") task = StoryTask(story_key=story_key, epic=0, phase=Phase.DEV_VERIFY) task.spec_file = str(spec) + if worktree_path: + task.worktree_path = worktree_path + if worktree_path and not spec_outside_worktree: + # The isolated shape. The body differs per tree so "the worktree copy was + # read/written" is checkable against "the main-checkout twin was not" — with + # identical payloads either assertion could pass on the wrong file. + twin = spec # the main checkout keeps today's body, at the same relpath + spec = Path(worktree_path) / twin.relative_to(root) + spec.parent.mkdir(parents=True, exist_ok=True) + spec.write_text(body.replace("# plan for", "# worktree plan for"), encoding="utf-8") + task.spec_file = str(spec) # to_dict re-persists this RELATIVE to the mount task.review_cycle = review_cycle if commit_sha: task.commit_sha = commit_sha @@ -3434,6 +3465,397 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): assert strips == [(spec, project.project)] +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. + + Production stores `unresolved_wt.resolve()`, so on a symlinked temp root (macOS + `/tmp` -> `/private/tmp`) this and the real mount differ. Deliberately not resolved + here: that `.resolve()` divergence is the one way an isolated spec lands outside + `project`, which `runs.task_spec_root` treats as its own case, and pinning the + lexical spelling keeps these rows measuring the anchor rather than the sandbox. + """ + return root / RUNS_DIR / run_id / "worktrees" / unit + + +async def test_plan_checkpoint_replan_writes_the_worktree_spec_not_the_main_twin( + project, monkeypatch +): + """Under isolation the replan must reset the spec the RUN owns, not its twin. + + `StoryTask._serialized_worktree_path` persists an isolated unit's `spec_file` + RELATIVE to the mounted worktree and `from_dict` reads it back raw, so + `_paused_spec`'s bare `Path(task.spec_file)` resolved against the TUI process cwd + — the project root, which carries the very same `epic-1/stories/...` layout. Both + destructive writers then landed on the MAIN CHECKOUT's twin: `confine_root` (the + project) accepted it because it genuinely is under `project`, `reset_spec_status` + answered True, the operator got a "plan reset to draft" notice and the run + resumed — while the worktree's real spec kept its terminal status, so the next + dispatch did not re-plan, and an unrelated tracked file was rewritten. + + The cwd is set EXPLICITLY: pytest does not run from the sandbox, so without the + `chdir` the reverted code would merely fail to resolve the relpath and this row + would pass for the wrong reason instead of reproducing the hazard. The two copies + carry distinguishable bodies for the same reason — "the right file was written" + has to be checkable against "the other one was not". + + `confine_root` is captured as well as graded on bytes, because the two halves are + not one ablation: the worktree here is UNDER `project` (that is where + `workspace.open_unit_workspace` mounts it), so a root reverted to `self.project` + still lands on the right file — it just silently drops both writers off the + confined arm and loses its O_NOFOLLOW walk (#593), with no signal at all. + + Ablations: revert `_paused_spec` to `Path(task.spec_file)` and this reddens on + the worktree copy's status AND on the twin's byte-identity; pass `self.project` + as `_do_replan`'s `confine_root` and it reddens on the captured roots. + """ + from bmad_loop import devcontract + + calls: list[str] = [] + roots: list[Path] = [] + real_reset, real_strip = devcontract.reset_spec_status, devcontract.strip_auto_run_result + + def spy_reset(p, s, **kw): + roots.append(kw["confine_root"]) + return real_reset(p, s, **kw) + + def spy_strip(p, **kw): + roots.append(kw["confine_root"]) + return real_strip(p, **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, "reset_spec_status", spy_reset) + 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) + ) + twin = project.project / spec.relative_to(wt) + untouched = twin.read_bytes() + monkeypatch.chdir(project.project) # what the TUI actually runs from + + 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: calls == ["20260611-100000-aaaa"]) + assert verify.read_frontmatter(spec)["status"] == "draft" + assert twin.read_bytes() == untouched + assert roots == [wt, wt] + + +async def test_plan_checkpoint_replan_confines_on_the_project_for_an_out_of_mount_spec( + project, monkeypatch +): + """Matrix row 5 end-to-end: the root is the tree that can CONFINE the spec. + + An absolute `spec_file` beside a set `worktree_path` means the spec sits outside + the mount (`_serialized_worktree_path` keeps it verbatim exactly when + `relative_to` raises) — a shared artifact dir. The path passes through unchanged, + but the mount can never contain it, so a `confine_root` naming the worktree sends + both writers to the plain no-follow arm and drops #593's O_NOFOLLOW walk. + + The captured root is the ONLY discriminator at this layer, and deliberately so: + both roots land the write here (the confined gate is lexical, and its else-branch + still writes), so the reset-to-draft assertion below cannot tell them apart. It is + kept because the replan must still actually work for this shape, not to grade the + root. + + Ablation: revert `task_spec_root` to `Path(task.worktree_path or state.project)` + and this reddens on the captured roots — they become the mount. + """ + from bmad_loop import devcontract + + calls: list[str] = [] + roots: list[Path] = [] + real_reset, real_strip = devcontract.reset_spec_status, devcontract.strip_auto_run_result + + def spy_reset(p, s, **kw): + roots.append(kw["confine_root"]) + return real_reset(p, s, **kw) + + def spy_strip(p, **kw): + roots.append(kw["confine_root"]) + return real_strip(p, **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, "reset_spec_status", spy_reset) + monkeypatch.setattr(devcontract, "strip_auto_run_result", spy_strip) + _run_dir, spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + worktree_path=str(_unit_worktree(project.project)), + spec_outside_worktree=True, + ) + assert not spec.is_relative_to(_unit_worktree(project.project)) # the shape under test + + 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: calls == ["20260611-100000-aaaa"]) + assert roots == [project.project, project.project] + assert verify.read_frontmatter(spec)["status"] == "draft" + + +async def test_plan_checkpoint_renders_the_worktree_spec_under_isolation(project, monkeypatch): + """The read half of the same anchor: the viewers show the spec the run used. + + Pre-fix the raw relpath resolved against the TUI's cwd and the modal rendered the + main checkout's twin — same layout, different file, nothing on screen to say so. + The `chdir` and the per-tree bodies are load-bearing for the same reasons the + replan row documents. + + Ablation: revert `_paused_spec` to `Path(task.spec_file)` and this reddens — the + body is the twin's "# plan for 1". + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _stories_paused_run( + project.project, + stage="plan-checkpoint", + worktree_path=str(_unit_worktree(project.project)), + ) + monkeypatch.chdir(project.project) + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + body = render(app.screen.query_one("#spec Static", Static).content) + assert "# worktree plan for 1" in body + assert "# plan for 1" not in body # the main-checkout twin's body + + +async def test_spec_approval_gate_renders_the_worktree_spec_under_isolation(project, monkeypatch): + """The same anchor on the surface the matrix row names: the GATE viewer. + + `_paused_spec` has three consumers and they reach it by different stages — + plan-checkpoint (`_review_plan_checkpoint`), the spec-approval / epic-boundary / + story-gate trio (`_review_gate`), and escalation (`_review_escalation`). The + replan rows above only reach the first, so this pins the gate arm: an operator + approving a frozen spec must be looking at the spec the run actually froze, not + the main checkout's twin at the same relpath. + + Ablation: revert `_paused_spec` to `Path(task.spec_file)` and this reddens — the + body is the twin's "# plan for 1". + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _stories_paused_run( + project.project, + stage="spec-approval", + worktree_path=str(_unit_worktree(project.project)), + ) + monkeypatch.chdir(project.project) + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + body = render(app.screen.query_one("#spec Static", Static).content) + assert "# worktree plan for 1" in body + assert "# plan for 1" not in body # the main-checkout twin's body + + +async def test_paused_spec_undecodable_spec_does_not_crash_the_dashboard(project, monkeypatch): + """A non-UTF-8 spec degrades one byte, not the whole document — and never raises. + + `read_text(encoding="utf-8")` raises `UnicodeDecodeError`, which is a ValueError and + so escaped the `except OSError` arm entirely; all three review surfaces call + `_paused_spec` from the Textual event loop, where an escaping raise kills the + dashboard instead of rendering the fault. Closed the way `_commit_subject` closes it + — `errors="replace"` — rather than by widening the except arm, because replacing the + entire body with a failure sentence cost the reviewer the WHOLE spec at a gate whose + only purpose is reading it. The failure body is now reserved for ABSENCE, which is + the case the anchoring argument is actually about + (`test_paused_spec_missing_at_the_anchor_reads_as_not_found`). + + Ablation: restore `path.read_text(encoding="utf-8")` and this reddens — the modal + never opens, because the worker raised. + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _run_dir, spec = _stories_paused_run(project.project, stage="plan-checkpoint") + spec.write_bytes(b"---\nstatus: ready-for-dev\n---\n\n# plan caf\xe9 for 1\n") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + body = render(app.screen.query_one("#spec Static", Static).content) + assert "(empty spec)" not in body + assert "could not be read" not in body + assert "plan caf" in body # the readable remainder survived the bad byte + # a decode fault is not an unreviewable spec, so the actions stay live + assert not app.screen.query_one("#act-approve", Button).disabled + + +async def test_replan_on_an_undecodable_spec_does_not_crash_the_dashboard(project, monkeypatch): + """The read-side fix made this button REACHABLE; the write side had to catch up. + + `devcontract.reset_spec_status` decodes strictly (`read_bytes().decode("utf-8")`), + and `_do_replan` caught only `(OSError, FrontmatterWriteError)` — + `UnicodeDecodeError` is a ValueError, so it escaped both. Before this change the + dashboard died earlier, at render, so the operator never got here. Once `_paused_spec` + began degrading a non-UTF-8 spec in place, the modal opens, the button is live, and + pressing it raised inside a Textual worker: the same event-loop crash the read-side + fix exists to prevent, moved one click later. + + Ablation: drop `UnicodeDecodeError` from `_do_replan`'s except tuple and this reddens + — the worker raises instead of notifying, and the run never fails safe. + """ + 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.write_bytes(b"---\nstatus: ready-for-dev\n---\n\n# plan caf\xe9 for 1\n") + + 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 pilot.pause() + assert app.is_running # the dashboard survived the failed write + assert calls == [] # and the run was NOT resumed on an unreplanned spec + + +async def test_unreadable_spec_refuses_the_destructive_actions(project, monkeypatch): + """A spec nobody could read is a gate nobody reviewed. + + `_paused_spec` reports the read failure as the body so it cannot be confused with + "(empty spec)", but the modal still rendered it in the style reserved for the spec's + own words and still offered `Approve & resume` — which resumes the run past a gate + whose whole purpose is a human reading the file. The verb is refused at the source + rather than left to fail downstream (replan was safe only by accident: the reset + returns False and the "could not reset" branch declines). + + Ablation: drop `disabled=self._unreadable` from `SpecReviewModal.compose` and this + reddens on the button state. + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _run_dir, spec = _stories_paused_run(project.project, stage="plan-checkpoint") + spec.unlink() # absent at the anchored path + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + body = render(app.screen.query_one("#spec Static", Static).content) + assert "could not be read" in body + assert app.screen.query_one("#act-approve", Button).disabled + assert app.screen.query_one("#act-replan", Button).disabled + + +async def test_escalation_modal_reads_the_worktree_spec_under_isolation(project, monkeypatch): + """Matrix row 3's THIRD consumer — the one the operator re-arms from. + + `_paused_spec` feeds `_blocking_condition`, whose `## Auto Run Result` block is the + terminal verdict an operator reads before deciding to re-arm or resolve. The plan- + checkpoint and gate surfaces were graded under isolation; this one was not, so the + pre-fix bug — showing the MAIN CHECKOUT's verdict for a run whose real halt is in + the mount — had no row at all. + + Ablation: revert `_paused_spec` to `Path(task.spec_file)` and this reddens on the + blocking condition — the modal reports the decoy twin's. + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + wt = _unit_worktree(project.project) + _run_dir, spec = _stories_paused_run( + project.project, stage="escalation", worktree_path=str(wt), blocked_result="decoy halt" + ) + # the fixture copies one body into both trees; the halt text has to differ for + # "read the run's tree" to be checkable against "did not read the other one" + spec.write_text( + spec.read_text(encoding="utf-8").replace("decoy halt", "the mounts real halt"), + encoding="utf-8", + ) + monkeypatch.chdir(project.project) # what the TUI actually runs from + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, EscalationModal) + body = render(app.screen.query_one("#blocking Static", Static).content) + assert "the mounts real halt" in body + assert "decoy halt" not in body + + +async def test_sentinel_indicator_reads_the_worktree_under_isolation(project, monkeypatch): + """The other half of the same modal had to move with it. + + `_sentinel_kind` scanned `self.project` while `_paused_spec` anchored on the run's + tree, and BOTH feed one `EscalationModal`. Under isolation the engine writes the + sentinel into the mount (`stories_engine._stories_folder` IS the worktree during a + driven story), so a modal built from two trees could show the mount's spec text + beside "no sentinel" — a pre-planning wedge presenting as an ordinary escalation, + which is a different operator decision. + + The main checkout's copy is removed so the two anchors give different answers; + with a twin present, both spellings find a sentinel and nothing is graded. + + Ablation: revert `_sentinel_kind` to `stories.resolve_spec_folder(self.project, ...)` + and this reddens — the indicator disappears. + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + wt = _unit_worktree(project.project) + _run_dir, spec = _stories_paused_run( + project.project, stage="escalation", worktree_path=str(wt), sentinel=True + ) + (project.project / spec.relative_to(wt)).unlink() # only the mount has the sentinel + monkeypatch.chdir(project.project) + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, EscalationModal) + shown = " ".join(render(s.content) for s in app.screen.query(Static)) + assert "pre-planning-halt sentinel" in shown + + +def test_paused_spec_root_without_a_task_answers_the_states_project(tmp_path): + """Both arms of `_paused_spec_root` make ONE claim about the project. + + The delegate (`runs.task_spec_root`) answers from `state.project` — the string the + run persisted at launch — while `self.project` is the constructor's + `resolve_or_lexical` of whatever path the operator opened the dashboard with. The + two can differ, so a no-task arm returning `self.project` left a second claim lying + around for a future caller to trip on. + + Graded directly because the arm is unreachable from the write path today: + `_review_plan_checkpoint`'s `done()` refuses a `None` `spec_path` before calling + `_do_replan`, and `_paused_spec` returns `None` exactly when there is no task. An + end-to-end row could not reach it, so this calls the method. + + Ablation: return `self.project` from the no-task arm and this reddens — the two + directories are deliberately different here. + """ + app = BmadLoopApp(tmp_path / "opened-here") + state = RunState( + run_id="20260611-100000-aaaa", + project=str(tmp_path / "persisted-at-launch"), + started_at="2026-06-11T10:00:00", + ) + assert state.paused_story_key is None # the no-task arm + assert app._paused_spec_root(state) == tmp_path / "persisted-at-launch" + assert app._paused_spec_root(state) != app.project + + +async def test_paused_spec_missing_at_the_anchor_reads_as_not_found(project, monkeypatch): + """An absent spec at the ANCHORED path is the signal that the anchoring failed, so + it must not render as `SpecReviewModal`'s `(empty spec)` — which is also what a + spec that read fine and is blank renders as. Ablation: return `path, ""` from + `_paused_spec`'s degrade arm and this reddens on the `(empty spec)` assertion.""" + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _run_dir, spec = _stories_paused_run(project.project, stage="plan-checkpoint") + spec.unlink() + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + body = render(app.screen.query_one("#spec Static", Static).content) + assert "(empty spec)" not in body + assert "could not be read" in body + + async def test_story_checkpoint_continue_resumes(project, monkeypatch): calls: list[str] = [] monkeypatch.setattr(launch, "mux_available", lambda: True) @@ -3852,8 +4274,10 @@ async def test_active_agent_shows_in_header_and_task_cell(project, monkeypatch): tasks_table = screen.query_one("#tasks", DataTable) await until( pilot, - lambda: tasks_table.row_count == 1 - and tasks_table.get_cell("1-1-alpha", "agent") == "claude·opus", + lambda: ( + tasks_table.row_count == 1 + and tasks_table.get_cell("1-1-alpha", "agent") == "claude·opus" + ), ) @@ -3908,8 +4332,10 @@ async def test_idle_run_shows_configured_agents_and_cell_falls_back(project, mon # cell reads the stamped record's model (haiku), distinct from the config await until( pilot, - lambda: tasks_table.row_count == 1 - and tasks_table.get_cell("1-1-alpha", "agent") == "claude·haiku", + lambda: ( + tasks_table.row_count == 1 + and tasks_table.get_cell("1-1-alpha", "agent") == "claude·haiku" + ), ) @@ -3945,7 +4371,9 @@ async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypat 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: rearms.append(sk) or "ready-for-dev" + runs, + "rearm_escalation", + lambda rd, sk, **_k: rearms.append(sk) or "ready-for-dev", ) run_dir, _spec = _stories_paused_run( project.project, @@ -3967,6 +4395,119 @@ 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_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, monkeypatch): + """The mode `runs.rearm_escalation` needs comes from policy.toml, read HERE. + + It decides three things the operator acts on — which ref a correction has to reach, + whether the working-tree flip reaches the re-drive at all, whether a restore latch + can be honored — and run state cannot answer any of them: `scm.isolation` is re-read + at every resume, and a mid-run change is journalled rather than refused, so the + recorded `task.worktree_path` describes only the attempt that already ran. This + gesture re-arms BEFORE it resumes, so nothing downstream can supply the value later. + + Ablation: pass a literal `isolated_redrive=False` at the call site and this reddens + — the modes stop tracking policy.toml and every isolated run gets the in-place + answers. + """ + from bmad_loop import resolve, runs + + bmad = project.project / ".bmad-loop" + bmad.mkdir(parents=True, exist_ok=True) + (bmad / "policy.toml").write_text('[scm]\nisolation = "worktree"\n', encoding="utf-8") + seen: list[bool] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + 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, *, isolated_redrive: seen.append(isolated_redrive) or "ready-for-dev", + ) + 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: seen == [True]) + + # ...and the other mode is not a constant: the same gesture on `none` says so + (bmad / "policy.toml").write_text('[scm]\nisolation = "none"\n', encoding="utf-8") + seen.clear() + 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: seen == [False]) + + +async def test_escalation_rearm_refuses_when_the_policy_cannot_be_read(project, monkeypatch): + """An unreadable policy.toml REFUSES this gesture — a deliberate departure from how + this surface treats every other read of that file. + + The launch guard and this block's own conflict check both fall through on an + unreadable policy, and correctly: they cannot tell "no conflict" from "could not + look", and the detached CLI re-reads the same file and fails loudly on it. That + reasoning does not extend to an INPUT of a repair write. Without the mode the re-arm + would still flip the spec and then name a tree chosen by a default — and a re-arm + CONSUMES the escalation, so the story is no longer ESCALATED for `resolve` to + correct. Refusing costs the operator one fix-and-retry; proceeding costs them the + escalation. + + Graded on the re-arm not running at all, not merely on the notice: the message is + the trace, the un-consumed escalation is the property. + + Ablation: restore the fall-through (default the mode instead of returning) and this + reddens on `rearms` — the gesture re-arms against a guessed isolation mode. + """ + from bmad_loop import resolve, runs + + bmad = project.project / ".bmad-loop" + bmad.mkdir(parents=True, exist_ok=True) + # bytes no UTF-8 decoder accepts + (bmad / "policy.toml").write_bytes(b'[scm]\nisolation = "\xff\xfe"\n') + rearms: list[str] = [] + notes: list[str] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + 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" + ) + 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("isolation mode" in n for n in notes)) + + assert rearms == [] # the escalation is NOT consumed + assert not any("re-armed" in n for n in notes) + + def test_restore_recorded_helper(tmp_path): """review F8: absent marker / no restore field -> False; a recorded restore_patch -> True; an UNREADABLE marker -> True (it may carry one, so @@ -3998,7 +4539,9 @@ async def test_escalation_rearm_warns_when_restore_recorded(project, monkeypatch 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: rearms.append(sk) or "ready-for-dev" + runs, + "rearm_escalation", + lambda rd, sk, **_k: rearms.append(sk) or "ready-for-dev", ) orig_notify = BmadLoopApp.notify monkeypatch.setattr( @@ -4048,7 +4591,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): + def fake_rearm(rd, sk, *, isolated_redrive=False): Journal(rd).append( "rearm-baseline-advance-failed", story_key=sk, @@ -4108,7 +4651,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): + def fake_rearm(rd, sk, *, isolated_redrive=False): seen.append(load_state(rd).code_root) return "ready-for-dev" @@ -4249,7 +4792,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): + def fake_rearm(rd, sk, *, isolated_redrive=False): journal = Journal(rd) journal.append( "stale-restore-commits", @@ -4277,8 +4820,10 @@ def fake_rearm(rd, sk): monkeypatch.setattr( BmadLoopApp, "notify", - lambda self, msg, **kw: notes.append((str(msg), str(kw.get("severity", "information")))) - or orig_notify(self, msg, **kw), + lambda self, msg, **kw: ( + notes.append((str(msg), str(kw.get("severity", "information")))) + or orig_notify(self, msg, **kw) + ), ) run_dir, _spec = _stories_paused_run( project.project, @@ -4342,7 +4887,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): + def fake_rearm(rd, sk, *, isolated_redrive=False): Journal(rd).append( "rearm-spec-write-unreachable", story_key=sk, @@ -4384,7 +4929,7 @@ def fake_rearm(rd, sk): assert any("re-armed 1" in n for n in notes) # ...while the re-arm itself stands assert any("commit the corrected spec, then resume this run" in n for n in notes) # the record that proved it still renders, and its warning sibling did not hold - assert any("land in a worktree the re-drive discards" in n for n in notes) + assert any("land in a tree it discards" in n for n in notes) assert any("is not a readable file from here" in n for n in notes) @@ -4414,7 +4959,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): + 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"] @@ -4475,7 +5020,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): + def fake_rearm(rd, sk, *, isolated_redrive=False): Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] ) @@ -4625,10 +5170,13 @@ async def test_epic_boundary_pause_shows_reason_and_run_id_subtitle(project, mon async def test_spec_approval_unreadable_spec_still_uses_spec_viewer(project, monkeypatch): - """An unreadable spec file returns (path, "") from _paused_spec — a spec that + """An unreadable spec file still returns its PATH from _paused_spec — a spec that exists in the task and cannot be read, not a spec-less gate. It keeps the spec - viewer (path line + "(empty spec)"), which pins the branch as `spec_path is - None` rather than `not spec_text`.""" + viewer, which pins the branch as `spec_path is None` rather than `not spec_text`. + The body is now the read failure rather than "" (an absent spec at the anchored + path is the signal that anchoring failed, so it must not render as "(empty spec)" + — see `test_paused_spec_missing_at_the_anchor_reads_as_not_found`); this row + grades only that the viewer, not the reason-only modal, is chosen.""" monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_VERIFY) task.spec_file = str(project.project / "gone" / "spec-1-1-a.md") @@ -5324,3 +5872,115 @@ def boom(*_args, **_kwargs): # walk behind it. The `run_test` context exiting without raising is the # other half — an escape into the event loop surfaces there, not here. assert app.is_running + + +async def test_gate_unreadable_spec_refuses_approve_and_resume(project, monkeypatch): + """The GATE arm of the same refusal — its sibling row grades plan-checkpoint only. + + `_review_gate` and `_review_plan_checkpoint` both build a `SpecReviewModal` and both + forward `unreadable=not readable`, but the verbs differ: the checkpoint offers + `#act-approve`/`#act-replan` and the gate offers `#act-resume`. Only the checkpoint + pair was pinned, so `unreadable=` could be dropped from `_review_gate` with + `tests/test_tui_app.py` fully green — and `Approve & resume` at a spec-approval gate + is the verb that carries the run PAST the gate whose only purpose is a human reading + that file. + + Ablation: pass `unreadable=False` in `_review_gate` and this reddens on the button + state. + """ + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_VERIFY) + task.spec_file = str(project.project / "gone" / "spec-1-1-a.md") + make_run( + project.project, + "20260611-100000-aaaa", + paused_stage="spec-approval", + paused_reason="awaiting spec approval", + paused_story_key="1-1-a", + tasks={"1-1-a": task}, + ) + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + body = render(app.screen.query_one("#spec Static", Static).content) + assert "could not be read" in body + assert app.screen.query_one("#act-resume", Button).disabled + + +async def test_escalation_unreadable_spec_refuses_rearm_but_keeps_resolve(project, monkeypatch): + """The escalation modal discarded the read verdict entirely. + + `_review_escalation` bound `_readable` and dropped it, so an unreadable spec reached + `_blocking_condition` — a `find("## Auto Run Result")` that answers "" for the read- + failure sentence exactly as it does for any spec without a halt block. The modal + then rendered "(no blocking condition recorded)", BYTE-IDENTICAL to a spec that was + read fine and simply halted without one, while `Re-arm & resume` stayed live. Re-arm + flips the spec's frontmatter, strips its `## Auto Run Result` and re-stamps the + baseline, so that is a destructive write driven from a modal reporting evidence + nobody could read. + + The refusal is asymmetric, and deliberately so. `Re-arm` is refused: it flips the + spec's frontmatter, strips its result and re-stamps the baseline. `Resolve` is NOT — + it opens an interactive agent and writes nothing itself, it is precisely what repairs + a bad anchor, and gating it left `close` as the modal's only action while the `R` + binding (`action_resolve_run`, which has no readability check) reached the same agent + anyway, making the refusal advisory rather than enforced. + + Ablation: drop `unreadable=` from `_review_escalation`'s `EscalationModal(...)` and + this reddens on the notice, the re-arm button and the hint. + """ + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _run_dir, spec = _stories_paused_run(project.project, stage="escalation") + spec.unlink() # absent at the anchored path + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, EscalationModal) + rendered = " ".join( + render(s.content) for s in app.screen.query("#blocking Static").results(Static) + ) + # the distinguishing claim: unknown, NOT absent + assert "could not be read" in rendered + # and the lie is GONE, not merely outvoted by a warning above it. The unreadable + # arm used to prepend its notice and then fall through to the shared body render, + # which answers "" for the failure sentence — so the modal showed the warning and + # "(no blocking condition recorded)" together, the second denying the first. + assert "no blocking condition recorded" not in rendered + assert app.screen.query_one("#act-rearm", Button).disabled + # Resolve stays OPEN — the non-destructive remedy for the failure on screen + assert not app.screen.query_one("#act-resolve", Button).disabled + # The hint explains THIS refusal. Unasserted, it could silently revert to the + # restore-latch or "re-arm unlocks once..." text — both of which explain a + # condition that is not why the button is dark — while the button state stayed + # green. + hint = render(app.screen.query_one("#hint", Static).content) + assert "unreadable" in hint + assert "bmad-loop resolve" in hint # the CLI fallback is named, not just refused + + +async def test_replan_on_a_spec_that_vanished_after_render_names_the_anchored_path( + project, monkeypatch +): + """`_do_replan`'s absent-spec branch, which no row reached. + + The branch is narrow by construction — the same absence that produces it also + disables `#act-replan`, so only a spec deleted BETWEEN render and click gets here — + but it is the arm that distinguishes "absent at the anchor" from "present with no + frontmatter status", and `reset_spec_status` answers False to both. Driven directly + because the TOCTOU window cannot be opened through the modal. + + Ablation: delete the `is_file()` branch and this reddens — the shared "could not + reset the plan to draft" notice takes over and never names the path consulted. + """ + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + run_dir, spec = _stories_paused_run(project.project, stage="plan-checkpoint") + run_id = run_dir.name + spec.unlink() # vanished after the modal rendered + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + app._do_replan(run_id, spec, project.project) + await pilot.pause() + assert any(f"no spec at {spec}" in m for m in notifications(app)) + assert not any("could not reset" in m for m in notifications(app))