From 5f6f986012f92e098414f007671d562d8fa804ea Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 12:43:44 -0700 Subject: [PATCH 01/22] fix(tui,resolve): anchor the paused-spec read and replan write on the run's tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An isolated unit's `spec_file` is persisted relative to its mounted worktree, and the dashboard resolved that raw against its own cwd — the project root, which carries the same layout. The review modals rendered the main checkout's copy, and `Request replan` reset that copy to `draft` instead of the run's: both writes reported success, because the wrong file genuinely is inside the confinement root, so the run resumed while the worktree's real spec kept its terminal status and the next dispatch did not re-plan. Promote `runs._task_spec_path`/`_task_spec_root` to public (rename only) and route the TUI read, the replan's `confine_root`, and `resolve.build_context` through them, so the anchor and the containment root are one claim about which tree owns the spec. A spec missing or undecodable at the anchored path now reads as an explicit fault rather than an empty body. --- CHANGELOG.md | 12 +++ src/bmad_loop/diagnostics.py | 2 +- src/bmad_loop/resolve.py | 15 ++- src/bmad_loop/runs.py | 31 +++--- src/bmad_loop/tui/app.py | 54 ++++++++-- tests/test_resolve.py | 88 +++++++++++++++- tests/test_tui_app.py | 199 ++++++++++++++++++++++++++++++++++- 7 files changed, 366 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b1be41..eb04af9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,18 @@ breaking changes may land in a minor release. ### Fixed +- **The TUI's paused-spec read and its replan write anchor on the tree the run owns.** An + isolated unit's `spec_file` is persisted relative to its mounted worktree, and the dashboard + resolved that raw against its own cwd — the project root, which carries the same + `_bmad-output/specs/...` layout — so the review modals rendered the main checkout's copy of the + spec, and `Request replan` reset that copy to `draft` instead of the run's. Both writes reported + success (the wrong file genuinely is inside the confinement root), the run resumed, and the + worktree's real spec kept its terminal status, so the next dispatch did not re-plan. The path and + the confinement root now come from one claim about which tree owns the spec, and a spec missing at + the anchored path reads as an explicit read failure rather than an empty body. + `bmad-loop resolve`'s `context.json` reports `spec_file` as an absolute path for the same + reason. + - **`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/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 0ed34a58..cd0c1b7d 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -140,7 +140,7 @@ # 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`) + # `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 diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index ac62008a..6feeb8c0 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -23,7 +23,7 @@ from .adapters.base import SessionSpec from .model import RunState from .platform_util import safe_segment -from .runs import validate_restore_latch +from .runs import task_spec_path, validate_restore_latch RESOLVE_DIR = "resolve" @@ -110,7 +110,18 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: 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()`. + "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), diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index c4b7f1b8..98e7d7a6 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,21 @@ 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. + + Precondition: `task.spec_file` is non-empty. `Path("")` is `.`, so an empty one + yields the ROOT DIRECTORY rather than a spec — callers guard before calling. """ raw = Path(task.spec_file or "") 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: +def task_spec_root(task: StoryTask, state: RunState) -> Path: """The tree a relative `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 @@ -2295,7 +2298,7 @@ def _redrive_base_ref(state: RunState, task: StoryTask) -> str: 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`, + 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 @@ -2359,7 +2362,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 " @@ -2571,7 +2574,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 @@ -2591,7 +2594,7 @@ def rearm_escalation( task.sentinel_kind = "" # verdict discharged; the re-dispatch is clean 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 @@ -2692,7 +2695,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,7 +2767,7 @@ 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 @@ -2799,7 +2802,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 +2966,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 +2978,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 +3013,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 diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 4dd933cc..e0f2301a 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -624,7 +624,7 @@ 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) @@ -785,10 +785,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 @@ -797,8 +805,8 @@ def _do_replan(self, run_id: str, spec_path: Path) -> None: if self._resolve_blocked_by_liveness(run_id, run_dir): 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) + 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, 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 @@ -946,15 +954,43 @@ def _resolve_blocked_by_liveness(self, run_id: str, run_dir: Path) -> bool: 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 has no spec file (e.g. an ambiguous-match escalation). + + 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 = state.tasks.get(state.paused_story_key) if state.paused_story_key else None if task is None or not task.spec_file: return None, "" - path = Path(task.spec_file) + path = runs.task_spec_path(task, state) try: return path, path.read_text(encoding="utf-8") - except OSError: - return path, "" + except (OSError, UnicodeDecodeError) 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. + # + # UnicodeDecodeError is a ValueError, so the OSError arm alone let a + # non-UTF-8 spec past — and all three review surfaces call this from the + # Textual event loop, where an escaping raise takes the dashboard down + # instead of rendering the fault. Same trap `_commit_subject` closes. + return path, f"(spec could not be read — {e})" + + 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). No task means nothing was + re-anchored, so the project root stays the honest root.""" + task = state.tasks.get(state.paused_story_key) if state.paused_story_key else None + return runs.task_spec_root(task, state) if task else self.project def _story_subtitle(self, state: RunState) -> Text: key = state.paused_story_key or "?" diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 78d86ab2..f7172522 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 @@ -533,6 +534,83 @@ 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") + 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").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").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") @@ -628,7 +706,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 @@ -1077,7 +1155,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 +1164,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. @@ -2341,7 +2419,7 @@ def _refuse(self, *a, **kw): 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 +2432,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. """ diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 37809066..9da1474d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3335,9 +3335,17 @@ def _stories_paused_run( review_cycle: int = 0, blocked_result: str = "", sentinel: bool = False, + worktree_path: str = "", ) -> 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.""" import yaml folder = root / "epic-1" @@ -3366,6 +3374,16 @@ 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: + # 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.worktree_path = worktree_path + 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 +3452,176 @@ 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: + """Where `workspace.open_unit_workspace` actually mounts a unit's worktree.""" + 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_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 must render as a read failure, not take the TUI down. + + `read_text(encoding="utf-8")` raises `UnicodeDecodeError`, which is a ValueError + and so escaped the `except OSError` arm entirely — and all three review surfaces + call `_paused_spec` from the Textual event loop, where an escaping raise kills the + dashboard instead of rendering the fault. The same trap `_commit_subject` closes + for git subject bytes. + + Ablation: narrow the arm back to `except OSError` 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" in body + + +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) @@ -4625,10 +4813,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") From 850f65d6bf383c415f21713f223efdaeef9212b9 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 14:27:34 -0700 Subject: [PATCH 02/22] fix(runs,tui): confine spec writes on a tree that can contain the path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `task_spec_path` passes an absolute `spec_file` through verbatim, but `task_spec_root` returned the worktree unconditionally. `_serialized_worktree_path` keeps a path verbatim exactly when `relative_to(worktree_path)` raises, so an absolute value beside a set `worktree_path` is precisely the out-of-mount shape — and the worktree can then never contain it. `_atomic_write_spec` gates on the same lexical `is_relative_to` and silently took the plain no-follow arm, losing the confined arm's O_NOFOLLOW walk (#593); `_restore_rearmed_spec`, which calls the confined writer directly, raised `UnconfinedWriteError` instead, turning a recoverable re-arm abort into a lost undo. Yield the project for that shape, tested with the same lexical comparison the writer gates on so the root and the gate agree by construction. This deliberately reaches `rearm_escalation`'s four call sites: where the project contains the spec a skipped or refused confined write becomes a taken one, and where nothing contains it the outcome is unchanged. Also make `_paused_spec_root`'s no-task arm answer `Path(state.project)` rather than `self.project`, so both arms make one claim about which tree owns the spec. --- CHANGELOG.md | 15 +++++-- src/bmad_loop/runs.py | 29 +++++++++++- src/bmad_loop/tui/app.py | 14 ++++-- tests/test_resolve.py | 45 +++++++++++++++++++ tests/test_runs.py | 69 +++++++++++++++++++++++++++++ tests/test_tui_app.py | 95 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 257 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb04af9d..0e23c3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,9 +169,18 @@ breaking changes may land in a minor release. success (the wrong file genuinely is inside the confinement root), the run resumed, and the worktree's real spec kept its terminal status, so the next dispatch did not re-plan. The path and the confinement root now come from one claim about which tree owns the spec, and a spec missing at - the anchored path reads as an explicit read failure rather than an empty body. - `bmad-loop resolve`'s `context.json` reports `spec_file` as an absolute path for the same - reason. + the anchored path reads as an explicit read failure rather than an empty body — including a + spec that is present but not valid UTF-8, which previously escaped the read guard as a + `UnicodeDecodeError` and took the dashboard down. `bmad-loop resolve`'s `context.json` reports + `spec_file` as an absolute path for the same reason. + + The confinement root now also has to be a tree that can actually contain the spec. A spec + recorded as an absolute path alongside a worktree sits outside that worktree by construction, + so naming the worktree left every spec write unable to satisfy its own containment check: the + status flip, the result strip and the baseline re-stamp silently fell back to an unguarded + write, and the re-arm's undo failed outright, leaving a half-rewritten spec on a story the run + still reported as escalated. Such a spec now anchors on the project, which can contain it. + Where nothing can, the behavior is unchanged. - **`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 diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 98e7d7a6..58a9bf2b 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2200,7 +2200,7 @@ def task_spec_path(task: StoryTask, state: RunState) -> Path: def task_spec_root(task: StoryTask, state: RunState) -> Path: - """The tree a relative `task.spec_file` is anchored on — and confined to. + """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 @@ -2217,8 +2217,33 @@ 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; when it cannot, the outcome is what it already + was, so this arm only ever trades a skipped or refused confined write for a taken + one. + + The test is the LEXICAL `is_relative_to` that `devcontract._atomic_write_spec` + itself gates on, so the root and the writer's own check agree by construction. + 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. """ - return Path(task.worktree_path or state.project) + 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 _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index e0f2301a..b163d490 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -987,10 +987,18 @@ def _paused_spec_root(self, state: RunState) -> Path: 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). No task means nothing was - re-anchored, so the project root stays the honest root.""" + 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` exactly when + there is no task — so this is about not leaving a second claim lying around for + a future caller, not a live bug.""" task = state.tasks.get(state.paused_story_key) if state.paused_story_key else None - return runs.task_spec_root(task, state) if task else self.project + 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 "?" diff --git a/tests/test_resolve.py b/tests/test_resolve.py index f7172522..5024274c 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -1688,6 +1688,51 @@ def boom(spec_path, *, confine_root): runs.rearm_escalation(run_dir) assert spec.read_bytes() == before # the published flip is rolled back + + +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) + + 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 diff --git a/tests/test_runs.py b/tests/test_runs.py index 3b1135fa..732371f9 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3833,3 +3833,72 @@ 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, but it can, and where it cannot + the outcome is byte-identical to today's — so this arm only ever trades a skipped + or refused confined write for a taken 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) + ) + + +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 diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 9da1474d..34195406 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3336,6 +3336,7 @@ def _stories_paused_run( 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). @@ -3345,7 +3346,12 @@ def _stories_paused_run( 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.""" + 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 folder = root / "epic-1" @@ -3375,6 +3381,8 @@ def _stories_paused_run( 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. @@ -3382,7 +3390,6 @@ def _stories_paused_run( 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.worktree_path = worktree_path task.spec_file = str(spec) # to_dict re-persists this RELATIVE to the mount task.review_cycle = review_cycle if commit_sha: @@ -3525,6 +3532,62 @@ def spy_strip(p, **kw): 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. @@ -3605,6 +3668,34 @@ async def test_paused_spec_undecodable_spec_does_not_crash_the_dashboard(project assert "could not be read" in body +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 From 69e5d5c39739907b8bebffa95383ac9089c256f1 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 15:44:24 -0700 Subject: [PATCH 03/22] test(resolve): make the build_context spec_file fixture OS-absolute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_build_context_gathers_critical_escalations` passed the literal "/abs/spec.md" and asserted it reached `context.json` verbatim. On Windows that string is drive-relative, not absolute, so `runs.task_spec_path` took its anchoring arm and pathlib kept the root's drive while discarding its path — emitting "D:/abs/spec.md" and reddening both Windows legs. The literal passed unnoticed before this branch only because `build_context` emitted `task.spec_file` raw. Anchor the fixture on `tmp_path`, which is absolute on every OS, and grade against `spec.as_posix()`. The row keeps testing that an absolute `spec_file` passes through verbatim, and on Windows now also grades the `.as_posix()` half that `str()` would fail. Test-only: no `runs.py` behavior change, which the spec's frozen Boundaries forbid for every caller outside the renegotiated confining-root arm. --- tests/test_resolve.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 5024274c..097c6b24 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -506,7 +506,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( @@ -523,7 +529,7 @@ def test_build_context_gathers_critical_escalations(tmp_path): path = resolve.build_context(state, run_dir, "6-4-cli-list-command") 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 From 68bb84b973a3f6372d179a494101c0f0276269f5 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 18:19:32 -0700 Subject: [PATCH 04/22] fix(runs,resolve,tui): anchor the spec's neighbouring fields on the run's tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-anchor landed for `spec_file` alone, so every field beside it still resolved against the main checkout. `_sentinel_kind` scanned `self.project` while `_paused_spec` read the run's tree, and both feed one `EscalationModal` — a pre-planning sentinel wedge then rendered as an ordinary escalation, which is a different operator decision. `context.json` named the run's tree in `spec_file` and the project's in `stories.sentinel`, describing a file the re-arm never touches. `stories_engine`'s pause notices printed the raw worktree-relative path on the surface an operator reads before any dashboard. Route all three through `task_spec_root`. The dev-session prompt at `spec_ref` is deliberately left relative: that session's cwd IS the mount, so the anchor belongs to the consumer, not to the field. Correct the guarantee `task_spec_root` claimed. "Only ever trades a skipped or refused confined write for a taken one" was false in its docstring, in the CHANGELOG and in a test name: a spec lexically inside the project but reached through a symlinked component moves from a succeeding plain write to `UnconfinedWriteError`, which `rearm_escalation` re-raises as `RearmError`. Gating the arm on `path_is_confined` was implemented and backed out — that predicate answers False for a component it cannot probe, so the confine root would have depended on filesystem state, anchoring on the worktree before a directory existed and on the project after. A root that moves under a `mkdir` is not a definition, and the refusal is correct on #593's own terms, so the behavior is kept, the claim narrowed, and the exception is now graded. Close what the read-side fix exposed. `_do_replan` caught only `(OSError, FrontmatterWriteError)` while `reset_spec_status` decodes strictly, so making the modal survivable on a non-UTF-8 spec moved the event-loop crash one click later. A decode fault now degrades one byte rather than discarding the whole document, the failure body is reserved for absence, an unreadable spec dims its text and disables its destructive verbs, and `task_spec_path` enforces its empty-`spec_file` precondition instead of documenting it. `context.json` carries `spec_reaches_the_redrive`, so a session is not sent to edit a spec the mount discards. Restore the ESCALATED-phase assertion an inserted sibling row had absorbed from `test_rearm_restores_the_spec_when_the_result_strip_faults`, and grade the escalation modal under isolation — matrix row 3's third consumer, previously ungraded on both its spec text and its sentinel indicator. --- CHANGELOG.md | 36 ++++++- docs/FEATURES.md | 11 +- docs/tui-guide.md | 6 +- src/bmad_loop/diagnostics.py | 20 ++-- src/bmad_loop/resolve.py | 35 +++++-- src/bmad_loop/runs.py | 62 ++++++++--- src/bmad_loop/stories_engine.py | 33 ++++-- src/bmad_loop/tui/app.py | 96 +++++++++++++---- src/bmad_loop/tui/screens/modals.py | 22 +++- tests/test_resolve.py | 99 +++++++++++++++++- tests/test_runs.py | 85 ++++++++++++++- tests/test_stories_engine.py | 28 +++++ tests/test_tui_app.py | 157 ++++++++++++++++++++++++++-- 13 files changed, 612 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e23c3ee..a372d13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,7 +180,41 @@ breaking changes may land in a minor release. status flip, the result strip and the baseline re-stamp silently fell back to an unguarded write, and the re-arm's undo failed outright, leaving a half-rewritten spec on a story the run still reported as escalated. Such a spec now anchors on the project, which can contain it. - Where nothing can, the behavior is unchanged. + Where nothing can, the write lands on the arm it already took — with one exception, which is + deliberate and now graded: a spec lexically inside the project but reached through a symlinked + component moves from a succeeding unguarded write to a refused confined one. Predicting that + walk would make the confinement root depend on filesystem state, so the narrow loud failure is + kept over a root that changes under a `mkdir`. + +- **The tree the spec is anchored on is now the tree its neighbouring fields describe.** The + re-anchor had been adopted for `spec_file` alone, leaving the fields beside it resolving against + the main checkout. The escalation modal read its spec text from the run's tree while its sentinel + indicator scanned the project, so one modal could contradict itself and show a pre-planning + sentinel wedge as an ordinary escalation; `context.json` named the run's tree in `spec_file` and + the project's in `stories.sentinel`, describing a file the re-arm will never touch. Both now + answer from one root. + +- **Pause notifications hand the operator a path that resolves from where they are standing.** + The spec-approval and plan-checkpoint pauses, and the `checkpoint-pause` journal record, printed + the raw worktree-relative `spec_file` — the same wrong-tree string the dashboard fix removed, on + the surface the operator reads first. The dev-session prompt is deliberately left relative: that + session's working directory is the mount, so the anchor belongs to the consumer, not the field. + +- **`Request replan` no longer takes the dashboard down on a spec that is not valid UTF-8.** + Making the read survive a bad byte made the button reachable on such a spec, where the reset + decodes strictly and raised past a guard that caught only `OSError`. A non-UTF-8 spec now + degrades one byte rather than losing the whole document to a failure sentence, and the failure + body is reserved for a spec that is actually absent. + +- **A spec that could not be read no longer offers `Approve & resume`.** The verbs act on the + spec — approve resumes the run past the gate — so a gate nobody could review is refused at the + source rather than downstream, and the failure text is dimmed instead of being rendered in the + style reserved for the spec's own words. + +- **`context.json` reports whether an edit to the spec survives to the re-drive.** Under worktree + isolation the mount is discarded before the re-drive reads anything, so a resolve session could + edit a worktree-local spec, see every write succeed, and have the work vanish. The verdict the + re-arm already journals is now carried in the context the session reads. - **`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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f46c7f33..ea55c1f9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -78,9 +78,14 @@ 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) answer from that one root rather than the project, 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, diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 864120d4..f2a1b95d 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 diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index cd0c1b7d..022bcfe3 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 + # `stories_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 diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 6feeb8c0..27e36b7c 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -23,7 +23,12 @@ from .adapters.base import SessionSpec from .model import RunState from .platform_util import safe_segment -from .runs import task_spec_path, validate_restore_latch +from .runs import ( + spec_reaches_the_redrive, + task_spec_path, + task_spec_root, + validate_restore_latch, +) RESOLVE_DIR = "resolve" @@ -107,6 +112,10 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: validate_restore_latch(state, task, story_key, worktree_isolation=isolation == "worktree") is None ) + # The one claim about which tree owns this story, shared by every field below that + # names a file. `task_spec_root` is the same definition the re-arm's writers confine + # against, so the context cannot describe a tree the write will not land on. + spec_root = task_spec_root(task, state) if task else Path(state.project) context = { "story_key": story_key, "run_id": state.run_id, @@ -120,7 +129,10 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # 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()`. + # 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, @@ -129,13 +141,20 @@ 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. + "spec_reaches_the_redrive": (spec_reaches_the_redrive(task, state) 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, spec_root) if stories_ctx: context["stories"] = stories_ctx path = context_path(run_dir, story_key) @@ -144,7 +163,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 @@ -152,8 +171,12 @@ 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)`: this block describes the same story whose + # `spec_file` the caller anchored on the run's own tree, and one `context.json` that + # names two trees is worse than one that names the wrong one — `sentinel.path` and + # `blocking_condition` would 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 58a9bf2b..fe7cd676 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2190,10 +2190,14 @@ def task_spec_path(task: StoryTask, state: RunState) -> Path: Absolute paths pass through: a spec outside the worktree is persisted verbatim. - Precondition: `task.spec_file` is non-empty. `Path("")` is `.`, so an empty one - yields the ROOT DIRECTORY rather than a spec — callers guard before calling. + 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 @@ -2226,13 +2230,32 @@ def task_spec_root(task: StoryTask, state: RunState) -> 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; when it cannot, the outcome is what it already - was, so this arm only ever trades a skipped or refused confined write for a taken - one. - The test is the LEXICAL `is_relative_to` that `devcontract._atomic_write_spec` - itself gates on, so the root and the writer's own check agree by construction. - Deliberately not canonicalized: `_spec_is_shared_with_the_redrive` answers a + The project can often confine it, and where nothing can, the write lands on the arm + it already took. NOT unconditionally, though, and the 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. @@ -2337,6 +2360,23 @@ def _redrive_base_ref(state: RunState, task: StoryTask) -> str: return "HEAD" +def spec_reaches_the_redrive(task: StoryTask, state: RunState) -> 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. That one says WHICH file the run's own + tooling writes; this says whether that file still exists by the time the re-drive + reads it. They differ exactly under isolation: `engine._finish_inflight` discards + the mount, so a worktree-local spec is destroyed with it, while a spec in an + artifact dir configured outside the project tree is shared across checkouts and + survives (`_spec_is_shared_with_the_redrive` carries that argument in full). + + 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. + """ + return not task.worktree_path or _spec_is_shared_with_the_redrive(state, task) + + def _restore_rearmed_spec( spec_path: Path, original: bytes | None, task: StoryTask, state: RunState ) -> None: @@ -2661,9 +2701,7 @@ def rearm_escalation( # 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) # Narrowed to the case an operator can ACT on. Every isolated escalation # carries a mounted `worktree_path` — `worktree_flow.escalate_unit` never # clears it, and `keep_branch_and_escalate` deliberately leaves the worktree diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 0d6db7d3..0969b9dd 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -54,7 +54,7 @@ Phase, StoryTask, ) -from .runs import graceful_stop_requested +from .runs import graceful_stop_requested, task_spec_path @dataclass(frozen=True) @@ -598,7 +598,7 @@ 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 `bmad-loop resume {self.state.run_id}`", ) raise RunPaused( f"awaiting spec approval for {task.story_key}", @@ -607,6 +607,24 @@ def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) ) self._review_and_commit(task) + def _operator_spec_path(self, task: StoryTask) -> str: + """The task's spec spelled the way an operator can actually open it. + + Every pause below hands a human a path and tells them to review it, 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. + + Falls back to the story key on a spec-less task, matching `spec_ref` above + 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 _pause_plan_checkpoint(self, task: StoryTask) -> None: """Leg 1 of a spec_checkpoint story verified (plan at ready-for-dev): pause for human plan review. The task stays at DEV_VERIFY with @@ -614,13 +632,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 +668,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 +676,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/tui/app.py b/src/bmad_loop/tui/app.py index b163d490..277509f3 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"), @@ -630,7 +632,7 @@ def done(verb: str | None) -> None: 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,7 +718,7 @@ 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( @@ -804,16 +807,31 @@ def _do_replan(self, run_id: str, spec_path: Path, confine_root: 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=confine_root) devcontract.strip_auto_run_result(spec_path, confine_root=confine_root) - except (OSError, verify.FrontmatterWriteError) as e: + 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: @@ -952,9 +970,26 @@ 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). + 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 @@ -962,23 +997,26 @@ def _paused_spec(self, state: RunState) -> tuple[Path | None, str]: 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 = state.tasks.get(state.paused_story_key) if state.paused_story_key else None + task = self._paused_task(state) if task is None or not task.spec_file: - return None, "" + return None, "", True path = runs.task_spec_path(task, state) try: - return path, path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as e: + # `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. - # - # UnicodeDecodeError is a ValueError, so the OSError arm alone let a - # non-UTF-8 spec past — and all three review surfaces call this from the - # Textual event loop, where an escaping raise takes the dashboard down - # instead of rendering the fault. Same trap `_commit_subject` closes. - return path, f"(spec could not be read — {e})" + # 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. @@ -994,10 +1032,12 @@ def _paused_spec_root(self, state: RunState) -> Path: 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` exactly when - there is no task — so this is about not leaving a second claim lying around for - a future caller, not a live bug.""" - task = state.tasks.get(state.paused_story_key) if state.paused_story_key else None + 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: @@ -1022,11 +1062,21 @@ 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 = state.tasks.get(key) + root = runs.task_spec_root(task, state) if task else Path(state.project) # 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..dc99083a 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: diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 097c6b24..43aa073a 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -1673,7 +1673,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" @@ -1694,6 +1697,7 @@ def boom(spec_path, *, confine_root): runs.rearm_escalation(run_dir) 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( @@ -2096,6 +2100,99 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): 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").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).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_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").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").read_text( + encoding="utf-8" + ) + ) + assert plain["spec_reaches_the_redrive"] is True + + @pytest.mark.parametrize( ("committed_status", "warns"), [("ready-for-dev", False), ("blocked", True)], diff --git a/tests/test_runs.py b/tests/test_runs.py index 732371f9..1d1fd3df 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3850,9 +3850,11 @@ def test_task_spec_root_yields_the_project_when_the_worktree_cannot_confine_the_ `_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, but it can, and where it cannot - the outcome is byte-identical to today's — so this arm only ever trades a skipped - or refused confined write for a taken one. + 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. @@ -3868,6 +3870,83 @@ def test_task_spec_root_yields_the_project_when_the_worktree_cannot_confine_the_ ) +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`, which `rearm_escalation` re-raises as `RearmError`. + + 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) + with pytest.raises(platform_util.UnconfinedWriteError): + platform_util.atomic_write_bytes_confined(spec, b"x", confine_root=root) + + +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. + + 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) 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) is True + + 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. diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 2777a3c5..b2a3c345 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -964,6 +964,34 @@ 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: revert either site to a bare `task.spec_file` and this reddens. + """ + 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" + + # -------- MAJOR-B: a spec_checkpoint story can never commit without a plan review diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 34195406..9a54f139 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3460,7 +3460,14 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): def _unit_worktree(root: Path, run_id: str = "20260611-100000-aaaa", unit: str = "1") -> Path: - """Where `workspace.open_unit_workspace` actually mounts a unit's worktree.""" + """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 @@ -3645,15 +3652,19 @@ async def test_spec_approval_gate_renders_the_worktree_spec_under_isolation(proj async def test_paused_spec_undecodable_spec_does_not_crash_the_dashboard(project, monkeypatch): - """A non-UTF-8 spec must render as a read failure, not take the TUI down. - - `read_text(encoding="utf-8")` raises `UnicodeDecodeError`, which is a ValueError - and so escaped the `except OSError` arm entirely — and all three review surfaces - call `_paused_spec` from the Textual event loop, where an escaping raise kills the - dashboard instead of rendering the fault. The same trap `_commit_subject` closes - for git subject bytes. - - Ablation: narrow the arm back to `except OSError` and this reddens — the modal + """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) @@ -3665,7 +3676,133 @@ async def test_paused_spec_undecodable_spec_does_not_crash_the_dashboard(project 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): From e9dbc6a82bda0ef4b8e32accdb7c28097b0f181d Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 18:54:50 -0700 Subject: [PATCH 05/22] fix(engine,model): anchor spec ownership before the mount is discarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spec_file` and `dispatched_spec_file` are both persisted relative to the mounted worktree by `model._serialized_worktree_path`, and `from_dict` reads them back raw. Every `_finish_inflight` arm that continues an isolated unit re-anchors them through `reopen_unit` first — but two do not: - the restart arm discards the mount and clears `worktree_path` before its durable save, so a host death in the re-mount window persists a mount-relative spelling beside an empty `worktree_path`; and - the `isolated` test is live policy, while the relative spelling is persisted state — an `isolation` change across a resume is journaled, never refused — so a task that still carries a mount takes the in-place arms. Either way the raw value then resolves against the main checkout, which carries the same layout. `recovery_flow._attempt_owned_spec` finds exactly one candidate (the artifacts probe doubles the prefix and cannot exist), its `len(resolved_files) != 1` guard passes on the wrong single file, and `spec_within_roots` accepts it against the same roots — so a rollback could restore a dead attempt's snapshot bytes over the operator's own copy of the spec, and its Git exclusion named the wrong tree's file. The engine's own reader was never exposed: `_read_dispatched_spec_snapshot` resolves strictly and rejects `resolved != spec_path`, which no relative path can satisfy, so it fails closed. Nine of the ten other engine sites only test the field against `None`. The defect is at the producer, not any reader, so no read site changed. `_finish_inflight` now re-anchors both fields on `task.worktree_path` before the `isolated` gate. A binding whose tree is gone is then unresolvable, and recovery refuses it loudly instead of rewriting a file the run never used. The rule gets one owner: `StoryTask.rebase_spec_paths_on`, on the class whose `_serialized_worktree_path` creates the relative spelling. `reopen_unit` calls it instead of its own loop, so this lands as a third caller rather than a third copy. `to_dict` / `_serialized_worktree_path` / `from_dict` are untouched — the persisted form stays a compatibility contract and the asymmetry is still fixed on the read side only. --- CHANGELOG.md | 15 ++++++ src/bmad_loop/engine.py | 16 ++++++ src/bmad_loop/model.py | 39 ++++++++++++++ src/bmad_loop/worktree_flow.py | 9 ++-- tests/test_engine_worktree.py | 98 ++++++++++++++++++++++++++++++++++ tests/test_model.py | 46 ++++++++++++++++ 6 files changed, 218 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a372d13d..98ed8f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -200,6 +200,21 @@ breaking changes may land in a minor release. the surface the operator reads first. The dev-session prompt is deliberately left relative: that session's working directory is the mount, so the anchor belongs to the consumer, not the field. +- **A discarded worktree no longer leaves its spec ownership pointing at the main checkout.** + `spec_file` and `dispatched_spec_file` are both persisted relative to the mounted worktree, and + resume read them back raw. Every arm of the in-flight reconcile that continues an isolated unit + re-anchors them first, but two do not: the restart arm discards the mount and clears + `worktree_path` before saving, and the `isolated` test is live policy — an `isolation` change + across a resume is journaled, never refused — so a task that still carries a mount takes the + in-place arms. Either way the raw value then resolved against the main checkout, which carries + the same layout, so recovery's attempt-owned spec lookup found exactly one candidate and + containment accepted it: a rollback could restore a dead attempt's snapshot bytes over the + operator's own copy of the spec, and its Git exclusion named the wrong tree's file. Both fields + are now re-anchored on the mount recorded on the task before either arm runs, so a binding whose + tree is gone is unresolvable and recovery refuses it loudly instead of rewriting a file the run + never used. The engine's own reader was never exposed — it resolves strictly and compares the + result against the spelling it started from, which no relative path can satisfy. + - **`Request replan` no longer takes the dashboard down on a spec that is not valid UTF-8.** Making the read survive a bad byte made the button reachable on such a spec, where the reset decodes strictly and raised past a guard that caught only `OSError`. A non-UTF-8 spec now diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 0dee3579..a9871696 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1497,6 +1497,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. diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 71d2308c..79e90bbc 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,31 @@ def _serialized_worktree_path(self, path: str | None) -> str | None: except ValueError: return path # spec lives outside the worktree; keep absolute + 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/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_engine_worktree.py b/tests/test_engine_worktree.py index 9679233f..8b757999 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, @@ -2258,6 +2259,103 @@ 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_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_worktree_spec_approval_pause_resumes_in_same_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) gated = Policy( diff --git a/tests/test_model.py b/tests/test_model.py index 6bc1850d..c3b013bf 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) From 8c8c2bf3370035d93fe6c9256f6a4a10e839a7d8 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 19:00:54 -0700 Subject: [PATCH 06/22] fix(engine,sweep): clear the baseline with the mount it was measured in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart arm dropped a half-built unit worktree and cleared `worktree_path` and `branch`, but left `baseline_commit` and `baseline_untracked` — both stamped by `_dev_phase` from `self.workspace.root`, which is the unit worktree under isolation. The arm then saves, so that pair is durable beside an empty `worktree_path`, and any later resume takes the in-place `elif task.baseline_commit:` leg into `recovery_flow.rollback_or_pause` against the MAIN checkout. No host death is required to reach it: `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 in the main checkout: - Linked worktrees share the main repo's object database, so force-deleting the unit branch does not make the baseline unresolvable. `git diff` reads it, and `git reset --hard` onto it SUCCEEDS — moving the operator's branch onto a commit that only ever lived on the deleted unit branch, in the `branch_per="run"` and re-entered-`_dev_phase` shapes where the baseline is a unit-branch commit rather than the shared cut point. - 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 non-ignored file in the operator's own checkout reads as this attempt's debris. Under `scm.rollback_on_failure` those are unlinked; with the default off it pauses on a dirtiness no operator action can clear, so the manual-recovery loop cannot terminate — the exact non-termination `rollback_or_pause`'s docstring promises against. `rollback_or_pause`'s routing was already correct and is untouched: `in_unit_worktree` is compared by path, not policy, precisely so a resume with no worktree recorded still targets the main checkout and pauses. The bug was never which arm ran, only which operands it ran on. Both fields are now cleared with the mount that defined them, via a shared `_discard_unit_for_restart` that `sweep`'s identical arm also uses. `None` rather than `[]` for the untracked half is the value `attempt_dirty` and `_rollback_cleanup_plan` both read as "nothing here is this attempt's to remove". `_dev_phase` re-stamps both from the replacement mount, so the leg becomes a correct no-op rather than a probe of the wrong tree. --- CHANGELOG.md | 17 ++++++++++++ src/bmad_loop/engine.py | 46 +++++++++++++++++++++++++++---- src/bmad_loop/sweep.py | 7 +---- tests/test_engine_worktree.py | 52 +++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ed8f5d..3d9879ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -200,6 +200,23 @@ breaking changes may land in a minor release. the surface the operator reads first. The dev-session prompt is deliberately left relative: that session's working directory is the mount, so the anchor belongs to the consumer, not the field. +- **A discarded worktree no longer leaves its baseline pointing at a tree that is gone.** The + restart arm cleared `worktree_path` and `branch` when it dropped a half-built unit, but left + `baseline_commit` and `baseline_untracked` — both measured INSIDE that mount by `_dev_phase`. + The arm saves before the replacement is mounted, and `run_isolated` records the new + `worktree_path` only after `open_unit_workspace` returns, so a git spawn fault there (no host + death needed) persists that pair beside an empty `worktree_path`. Any later resume then took the + in-place `elif task.baseline_commit:` leg and probed the MAIN checkout with them. Neither + operand failed loud: linked worktrees share the main repo's object database, so force-deleting + the unit branch left the baseline resolvable and a reset onto it succeeding, while a fresh + worktree's empty untracked snapshot made `_rollback_cleanup_plan`'s + `untracked_files(repo) - baseline_untracked` name every untracked file in the operator's own + checkout as attempt debris — deleted outright under `scm.rollback_on_failure`, and with the + default off, a dirtiness no operator action could clear, so the manual-recovery loop could not + terminate. Both fields are now cleared with the mount that defined them; `_dev_phase` re-stamps + them from the replacement, so the leg becomes a correct no-op instead of a probe of the wrong + tree. The sweep's identical arm routes through the same helper. + - **A discarded worktree no longer leaves its spec ownership pointing at the main checkout.** `spec_file` and `dispatched_spec_file` are both persisted relative to the mounted worktree, and resume read them back raw. Every arm of the in-flight reconcile that continues an isolated unit diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index a9871696..b2fbbce2 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1355,6 +1355,46 @@ 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 EVERY field that described it. + + `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 + ) + task.worktree_path = "" + task.branch = "" + task.baseline_commit = None + task.baseline_untracked = None + def _safe_reset(self, task: StoryTask, *, preserve: tuple[str, ...] = ()) -> None: self._recovery_flow.safe_reset(task, preserve=preserve) @@ -1626,11 +1666,7 @@ 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 = "" + self._discard_unit_for_restart(task) elif task.baseline_commit: # latch resolved_redrive so the corrected spec stays protected # through every reset of this re-drive, not just this first one diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 22dff5f5..0f132fbc 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: @@ -844,11 +843,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/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 8b757999..9d8c9add 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2312,6 +2312,58 @@ def _spy(*_args, **_kwargs): 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_finish_inflight_anchors_on_the_persisted_mount_not_the_live_isolation_policy(project): """The relative spelling is persisted state; `isolated` is re-read policy. From fe25dd63d82d7ebc1bcb163a7ff79897ac833707 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 22:14:10 -0700 Subject: [PATCH 07/22] fix(sweep,tui,runs): anchor the surfaces the fourth review pass found unanchored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-anchor landed on `Engine._finish_inflight` and never reached sweep: `SweepEngine` replaces `_loop` wholesale and `Engine._loop` is the only caller of `_finish_inflight`, so a bundle inherited the shared discard helper's baseline half and none of the spec-ownership half. Re-anchored in `_recover_inflight_bundle`, above the `isolated` gate for the same reason the engine puts it there — the gate is live policy, the relative spelling is persisted state. The escalation modal discarded the read verdict, so an unreadable spec rendered "(no blocking condition recorded)" — indistinguishable from a spec that halted without one — while `Re-arm & resume` stayed live over a write that flips frontmatter, strips the result and re-stamps the baseline. It now reports the condition as unknown and refuses both verbs. `task_spec_root` answered which tree can CONFINE a write to `spec_file`, and the sentinel and stories block borrowed it as a folder anchor; its out-of-mount arm sent them to the main checkout while `_stories_folder` stayed on the mount. Split out `task_stories_root`, which mirrors the engine's rule. Sprint mode's spec-approval pause still printed the raw field, so `_operator_spec_path` moves to `Engine`. `spec_reaches_the_redrive` is gated on `task.spec_file` like its sibling, so no verdict is emitted beside a null spec. Tests: five rows, each ablated to confirm it reddens without its fix. Two close gaps that were fully green under ablation — every `_operator_spec_path` call site, and `_review_gate`'s unreadable refusal. `test_task_spec_root_refuses_a_spec_the_project_cannot_reach` now drives `reset_spec_status` rather than the primitive, so it grades arm selection, and its docstring no longer claims the `rearm_escalation` link it never exercised. Also corrects three docstrings/comments that had become false, the CHANGELOG clause attaching the explicit-read-failure claim to undecodable specs (they degrade in place), and the tui-guide entries for the escalation and gate views. --- CHANGELOG.md | 37 +++++++++--- docs/tui-guide.md | 16 +++-- src/bmad_loop/diagnostics.py | 9 ++- src/bmad_loop/engine.py | 39 +++++++++++- src/bmad_loop/resolve.py | 34 +++++++---- src/bmad_loop/runs.py | 44 +++++++++++++- src/bmad_loop/stories_engine.py | 20 +------ src/bmad_loop/sweep.py | 17 ++++++ src/bmad_loop/tui/app.py | 19 +++++- src/bmad_loop/tui/screens/modals.py | 35 ++++++++++- tests/test_runs.py | 16 ++++- tests/test_stories_engine.py | 38 +++++++++++- tests/test_sweep.py | 58 ++++++++++++++++++ tests/test_tui_app.py | 92 +++++++++++++++++++++++++++++ 14 files changed, 413 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9879ea..c4e99c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,10 +169,9 @@ breaking changes may land in a minor release. success (the wrong file genuinely is inside the confinement root), the run resumed, and the worktree's real spec kept its terminal status, so the next dispatch did not re-plan. The path and the confinement root now come from one claim about which tree owns the spec, and a spec missing at - the anchored path reads as an explicit read failure rather than an empty body — including a - spec that is present but not valid UTF-8, which previously escaped the read guard as a - `UnicodeDecodeError` and took the dashboard down. `bmad-loop resolve`'s `context.json` reports - `spec_file` as an absolute path for the same reason. + the anchored path reads as an explicit read failure rather than an empty body. + `bmad-loop resolve`'s `context.json` reports `spec_file` as an absolute path for the same + reason. The confinement root now also has to be a tree that can actually contain the spec. A spec recorded as an absolute path alongside a worktree sits outside that worktree by construction, @@ -186,6 +185,21 @@ breaking changes may land in a minor release. walk would make the confinement root depend on filesystem state, so the narrow loud failure is kept over a root that changes under a `mkdir`. +- **A sweep bundle's restart arm re-anchors spec ownership before it discards the mount.** The + engine's arm was fixed; sweep's was not, and it never inherited the fix — `SweepEngine` replaces + `_loop` wholesale, so the base engine's `_finish_inflight` (which carries the re-anchor) never + runs, while both engines share the discard helper itself. A bundle interrupted under isolation + therefore persisted a mount-relative spec path beside an emptied `worktree_path`, and recovery + resolved it against the main checkout — where the same layout answers, so the snapshot restore + rewrote the operator's own copy of the spec. + +- **The stories folder is located from the workspace root, not from the spec's confinement root.** + The sentinel indicator and `context.json`'s stories block borrowed `task_spec_root`, which + answers which tree can CONFINE a write to `spec_file` and falls back to the project for a spec + outside the mount. For such a run they read the main checkout while the engine's own + `_stories_folder` stayed on the worktree, so one surface could again describe two trees. They now + use a separate resolver that mirrors the engine's rule. + - **The tree the spec is anchored on is now the tree its neighbouring fields describe.** The re-anchor had been adopted for `spec_file` alone, leaving the fields beside it resolving against the main checkout. The escalation modal read its spec text from the run's tree while its sentinel @@ -197,8 +211,10 @@ breaking changes may land in a minor release. - **Pause notifications hand the operator a path that resolves from where they are standing.** The spec-approval and plan-checkpoint pauses, and the `checkpoint-pause` journal record, printed the raw worktree-relative `spec_file` — the same wrong-tree string the dashboard fix removed, on - the surface the operator reads first. The dev-session prompt is deliberately left relative: that - session's working directory is the mount, so the anchor belongs to the consumer, not the field. + the surface the operator reads first. Sprint mode's spec-approval pause is included: it prints + through the same helper, which now lives on `Engine` rather than on `StoriesEngine`. The + dev-session prompt keeps the raw field, whatever spelling it holds: that session's working + directory is the mount, so the anchor belongs to the consumer, not the field. - **A discarded worktree no longer leaves its baseline pointing at a tree that is gone.** The restart arm cleared `worktree_path` and `branch` when it dropped a half-built unit, but left @@ -238,10 +254,13 @@ breaking changes may land in a minor release. degrades one byte rather than losing the whole document to a failure sentence, and the failure body is reserved for a spec that is actually absent. -- **A spec that could not be read no longer offers `Approve & resume`.** The verbs act on the - spec — approve resumes the run past the gate — so a gate nobody could review is refused at the +- **A spec that could not be read no longer offers `Approve & resume`, `Re-arm` or `Resolve`.** + The verbs act on the spec — approve resumes the run past the gate, re-arm flips its frontmatter, + strips its result and re-stamps the baseline — so a gate nobody could review is refused at the source rather than downstream, and the failure text is dimmed instead of being rendered in the - style reserved for the spec's own words. + style reserved for the spec's own words. The escalation modal reports the blocking condition as + UNKNOWN rather than absent: it is parsed from the spec, so an unreadable one previously rendered + "(no blocking condition recorded)" — indistinguishable from a spec that halted without one. - **`context.json` reports whether an edit to the spec survives to the re-drive.** Under worktree isolation the mount is discarded before the re-drive reads anything, so a resolve session could diff --git a/docs/tui-guide.md b/docs/tui-guide.md index f2a1b95d..75d5496b 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -486,13 +486,19 @@ 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 both verbs. **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/diagnostics.py b/src/bmad_loop/diagnostics.py index 022bcfe3..3608c07e 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index b2fbbce2..4d50d21c 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 @@ -1356,7 +1357,16 @@ 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 EVERY field that described it. + """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. The spec-ownership pair + (`dispatched_spec_file`, `dispatched_spec_snapshot`) is NOT cleared here — it + records which spec an attempt owned, which outlives the mount, and + `_bind_dispatched_spec_for_attempt` rebinds it on the next attempt before any + reader can act on it. Note the caller re-anchors that pair immediately above, so + between here and the rebind it holds an absolute path into the deleted tree. `worktree_path`/`branch` name the mount; `baseline_commit`/`baseline_untracked` were MEASURED in it (`_dev_phase` stamps both from `self.workspace.root`, which @@ -1960,6 +1970,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 @@ -1968,7 +2002,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/resolve.py b/src/bmad_loop/resolve.py index 27e36b7c..ef188e1d 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -26,7 +26,7 @@ from .runs import ( spec_reaches_the_redrive, task_spec_path, - task_spec_root, + task_stories_root, validate_restore_latch, ) @@ -112,10 +112,13 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: validate_restore_latch(state, task, story_key, worktree_isolation=isolation == "worktree") is None ) - # The one claim about which tree owns this story, shared by every field below that - # names a file. `task_spec_root` is the same definition the re-arm's writers confine - # against, so the context cannot describe a tree the write will not land on. - spec_root = task_spec_root(task, state) if task else Path(state.project) + # 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, @@ -147,14 +150,20 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # 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. - "spec_reaches_the_redrive": (spec_reaches_the_redrive(task, state) if task else None), + # 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) if task and task.spec_file 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, spec_root) + stories_ctx = _stories_context(state, story_key, stories_root) if stories_ctx: context["stories"] = stories_ctx path = context_path(run_dir, story_key) @@ -171,11 +180,12 @@ def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, A an unreadable manifest just yields the folder (resolve still runs).""" from . import stories - # `root`, not `Path(state.project)`: this block describes the same story whose - # `spec_file` the caller anchored on the run's own tree, and one `context.json` that - # names two trees is worse than one that names the wrong one — `sentinel.path` and - # `blocking_condition` would describe a file the re-arm will never touch, or vanish - # entirely because the main checkout has no sentinel while the mount does. + # `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: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index fe7cd676..023a4826 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2231,8 +2231,18 @@ def task_spec_root(task: StoryTask, state: RunState) -> Path: 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, and where nothing can, the write lands on the arm - it already took. NOT unconditionally, though, and the exception is graded by + 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 @@ -2269,6 +2279,36 @@ def task_spec_root(task: StoryTask, state: RunState) -> Path: 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. + + Accepts `None` so the two call sites do not each re-spell the no-task fallback. + """ + if task is None or not task.worktree_path: + return Path(state.project) + return Path(task.worktree_path) + + 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. diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 0969b9dd..741ca8a6 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -54,7 +54,7 @@ Phase, StoryTask, ) -from .runs import graceful_stop_requested, task_spec_path +from .runs import graceful_stop_requested @dataclass(frozen=True) @@ -607,24 +607,6 @@ def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None) ) self._review_and_commit(task) - def _operator_spec_path(self, task: StoryTask) -> str: - """The task's spec spelled the way an operator can actually open it. - - Every pause below hands a human a path and tells them to review it, 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. - - Falls back to the story key on a spec-less task, matching `spec_ref` above - 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 _pause_plan_checkpoint(self, task: StoryTask) -> None: """Leg 1 of a spec_checkpoint story verified (plan at ready-for-dev): pause for human plan review. The task stays at DEV_VERIFY with diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 0f132fbc..302f828d 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -804,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 diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 277509f3..a520647d 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -718,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, _readable = 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), @@ -1070,8 +1078,13 @@ def _sentinel_kind(self, state: RunState, key: str) -> str: # `_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 = state.tasks.get(key) - root = runs.task_spec_root(task, state) if task else Path(state.project) + # + # `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. diff --git a/src/bmad_loop/tui/screens/modals.py b/src/bmad_loop/tui/screens/modals.py index dc99083a..554217e5 100644 --- a/src/bmad_loop/tui/screens/modals.py +++ b/src/bmad_loop/tui/screens/modals.py @@ -722,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 @@ -732,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() @@ -753,6 +755,21 @@ def compose(self) -> ComposeResult: ) with Vertical(id="blocking"): body = self._blocking.strip() + 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. + yield Static( + Text( + "⚠ the spec could not be read at the anchored path — " + "the blocking condition below is unknown, not absent", + style="red", + ) + ) yield Static( Text(body) if body @@ -765,7 +782,16 @@ 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( + "both verbs are refused while the spec is unreadable — re-arm " + "flips its frontmatter, strips the result and re-stamps the " + "baseline, and an unreviewable escalation is not actionable", + 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. @@ -786,13 +812,16 @@ def compose(self) -> ComposeResult: yield Static(hint, id="hint") with Horizontal(classes="buttons"): yield Button( - "Resolve", variant="primary", id="act-resolve", disabled=self._engine_live + "Resolve", + variant="primary", + id="act-resolve", + disabled=self._engine_live or self._unreadable, ) 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/tests/test_runs.py b/tests/test_runs.py index 1d1fd3df..a97025e0 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3879,7 +3879,10 @@ def test_task_spec_root_refuses_a_spec_the_project_cannot_reach(tmp_path): 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`, which `rearm_escalation` re-raises as `RearmError`. + `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 @@ -3905,8 +3908,17 @@ def test_task_spec_root_refuses_a_spec_the_project_cannot_reach(tmp_path): 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): - platform_util.atomic_write_bytes_confined(spec, b"x", confine_root=root) + 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): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index b2a3c345..77d6aeda 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -979,7 +979,11 @@ def test_operator_spec_path_anchors_an_isolated_units_spec(project): mount, so the relative spelling is the correct one there. The anchor belongs to the consumer, not to the field. - Ablation: revert either site to a bare `task.spec_file` and this reddens. + 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" @@ -992,6 +996,38 @@ def test_operator_spec_path_anchors_an_isolated_units_spec(project): 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" + + # -------- MAJOR-B: a spec_checkpoint story can never commit without a plan review diff --git a/tests/test_sweep.py b/tests/test_sweep.py index c6e57479..ea50b7c0 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -5519,3 +5519,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 9a54f139..657ec44d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -5743,3 +5743,95 @@ 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_and_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. + + Ablation: drop `unreadable=` from `_review_escalation`'s `EscalationModal(...)` and + this reddens on both the notice and the two button states. + """ + 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 + assert app.screen.query_one("#act-rearm", Button).disabled + assert app.screen.query_one("#act-resolve", Button).disabled + + +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)) From f9de3cb14d466ed2cfc5551146db81a48097ae74 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 28 Aug 2026 23:46:31 -0700 Subject: [PATCH 08/22] fix(tui,tests): keep Resolve reachable on an unreadable spec, and make the anchor rule checkable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous pass refused BOTH escalation verbs while the spec could not be read. Resolve is the wrong half to refuse: it opens an interactive agent and writes nothing itself, a bad anchor is exactly what it repairs, and gating it left `close` as the modal's only action on the one failure the resolve agent exists to fix. It also put 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. Re-arm stays refused: it flips the frontmatter, strips the result and re-stamps the baseline. The hint now explains THIS refusal and names the CLI fallback. The unreadable notice PREFIXED the shared body render, which answers "" for the failure sentence — so the modal printed the warning and "(no blocking condition recorded)" together, the second denying the first. It now replaces the body. `_stories_entry` read the manifest through `self.project` while `_sentinel_kind` beside it read the mount, so one `EscalationModal` could take its title from one tree and its sentinel from another — the same one-surface-two-trees defect the anchor exists to close. Both now use `task_stories_root`. Nothing made the anchor rule checkable, which is why four rounds each found only the next unanchored reader. `test_portability_guard` now flags a raw `Path(x.spec_file)` / `Path(x.dispatched_spec_file)` by call shape, so an alias is caught too, with `runs.py`, `engine.py`, `verify.py` and `recovery_flow.py` allowlisted as the tree-local consumers. Two companion rows grade the detector itself — it fires on the exact line the defect shipped as, and stays silent on the sanctioned spellings — since the guard asserts an absence. Tests: the stories/spec root split is now graded by a row where the two resolvers genuinely disagree (absolute out-of-mount `spec_file`), at both the `runs` primitive and `build_context`; every other row builds the relative shape where they agree by construction, which is why collapsing them left the suite green. The plan-checkpoint row asserts the NOTIFY body, not only the journal — no row observed any `gates.notify` body before, so every notification site could have been reverted with the suite green. `_stories_paused_run` now refuses `spec_outside_worktree` without a `worktree_path`, a combination that read like an isolated row while grading nothing, and the symlink row skips on win32. `engine.py` records why `baseline_ledger_digest` and the `pre_harvest_ledger` pair are deliberately NOT cleared with the mount: the criterion is "read before anything re-measures it", and clearing them would destroy the crash-replay attribution `_disarm_ledger_snapshot` exists to preserve. CHANGELOG: the section had drifted back into multi-paragraph root-cause narration; condensed to one bullet per change. --- CHANGELOG.md | 148 ++++++++-------------------- docs/tui-guide.md | 6 +- src/bmad_loop/engine.py | 12 +++ src/bmad_loop/stories_engine.py | 3 +- src/bmad_loop/tui/app.py | 11 ++- src/bmad_loop/tui/screens/modals.py | 34 +++++-- tests/test_portability_guard.py | 94 ++++++++++++++++++ tests/test_resolve.py | 47 +++++++++ tests/test_runs.py | 35 +++++++ tests/test_stories_engine.py | 12 +++ tests/test_tui_app.py | 32 +++++- 11 files changed, 313 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e99c11..e8dda4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,111 +161,49 @@ breaking changes may land in a minor release. ### Fixed -- **The TUI's paused-spec read and its replan write anchor on the tree the run owns.** An - isolated unit's `spec_file` is persisted relative to its mounted worktree, and the dashboard - resolved that raw against its own cwd — the project root, which carries the same - `_bmad-output/specs/...` layout — so the review modals rendered the main checkout's copy of the - spec, and `Request replan` reset that copy to `draft` instead of the run's. Both writes reported - success (the wrong file genuinely is inside the confinement root), the run resumed, and the - worktree's real spec kept its terminal status, so the next dispatch did not re-plan. The path and - the confinement root now come from one claim about which tree owns the spec, and a spec missing at - the anchored path reads as an explicit read failure rather than an empty body. - `bmad-loop resolve`'s `context.json` reports `spec_file` as an absolute path for the same - reason. - - The confinement root now also has to be a tree that can actually contain the spec. A spec - recorded as an absolute path alongside a worktree sits outside that worktree by construction, - so naming the worktree left every spec write unable to satisfy its own containment check: the - status flip, the result strip and the baseline re-stamp silently fell back to an unguarded - write, and the re-arm's undo failed outright, leaving a half-rewritten spec on a story the run - still reported as escalated. Such a spec now anchors on the project, which can contain it. - Where nothing can, the write lands on the arm it already took — with one exception, which is - deliberate and now graded: a spec lexically inside the project but reached through a symlinked - component moves from a succeeding unguarded write to a refused confined one. Predicting that - walk would make the confinement root depend on filesystem state, so the narrow loud failure is - kept over a root that changes under a `mkdir`. - -- **A sweep bundle's restart arm re-anchors spec ownership before it discards the mount.** The - engine's arm was fixed; sweep's was not, and it never inherited the fix — `SweepEngine` replaces - `_loop` wholesale, so the base engine's `_finish_inflight` (which carries the re-anchor) never - runs, while both engines share the discard helper itself. A bundle interrupted under isolation - therefore persisted a mount-relative spec path beside an emptied `worktree_path`, and recovery - resolved it against the main checkout — where the same layout answers, so the snapshot restore - rewrote the operator's own copy of the spec. - -- **The stories folder is located from the workspace root, not from the spec's confinement root.** - The sentinel indicator and `context.json`'s stories block borrowed `task_spec_root`, which - answers which tree can CONFINE a write to `spec_file` and falls back to the project for a spec - outside the mount. For such a run they read the main checkout while the engine's own - `_stories_folder` stayed on the worktree, so one surface could again describe two trees. They now - use a separate resolver that mirrors the engine's rule. - -- **The tree the spec is anchored on is now the tree its neighbouring fields describe.** The - re-anchor had been adopted for `spec_file` alone, leaving the fields beside it resolving against - the main checkout. The escalation modal read its spec text from the run's tree while its sentinel - indicator scanned the project, so one modal could contradict itself and show a pre-planning - sentinel wedge as an ordinary escalation; `context.json` named the run's tree in `spec_file` and - the project's in `stories.sentinel`, describing a file the re-arm will never touch. Both now - answer from one root. - -- **Pause notifications hand the operator a path that resolves from where they are standing.** - The spec-approval and plan-checkpoint pauses, and the `checkpoint-pause` journal record, printed - the raw worktree-relative `spec_file` — the same wrong-tree string the dashboard fix removed, on - the surface the operator reads first. Sprint mode's spec-approval pause is included: it prints - through the same helper, which now lives on `Engine` rather than on `StoriesEngine`. The - dev-session prompt keeps the raw field, whatever spelling it holds: that session's working - directory is the mount, so the anchor belongs to the consumer, not the field. - -- **A discarded worktree no longer leaves its baseline pointing at a tree that is gone.** The - restart arm cleared `worktree_path` and `branch` when it dropped a half-built unit, but left - `baseline_commit` and `baseline_untracked` — both measured INSIDE that mount by `_dev_phase`. - The arm saves before the replacement is mounted, and `run_isolated` records the new - `worktree_path` only after `open_unit_workspace` returns, so a git spawn fault there (no host - death needed) persists that pair beside an empty `worktree_path`. Any later resume then took the - in-place `elif task.baseline_commit:` leg and probed the MAIN checkout with them. Neither - operand failed loud: linked worktrees share the main repo's object database, so force-deleting - the unit branch left the baseline resolvable and a reset onto it succeeding, while a fresh - worktree's empty untracked snapshot made `_rollback_cleanup_plan`'s - `untracked_files(repo) - baseline_untracked` name every untracked file in the operator's own - checkout as attempt debris — deleted outright under `scm.rollback_on_failure`, and with the - default off, a dirtiness no operator action could clear, so the manual-recovery loop could not - terminate. Both fields are now cleared with the mount that defined them; `_dev_phase` re-stamps - them from the replacement, so the leg becomes a correct no-op instead of a probe of the wrong - tree. The sweep's identical arm routes through the same helper. - -- **A discarded worktree no longer leaves its spec ownership pointing at the main checkout.** - `spec_file` and `dispatched_spec_file` are both persisted relative to the mounted worktree, and - resume read them back raw. Every arm of the in-flight reconcile that continues an isolated unit - re-anchors them first, but two do not: the restart arm discards the mount and clears - `worktree_path` before saving, and the `isolated` test is live policy — an `isolation` change - across a resume is journaled, never refused — so a task that still carries a mount takes the - in-place arms. Either way the raw value then resolved against the main checkout, which carries - the same layout, so recovery's attempt-owned spec lookup found exactly one candidate and - containment accepted it: a rollback could restore a dead attempt's snapshot bytes over the - operator's own copy of the spec, and its Git exclusion named the wrong tree's file. Both fields - are now re-anchored on the mount recorded on the task before either arm runs, so a binding whose - tree is gone is unresolvable and recovery refuses it loudly instead of rewriting a file the run - never used. The engine's own reader was never exposed — it resolves strictly and compares the - result against the spelling it started from, which no relative path can satisfy. - -- **`Request replan` no longer takes the dashboard down on a spec that is not valid UTF-8.** - Making the read survive a bad byte made the button reachable on such a spec, where the reset - decodes strictly and raised past a guard that caught only `OSError`. A non-UTF-8 spec now - degrades one byte rather than losing the whole document to a failure sentence, and the failure - body is reserved for a spec that is actually absent. - -- **A spec that could not be read no longer offers `Approve & resume`, `Re-arm` or `Resolve`.** - The verbs act on the spec — approve resumes the run past the gate, re-arm flips its frontmatter, - strips its result and re-stamps the baseline — so a gate nobody could review is refused at the - source rather than downstream, and the failure text is dimmed instead of being rendered in the - style reserved for the spec's own words. The escalation modal reports the blocking condition as - UNKNOWN rather than absent: it is parsed from the spec, so an unreadable one previously rendered - "(no blocking condition recorded)" — indistinguishable from a spec that halted without one. - -- **`context.json` reports whether an edit to the spec survives to the re-drive.** Under worktree - isolation the mount is discarded before the re-drive reads anything, so a resolve session could - edit a worktree-local spec, see every write succeed, and have the work vanish. The verdict the - re-arm already journals is now carried in the context the session reads. +- Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run + owns. An isolated unit's `spec_file` is persisted relative to its mounted worktree, and + the dashboard resolved it against its own cwd — the project root, which carries the same + layout — so the review modals showed the main checkout's copy and the replan reset that + copy to `draft`. Both writes reported success, so the run resumed with the worktree's + real spec still at its terminal status and the next dispatch did not re-plan. The path + and the confinement root now come from one claim about which tree owns the spec, an + absent spec reads as a read failure rather than an empty body, an undecodable one + degrades a byte rather than the document, and `bmad-loop resolve`'s `context.json` + reports `spec_file` absolute. +- Anchor the confinement root on a tree that can actually contain the spec. A spec recorded + absolute beside a worktree sits outside it by construction, so naming the worktree left + every spec write failing its own containment check and falling back to an unguarded + write, while the re-arm's undo failed outright. Such a spec now anchors on the project. + One deliberate, graded exception: a spec inside the project but reached through a + symlinked component moves from a succeeding unguarded write to a refused confined one — + predicting that walk would make the root depend on filesystem state. +- Re-anchor spec ownership and the attempt baseline before discarding a mount, in both the + engine and sweep. The restart arm cleared `worktree_path` but left `baseline_commit`, + `baseline_untracked`, `spec_file` and `dispatched_spec_file` — all measured inside the + mount — and saves before the replacement is mounted, so a git spawn fault persisted them + beside an empty `worktree_path`. Later resumes then probed the main checkout: a rollback + could delete untracked files the operator already had, and recovery could restore a dead + attempt's snapshot over their own copy of the spec. Sweep never inherited the engine's + fix at all, because `SweepEngine` replaces `_loop` wholesale and the base engine's + `_finish_inflight` carries the re-anchor. +- Locate the stories folder from the workspace root rather than from the spec's confinement + root. The sentinel indicator and `context.json`'s stories block borrowed the confinement + answer, which falls back to the project for a spec outside the mount, so one modal could + read its spec text from the run's tree and its sentinel from the project — and show a + pre-planning sentinel wedge as an ordinary escalation. +- Anchor pause notifications and the `checkpoint-pause` journal on the run's tree. The + spec-approval and plan-checkpoint pauses printed the raw worktree-relative path on the + surface the operator reads first; sprint mode is included. The dev-session prompt keeps + the raw field: that session's working directory is the mount. +- Refuse the destructive verbs on a spec that could not be read. `Approve & resume` and + `Re-arm` act on the spec — re-arm flips its frontmatter, strips its result and re-stamps + the baseline — so a gate nobody could review is refused at the source. `Resolve` stays + offered: it writes nothing and is what repairs a bad anchor. The escalation modal now + reports the blocking condition as unknown rather than absent. +- Report in `context.json` whether an edit to the spec survives to the re-drive. Under + isolation the mount is discarded first, so a resolve session could edit a worktree-local + spec, see every write succeed, and lose the work. - **`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 diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 75d5496b..2518ff69 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -491,7 +491,11 @@ artifacts the engine already wrote. 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 both verbs. **Resolve** launches the same interactive agent as `R`; + 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 writes nothing, it 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. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 4d50d21c..10273886 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1368,6 +1368,18 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: reader can act on it. Note the caller re-anchors that pair immediately above, so between here and the rebind it holds an absolute path into the deleted tree. + `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 diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 741ca8a6..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 {self._operator_spec_path(task)}, 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/tui/app.py b/src/bmad_loop/tui/app.py index a520647d..8ca40bcd 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1060,8 +1060,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 "", "" diff --git a/src/bmad_loop/tui/screens/modals.py b/src/bmad_loop/tui/screens/modals.py index 554217e5..88004545 100644 --- a/src/bmad_loop/tui/screens/modals.py +++ b/src/bmad_loop/tui/screens/modals.py @@ -763,18 +763,23 @@ def compose(self) -> ComposeResult: # 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 below is unknown, not absent", + "the blocking condition is unknown, not absent", style="red", ) ) - yield Static( - Text(body) - if body - else Text("(no blocking condition recorded)", style="dim") - ) + 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") @@ -786,9 +791,11 @@ def compose(self) -> ComposeResult: # Precedence over both branches below: they explain when Re-arm # unlocks, and neither is true while the evidence cannot be read. hint.append( - "both verbs are refused while the spec is unreadable — re-arm " - "flips its frontmatter, strips the result and re-stamps the " - "baseline, and an unreviewable escalation is not actionable", + "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: @@ -811,11 +818,18 @@ 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 or self._unreadable, + disabled=self._engine_live, ) yield Button( "Re-arm & resume", 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 43aa073a..3d6a859e 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2165,6 +2165,53 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): 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).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. diff --git a/tests/test_runs.py b/tests/test_runs.py index a97025e0..4c848eaf 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3870,6 +3870,7 @@ def test_task_spec_root_yields_the_project_when_the_worktree_cannot_confine_the_ ) +@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. @@ -3993,3 +3994,37 @@ def test_task_spec_root_without_a_worktree_is_the_project(tmp_path): 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" + run = escalated_run(tmp_path, "r1", spec_file="/elsewhere/6-4.md", 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 diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 77d6aeda..a8be5287 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1027,6 +1027,18 @@ def test_plan_checkpoint_pause_journals_the_mount_anchored_spec(project): 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 diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 657ec44d..44e9ff57 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3354,6 +3354,12 @@ def _stories_paused_run( 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") @@ -5778,7 +5784,7 @@ async def test_gate_unreadable_spec_refuses_approve_and_resume(project, monkeypa assert app.screen.query_one("#act-resume", Button).disabled -async def test_escalation_unreadable_spec_refuses_rearm_and_resolve(project, monkeypatch): +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 @@ -5790,8 +5796,15 @@ async def test_escalation_unreadable_spec_refuses_rearm_and_resolve(project, mon 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 both the notice and the two button states. + 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") @@ -5806,8 +5819,21 @@ async def test_escalation_unreadable_spec_refuses_rearm_and_resolve(project, mon ) # 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 - assert app.screen.query_one("#act-resolve", 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( From a3deb5e1d96dc5792f6338615924a10a698d9c76 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 00:03:30 -0700 Subject: [PATCH 09/22] test(runs): make the stories-root divergence fixture OS-absolute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_task_stories_root_stays_on_the_mount_for_an_out_of_mount_spec` passed the literal "/elsewhere/6-4.md" and asserted `task_spec_root` answers the PROJECT while `task_stories_root` answers the mount. That divergence is the entire reason the second function exists, and the arm producing it gates on `Path.is_absolute()` — which is False on Windows for a DRIVE-relative string. Both Windows legs therefore took the fallback, got the worktree, and reddened; POSIX graded the row as intended. Anchor the fixture on `tmp_path`, absolute on every OS. What the arm needs is a spec outside the MOUNT, not outside the project, so the shape is unchanged and the row now grades the same divergence on both platforms. Same trap as `69e5d5c3`, one file over: a rooted literal is not an absolute path. Re-ablated after the change — delegating `task_stories_root` to `task_spec_root` still reddens the first assertion, so the fixture swap did not cost the row its teeth. Test-only; no `runs.py` change. --- tests/test_runs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_runs.py b/tests/test_runs.py index 4c848eaf..f11984bc 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -4016,7 +4016,14 @@ def test_task_stories_root_stays_on_the_mount_for_an_out_of_mount_spec(tmp_path) 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" - run = escalated_run(tmp_path, "r1", spec_file="/elsewhere/6-4.md", worktree_path=str(wt)) + # 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" + 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 From b5525d5d2a8268aca66ee9d86752add35edaef33 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 00:03:30 -0700 Subject: [PATCH 10/22] fix(skills): act on the spec-reachability verdict, and condense the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings from the codex gate on `f9de3cb1`, validated before acting. `spec_reaches_the_redrive` was emitted into `context.json` and read by nobody. The resolve skill is that file's only consumer, and its schema, its step 4 and its commit prohibition were all silent on the field — so an agent handed `false` followed the unconditional "update the frozen spec" instruction, edited the worktree-local copy `_finish_inflight` discards, and recorded a successful resolution over lost work. Exactly the scenario the verdict was added to prevent. The skill now documents the field, keeps the edit (the corrected spec is what gets carried over) and requires the agent to say the copy does not survive the re-arm, and the prohibition names whose job the commit is rather than reading as a refusal of the remedy step 4 now demands. `tests/test_resolve_skill_contract.py` makes the class checkable instead of leaving the next one to be found by hand: every key `build_context` emits must be named in the skill, in the schema block or in prose — `restore_supported` is documented the second way and passes, which is why the guard accepts both and needs no allowlist. Keys are read from the source literal, so one added tomorrow is in scope the moment it is written. Ablated both ways: undocumenting the field reddens the documentation row AND the branching row, while keeping the schema and dropping only step 4's branch reddens the branching row alone — so the two grade different things. A third row pins the key scan itself, since the guard asserts an absence and an `ast` walk that matched nothing would pass. The CHANGELOG's new entries had drifted back into root-cause narration at 44 lines; cut to 26 across 8 bullets, one user-facing topic each, with the internals left to the commits and docstrings that already carry them. The dashboard-crash fix gets its own bullet rather than a subordinate clause. --- CHANGELOG.md | 64 ++++------ .../data/skills/bmad-loop-resolve/SKILL.md | 25 +++- tests/test_resolve_skill_contract.py | 112 ++++++++++++++++++ 3 files changed, 158 insertions(+), 43 deletions(-) create mode 100644 tests/test_resolve_skill_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e8dda4e4..a3e9dab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,48 +162,30 @@ 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. An isolated unit's `spec_file` is persisted relative to its mounted worktree, and - the dashboard resolved it against its own cwd — the project root, which carries the same - layout — so the review modals showed the main checkout's copy and the replan reset that - copy to `draft`. Both writes reported success, so the run resumed with the worktree's - real spec still at its terminal status and the next dispatch did not re-plan. The path - and the confinement root now come from one claim about which tree owns the spec, an - absent spec reads as a read failure rather than an empty body, an undecodable one - degrades a byte rather than the document, and `bmad-loop resolve`'s `context.json` - reports `spec_file` absolute. -- Anchor the confinement root on a tree that can actually contain the spec. A spec recorded - absolute beside a worktree sits outside it by construction, so naming the worktree left - every spec write failing its own containment check and falling back to an unguarded - write, while the re-arm's undo failed outright. Such a spec now anchors on the project. - One deliberate, graded exception: a spec inside the project but reached through a - symlinked component moves from a succeeding unguarded write to a refused confined one — - predicting that walk would make the root depend on filesystem state. -- Re-anchor spec ownership and the attempt baseline before discarding a mount, in both the - engine and sweep. The restart arm cleared `worktree_path` but left `baseline_commit`, - `baseline_untracked`, `spec_file` and `dispatched_spec_file` — all measured inside the - mount — and saves before the replacement is mounted, so a git spawn fault persisted them - beside an empty `worktree_path`. Later resumes then probed the main checkout: a rollback - could delete untracked files the operator already had, and recovery could restore a dead - attempt's snapshot over their own copy of the spec. Sweep never inherited the engine's - fix at all, because `SweepEngine` replaces `_loop` wholesale and the base engine's - `_finish_inflight` carries the re-anchor. + 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. - Locate the stories folder from the workspace root rather than from the spec's confinement - root. The sentinel indicator and `context.json`'s stories block borrowed the confinement - answer, which falls back to the project for a spec outside the mount, so one modal could - read its spec text from the run's tree and its sentinel from the project — and show a - pre-planning sentinel wedge as an ordinary escalation. -- Anchor pause notifications and the `checkpoint-pause` journal on the run's tree. The - spec-approval and plan-checkpoint pauses printed the raw worktree-relative path on the - surface the operator reads first; sprint mode is included. The dev-session prompt keeps - the raw field: that session's working directory is the mount. -- Refuse the destructive verbs on a spec that could not be read. `Approve & resume` and - `Re-arm` act on the spec — re-arm flips its frontmatter, strips its result and re-stamps - the baseline — so a gate nobody could review is refused at the source. `Resolve` stays - offered: it writes nothing and is what repairs a bad anchor. The escalation modal now - reports the blocking condition as unknown rather than absent. -- Report in `context.json` whether an edit to the spec survives to the re-drive. Under - isolation the mount is discarded first, so a resolve session could edit a worktree-local - spec, see every write succeed, and lose the work. + 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. - **`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 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..280f0fd9 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,7 @@ 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, "baseline_commit": "", "paused_reason": "CRITICAL escalation from review session: ...", "escalations": [ @@ -45,6 +46,20 @@ These environment variables are set: } ``` +**`spec_reaches_the_redrive` says whether your edit has a future.** Under worktree +isolation the run's mount is discarded before the re-drive reads anything, so a spec +that lives 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. + +Do not skip the edit when it is `false` — the corrected spec is what gets carried +over. Do step 4 as usual, then tell the human, in the same breath as the resolution, +that **this copy does not survive the re-arm and the correction has to be committed +to reach the re-driven session**. The orchestrator prints the same remedy, with the +branch to commit on, when it re-arms; say it here so they hear it before they close +the session rather than after. + 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 +120,10 @@ 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 discarded with the run's worktree, so the correction must be + committed to reach the re-drive** — an unflagged edit here is lost work. 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 +188,10 @@ 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: committing the corrected spec is the + HUMAN's step, on the branch the orchestrator names at re-arm. Tell them it is + required; 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. diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py new file mode 100644 index 00000000..6e2c13f9 --- /dev/null +++ b/tests/test_resolve_skill_contract.py @@ -0,0 +1,112 @@ +"""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_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 + assert "the correction must be committed to reach the re-drive" in normalized + # and the prohibition names whose job the commit is, rather than just refusing it + assert "committing the corrected spec is the HUMAN's step" in normalized From f225517acf8e3d3ef22a02b5cd05e6b6c7a1145e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 10:05:29 -0700 Subject: [PATCH 11/22] fix(engine,model): release spec ownership with the mount the restart discards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on `b5525d5d`, confirmed by reproducing the sequence: this branch's own re-anchor introduced it. `_finish_inflight` re-anchors `spec_file` onto the mount before the restart arm discards it — correct, and the reason recovery stopped resolving it against the main checkout. But `_discard_unit_for_restart` then deleted that mount and left the field absolute into it. `verify.resolve_spec_path` passes an absolute path through untouched, so `_dispatched_spec_for_attempt` resolved a dead path with `strict=True`, swallowed the `FileNotFoundError`, and left the fresh attempt UNBOUND on a story whose spec sits in the replacement mount at the same relative place. Nothing downstream repaired it: `_record_dev_spec` no-ops while `spec_file` is set, and verify's three re-stamps all run after the session the binding was needed for. Before the re-anchor the value stayed relative and `resolve_spec_path` re-probed it against the live workspace, so it bound. `StoryTask.release_spec_paths_from_mount` splits the pair by role rather than treating them as one asymmetry, because at a discard they stop being one: the attempt-owned pair (`dispatched_spec_file` + its snapshot) died with its tree and is cleared together, while `spec_file` is the accepted artifact that outlives the attempt and goes back to the mount-relative spelling. It reuses `_serialized_worktree_path`, so the discarded-mount spelling and the persisted one agree by construction and an out-of-mount spec stays verbatim. Called before `worktree_path` is cleared, since the relativization is measured against it. Sweep routes through the same helper, so its restart arm is covered too. `_discard_unit_for_restart`'s docstring argued the opposite — that the pair could be left because the next rebind precedes any reader. The rebind does run; it returns None. Rewritten to say what actually happens. Tests: three pure-core rows for the new method (relativize, clear the pair, leave an out-of-mount spec verbatim) and one seam row that drives the real restart arm and then resolves the saved spelling against a REPLACEMENT mount, so it grades the binding rather than the spelling alone — the main checkout carries an identical layout and would answer a relative value from the wrong tree. The whole suite passed with this fix applied AND with it ablated before these rows existed, which is why the seam row asserts the durable state. --- CHANGELOG.md | 5 +++ src/bmad_loop/engine.py | 27 +++++++++--- src/bmad_loop/model.py | 32 ++++++++++++++ tests/test_engine_worktree.py | 79 +++++++++++++++++++++++++++++++++++ tests/test_model.py | 63 ++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e9dab6..10e9c4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,11 @@ breaking changes may land in a minor release. 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. - 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. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 10273886..9eb16c2e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1361,12 +1361,25 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: 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. The spec-ownership pair - (`dispatched_spec_file`, `dispatched_spec_snapshot`) is NOT cleared here — it - records which spec an attempt owned, which outlives the mount, and - `_bind_dispatched_spec_for_attempt` rebinds it on the next attempt before any - reader can act on it. Note the caller re-anchors that pair immediately above, so - between here and the rebind it holds an absolute path into the deleted tree. + 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 @@ -1412,6 +1425,8 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: discard_worktree( self.paths.repo_root, task.worktree_path, task.branch, run_dir=self.run_dir ) + # before the clear below: the relativization is measured against this field + task.release_spec_paths_from_mount() task.worktree_path = "" task.branch = "" task.baseline_commit = None diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 79e90bbc..e01e3508 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -476,6 +476,38 @@ 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 rebase_spec_paths_on(self, root: Path) -> None: """Re-absolutize both spec-ownership paths against the tree that owns them. diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 9d8c9add..a2332dfb 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2364,6 +2364,85 @@ def _stop(*_args, **_kwargs): 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. diff --git a/tests/test_model.py b/tests/test_model.py index c3b013bf..3bb4fca2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -648,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" From 66d24a841935f5dce154c97bdd1cf5b6823681ec Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 10:22:24 -0700 Subject: [PATCH 12/22] fix(engine,runs,skills): close the three surfaces the seventh review pass found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three from codex on `f225517a`; the first two validated against the code before acting, the third accepted as stated. `task_stories_root` trusted a `worktree_path` whose mount was gone. That field is cleared at exactly ONE site in the engine — the restart discard — so a task retired through successful integration keeps naming the worktree its own teardown removed. The `done_checkpoint` pause is raised in that window and the TUI reads this root for the checkpoint card, so it looked for `stories.yaml` under a deleted directory and dropped the committed story's title and description while the merged manifest sat in the project. It now degrades to the project when the mount is not a directory. Deciding on filesystem state is right here and would be wrong in `task_spec_root`: that one is a write-confinement root, where an answer that moves under a `mkdir` is not a definition; this is a read locator, and observation degrades. The isolation flip left a mount in limbo. `[scm] isolation` is re-read on every resume and a change is journaled, never refused, so `worktree -> none` reaches `_finish_inflight` with a mount still recorded: the re-anchor makes `spec_file` absolute into it (it must — recovery would otherwise resolve the relative spelling against the main checkout), and then no arm reopens or discards it while the re-run happens in the main workspace, against a spec outside its own roots. The restart arm now releases spec ownership for that shape through the same helper the discard uses. The mount is deliberately left standing: this arm did not build it, and an isolation flip is not an instruction to delete the operator's tree. The in-place rollback leg becomes a plain `if` so it still runs for the released case. `spec_reaches_the_redrive: false` stated a problem with no remedy, and both obvious repairs fail silently: committing from the main checkout cannot include a file that lives in a linked unit worktree, and committing on the unit's own branch does not put it on the ref the replacement worktree is cut from. `context.json` now carries `redrive_base_ref` beside the verdict — the run's pinned `target_branch` while a mount is recorded — and the skill says where the correction has to land and why the two near-misses are near-misses. `_redrive_base_ref` is promoted to public for the second consumer, the same move `task_spec_path`/`task_spec_root` took earlier on this branch, so the session and `rearm_escalation`'s unreachable-write record quote one answer. The skill-contract guard added last round caught the new context key itself — `redrive_base_ref` failed `test_every_emitted_context_key_is_documented` until the schema documented it, which is the class of miss that guard exists for. Tests: the stories-root fallback is ablated both ways (drop the guard and the mount-gone row reddens; collapse the function to the project and the divergence row reddens), so it cannot be satisfied by either over-broad variant. The existing divergence row now creates its mount, since a never-created path would have graded the new fallback instead of the split it was written for. The context row pins both legs and reddens when the ref helper is hardcoded to HEAD. --- CHANGELOG.md | 12 ++++++ .../data/skills/bmad-loop-resolve/SKILL.md | 30 +++++++++++---- src/bmad_loop/engine.py | 21 ++++++++++- src/bmad_loop/resolve.py | 12 ++++++ src/bmad_loop/runs.py | 32 +++++++++++++--- tests/test_resolve.py | 37 +++++++++++++++++++ tests/test_resolve_skill_contract.py | 8 +++- tests/test_runs.py | 25 +++++++++++++ 8 files changed, 161 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10e9c4be..3ef703c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,18 @@ breaking changes may land in a minor release. 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 spec ownership when a resumed run stops treating a persisted mount 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 against a + spec outside its own roots. The mount itself is left standing. +- 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. 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 280f0fd9..c466b2dc 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -33,6 +33,7 @@ These environment variables are set: "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": [ @@ -54,11 +55,22 @@ 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. Do not skip the edit when it is `false` — the corrected spec is what gets carried -over. Do step 4 as usual, then tell the human, in the same breath as the resolution, -that **this copy does not survive the re-arm and the correction has to be committed -to reach the re-driven session**. The orchestrator prints the same remedy, with the -branch to commit on, when it re-arms; say it here so they hear it before they close -the session rather than after. +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**: committed on `redrive_base_ref`. + +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. 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 @@ -123,7 +135,9 @@ case below — omit it entirely for an ordinary resolution. `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 discarded with the run's worktree, so the correction must be - committed to reach the re-drive** — an unflagged edit here is lost work. + committed on `redrive_base_ref` to reach the re-drive** — 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 @@ -190,8 +204,8 @@ entirely: the orchestrator re-drives from scratch against the corrected intent. - **Do NOT** implement the story, write feature code, run tests, or commit. Your job ends at a corrected spec + the resolution marker. That holds when `spec_reaches_the_redrive` is `false` too: committing the corrected spec is the - HUMAN's step, on the branch the orchestrator names at re-arm. Tell them it is - required; do not do it yourself. + HUMAN's step, on `redrive_base_ref`. Tell them it is required, and where; 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. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 9eb16c2e..8a980fdd 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1704,7 +1704,26 @@ def _finish_inflight(self) -> None: if isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) - elif task.baseline_commit: + elif task.worktree_path: + # A persisted mount the live policy no longer treats as isolated: + # `isolation` is re-read every resume and a change is journaled, + # never refused, so `worktree -> none` reaches here with the mount + # still recorded. The re-anchor above has already made `spec_file` + # absolute INTO that mount (it must — `recovery_flow` would + # otherwise resolve the relative spelling against the main + # checkout), but nothing below reopens or discards it, and the + # re-run happens in the main workspace. Left as-is the attempt + # would resolve a spec outside its live roots, + # `_dispatched_spec_for_attempt` would leave it unbound, and an + # explicit-spec prompt would meet the snapshot gate with nothing + # bound. Releasing gives back the relative spelling the main + # workspace re-probes, and drops an attempt binding whose tree + # this run will not enter again. The mount itself is deliberately + # left standing: this arm did not build it and an isolation flip + # is not an instruction to delete the operator's tree. + task.release_spec_paths_from_mount() + + 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 diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index ef188e1d..794e3a2a 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -24,6 +24,7 @@ from .model import RunState from .platform_util import safe_segment from .runs import ( + redrive_base_ref, spec_reaches_the_redrive, task_spec_path, task_stories_root, @@ -157,6 +158,17 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: "spec_reaches_the_redrive": ( spec_reaches_the_redrive(task, state) 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` while a mount is recorded, `HEAD` otherwise — and it + # is the same value `rearm_escalation`'s unreachable-write record names, so + # the session and the orchestrator quote one answer. + "redrive_base_ref": redrive_base_ref(state, task) 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 diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 023a4826..4ab1fe28 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2302,11 +2302,31 @@ def task_stories_root(task: StoryTask | None, state: RunState) -> Path: 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. """ if task is None or not task.worktree_path: return Path(state.project) - return Path(task.worktree_path) + 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: @@ -2364,7 +2384,7 @@ def _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: return False -def _redrive_base_ref(state: RunState, task: StoryTask) -> str: +def redrive_base_ref(state: RunState, task: StoryTask) -> 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. @@ -2485,7 +2505,7 @@ def _committed_spec_status(state: RunState, task: StoryTask) -> str: 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 + `redrive_base_ref` names, which is the run's pinned `target_branch` for an isolated unit rather than that tree's current `HEAD`. Degrades to ``""`` on every uncertainty: a spec recorded absolute (nothing names @@ -2507,7 +2527,7 @@ def _committed_spec_status(state: RunState, task: StoryTask) -> str: return "" 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, task), raw.as_posix() ) except verify.GitError: return "" @@ -2754,7 +2774,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. # @@ -2762,7 +2782,7 @@ 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 + # `redrive_base_ref` degrades to `HEAD` for, and the notice drops the # clause rather than naming a ref it cannot source. # # Spelled `target_branch` and NOT `base`, because `diagnostics` routes the diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 3d6a859e..40b9e92e 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2240,6 +2240,43 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): 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").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").read_text( + encoding="utf-8" + ) + ) + assert plain["redrive_base_ref"] == "HEAD" + + @pytest.mark.parametrize( ("committed_status", "warns"), [("ready-for-dev", False), ("blocked", True)], diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index 6e2c13f9..6a1e1df1 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -107,6 +107,12 @@ def test_skill_branches_on_spec_reachability(skill_md): 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 - assert "the correction must be committed to reach the re-drive" 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 commit is, rather than just refusing it assert "committing the corrected spec is the HUMAN's step" in normalized diff --git a/tests/test_runs.py b/tests/test_runs.py index f11984bc..3f6ae222 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -4023,6 +4023,10 @@ def test_task_stories_root_stays_on_the_mount_for_an_out_of_mount_spec(tmp_path) # 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 @@ -4035,3 +4039,24 @@ def test_task_stories_root_without_a_worktree_is_the_project(tmp_path): 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 From 9d6b52efec31eb63aca90853ea0c23317d5d069a Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 10:37:11 -0700 Subject: [PATCH 13/22] fix(engine,model): release the mount's baseline with its spec, in both arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on `66d24a84` — a data-loss bug introduced by the previous commit. The isolation-flip arm added last round released spec ownership and stopped there, leaving `baseline_commit`/`baseline_untracked` set. Both were stamped from `self.workspace.root` — the UNIT — and the leg below hands them to `recovery_flow.rollback_or_pause` with the main checkout as the active workspace. Neither operand fails loud: linked worktrees share the object database, so the unit baseline still resolves and a reset onto it succeeds, and a fresh worktree is tracked-only, 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 — the operator's own files, for a story that only changed isolation mode. This is the same defect `8c8c2bf3` fixed for the discard path, reintroduced on its twin: one arm was fixed and the other written narrower. That is the pattern this branch keeps hitting, so the fix is structural rather than another parallel edit. `StoryTask.release_mount_owned_state` is now the single definition of what a mount owned — the spec pair plus the two measurements — and both arms call it. `_discard_unit_for_restart` loses its own two `= None` lines to the shared method, so a future field added to one arm cannot miss the other. Clearing costs the re-run nothing: `_dev_phase` re-stamps both from whatever workspace it re-enters with, so the `baseline_commit` leg becomes a correct no-op instead of a probe of the wrong tree. The mount stays mounted — this arm did not build it, and an isolation flip is not an instruction to delete the operator's tree. Test grades the LEG, not just the fields: it spies `_rollback_or_pause` and asserts it is never entered, since the cleared fields are the mechanism and the un-entered leg is the property. Ablated by narrowing the arm back to the spec half — the spy reddens, while the discard path's own baseline row stays green, which is precisely the blind spot that let the two arms drift apart. --- CHANGELOG.md | 11 +++-- src/bmad_loop/engine.py | 18 +++++--- src/bmad_loop/model.py | 29 +++++++++++++ tests/test_engine_worktree.py | 77 +++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef703c5..6e114f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,10 +177,13 @@ breaking changes may land in a minor release. 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 spec ownership when a resumed run stops treating a persisted mount 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 against a - spec outside its own roots. The mount itself is left standing. +- 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 mount itself is left standing. - 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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 8a980fdd..4ab3b2d1 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1425,12 +1425,10 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: discard_worktree( self.paths.repo_root, task.worktree_path, task.branch, run_dir=self.run_dir ) - # before the clear below: the relativization is measured against this field - task.release_spec_paths_from_mount() + # before the clears below: the relativization is measured against this field + task.release_mount_owned_state() task.worktree_path = "" task.branch = "" - task.baseline_commit = None - task.baseline_untracked = None def _safe_reset(self, task: StoryTask, *, preserve: tuple[str, ...] = ()) -> None: self._recovery_flow.safe_reset(task, preserve=preserve) @@ -1721,7 +1719,17 @@ def _finish_inflight(self) -> None: # this run will not enter again. The mount itself is deliberately # left standing: this arm did not build it and an isolation flip # is not an instruction to delete the operator's tree. - task.release_spec_paths_from_mount() + # + # The BASELINE goes with it, not just the spec: `baseline_commit` + # and `baseline_untracked` were measured inside that mount, and + # the leg below would otherwise hand them to + # `_rollback_or_pause` against the main checkout — where a unit's + # empty untracked snapshot makes every untracked file in the + # operator's own checkout read as this attempt's debris, deleted + # outright under an auto-recovering cause. Releasing clears them, + # so that leg becomes a correct no-op and `_dev_phase` re-stamps + # from the workspace it actually re-enters. + task.release_mount_owned_state() if not isolated and task.baseline_commit: # latch resolved_redrive so the corrected spec stays protected diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index e01e3508..c9f784c2 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -508,6 +508,35 @@ def release_spec_paths_from_mount(self) -> 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. diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index a2332dfb..b02a12bb 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2487,6 +2487,83 @@ def test_finish_inflight_anchors_on_the_persisted_mount_not_the_live_isolation_p 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 is asserted still standing — this arm did not build it, and an isolation + flip is not an instruction to delete the operator's tree. + + 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. + """ + 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 + assert mount.is_dir() # left standing: this arm did not build it + + def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) gated = Policy( From 46426f751712adf5bbcc506c43cae0eb1f4468bd Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 10:40:38 -0700 Subject: [PATCH 14/22] fix(engine): drop the mount CLAIM when a resume leaves isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit Major on `66d24a84`, and the root of what the last two commits were patching around. `worktree_path` is overloaded: it names a directory AND is how `runs` recognizes an isolated unit at all — `task_spec_root`, `task_stories_root`, `spec_reaches_the_redrive` and `redrive_base_ref` every one gate on it. Keeping the field set to avoid deleting the operator's tree therefore left the task CLAIMING a mount it no longer executes in, so those helpers answered for the unit while the re-run used the project checkout. `redrive_base_ref` is the sharpest case: it returned the run's pinned `target_branch` when an in-place re-drive reads `HEAD` — feeding the wrong ref into the `context.json` field added two commits ago, and sending a resolve session to commit its correction on a branch this run never reads. Clearing the field is not deleting the tree. The directory stays exactly where it is; the task simply stops asserting ownership of it, which is what makes every isolation-gated helper answer in-place consistently. The orphan is journaled (`isolation-flip-orphaned-worktree`) so a worktree left behind by a policy change is recorded rather than silent. The test now grades the claim and the directory separately — `worktree_path` and `branch` cleared, `redrive_base_ref` answering `HEAD`, `task_stories_root` answering the project, the directory still present, the orphan journaled — and carries a second ablation: dropping the `worktree_path = ""` reddens the claim assertions while the rollback spy stays green, which is why both live in one row. --- CHANGELOG.md | 5 ++++- src/bmad_loop/engine.py | 22 ++++++++++++++++++++++ tests/test_engine_worktree.py | 26 +++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e114f6e..17620fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,7 +183,10 @@ breaking changes may land in a minor release. 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 mount itself is left standing. + The task also stops CLAIMING the mount: `worktree_path` doubles as the isolated-unit + proxy, so keeping it set made the spec, stories-root and re-drive-ref helpers answer for + the unit while execution used the project checkout. The directory itself is left + standing and the orphan is journaled. - 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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 4ab3b2d1..050fb56a 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1729,7 +1729,29 @@ def _finish_inflight(self) -> None: # outright under an auto-recovering cause. Releasing clears them, # so that leg becomes a correct no-op and `_dev_phase` re-stamps # from the workspace it actually re-enters. + orphan = task.worktree_path task.release_mount_owned_state() + # ...and the CLAIM goes too. `worktree_path` is overloaded: it + # names a directory AND is how `runs` recognizes an isolated unit + # at all (`task_spec_root`, `task_stories_root`, + # `spec_reaches_the_redrive`, `redrive_base_ref` all gate on it). + # Keeping it set to avoid deleting the tree left the task + # CLAIMING a mount it no longer runs in, so those helpers answered + # for the unit while execution used the main checkout — + # `redrive_base_ref` in particular returned the run's pinned + # `target_branch` when an in-place re-drive reads `HEAD`, which + # would send a resolve session to commit its correction on a + # branch this run will not read. Clearing the field is not + # deleting the tree: the directory stays exactly where it is, and + # the journal records it so an orphan left by a policy change is + # not silent. + task.worktree_path = "" + task.branch = "" + self.journal.append( + "isolation-flip-orphaned-worktree", + story_key=task.story_key, + worktree=orphan, + ) if not isolated and task.baseline_commit: # latch resolved_redrive so the corrected spec stays protected diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index b02a12bb..6066a10b 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2508,12 +2508,23 @@ def test_isolation_flip_releases_the_units_baseline_before_the_in_place_rollback 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 is asserted still standing — this arm did not build it, and an isolation - flip is not an instruction to delete the operator's tree. + + The mount's CLAIM and the mount's DIRECTORY are separated, and both are asserted. + `worktree_path` is overloaded — it names a directory and is also how `runs` + recognizes an isolated unit (`task_spec_root`, `task_stories_root`, + `spec_reaches_the_redrive` and `redrive_base_ref` all gate on it) — so a task that + keeps the field set while executing in the main checkout makes those helpers + answer for the wrong tree. `redrive_base_ref` is the sharpest case: it would + return the run's pinned `target_branch` when an in-place re-drive reads `HEAD`, + sending a resolve session to commit its correction where this run never looks. 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. 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. + 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( @@ -2561,7 +2572,16 @@ def _stop(*_a, **_k): 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: every isolation-gated helper must now answer in-place + assert saved.worktree_path == "" + assert saved.branch == "" + assert runs.redrive_base_ref(engine.state, saved) == "HEAD" # not target_branch + assert runs.task_stories_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_worktree_spec_approval_pause_resumes_in_same_worktree(project): From 2ab9f4ea566e6580975111c411a47c46cc264476 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 11:47:53 -0700 Subject: [PATCH 15/22] fix(runs,resolve,tui): route the re-drive's isolation mode from live policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Is this task isolated?" was answered from one source for two different questions. `task.worktree_path` is the RETROSPECTIVE answer — which tree owns the state this task already persisted — and `task_spec_path`, `task_spec_root` and `task_stories_root` read it correctly. `redrive_base_ref` and `spec_reaches_the_redrive` ask a PROSPECTIVE question ("will the re-drive mount?") and borrowed the same proxy. That is the whole defect class. `_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: - worktree -> none: the mount is still recorded, so `redrive_base_ref` named the run's pinned `target_branch` while an in-place re-drive reads `HEAD` — the resolve session was told to commit its correction where the run never looks. (The reported P2.) - none -> worktree: no mount was ever recorded, so `spec_reaches_the_redrive` answered True and `redrive_base_ref` answered `HEAD` — the session was told its working-tree edit was fine, and the fresh mount, cut from git, read none of it. Silent loss of human work, and unreachable by any claim-clearing at resume: there is no claim to clear. No resume-time bookkeeping could ever have closed this. `bmad-loop resolve` builds `context.json` in a SEPARATE process, before the resume runs, so the consumer that needs the live fact has already run by the time the engine touches persisted state. Three rounds of adjusting `worktree_path` at resume were rounds against the wrong seam. So the fact is injected instead, extending the pattern `validate_restore_latch` already established on this exact surface: `policy.py` owns the mode, each process boundary computes `scm.isolation == "worktree"`, and `runs` stays pure and takes it as a required keyword. `state.json` is untouched — no field changes meaning, no migration. A persisted flag was rejected deliberately: it is a stamp, and it goes stale across the very flip it would exist to describe. Also: - `_spec_is_shared_with_the_redrive` drops its `not task.worktree_path` early return, so an artifact dir outside the project stays reachable for a re-drive that mounts without one on record — otherwise the none -> worktree fix would have warned on every such run. - `rearm-spec-write-unreachable` carries a `redrive` discriminator and drops `target_branch` on the in-place arm. One kind, two remedies, and the reader is out of process: under isolation the correction must be COMMITTED on the named branch; in place the re-drive reads a working tree, so it must be re-applied in the main checkout and a commit is beside the point. A record predating the field reads as isolated — the only shape the producer could write before. - SKILL.md branches on the same fork, so the agent stops telling humans to commit onto a branch an in-place re-drive never reads. - The TUI's re-arm refuses on an unreadable `policy.toml` instead of falling through. That fall-through was right while the policy fed one optional CHECK; it is not right for an INPUT to a repair write, and a re-arm consumes the escalation, so a guessed mode is unrecoverable. Tests: pure-core rows for both flip directions on both helpers, the in-place journal record and its notice, the legacy-record default, the in-place arm measuring the mount rather than its presence, and both TUI dispositions. Eleven ablations run: each gate deleted, each named test confirmed to redden. --- CHANGELOG.md | 19 +- src/bmad_loop/cli.py | 7 +- .../data/skills/bmad-loop-resolve/SKILL.md | 41 ++- src/bmad_loop/engine.py | 28 +- src/bmad_loop/resolve.py | 38 ++- src/bmad_loop/runs.py | 230 +++++++++++--- src/bmad_loop/tui/app.py | 44 ++- tests/test_cli.py | 18 +- tests/test_engine.py | 33 +- tests/test_engine_worktree.py | 35 ++- tests/test_resolve.py | 286 +++++++++++++----- tests/test_resolve_skill_contract.py | 31 +- tests/test_runs.py | 203 ++++++++++++- tests/test_stories_engine.py | 8 +- tests/test_sweep.py | 22 +- tests/test_tui_app.py | 153 +++++++++- 16 files changed, 932 insertions(+), 264 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17620fe7..fddc33b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,10 +183,10 @@ breaking changes may land in a minor release. 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 isolated-unit - proxy, so keeping it set made the spec, stories-root and re-drive-ref helpers answer for - the unit while execution used the project checkout. The directory itself is left - standing and the orphan is journaled. + 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. The directory + itself is left standing and the orphan is journaled. - 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 @@ -209,6 +209,17 @@ breaking changes may land in a minor release. - 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. - **`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 diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 96a33a8a..992c3074 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2989,7 +2989,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 c466b2dc..5917e553 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -47,12 +47,16 @@ These environment variables are set: } ``` -**`spec_reaches_the_redrive` says whether your edit has a future.** Under worktree -isolation the run's mount is discarded before the re-drive reads anything, so a spec -that lives 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. +**`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 @@ -72,6 +76,15 @@ tree's copy of the spec and commit it there. The orchestrator names the same ref 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: @@ -134,10 +147,11 @@ case below — omit it entirely for an ordinary resolution. smallest change that removes the ambiguity. You MAY use the `bmad-spec` or `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 discarded with the run's worktree, so the correction must be - committed on `redrive_base_ref` to reach the re-drive** — 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. + 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 @@ -203,9 +217,10 @@ entirely: the orchestrator re-drives from scratch against the corrected intent. 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. That holds when - `spec_reaches_the_redrive` is `false` too: committing the corrected spec is the - HUMAN's step, on `redrive_base_ref`. Tell them it is required, and where; do not do - it yourself. + `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. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 050fb56a..00e3393e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1731,20 +1731,22 @@ def _finish_inflight(self) -> None: # from the workspace it actually re-enters. orphan = task.worktree_path task.release_mount_owned_state() - # ...and the CLAIM goes too. `worktree_path` is overloaded: it - # names a directory AND is how `runs` recognizes an isolated unit - # at all (`task_spec_root`, `task_stories_root`, - # `spec_reaches_the_redrive`, `redrive_base_ref` all gate on it). - # Keeping it set to avoid deleting the tree left the task - # CLAIMING a mount it no longer runs in, so those helpers answered - # for the unit while execution used the main checkout — - # `redrive_base_ref` in particular returned the run's pinned - # `target_branch` when an in-place re-drive reads `HEAD`, which - # would send a resolve session to commit its correction on a - # branch this run will not read. Clearing the field is not - # deleting the tree: the directory stays exactly where it is, and - # the journal records it so an orphan left by a policy change is + # ...and the CLAIM goes too. `worktree_path` names a directory AND + # is how `runs` answers which tree owns the state this task already + # persisted (`task_spec_root`, `task_stories_root`). Keeping it set + # to avoid deleting the tree left the task CLAIMING a mount it no + # longer runs in, so those readers anchored on a tree the run has + # left while execution used the main checkout. Clearing the field is + # not deleting the tree: the directory stays exactly where it is, + # and the journal records it so an orphan left by a policy change is # not silent. + # + # It does NOT fix `redrive_base_ref` / `spec_reaches_the_redrive`, + # and was never able to: those describe the re-drive rather than the + # attempt, and `bmad-loop resolve` asks them in a SEPARATE process + # before this resume runs, so a write here is invisible to the + # reader that needed it. They take the live isolation mode as a + # parameter instead — see `runs.redrive_base_ref`. task.worktree_path = "" task.branch = "" self.journal.append( diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 794e3a2a..34a71c78 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -100,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, @@ -110,8 +121,7 @@ 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 @@ -156,7 +166,9 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # 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) if task and task.spec_file else None + 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 @@ -165,10 +177,20 @@ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: # 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` while a mount is recorded, `HEAD` otherwise — and it - # is the same value `rearm_escalation`'s unreachable-write record names, so + # 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. - "redrive_base_ref": redrive_base_ref(state, task) if task else None, + # + # 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 diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 4ab1fe28..8647f64e 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2330,8 +2330,16 @@ def task_stories_root(task: StoryTask | None, state: RunState) -> Path: 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 @@ -2356,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 @@ -2371,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 @@ -2405,36 +2454,63 @@ 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) -> bool: +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. That one says WHICH file the run's own - tooling writes; this says whether that file still exists by the time the re-drive - reads it. They differ exactly under isolation: `engine._finish_inflight` discards - the mount, so a worktree-local spec is destroyed with it, while a spec in an - artifact dir configured outside the project tree is shared across checkouts and - survives (`_spec_is_shared_with_the_redrive` carries that argument in full). + 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. """ - return not task.worktree_path or _spec_is_shared_with_the_redrive(state, task) + if isolated_redrive: + return _spec_is_shared_with_the_redrive(state, task) + return not _spec_is_inside_the_mount(task) def _restore_rearmed_spec( @@ -2497,7 +2573,7 @@ def _restore_rearmed_spec( ) from e -def _committed_spec_status(state: RunState, task: StoryTask) -> str: +def _committed_spec_status(state: RunState, task: StoryTask, *, isolated_redrive: bool) -> str: """The spec's status as COMMITTED in the tree the re-drive reads, or ``""`` when unprovable. @@ -2505,8 +2581,11 @@ def _committed_spec_status(state: RunState, task: StoryTask) -> str: 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`. + `redrive_base_ref` names, which is the run's pinned `target_branch` when the re-drive + will mount rather than that tree's current `HEAD`. `isolated_redrive` is forwarded + verbatim for that call and read nowhere else here: this function must measure at the + same ref the caller's remedy names, or the proof and the instruction describe two + trees. 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, @@ -2527,7 +2606,9 @@ def _committed_spec_status(state: RunState, task: StoryTask) -> str: return "" 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 "" @@ -2583,7 +2664,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. @@ -2639,6 +2724,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 — @@ -2664,7 +2760,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) @@ -2761,7 +2857,9 @@ def rearm_escalation( # on. See `_spec_is_shared_with_the_redrive` for why an isolated unit's spec # is nevertheless reachable when it sits in an artifact dir configured # outside the project tree. - write_reaches_the_redrive = spec_reaches_the_redrive(task, state) + write_reaches_the_redrive = spec_reaches_the_redrive( + task, state, isolated_redrive=isolated_redrive + ) # Narrowed to the case an operator can ACT on. Every isolated escalation # carries a mounted `worktree_path` — `worktree_flow.escalate_unit` never # clears it, and `keep_branch_and_escalate` deliberately leaves the worktree @@ -2783,7 +2881,19 @@ def rearm_escalation( # 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. + # 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` @@ -2799,14 +2909,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 _committed_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 @@ -3268,7 +3380,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": @@ -3297,6 +3409,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 @@ -3306,9 +3440,9 @@ 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", ) diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 8ca40bcd..5cdaef01 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -847,8 +847,7 @@ def _do_replan(self, run_id: str, spec_path: Path, confine_root: 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 @@ -893,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 @@ -915,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 @@ -934,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 diff --git a/tests/test_cli.py b/tests/test_cli.py index bcc03ec1..592054a7 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"] @@ -3013,7 +3013,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 +3080,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", 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 6066a10b..cc1fd446 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2101,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() @@ -2510,16 +2510,21 @@ def test_isolation_flip_releases_the_units_baseline_before_the_in_place_rollback 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` is overloaded — it names a directory and is also how `runs` - recognizes an isolated unit (`task_spec_root`, `task_stories_root`, - `spec_reaches_the_redrive` and `redrive_base_ref` all gate on it) — so a task that - keeps the field set while executing in the main checkout makes those helpers - answer for the wrong tree. `redrive_base_ref` is the sharpest case: it would - return the run's pinned `target_branch` when an in-place re-drive reads `HEAD`, - sending a resolve session to commit its correction where this run never looks. 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. + `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 @@ -2573,11 +2578,11 @@ def _stop(*_a, **_k): assert saved.dispatched_spec_file is None assert saved.dispatched_spec_snapshot is None - # the CLAIM is dropped: every isolation-gated helper must now answer in-place + # the CLAIM is dropped: the retrospective readers must now answer the main checkout assert saved.worktree_path == "" assert saved.branch == "" - assert runs.redrive_base_ref(engine.state, saved) == "HEAD" # not target_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 @@ -4032,7 +4037,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", ), @@ -5647,7 +5652,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_resolve.py b/tests/test_resolve.py index 40b9e92e..dc9dd903 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -526,7 +526,7 @@ def test_build_context_gathers_critical_escalations(tmp_path): ), encoding="utf-8", ) - path = resolve.build_context(state, run_dir, "6-4-cli-list-command") + path = resolve.build_context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == "6-4-cli-list-command" assert ctx["spec_file"] == spec.as_posix() @@ -584,7 +584,7 @@ def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_ run_dir, state, _ = _escalated_run(tmp_path, spec_file=rel, worktree_path=str(wt)) monkeypatch.chdir(tmp_path) # what the resolve session actually runs from - path = resolve.build_context(state, run_dir, "6-4-cli-list-command") + 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 @@ -606,20 +606,24 @@ def test_build_context_spec_file_is_none_without_a_task_or_a_spec(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=None, worktree_path=str(wt)) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command").read_text(encoding="utf-8") + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) assert ctx["spec_file"] is None # task present, spec-less escalation assert "no-such-story" not in state.tasks ctx = json.loads( - resolve.build_context(state, run_dir, "no-such-story").read_text(encoding="utf-8") + resolve.build_context(state, run_dir, "no-such-story", isolation="worktree").read_text( + encoding="utf-8" + ) ) assert ctx["spec_file"] is None # no task at all 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") @@ -634,25 +638,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 @@ -663,7 +667,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 @@ -677,7 +681,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] @@ -701,7 +705,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" @@ -744,7 +748,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" @@ -766,7 +770,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"] == [] @@ -834,9 +838,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": @@ -867,7 +871,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 @@ -886,7 +890,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 @@ -905,7 +909,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 @@ -915,7 +919,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" @@ -935,7 +939,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" @@ -963,7 +967,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"]) @@ -987,7 +991,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 @@ -1012,7 +1016,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 @@ -1020,7 +1024,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 @@ -1045,7 +1049,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 @@ -1103,7 +1107,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 @@ -1144,7 +1148,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 @@ -1191,7 +1195,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 @@ -1227,7 +1231,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"] @@ -1266,7 +1272,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"] @@ -1294,7 +1300,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"] == [] @@ -1323,7 +1329,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 @@ -1363,7 +1369,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 @@ -1383,7 +1389,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 @@ -1400,7 +1406,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 @@ -1426,7 +1432,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] @@ -1446,14 +1454,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 @@ -1469,13 +1481,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): @@ -1502,7 +1516,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" @@ -1542,7 +1556,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 @@ -1592,7 +1606,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 @@ -1643,7 +1657,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 @@ -1694,7 +1708,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir) + 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 @@ -1740,7 +1754,7 @@ def boom(spec_path, *, confine_root): monkeypatch.setattr(runs.devcontract, "strip_auto_run_result", boom) with pytest.raises(runs.RearmError, match="No space left on device"): - runs.rearm_escalation(run_dir) + 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 @@ -1759,7 +1773,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 @@ -1768,7 +1782,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 @@ -1794,7 +1808,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 @@ -1830,7 +1844,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 @@ -1847,7 +1861,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 @@ -1883,7 +1897,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 @@ -1898,7 +1912,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 @@ -1919,7 +1933,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] @@ -1939,7 +1953,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 @@ -1972,7 +1986,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): @@ -1980,7 +1994,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 @@ -1999,7 +2013,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 @@ -2012,7 +2026,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 @@ -2023,7 +2037,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") @@ -2061,7 +2075,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" @@ -2085,7 +2101,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"] @@ -2095,7 +2113,9 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): """Sprint mode leaves the context contract unchanged — no stories block.""" run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") # sprint source ctx = json.loads( - 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 @@ -2117,7 +2137,9 @@ def test_build_context_leaves_an_out_of_mount_spec_unchanged(tmp_path): run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(spec), worktree_path=str(wt)) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command").read_text(encoding="utf-8") + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) assert ctx["spec_file"] == spec.as_posix() @@ -2157,7 +2179,9 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): ) state.spec_folder = "epic-1" - ctx = json.loads(resolve.build_context(state, run_dir, key).read_text(encoding="utf-8")) + 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"] @@ -2204,7 +2228,9 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec ) state.spec_folder = "epic-1" - ctx = json.loads(resolve.build_context(state, run_dir, key).read_text(encoding="utf-8")) + 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"] @@ -2225,7 +2251,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): wt = tmp_path / ".bmad-loop" / "runs" / "20260613-111429-6a14" / "worktrees" / "1" run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command").read_text(encoding="utf-8") + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) assert ctx["spec_reaches_the_redrive"] is False @@ -2233,9 +2261,9 @@ def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): tmp_path, "20260613-111429-6a15", spec_file=str(tmp_path / "specs" / "6-4.md") ) plain = json.loads( - resolve.build_context(plain_state, plain_dir, "6-4-cli-list-command").read_text( - encoding="utf-8" - ) + resolve.build_context( + plain_state, plain_dir, "6-4-cli-list-command", isolation="" + ).read_text(encoding="utf-8") ) assert plain["spec_reaches_the_redrive"] is True @@ -2258,7 +2286,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat run_dir, state, _ = _escalated_run(tmp_path, spec_file="specs/6-4.md", worktree_path=str(wt)) state.target_branch = "feat/the-pinned-one" ctx = json.loads( - resolve.build_context(state, run_dir, "6-4-cli-list-command").read_text(encoding="utf-8") + resolve.build_context( + state, run_dir, "6-4-cli-list-command", isolation="worktree" + ).read_text(encoding="utf-8") ) # the paired claim: the edit has no future, and THIS is the tree that does assert ctx["spec_reaches_the_redrive"] is False @@ -2270,9 +2300,9 @@ def test_build_context_names_where_an_unreachable_correction_has_to_land(tmp_pat ) plain_state.target_branch = "feat/the-pinned-one" # set, but no mount to make it apply plain = json.loads( - resolve.build_context(plain_state, plain_dir, "6-4-cli-list-command").read_text( - encoding="utf-8" - ) + resolve.build_context( + plain_state, plain_dir, "6-4-cli-list-command", isolation="" + ).read_text(encoding="utf-8") ) assert plain["redrive_base_ref"] == "HEAD" @@ -2319,7 +2349,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 @@ -2391,7 +2421,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 @@ -2405,6 +2435,92 @@ def _commit(status, message): assert "`main`" in runs.rearm_event_notice(unreachable[0])[2] +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 `_committed_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_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. @@ -2435,7 +2551,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"] == [] @@ -2487,7 +2603,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"] @@ -2519,7 +2637,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"] @@ -2561,7 +2679,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 @@ -2605,7 +2723,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" @@ -2644,7 +2762,7 @@ 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" @@ -2682,7 +2800,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 diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index 6a1e1df1..ae30c7d3 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -84,6 +84,33 @@ def test_every_emitted_context_key_is_documented(skill_md): ) +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. + """ + 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 + # step 4 and the prohibition both have to carry the fork too, or the agent reads a + # blanket "commit it" two screens after the paragraph that carved the exception + 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` @@ -114,5 +141,5 @@ def test_skill_branches_on_spec_reachability(skill_md): 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 commit is, rather than just refusing it - assert "committing the corrected spec is the HUMAN's step" 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 3f6ae222..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 @@ -3949,15 +3951,194 @@ def test_spec_reaches_the_redrive_is_false_for_a_worktree_local_spec(tmp_path): `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) is False + 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) is True + 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): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index a8be5287..94720d20 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1329,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 @@ -1369,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"])}) @@ -1403,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 ea50b7c0..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() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 44e9ff57..4a3a863e 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4274,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" + ), ) @@ -4330,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" + ), ) @@ -4367,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, @@ -4389,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 @@ -4420,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( @@ -4470,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, @@ -4530,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" @@ -4671,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", @@ -4699,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, @@ -4764,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, @@ -4806,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) @@ -4836,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"] @@ -4897,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"] ) From d77a329ecf78a3a6b3f8a3b8ccc0e10667e82fda Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 11:52:51 -0700 Subject: [PATCH 16/22] fix(runs): prove the in-place remedy against the tree that re-drive reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_committed_spec_status` only ever read a COMMITTED tree, which was right while the record it gates was isolated-only. It is not right for the in-place arm this branch just gave it: an in-place re-drive re-runs the story in the main checkout and reads that tree's WORKING copy, so a commit is neither required nor sufficient. The consequence was self-contradictory, and mine: `rearm-spec-write-unreachable` HOLDS the resume, and the in-place notice added one commit ago tells the operator to re-apply the correction in the main checkout without committing it. Doing exactly that left the committed spec terminal, so the proof still failed, the record fired again and the resume was held again — the record's own remedy could not clear it, on any number of resolve cycles. So the function reads what the re-drive reads, and is renamed to `_redrive_spec_status` because "committed" is now only half of its answer: the committed blob at `redrive_base_ref` when the re-drive will mount, and `state.project / spec_file` from the working tree when it runs in place. Every degrade-to-`""` path is unchanged, including the absolute-spelling arm — the one absolute shape whose write the re-drive does read is already answered by `_spec_is_shared_with_the_redrive` before this is called. Test: both rows leave the COMMITTED spec terminal so only the working tree moves, which is what makes a committed read unable to pass either row. Ablation: drop the in-place arm and the corrected row reddens. --- src/bmad_loop/runs.py | 61 +++++++++++++++++++++++++++-------------- tests/test_resolve.py | 63 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 8647f64e..11663622 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2573,19 +2573,32 @@ def _restore_rearmed_spec( ) from e -def _committed_spec_status(state: RunState, task: StoryTask, *, isolated_redrive: bool) -> str: - """The spec's status as COMMITTED in the tree the re-drive reads, or ``""`` when - unprovable. - - 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` when the re-drive - will mount rather than that tree's current `HEAD`. `isolated_redrive` is forwarded - verbatim for that call and read nowhere else here: this function must measure at the - same ref the caller's remedy names, or the proof and the instruction describe two - trees. +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. + + 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, @@ -2595,15 +2608,23 @@ def _committed_spec_status(state: RunState, task: StoryTask, *, isolated_redrive 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, @@ -2852,7 +2873,7 @@ 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 @@ -2909,7 +2930,7 @@ def rearm_escalation( # to `branch` would pseudonymize statuses as branches. if ( not write_reaches_the_redrive - and _committed_spec_status(state, task, isolated_redrive=isolated_redrive) + and _redrive_spec_status(state, task, isolated_redrive=isolated_redrive) != target_status ): journal.append( @@ -3009,7 +3030,7 @@ def rearm_escalation( # 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"), @@ -3519,7 +3540,7 @@ def rearm_holds_the_resume(entry: dict[str, Any]) -> bool: 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 + `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 diff --git a/tests/test_resolve.py b/tests/test_resolve.py index dc9dd903..b34c855d 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2388,7 +2388,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. @@ -2465,7 +2465,7 @@ def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_pat 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 `_committed_spec_status` cannot suppress the record + # 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") @@ -2496,6 +2496,53 @@ def test_rearm_records_the_in_place_remedy_when_isolation_was_turned_off(tmp_pat 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 @@ -2527,7 +2574,7 @@ def test_rearm_base_ref_degrades_to_head_for_a_run_that_pinned_no_target(tmp_pat `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. @@ -2574,7 +2621,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 @@ -2619,13 +2666,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. """ @@ -2734,7 +2781,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. @@ -2874,7 +2921,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 From 0cc71643a52b2176bf21760098909a79502ef861 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 12:17:24 -0700 Subject: [PATCH 17/22] fix(engine,workspace): release the orphaned mount on every leg, and reclaim it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_finish_inflight` re-anchors `spec_file` INTO a recorded mount unconditionally, above the `isolated` gate, so every non-isolated leg has to undo that anchor. The release was written into the restart arm alone, but 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. They went on consuming a spec absolutized into a tree the run had left: `_dispatched_spec_for_attempt` resolves it `strict=True`, raises, and leaves the attempt unbound. Extracted as `_release_orphaned_mount` and called from all four legs rather than hoisted above the arm dispatch. The restart arm asks `_refuse_gated_story` first and that can raise `RunPaused`; the anchored spelling is what makes recovery refuse loudly instead of restoring over the operator's own copy, so it must survive until an arm commits to acting. The flip deliberately leaves the mount's DIRECTORY standing, which made the opposite flip unrecoverable: the mount path and unit branch are both deterministic in (run_id, unit_key, run_dir), so flipping back to `worktree` re-derived the same path and met a `git worktree add` that refuses an existing target. Reclaim at the mount site instead of deleting at the flip — preservation is kept for the in-place run and the orphan is spent only when a mount actually needs its path. The branch is deliberately not passed to `discard_worktree`: under `branch_per=run` that name is the shared run branch, and force-deleting it would drop commits earlier units landed. Both found by the fifth codex pass on this seam. --- CHANGELOG.md | 10 ++- src/bmad_loop/engine.py | 115 +++++++++++++++++++--------------- src/bmad_loop/workspace.py | 19 ++++++ tests/test_engine_worktree.py | 110 ++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fddc33b2..3bc58572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,8 +185,14 @@ breaking changes may land in a minor release. 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. The directory - itself is left standing and the orphan is journaled. + 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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 00e3393e..9a0810aa 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1430,6 +1430,60 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: 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) @@ -1619,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 @@ -1649,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 @@ -1670,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 @@ -1702,58 +1759,12 @@ def _finish_inflight(self) -> None: if isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) - elif task.worktree_path: - # A persisted mount the live policy no longer treats as isolated: - # `isolation` is re-read every resume and a change is journaled, - # never refused, so `worktree -> none` reaches here with the mount - # still recorded. The re-anchor above has already made `spec_file` - # absolute INTO that mount (it must — `recovery_flow` would - # otherwise resolve the relative spelling against the main - # checkout), but nothing below reopens or discards it, and the - # re-run happens in the main workspace. Left as-is the attempt - # would resolve a spec outside its live roots, - # `_dispatched_spec_for_attempt` would leave it unbound, and an - # explicit-spec prompt would meet the snapshot gate with nothing - # bound. Releasing gives back the relative spelling the main - # workspace re-probes, and drops an attempt binding whose tree - # this run will not enter again. The mount itself is deliberately - # left standing: this arm did not build it and an isolation flip - # is not an instruction to delete the operator's tree. - # - # The BASELINE goes with it, not just the spec: `baseline_commit` - # and `baseline_untracked` were measured inside that mount, and - # the leg below would otherwise hand them to - # `_rollback_or_pause` against the main checkout — where a unit's - # empty untracked snapshot makes every untracked file in the - # operator's own checkout read as this attempt's debris, deleted - # outright under an auto-recovering cause. Releasing clears them, - # so that leg becomes a correct no-op and `_dev_phase` re-stamps - # from the workspace it actually re-enters. - orphan = task.worktree_path - task.release_mount_owned_state() - # ...and the CLAIM goes too. `worktree_path` names a directory AND - # is how `runs` answers which tree owns the state this task already - # persisted (`task_spec_root`, `task_stories_root`). Keeping it set - # to avoid deleting the tree left the task CLAIMING a mount it no - # longer runs in, so those readers anchored on a tree the run has - # left while execution used the main checkout. Clearing the field is - # not deleting the tree: the directory stays exactly where it is, - # and the journal records it so an orphan left by a policy change is - # not silent. - # - # It does NOT fix `redrive_base_ref` / `spec_reaches_the_redrive`, - # and was never able to: those describe the re-drive rather than the - # attempt, and `bmad-loop resolve` asks them in a SEPARATE process - # before this resume runs, so a write here is invisible to the - # reader that needed it. They take the live isolation mode as a - # parameter instead — see `runs.redrive_base_ref`. - task.worktree_path = "" - task.branch = "" - self.journal.append( - "isolation-flip-orphaned-worktree", - story_key=task.story_key, - worktree=orphan, - ) + 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 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/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index cc1fd446..3a3bf153 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2589,6 +2589,116 @@ def _stop(*_a, **_k): assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) +def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, monkeypatch): + """Every non-isolated leg undoes the re-anchor, not just the restart arm. + + `_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( From 27588b41b62d1e189c4eac6b138989bf66b96112 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 13:27:59 -0700 Subject: [PATCH 18/22] docs(skills,diagnostics): carry the re-drive fork at all three landing sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's first review of this branch, validated against the code. The resolve skill told the human "where the correction has to land to be read: committed on `redrive_base_ref`" without condition, then carved the opposite exception sixteen lines later ("when `redrive_base_ref` is `HEAD`, do not tell them to commit anything"). Step 4 and the prohibition already branched; the sentence that most directly instructs did not, so an agent acting on it before reaching the exception sends the human to commit onto a tree the in-place re-drive never reads — the exact lost work the fork exists to prevent. `test_skill_branches_on_the_in_place_remedy` is why it survived four commits: its own comment says "step 4 and the prohibition both have to carry the fork too", enumerating two sites when there are three, so the unconditional spelling reddened nothing. The row now pins the instruction sentence as well; ablated by restoring the absolute wording, which reddens it. Also, three statements that had drifted from what the code does: - `FEATURES.md` said the sentinel and stories fields answer "from that one root", which contradicts the fix this branch made. `task_stories_root` is deliberately NOT `task_spec_root` — the latter's out-of-mount arm falls back to the project so a `confine_root` can always contain what it validates, and borrowing that for a READ looks the stories folder up in the main checkout while the dev session answers the worktree. Two roots, stated separately. - `tui-guide.md` said Resolve "writes nothing". It writes the resolver's `context.json` and the agent writes `resolution.json`; what it does not do is re-arm or rewrite the spec, which is the property the sentence was defending. - `diagnostics.py` credited `stories_engine._operator_spec_path`; the helper is defined on `Engine` (`engine.py`), and stories_engine only calls it. --- docs/FEATURES.md | 10 +++++++--- docs/tui-guide.md | 7 ++++--- .../data/skills/bmad-loop-resolve/SKILL.md | 4 +++- src/bmad_loop/diagnostics.py | 2 +- tests/test_resolve_skill_contract.py | 16 +++++++++++++--- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ea55c1f9..0d502e37 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -82,9 +82,13 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se 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) answer from that one root rather than the project, so a single - surface cannot describe two trees. The dev session's own prompt keeps the relative spelling, + `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 diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 2518ff69..3eb83955 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -493,9 +493,10 @@ artifacts the engine already wrote. 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 writes nothing, it 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`; + 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. 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 5917e553..dc5facb2 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -61,7 +61,9 @@ 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**: committed on `redrive_base_ref`. +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: diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 3608c07e..27d7d199 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -141,7 +141,7 @@ # one — but BOTH fields are mixed-shape, and neither is the reliable one: # Both fields now journal an absolute path wherever they carry one: `spec_file` # through `str(task_spec_path(...))` on all four kinds, and `spec` through - # `stories_engine._operator_spec_path` (which anchors `checkpoint-pause` the same + # `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 diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index ae30c7d3..396fee47 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -98,15 +98,25 @@ def test_skill_branches_on_the_in_place_remedy(skill_md): document the field. Ablation: delete the "When `redrive_base_ref` is `HEAD`" paragraph from SKILL.md - and this reddens on the first assertion. + 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 - # step 4 and the prohibition both have to carry the fork too, or the agent reads a - # blanket "commit it" two screens after the paragraph that carved the exception + # 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 From 7c86fc0b7371f613d150a13ffe2f9ca8d192843f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 15:20:43 -0700 Subject: [PATCH 19/22] fix(runs): hold a re-armed sentinel until its upstream correction reaches the re-drive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentinel arm of `rearm_escalation` bypassed the reachability gate entirely. A pre-planning 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`. No gate was ever computed for a sentinel, and no resume was ever held for one. The loss is not a missing warning. The correction that stops a sentinel RECURRING is upstream — `SPEC.md` / `stories.yaml`, where `bmad-loop-resolve` explicitly sends the agent instead of the sentinel — and an isolated re-drive mounts fresh from `redrive_base_ref` and re-plans from a COMMITTED tree. So the human's correction sat uncommitted in the main checkout, the re-plan read the same intent that wedged, minted the identical sentinel, and the escalation was spent with nothing anywhere reporting it. `rearm-upstream-write-unreachable` now records it, names the folder and the branch to commit on, and holds the resume through the existing `rearm_holds_the_resume` walk both surfaces already run. Narrowed by PROOF, not by configuration, and the need is sharper than it was for the spec record. Every isolated stories run resolves its spec folder inside the project, so reachability alone answers "unreachable" for 100% of sentinel re-arms under `isolation = "worktree"` — and since the kind holds the resume, an unnarrowed gate would turn every one of them into a two-command gesture for an outcome nothing decided. There is no status to route on for a sentinel, so `_redrive_reads_the_upstream_artifacts` proves byte equality instead: the record fires only while the ref the re-drive mounts from does not already hold this checkout's copy of those two files. The blob goes through `worktree_file_bytes_at_revision`, not a raw read — under `core.autocrlf=true` a raw comparison would mismatch every artifact and re-create the constant on Windows alone. One arm, not two, unlike the spec record: `task_spec_path` re-anchors a spec write ON the recorded mount, but the upstream artifacts are named by a project-relative `spec_folder` and `resolve.run_session` runs the agent with `cwd=project`, so the correction lands in the main checkout whichever way an isolation flip went. An in-place re-drive reads that same tree, so it short- circuits to reachable and writes no record at all. That short-circuit is deliberately the ONLY one: a second in-place arm inside the proof would shadow it and leave the reachability answer ungraded by any test — which is exactly what the first draft did, caught by ablating it. `stories_root` is dropped in `diagnostics`, following `repo` rather than `spec_file`: it is a directory, journalled by one kind, and one run has one spec folder, so it correlates nothing — and a `spec` alias would additionally collapse every run onto the same basename. Found by the sixth codex pass on this seam. Ablations, each confirmed to redden the named row alone: drop the proof conjunct (the already-committed row), force the in-place arm unreachable (the in-place row), delete the gate (the uncommitted row), read `HEAD` instead of `redrive_base_ref` (both ref rows), drop the project-containment arm (the in-project row), force the isolated arm unreachable (the external-artifact-dir row), and drop `stories_root` from `_JOURNAL_DROP_FIELDS` (the presence assertion, while the canary sweep stays green — the same false green `repo`'s own row documents). --- CHANGELOG.md | 13 + docs/FEATURES.md | 9 + .../data/skills/bmad-loop-resolve/SKILL.md | 10 + src/bmad_loop/diagnostics.py | 10 + src/bmad_loop/runs.py | 231 ++++++++++++++++- tests/test_cli.py | 6 +- tests/test_diagnostics.py | 50 ++++ tests/test_resolve.py | 243 +++++++++++++++++- 8 files changed, 568 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc58572..7f6bb20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -226,6 +226,19 @@ breaking changes may land in a minor release. 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. - **`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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0d502e37..d8a28a94 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -117,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/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md index dc5facb2..f6e5353f 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -243,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 27d7d199..73be7f2d 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -250,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/runs.py b/src/bmad_loop/runs.py index 11663622..ead38653 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2513,6 +2513,160 @@ def spec_reaches_the_redrive(task: StoryTask, state: RunState, *, isolated_redri 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: @@ -2834,6 +2988,44 @@ 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. @@ -3467,6 +3659,29 @@ def rearm_event_notice( 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` @@ -3539,7 +3754,7 @@ 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. + 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 @@ -3551,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 @@ -3561,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/tests/test_cli.py b/tests/test_cli.py index 592054a7..fdd63a43 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2984,7 +2984,11 @@ def fake_rearm(rd, key, *, restore_patch=None, isolated_redrive=False): 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 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_resolve.py b/tests/test_resolve.py index b34c855d..da1ac0e0 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -45,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. @@ -78,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 @@ -2435,6 +2438,244 @@ 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, state, 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_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. From 5cfca6c6d5a16a14f7a926f3702a3e8afb884fc3 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 15:21:56 -0700 Subject: [PATCH 20/22] fix(cli): re-read isolation after the resolve session, before the re-arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cmd_resolve` loads policy once, near the top, then calls `resolve.run_session` — which blocks on `subprocess.run` until a human finishes an interactive conversation of unbounded length. Everything below it kept keying on that stale answer: the restore-patch latch, the isolation-conflict refusal, and `rearm_escalation`'s `isolated_redrive`. Meanwhile `_resume_paused_run`, at the bottom of the same function, re-reads policy for the engine it arms. So an edit made while the agent was open split the two readers in the one window nobody can bound. `none -> worktree` re-armed treating the main-checkout correction as reachable, emitted no hold, and then mounted a fresh worktree cut from git that could not see it: escalation spent, story re-wedged, no error anywhere. The mirror flip is the same loss in the other direction. The window is real precisely because this session is interactive by contract — a human is present and the skill is allowed to ask. Deciding a story needs isolation is one of the things a resolve conversation concludes. Re-read is unguarded, exactly like the sibling load above it: 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` picks the work back up. A change across the session is reported on stderr, because the agent was told where the correction had to land under the old mode. The stale framing in an earlier note — "the window is after re-arm, before resume, and unclosable" — was wrong and is corrected here: `cmd_resolve` ends WITH `_resume_paused_run`, so re-arm and resume are one gesture and the window is during the session, which is closable. Found by the sixth codex pass on this seam. Ablation: delete the second `policy_mod.load` and the flipped row reddens on the recorded `isolated_redrive`; drop the warning `print` and it reddens on stderr alone with the isolation assertion still passing. The untouched-policy row stays green through both, which is what makes this a re-read rather than a hardcode. --- CHANGELOG.md | 7 +++++ src/bmad_loop/cli.py | 22 ++++++++++++++ tests/test_cli.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6bb20d..f15494af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -239,6 +239,13 @@ breaking changes may land in a minor release. 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 diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 992c3074..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. diff --git a/tests/test_cli.py b/tests/test_cli.py index fdd63a43..6771053a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3572,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 From 7b7e8c3314981202947b62413bcc289fdb22b167 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 15:25:17 -0700 Subject: [PATCH 21/22] test(resolve): pin the non-repo degrade the sentinel proof now depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_redrive_reads_the_upstream_artifacts` reads git on a path that did not before, so the sentinel arm inherits a requirement `_redrive_spec_status` already carries for the spec record: a project that is not a repository must still re-arm. A `GitError` escaping here aborts a re-arm that has ALREADY deleted the sentinel and preserved its copy, spending the escalation on a traceback. Found by probing the claim rather than trusting the docstring that makes it. The degrade direction is confirmed as the warning one: no proof means the record fires and the resume holds, so the operator is told to commit rather than quietly resumed into a re-plan nothing verified. Ablation: drop the `except verify.GitError` arm and this reddens with the GitError escaping `rearm_escalation` — "Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)" — the sentinel already gone from disk. --- tests/test_resolve.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index da1ac0e0..e99629c5 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2676,6 +2676,50 @@ def test_rearm_exempts_a_stories_folder_configured_outside_the_project( 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. From e057c162afd0d91767f0ee9d8756a90114be9dc6 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 29 Aug 2026 15:40:39 -0700 Subject: [PATCH 22/22] test(resolve): drop an unused binding in the sentinel fixture unpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's seventh-round review, validated. The binding really is unused — no caller of `_sentinel_run` consumes its `state`. Matches the idiom the sibling call site already uses two rows down. Not adopted as a lint rule: the finding cites RUF059, and `[tool.ruff.lint]` pins `select = ["E4", "E7", "E9", "F"]` with an explicit "Do NOT ratchet stricter than CI without moving CI first". So this is the binding cleaned up, not the rule family turned on. --- tests/test_resolve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index e99629c5..064e70c6 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -2542,7 +2542,7 @@ def test_rearm_holds_a_sentinel_until_the_upstream_correction_reaches_the_redriv reddens on `assert [] != []`. Every row alone is satisfied by a wrong fix. """ intent = CORRECTED_INTENT if committed_matches else WEDGED_INTENT - run_dir, state, sentinel = _sentinel_run( + run_dir, _, sentinel = _sentinel_run( tmp_path, committed_intent=intent, working_intent=CORRECTED_INTENT ) monkeypatch.chdir(tmp_path)