From 67a3e2d8d1ce3f4733500cff8fec744472672532 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 13:35:41 -0700 Subject: [PATCH 01/35] fix(loop): journal spent review budgets, dedupe harvest sightings, clamp at word boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bmad-loop notes from the build-review-economics work: - devcontract._flatten clamps at a word boundary: a mid-word cut backs up to the last join space, a cut ending exactly at a word end keeps the full cut, and a single unbroken token keeps the hard clamp (the DW-91-style mid-word `reason:` cut). - The process-exhaust ticket class (DW-55/64/90) is retired: a finalized, verify-green story whose review still recommends a follow-up journals the spent budget (review-followup-damped / review-budget-committed, the `refiled` field dropped, `re_review_capped` and the notify rules kept) and files no ledger entry. The refiled_followups record, its model field, and the #425 _carry_review_budget_followups carry are removed (three post-merge carries remain); DW-90 is closed in the ledger. - Harvest dedupes cross-spec sightings (DW-88 vs DW-65): a real finding that is not this spec's own replay but matches an OPEN entry — identical fingerprinted origin harvested from another spec, or a `DW-:`-prefixed summary — stamps a `seen-again:` line via the new deferredwork.mark_seen_again_many instead of filing a duplicate, and never enters harvested_deferrals, so the isolated carry cannot re-file it. Done entries never match; recurrence after a close files fresh. The spec-deferrals-harvested event gains an additive `seen_again` field. DW-88 is closed as a duplicate of DW-65. Gate: full pytest 7730 passed / 51 skipped, pyright 0 errors, trunk fmt/check clean. --- src/bmad_loop/deferredwork.py | 50 +++++ src/bmad_loop/devcontract.py | 18 +- src/bmad_loop/engine.py | 294 ++++++++++---------------- src/bmad_loop/model.py | 5 - src/bmad_loop/worktree_flow.py | 8 +- tests/test_deferredwork.py | 56 +++++ tests/test_devcontract.py | 12 ++ tests/test_engine.py | 193 ++++++++++++++--- tests/test_engine_worktree.py | 355 +++----------------------------- tests/test_model.py | 18 +- tests/test_portability_guard.py | 1 + tests/test_sweep.py | 28 +-- 12 files changed, 458 insertions(+), 580 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 36323cab..97c0793c 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -1018,6 +1018,56 @@ def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: return bool(mark_done_many(path, [dw_id], date, note)) +def mark_seen_again_many( + path: Path, dw_ids: Sequence[str], date: str, note: str +) -> tuple[list[bool], str | None]: + """Stamp `seen-again: ()` under each entry's status line, in ONE + read and ONE atomic write. Returns one applied flag per id, plus the text it + published — None when it wrote nothing (every id missing, or already carrying + this exact line). + + The writer half of the format doc's dedupe rule (deferred-work-format.md): a + finding that matches an existing entry is recorded as a sighting on that + entry, never as a duplicate entry. Idempotent per (id, date, note): a replay + stamping the same line is skipped, so a flag reads "this call inserted it", + not "the line is there". A missing id is False rather than an error — the + caller matched against a snapshot, and a rival may have archived the entry + since. A missing ledger applies nothing and takes no lock, like + :func:`mark_done_many`'s absent-file arm. + + ONE locked read->edit->write (#286/#469): each insert lands on the text the + previous one produced (:func:`_find_entry` re-parses the evolving text, so + spans stay honest), and the published text is handed back from inside the + hold for the same ``post_engine_ledger_digest`` reasons as + :func:`append_entries_published`. `date` is orchestrator-owned and raises + when malformed; `note` is sanitized to one line (#305). + """ + _require_iso_date(date) + line = f"seen-again: {date} ({_one_line(note)})" + if not dw_ids: + return [], None + if not path.is_file(): + return [False for _ in dw_ids], None + with ledger_lock(path): + if not path.is_file(): + return [False for _ in dw_ids], None + text = path.read_text(encoding="utf-8") + applied: list[bool] = [] + for dw_id in dw_ids: + entry = _find_entry(text, dw_id) + if entry is None or line in entry.body: + applied.append(False) + continue + text = _insert_after_status(text, entry, line) + applied.append(True) + if not any(applied): + return applied, None + atomic_write_text(path, text) + # Returned from INSIDE the hold: this is the published text by + # construction, not a read-back that a rival could have moved. + return applied, text + + _MARK_DONE_TAIL_RE = re.compile( r"\nresolution:[ \t]*(.*)" r"\nresolution-undo:[ \t]*([0-9a-f]{64})[ \t]+" diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 0c5ff0dc..19f7aa87 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -198,15 +198,23 @@ def harvest_fingerprint(*parts: str) -> str: def _flatten(value: Any, limit: int) -> str: - """Collapse a YAML scalar to one clamped line. + """Collapse a YAML scalar to one line clamped at a word boundary. - Strip after clamping because the cut can land on a join space. Keeping that - cleanup here makes the value written to the ledger and the value used in its - fingerprint identical. + A cut landing mid-word backs up to the last join space so the ledger never + records a truncated half-token; a cut ending exactly at a word end keeps the + full cut, and a single unbroken token longer than the limit keeps the hard + clamp. Fingerprints (``harvest_fingerprint(summary, location)``) therefore + drift only for over-limit multi-word values -- accepted. """ if value is None: return "" - return " ".join(str(value).split())[:limit].strip() + text = " ".join(str(value).split()) + if len(text) <= limit: + return text + if text[limit] == " ": + return text[:limit] + head, sep, _ = text[:limit].rpartition(" ") + return head if sep else text[:limit] def _is_yaml_scalar(value: Any) -> bool: diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 8ea90b1b..a81fd926 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1594,7 +1594,6 @@ def _replay_unlatched_ledger_carries(self) -> None: if not task.worktree_path or not ( task.harvested_deferrals or task.bundle_closes_intended - or task.refiled_followups or task.story_closes_intended or task.board_advance_intended ): @@ -2334,24 +2333,6 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # later isolated carry. Crash replay never enters this branch. if feedback is None: task.harvested_deferrals = [] - # Same rule, and the abandoned attempt's ledger row is already - # gone: an escalation leaves the unit worktree mounted and - # unmerged, and the re-drive that reaches here discarded it - # (`_finish_inflight`'s resume-restart arm), taking the only - # copy of that row. Carrying the record anyway files a - # follow-up against the attempt that COMMITTED, whose review - # recommended none — and `append_entry` has nothing to dedupe - # it against, so a tracked ledger commits the wrong row rather - # than absorbing it (#457). `rearm_escalation` already - # voids the history behind the record by resetting - # `followup_reviews_spent` for a fresh damping budget. - # - # The fixable-retry exemption above is inherited, not reasoned: - # this field's one producer fires on a finalized, verify-green - # story immediately before `_commit`, and no path leads from - # there back into a `feedback is not None` iteration, so such an - # iteration can never hold a record to preserve. - task.refiled_followups = [] # A fresh-baseline dispatch replaces stale ownership. A fixable # repair inherits the current working tree, but retains the chain's # first bound snapshot because a later non-fixable retry resets all @@ -2920,11 +2901,9 @@ def _review_and_commit( outcome = self._verify_review(task) if outcome.ok: if damped: - # refile BEFORE break so the ledger edit squashes into the - # same story commit (mirrors the exhaustion rescue ordering). # Verify-green here is the same authority as the converged / # rescue paths — never ships uncompleted work. - self._record_review_budget_followup(task, damped=True) + self._journal_review_budget_spent(task, damped=True) clean = True break self.journal.append( @@ -2974,9 +2953,9 @@ def _review_and_commit( # (a) the last *completed* pass left the story finalized + verify-green # (status: done) but kept recommending an independent follow-up # (`refileable_followup`, `clean` stays False). That work is - # committable — commit it and re-file the lingering follow-up as a - # fresh deferred-work entry instead of rolling everything back (the - # failure mode that silently threw away review-passing work). + # committable — commit it and journal the spent budget instead of + # rolling everything back (the failure mode that silently threw + # away review-passing work). # (b) anything else (non-terminal status, no outstanding follow-up, # verify failing): a genuine failure → defer + roll back as before. # A failed *final* review session never reaches here at all: with the @@ -2986,14 +2965,14 @@ def _review_and_commit( # completed pass's own signal) AND _verify_review — the same authoritative # gate the converged path uses (frontmatter status==done AND sprint==done # AND verify commands pass) — so it can never ship uncompleted work, nor - # re-file a follow-up the last pass did not actually recommend. Only for + # record a follow-up the last pass did not actually recommend. Only for # the non-isolated path: in worktree isolation a defer already keeps the # unit's worktree + patch (no work is lost), so there is nothing to # rescue and committing into the main repo would be wrong. if refileable_followup and not self._isolated: rescue = self._verify_review(task) if rescue.ok: - self._record_review_budget_followup(task) + self._journal_review_budget_spent(task) self._commit(task) return if rescue.contradiction: @@ -3127,9 +3106,9 @@ def _salvage_review_timeout(self, task: StoryTask, result: SessionResult) -> boo refiled: str | None = None if task.followup_review_recommended: # Refile BEFORE _commit so the ledger edit squashes into the story - # commit (mirrors _record_review_budget_followup's ordering). A new - # origin string: the review-budget-followup origin's wording and - # re-review cap are load-bearing for that path and must not blur. + # commit. A distinct origin string: `review-budget-followup` is the + # re-review-cap key `_journal_review_budget_spent` scans for, and a + # timeout salvage must not trip it. refiled = deferredwork.append_entry( self.workspace.paths.deferred_work, title=( @@ -4114,6 +4093,15 @@ def _harvest_spec_deferrals( like optional bookkeeping observation: a later verify read must not accept the session while silently dropping its recorded work. Ledger writes remain unguarded so a failed repair write raises. + + Cross-spec dedupe (DW-88 vs DW-65): a finding that is not this spec's own + replay but matches an OPEN entry — identical fingerprinted ``origin:`` + harvested from another spec, or a summary that begins with the entry's own + ``DW-:`` id — files nothing and stamps a ``seen-again:`` line on the + match instead. Done entries never match: a recurrence after a close files + fresh, consistent with ``_apply_append``'s open-only scan. A matched + finding is also excluded from ``harvested_deferrals``, so the isolated + carry cannot re-file the duplicate. """ if not self._generic_dev(): return @@ -4207,6 +4195,52 @@ def _harvest_spec_deferrals( return spec_name = spec_path.name + # Read BEFORE building records: the seen-again match below decides which + # findings become records at all. The durability ordering (records saved + # before any ledger write) is preserved by the reorder — both writes, + # the seen-again marks and the appends, still run after the record save. + ledger = self.workspace.paths.deferred_work + text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + seen = deferredwork.parse_ledger(text) + + # Cross-spec dedupe (DW-88 vs DW-65). A real finding that is not this + # spec's own replay (same origin AND source_spec, any status — checked + # first, unchanged) but matches an OPEN entry — same fingerprinted + # origin harvested from another spec, or a summary beginning with the + # entry's own `DW-:` id — is a sighting of recorded work, not new + # work: file nothing, stamp `seen-again:` on the match instead. Done + # entries never match; a recurrence after a close files fresh, exactly + # as `_apply_append`'s open-only scan treats its own dedupe. Matched + # findings are also excluded from `harvested_deferrals`, so the + # isolated carry cannot re-file the duplicate. + seen_again_ids: list[str] = [] + harvestable: list[devcontract.DeferredFinding] = [] + for finding in findings: + origin = f"{HARVEST_ORIGIN} {finding.fingerprint}" + if any( + deferredwork.field_line_present(entry.body, "origin", origin) + and deferredwork.field_line_present(entry.body, "source_spec", spec_name) + for entry in seen + ): + harvestable.append(finding) # this spec's own replay: dedupe below + continue + match = next( + ( + entry + for entry in seen + if entry.open + and ( + deferredwork.field_line_present(entry.body, "origin", origin) + or finding.summary.startswith(f"{entry.id}:") + ) + ), + None, + ) + if match is None: + harvestable.append(finding) + elif match.id not in seen_again_ids: + seen_again_ids.append(match.id) + # (origin, title, reason, location, severity), one row per entry this # harvest may file. The malformed loss is aggregated per spec so a bad # sibling never suppresses a valid finding and never disappears silently. @@ -4218,7 +4252,7 @@ def _harvest_spec_deferrals( finding.location or None, finding.severity or None, ) - for finding in findings + for finding in harvestable ] if malformed: self.journal.append( @@ -4275,9 +4309,28 @@ def _harvest_spec_deferrals( # already latched and its separate pre-write save will be skipped. self._save() - ledger = self.workspace.paths.deferred_work - text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" - seen = deferredwork.parse_ledger(text) + if seen_again_ids: + # Latch + save BEFORE the write, exactly as the append path does: a + # crash between the mark and the next save must replay as "the + # engine wrote this ledger". A gitignored isolated ledger loses the + # annotation with its worktree — acceptable: the suppression (no + # duplicate entry) is the point, and the mark is best-effort memory. + if not task.harvest_wrote_ledger: + task.harvest_wrote_ledger = True + self._save() + marked_published = deferredwork.mark_seen_again_many( + ledger, + seen_again_ids, + self._today(), + f"spec-deferral harvest of {spec_name}", + )[1] + if marked_published is not None: + # Re-anchor the pre-harvest restore's CAS on what the mark + # published; a following append overwrites this with its own + # published text (its locked read includes these marks). + task.post_engine_ledger_digest = _digest_of(marked_published) + self._save() + specs: list[deferredwork.EntrySpec] = [] deduped = 0 for origin, title, reason, location, severity in pending: @@ -4347,6 +4400,7 @@ def _harvest_spec_deferrals( story_key=task.story_key, spec=spec_name, dw_ids=filed, + seen_again=seen_again_ids, deduped=deduped, malformed=len(malformed), ) @@ -4468,9 +4522,9 @@ def _close_declared_deferred( # producer of the record and runs unconditionally at every commit boundary, # and DONE/AWAITING_OPERATOR are reachable only through the caller — so a # re-drive that reaches the carry has always re-entered here first, and this - # assignment IS the staleness guard (a `_dev_phase` clear like - # `refiled_followups`' would be a second branch saying the same thing, which - # no test could redden). The live read is authoritative: a resolve session + # assignment IS the staleness guard (a separate `_dev_phase` clear would + # be a second branch saying the same thing, which no test could redden). + # The live read is authoritative: a resolve session # that WITHDREW `closes_deferred:` must not have the abandoned attempt's # declaration carried on its behalf. task.story_closes_intended = [] @@ -6649,12 +6703,13 @@ def _fix_phase(self, task: StoryTask, reason: str) -> Decision: # is right only when the repair actually ran (#489). return Decision(Action.DEFER, session_failure) - def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) -> None: - """A *finalized, verify-green* story that the review pass kept recommending - a follow-up for is being committed (not rolled back); preserve the lingering - recommendation as a new open deferred-work entry so a later, deliberate - review can pick it up. Called immediately before ``_commit`` so the ledger - edit is squashed into the same commit. + def _journal_review_budget_spent(self, task: StoryTask, damped: bool = False) -> None: + """A *finalized, verify-green* story is being committed (not rolled back) + while its review pass still recommended a follow-up; journal the spent + budget. No ledger entry is filed — the DW-55/64/90 class showed such + tickets re-litigate a converged story's review rather than record work + anyone chose to defer, and a follow-up that matters resurfaces through + review of later work, not through a process ticket. Two callers, distinguished by ``damped``: * ``damped=False`` — the review loop *exhausted* its ``max_review_cycles`` @@ -6666,31 +6721,28 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) The expected steady state: stay quiet (no ATTENTION notice) unless the re-review cap also fires. - Re-review cap: if this story itself *originated* from such an entry (a - sweep bundle closing a ``review-budget-followup`` id), don't re-file again - — commit + notify only, so a second non-convergence reaches a human - instead of slowly looping across sweeps. The loud re-review notice fires on - both paths (a capped story that still won't converge must reach a human even - under damping).""" + Re-review cap: if this story itself *originated* from a + ``review-budget-followup`` entry (legacy and hand-filed rows still exist), + the loud notice fires on both paths, so a second non-convergence reaches a + human instead of slowly looping across sweeps.""" cycles = self.policy.limits.max_review_cycles cap = self.policy.limits.max_followup_reviews - spec = Path(task.spec_file).name if task.spec_file else task.story_key ledger = self.workspace.paths.deferred_work if damped: reason = ( f"The follow-up-review damping cap (limits.max_followup_reviews = {cap}) " f"was spent with the story finalized (status: done, verify green) while " f"the review pass still recommended an independent follow-up. The work " - f"was committed by bmad-loop run {self.state.run_id}; this entry " - f"preserves the lingering recommendation for a deliberate later review." + f"was committed by bmad-loop run {self.state.run_id}; the spent budget " + f"is journaled and no ledger entry is filed." ) else: reason = ( f"Review budget ({cycles} cycles) was exhausted with the story finalized " f"(status: done, verify green) while the review pass kept recommending an " f"independent follow-up. The work was committed by bmad-loop run " - f"{self.state.run_id}; this entry preserves the lingering follow-up " - f"recommendation for a deliberate later review." + f"{self.state.run_id}; the spent budget is journaled and no ledger entry " + f"is filed." ) re_review = False if task.dw_ids and ledger.is_file(): @@ -6704,56 +6756,12 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) ) for i in task.dw_ids ) - refiled: str | None = None - if not re_review: - tail = "the damping cap was spent" if damped else "the review budget was exhausted" - title = f"Follow-up review still recommended for {task.story_key} after {tail}" - entry = { - "title": title, - "origin": "review-budget-followup", # verbatim: re-review cap + replay dedupe key - "source_spec": spec, - "reason": reason, - "severity": "low", - } - # Persist the intent BEFORE the write, never after it succeeds. This - # writes the ACTIVE workspace's ledger, so under isolation the row is - # inside a unit worktree that `close_unit_workspace` deletes, and a - # gitignored one is skipped by `finalize_commit`'s `git add -A` in - # silence — the run journals `refiled: DW-n` having filed nothing a - # later sweep can reach (#425). The DONE-leg carry is the delivery - # path, and it reads only PERSISTED records. - # - # Recording after the append instead loses the row outright on a hard - # host loss: nothing saves between here and `_commit`'s COMMITTING - # save, and that window spans every blocking `pre_commit_gate` - # workflow (the shipped TEA plugin binds three, each a live session). - # The resumed run replays this same review result in the same - # worktree, `append_entry` dedupes the already-open row to None, the - # record is never made, and the carry finds an empty payload. - # - # Keyed dedupe, not an `if refiled:` gate, for the same reason - # `_harvest_spec_deferrals` keeps a stable union: a replay must not - # append a second copy, but it must not need a NEW id to record - # authorship either. Safe to pre-latch — `refiled_followups` is a - # record, not a suppression bit: both consumers (replay eligibility, - # the carry) only ever make the engine do more, and `append_entry` - # dedupes an already-open row, so recording an append that then fails - # costs at most a carry that files the row the operator was owed. - known = { - (str(item.get("origin", "")), str(item.get("source_spec", ""))) - for item in task.refiled_followups - } - if (entry["origin"], entry["source_spec"]) not in known: - task.refiled_followups.append(entry) - self._save() - refiled = deferredwork.append_entry(ledger, **entry) if damped: self.journal.append( "review-followup-damped", story_key=task.story_key, cycle=task.review_cycle, cap=cap, - refiled=refiled, re_review_capped=re_review, ) else: @@ -6761,7 +6769,6 @@ def _record_review_budget_followup(self, task: StoryTask, damped: bool = False) "review-budget-committed", story_key=task.story_key, cycles=cycles, - refiled=refiled, re_review_capped=re_review, ) note = reason @@ -7095,25 +7102,19 @@ def _carry_isolated_ledger_writes(self, task: StoryTask) -> None: APPENDS FIRST, THEN CLOSES — the ordering is a correctness contract, not a style. ``append_entry``'s idempotence scan is open-only, so a close that ran first would hide an already-filed row from it and mint a duplicate - under a fresh id. That is why the two appends lead, why the story close - below trails them, and why ``SweepEngine``'s override runs its bundle + under a fresh id. That is why the harvest append leads, why the story close + below trails it, and why ``SweepEngine``'s override runs its bundle close strictly after ``super()``. - The collision is reachable, not theoretical: a story may declare - ``closes_deferred:`` on the very ``review-budget-followup`` row that - ``_carry_review_budget_followups`` is about to dedupe against. Close it - first and the follow-up is re-filed as a second entry. - The two closes never coexist on one task — ``SweepEngine`` overrides the story producer to a no-op, and a story run has no bundle — so their relative order is unobservable. - ``_carry_board_advance`` trails all three and is ordered freely: it writes + ``_carry_board_advance`` trails the ledger carries and is ordered freely: it writes sprint-status.yaml, which shares no state with the deferred-work ledger, so the appends-before-closes contract has nothing to say about it. """ self._carry_harvested_deferrals(task) - self._carry_review_budget_followups(task) self._carry_story_deferred_closes(task) self._carry_board_advance(task) @@ -7206,82 +7207,10 @@ def _carry_harvested_deferrals(self, task: StoryTask) -> None: self._save() self.journal.append("harvest-carried", story_key=task.story_key, dw_ids=carried) - def _carry_review_budget_followups(self, task: StoryTask) -> None: - """Re-file an isolated unit's review-budget follow-ups into the main ledger. - - The third producer in the ``git add -A`` family (#425). - ``_record_review_budget_followup`` runs on a finalized, verify-green story - the review pass would not stop recommending a follow-up for; under - isolation that write is correct but is silently dropped when the main - ledger is gitignored. Hence a carry rather than a guard, which would - suppress a legitimate entry on every isolated run. - - No ``_isolated`` predicate: ``refiled_followups`` is populated by that one - producer and this hook is reached only from the isolated DONE leg and its - replay, so the record IS the guard. - - Unconditional and idempotent — ``append_entry`` dedupes an OPEN row with - the same ``origin:`` + ``source_spec:``, while an already-CLOSED row with - that provenance earns a fresh entry, exactly as a recurrence does in - place. That is what lets the producer record its intent BEFORE its own - append, which durability requires, so ``review-followup-carried`` with - ``dw_ids == []`` is an ordinary outcome and not a carry that ran on - nothing. - - A TRACKED ledger is safe unconditionally: no exclude or ignore rule masks a - tracked file's MODIFICATION, so the unit's own write always rides the merge - and its row arrives already deduped — yielding an empty ``carried`` and no - commit. - - The commit is best effort — unlike ``_carry_harvested_deferrals``, whose - re-raise is backed by ``harvest_carry_commit_pending`` — because it can - only ever FAIL: the sole ledger shape that reaches it is a gitignored one, - and ``git add`` refuses an ignored path with rc 1 every time, so a - commit-pending latch would just retry a refusal. Nor can it leave the tree - dirty, the shape it writes being the one git does not see. The row on disk - is the value; the commit is bookkeeping. - - Every record must belong to the attempt now being committed — a premise - ``_dev_phase`` enforces, not this frame. A record left over from an - ABANDONED attempt died with its discarded worktree, so it has nothing - upstream to dedupe against and the carry would append AND commit a row - about work that never landed; the fresh-attempt clear beside - ``harvested_deferrals`` is what prevents that (#457). - """ - if not task.refiled_followups: - return - ledger = self.paths.deferred_work - specs = [ - deferredwork.EntrySpec( - title=str(item["title"]), - origin=str(item["origin"]), - source_spec=str(item["source_spec"]), - reason=str(item["reason"]), - severity=str(item["severity"]) if item.get("severity") else None, - ) - for item in task.refiled_followups - ] - carried = [dw_id for dw_id in deferredwork.append_entries(ledger, specs) if dw_id] - if carried: - try: - verify.commit_paths( - self.paths.repo_root, - f"chore(deferred-work): carry {task.story_key}'s review follow-up", - [ledger], - ) - except verify.GitError as e: - self.journal.append( - "review-followup-carry-uncommitted", - story_key=task.story_key, - dw_ids=carried, - error=str(e), - ) - self.journal.append("review-followup-carried", story_key=task.story_key, dw_ids=carried) - def _carry_story_deferred_closes(self, task: StoryTask) -> None: """Re-apply a story's declared ledger CLOSES to the main checkout (#458). - The fourth and last producer in the ``git add -A`` family. + The last producer in the ``git add -A`` family. ``_close_declared_deferred`` writes the ACTIVE workspace's ledger, so under isolation a gitignored one is flipped inside a unit worktree, skipped by ``finalize_commit``'s ``git add -A`` in silence, and deleted with the @@ -7309,8 +7238,7 @@ def _carry_story_deferred_closes(self, task: StoryTask) -> None: with ``dw_ids == []`` is an ordinary outcome. Best effort, like ``SweepEngine``'s close and unlike - ``_carry_harvested_deferrals`` — and, as with - ``_carry_review_budget_followups``, because the commit can only ever FAIL + ``_carry_harvested_deferrals`` — because the commit can only ever FAIL here rather than because failure is rare. Of the three shapes the main ledger can take, a tracked one is already closed by the merge; a gitignored one reaches ``commit_paths`` and ``git add`` refuses that diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index d6b17f56..16bb3af0 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -256,9 +256,6 @@ class StoryTask: # JSON-native containers only; callers persist these through state.json. harvested_deferrals: list[dict[str, Any]] = field(default_factory=list) bundle_closes_intended: list[str] = field(default_factory=list) - # `append_entry` kwargs for review-budget follow-ups this task filed into the - # ACTIVE workspace's ledger, which under isolation is the unit worktree's. - refiled_followups: list[dict[str, Any]] = field(default_factory=list) # Deferred-work ids a story DECLARED it closes (`closes_deferred:`), recorded at # the commit boundary. Same ledger, same isolation problem: a gitignored path # never merges out of the unit worktree, so the flip has to be re-applied. @@ -455,7 +452,6 @@ def to_dict(self) -> dict[str, Any]: "ledger_changed_before_harvest": self.ledger_changed_before_harvest, "harvested_deferrals": self.harvested_deferrals, "bundle_closes_intended": self.bundle_closes_intended, - "refiled_followups": self.refiled_followups, "story_closes_intended": self.story_closes_intended, "board_advance_intended": self.board_advance_intended, "accepted_dev_session_index": self.accepted_dev_session_index, @@ -640,7 +636,6 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": ledger_changed_before_harvest=bool(d.get("ledger_changed_before_harvest", False)), harvested_deferrals=[deepcopy(dict(item)) for item in d.get("harvested_deferrals", [])], bundle_closes_intended=[str(i) for i in d.get("bundle_closes_intended", [])], - refiled_followups=[deepcopy(dict(item)) for item in d.get("refiled_followups", [])], story_closes_intended=[str(i) for i in d.get("story_closes_intended", [])], board_advance_intended=( str(d["board_advance_intended"]) diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index ba2fa42f..96644a77 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1783,10 +1783,10 @@ def _carried_artifact_rels(self, repo: Path) -> tuple[str, ...]: ``clean_incoming_collisions``' ``protected`` operand (#618). The sprint board and the deferred-work ledger, because those are the two - files the four post-merge carries name: ``_carry_harvested_deferrals``, - ``_carry_review_budget_followups`` and ``_carry_story_deferred_closes`` pass - ``paths.deferred_work`` and ``_carry_board_advance`` passes - ``paths.sprint_status``, all four to ``verify.commit_paths`` against this same + files the three post-merge carries name: ``_carry_harvested_deferrals`` + and ``_carry_story_deferred_closes`` pass ``paths.deferred_work`` and + ``_carry_board_advance`` passes + ``paths.sprint_status``, all three to ``verify.commit_paths`` against this same ``repo``. That call stages by PATHSPEC — `git add -- :(literal)` — so whatever the working tree holds at that path is committed no matter who wrote it, and a merge that walked past an operator's edit there hands the run its diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 64668b08..6297805d 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -29,6 +29,7 @@ mark_done_many_reopenable, mark_open, mark_open_many, + mark_seen_again_many, next_seq, open_ids, parse_declaration, @@ -147,6 +148,61 @@ def test_mark_done_missing_entry(tmp_path): assert path.read_text(encoding="utf-8") == snapshot +def test_mark_seen_again_many_inserts_after_status(tmp_path): + path = write_ledger(tmp_path) + applied, published = mark_seen_again_many( + path, ["DW-1"], "2026-08-31", "spec-deferral harvest of spec-2-2-b.md" + ) + assert applied == [True] + text = path.read_text(encoding="utf-8") + assert published == text + assert "status: open\nseen-again: 2026-08-31 (spec-deferral harvest of spec-2-2-b.md)" in text + entries = {e.id: e for e in parse_ledger(text)} + assert entries["DW-1"].open # the line does not disturb status parsing + assert "seen-again: 2026-08-31" not in entries["DW-3"].body # only the target + + +def test_mark_seen_again_many_is_idempotent_on_replay(tmp_path): + path = write_ledger(tmp_path) + assert mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x")[0] == [True] + snapshot = path.read_text(encoding="utf-8") + applied, published = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x") + assert applied == [False] and published is None + assert path.read_text(encoding="utf-8") == snapshot + # a different sighting (new date) is a new line, not a dupe to skip + assert mark_seen_again_many(path, ["DW-1"], "2026-09-01", "harvest of x")[0] == [True] + assert path.read_text(encoding="utf-8").count("seen-again:") == 3 # DW-3 owns one + + +def test_mark_seen_again_many_missing_id_is_false(tmp_path): + path = write_ledger(tmp_path) + applied, published = mark_seen_again_many(path, ["DW-99", "DW-1"], "2026-08-31", "harvest of x") + assert applied == [False, True] and published is not None + assert "seen-again: 2026-08-31 (harvest of x)" in path.read_text(encoding="utf-8") + # a missing ledger applies nothing and creates nothing + missing = tmp_path / "absent" / "deferred-work.md" + assert mark_seen_again_many(missing, ["DW-1"], "2026-08-31", "x") == ([False], None) + assert not missing.exists() + + +def test_mark_seen_again_many_sanitizes_the_note_to_one_line(tmp_path): + path = write_ledger(tmp_path) + applied, _ = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest\nof spec-x.md") + assert applied == [True] + text = path.read_text(encoding="utf-8") + assert "seen-again: 2026-08-31 (harvest of spec-x.md)" in text + # the break never minted a phantom entry or truncated DW-1's span + assert [e.id for e in parse_ledger(text)] == ["DW-1", "DW-2", "DW-3"] + + +def test_mark_seen_again_many_raises_on_a_bad_date_without_writing(tmp_path): + path = write_ledger(tmp_path) + snapshot = path.read_text(encoding="utf-8") + with pytest.raises(ValueError): + mark_seen_again_many(path, ["DW-1"], "08/31/2026", "x") + assert path.read_text(encoding="utf-8") == snapshot + + def test_mark_open_round_trips_one_reopenable_close_character_for_character(tmp_path): path = write_ledger(tmp_path) before = path.read_text(encoding="utf-8") diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index aa46c375..a91440b2 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -1934,6 +1934,18 @@ def test_flatten_strips_a_join_space_at_the_clamp_boundary(tmp_path): assert finding.fingerprint == devcontract.harvest_fingerprint("s", finding.location) +@pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param("alpha betas gamma", "alpha", id="mid-word cut backs up to the boundary"), + pytest.param("alpha beta gamma", "alpha beta", id="cut at a word end keeps the full cut"), + pytest.param("a" * 30, "a" * 10, id="single unbroken token keeps the hard clamp"), + ], +) +def test_flatten_clamps_at_a_word_boundary(raw, expected): + assert devcontract._flatten(raw, 10) == expected + + def test_deferred_fingerprint_ignores_evidence_but_tracks_location(tmp_path): def fingerprint(path: Path, evidence: str, location: str) -> str: findings, _ = devcontract.parse_deferred_findings( diff --git a/tests/test_engine.py b/tests/test_engine.py index e0124973..f93e8f1d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7002,8 +7002,9 @@ def review_cycle2(spec): def test_budget_exhausted_finalized_work_commits(project): """A finalized story (status: done, sprint done, verify green) whose review pass keeps recommending an independent follow-up is COMMITTED when the review - budget is exhausted — not rolled back. The lingering recommendation is - re-filed as a fresh open deferred-work entry, and the run records the event.""" + budget is exhausted — not rolled back. The spent budget is journaled and the + run notifies; no deferred-work entry is filed (the process-exhaust ticket + class is retired).""" from bmad_loop import deferredwork write_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -7030,12 +7031,16 @@ def test_budget_exhausted_finalized_work_commits(project): # the finalized work is committed, not reverted assert "change for 1-1-a" in (project.project / "src.txt").read_text() kinds = [e["kind"] for e in engine.journal.entries()] - assert "review-budget-committed" in kinds and "story-deferred" not in kinds - # the lingering follow-up is preserved as a new open deferred-work entry - open_entries = [ - e for e in deferredwork.parse_ledger(project.deferred_work.read_text()) if e.open - ] - assert any("origin: review-budget-followup" in e.body for e in open_entries) + assert "story-deferred" not in kinds + committed = [e for e in engine.journal.entries() if e["kind"] == "review-budget-committed"] + assert len(committed) == 1 and committed[0]["re_review_capped"] is False + assert "refiled" not in committed[0] + # the process-exhaust ticket class is retired: no ledger entry is filed + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) def test_budget_exhausted_unfinalized_defers(project): @@ -7491,7 +7496,7 @@ def test_followup_damping_converges_at_cap(project): """Default damping cap (1): a finalized story whose review keeps recommending an independent follow-up converges after honoring exactly ONE self-recommended follow-up. Round 1 spends the grant; round 2 (still recommending) is damped → - verify, refile, commit. The 3rd scripted review never runs, and — being the + verify, journal, commit. The 3rd scripted review never runs, and — being the expected steady state — the damped converge stays quiet (no ATTENTION).""" from bmad_loop import deferredwork @@ -7514,13 +7519,15 @@ def test_followup_damping_converges_at_cap(project): assert "review-followup-damped" in kinds assert "review-budget-committed" not in kinds # not the exhaustion path assert "story-deferred" not in kinds - # the lingering follow-up is preserved as exactly one open DW entry - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text()) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 + # the process-exhaust ticket class is retired: no ledger entry is filed + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) + damped = [e for e in engine.journal.entries() if e["kind"] == "review-followup-damped"] + assert len(damped) == 1 and damped[0]["re_review_capped"] is False + assert "refiled" not in damped[0] # damped convergence is the steady state — no review-budget ATTENTION notice # (the always-on run-finished notice is the only thing in the file). attention = engine.run_dir / "ATTENTION" @@ -7532,7 +7539,7 @@ def test_followup_damping_converges_at_cap(project): def test_followup_damping_cap_zero_converges_immediately(project): """Cap 0: the orchestrator never honors a pass's own follow-up. The first finalized round that still recommends one is damped immediately — verify, - refile, commit — after a single review round, with nothing spent.""" + journal, commit — after a single review round, with nothing spent.""" from bmad_loop import deferredwork write_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -7550,12 +7557,11 @@ def test_followup_damping_cap_zero_converges_immediately(project): assert task.followup_reviews_spent == 0 # cap 0 grants nothing to spend kinds = [e["kind"] for e in engine.journal.entries()] assert "review-followup-damped" in kinds - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text()) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) def test_nonterminal_rounds_do_not_spend_damping_cap(project): @@ -7628,8 +7634,8 @@ def test_followup_damping_resume_replay_does_not_double_count(project): """A host death in the post-session window of the grant-spending review round must not double-count the damping spend on resume. The recorded round-1 result replays (re-deriving the spend), then round 2 damps and converges: the story - reaches DONE with followup_reviews_spent == 1 (not 2) and exactly one refiled - entry — append_entry's open-dedupe keeps a replayed refile from duplicating. + reaches DONE with followup_reviews_spent == 1 (not 2) and no ledger entry — + the spent budget is journal-only. Modeled on test_resume_final_review_cycle_replays_clean_result.""" from bmad_loop import deferredwork @@ -7680,12 +7686,11 @@ def crashing_emit(stage, *args, **kwargs): assert final.review_cycle == 2 assert final.followup_reviews_spent == 1 # re-derived once, never double-counted assert len(adapter.sessions) == 1 # only round 2 re-ran; round 1 was replayed - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text()) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 # exactly one, even across the crash/replay + ledger = project.deferred_work.read_text() if project.deferred_work.exists() else "" + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) # journal-only, even across the crash/replay def _tail_death_review_effect(paths, story_key, *, followup: bool): @@ -14253,6 +14258,132 @@ def test_review_pass_deferrals_harvested_and_deduped_across_both_sites(project): assert events[-1]["deduped"] == 1 +def _seeded_ledger(project, *, origin: str, source_spec: str, status: str = "open") -> None: + """One hand-written open entry, shaped like a harvest from ANOTHER spec.""" + project.deferred_work.write_text( + "# Deferred Work\n\n" + f"### DW-1: {HARVEST_A['summary']}\n\n" + f"origin: {origin}\n" + f"location: {HARVEST_A['location']}\n" + f"source_spec: `{source_spec}`\n" + "severity: medium\n" + f"reason: {HARVEST_A['evidence']}\n" + f"status: {status}\n", + encoding="utf-8", + ) + + +def test_harvest_marks_a_cross_spec_duplicate_seen_again(project): + """DW-88 vs DW-65: an identical finding (same fingerprint) already harvested + from ANOTHER spec is a sighting, not new work — no entry is filed, the open + match gains a `seen-again:` line, the journal names it, and the finding never + enters `harvested_deferrals` (so the isolated carry cannot re-file it).""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + assert [e.id for e in entries] == ["DW-1"] # no new entry + assert "seen-again: " in entries[0].body + assert "(spec-deferral harvest of spec-1-1-a.md)" in entries[0].body + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == [] and event["seen_again"] == ["DW-1"] + assert event["deduped"] == 0 + assert engine.state.tasks["1-1-a"].harvested_deferrals == [] + + +def test_harvest_marks_a_dw_prefixed_summary_seen_again(project): + """The DW-88 shape itself: a finding whose summary begins with an open + entry's own `DW-:` id is that entry re-reported, whatever its + fingerprint or origin says.""" + _seeded_ledger( + project, + origin="code review of spec-9-9-z.md, 2026-06-01", + source_spec="spec-9-9-z.md", + ) + finding = { + "summary": "DW-1: the retry ceiling is still missing", + "evidence": "re-reported by a later review", + "location": "src/retry.py:90", + } + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[finding])], + policy=_harvest_policy(), + ) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + assert [e.id for e in entries] == ["DW-1"] + assert "seen-again: " in entries[0].body + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == [] and event["seen_again"] == ["DW-1"] + assert engine.state.tasks["1-1-a"].harvested_deferrals == [] + + +def test_harvest_seen_again_replay_adds_one_line_only(project): + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + assert engine.run().done == 1 + + task = engine.state.tasks["1-1-a"] + engine._harvest_spec_deferrals(task, {"spec_file": str(spec_path(project, "1-1-a"))}) + + text = project.deferred_work.read_text(encoding="utf-8") + assert text.count("seen-again: ") == 1 # the replay skipped its identical line + events = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert [e["seen_again"] for e in events] == [["DW-1"], ["DW-1"]] + assert [e["dw_ids"] for e in events] == [[], []] + + +def test_harvest_files_fresh_when_the_match_is_done(project): + """A closed entry never absorbs a sighting: recurrence after a close files a + fresh entry, consistent with `_apply_append`'s open-only dedupe scan.""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger( + project, + origin=f"spec-deferred {fp}", + source_spec="spec-9-9-z.md", + status="done 2026-06-05", + ) + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + assert [e.id for e in entries] == ["DW-1", "DW-2"] + assert entries[1].open and entries[1].title == HARVEST_A["summary"] + assert "seen-again: " not in entries[0].body + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == ["DW-2"] and event["seen_again"] == [] + + def test_ledger_digest_collapses_absent_and_empty_only(): assert _digest_of(None) == _digest_of("") assert _digest_of("# Deferred Work\n") != _digest_of(None) diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index ec0d9412..7a04cf4f 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1784,10 +1784,10 @@ def test_branch_per_run_kept_failure_detaches_so_next_unit_runs(project): def test_worktree_followup_damped_commits_and_integrates(project): """Damping fires the same in worktree isolation (default cap 1, no _isolated guard): a finalized unit whose review keeps recommending a follow-up converges - after one honored round, the work MERGES into the main repo, and the refiled - follow-up lands in the MAIN repo's ledger — not stranded inside the discarded - unit worktree. Exempting isolation would leave isolated runs non-convergent AND - deferred (strictly worse), which this locks out.""" + after one honored round and the work MERGES into the main repo. No ledger + entry is filed anywhere — the spent budget is journal-only. Exempting + isolation would leave isolated runs non-convergent AND deferred (strictly + worse), which this locks out.""" from bmad_loop import deferredwork commit_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -1806,330 +1806,40 @@ def test_worktree_followup_damped_commits_and_integrates(project): kinds = journal_kinds(engine) assert "review-followup-damped" in kinds and "unit-merged" in kinds assert "story-deferred" not in kinds - # the refiled follow-up is in the MAIN repo ledger, integrated from the worktree - open_refiled = [ - e - for e in deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8")) - if e.open and "origin: review-budget-followup" in e.body - ] - assert len(open_refiled) == 1 - - -# --------------------------------------------- review-budget follow-up carry (#425) -# -# The third producer in the `git add -A` family. Every row here GITIGNORES the -# ledger: with a tracked one the entry rides the unit commit and the merge -# delivers it, which is why `test_worktree_followup_damped_commits_and_integrates` -# passed all along while the reported defect was live. - - -def _refiled_followups(project): - """Open `review-budget-followup` rows in the MAIN checkout's ledger.""" - if not project.deferred_work.is_file(): - return [] - return [ - entry - for entry in deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8")) - if entry.open and "origin: review-budget-followup" in entry.body - ] - + # no refiled follow-up anywhere — the spent budget is journal-only + ledger = ( + project.deferred_work.read_text(encoding="utf-8") if project.deferred_work.exists() else "" + ) + assert not any( + e.open and "origin: review-budget-followup" in e.body + for e in deferredwork.parse_ledger(ledger) + ) -def _damping_script(project, story_key="1-1-a"): - """Dev, then three passes that keep recommending — the default cap 1 damps.""" - return [wt_dev_effect(project, story_key)] + [ - wt_review_effect(project, story_key, clean=False) for _ in range(3) - ] +# The review-budget follow-up carry (#425) is retired with the ticket class it +# delivered: a spent review budget is journaled, never filed as a ledger entry, +# so an isolated merge has nothing to strand. One row locks that in on the +# gitignored shape — the one whose row NEEDED the retired carry. -def test_gitignored_damped_followup_reaches_the_main_ledger(project): - """#425: `_record_review_budget_followup` writes `self.workspace.paths`, which - under isolation is the unit worktree's ledger. `finalize_commit`'s `git add -A` - skips a gitignored path in silence, the merge brings nothing over, and - `close_unit_workspace(success=True)` then deletes the worktree — the DONE leg - takes no `capture_diff`, so not even a `changes.patch` survives. Without the - carry the run journals `refiled: DW-1` while the main checkout has no ledger at - all. - `check-ignore` is the oracle, not the presence of the rule: a row that reads - the tracked-ledger shape by accident is the vacuity this whole block exists to - avoid.""" +def test_a_damped_isolated_story_journals_without_writing_any_ledger(project): + """Gitignored ledger — the shape whose row needed the retired #425 carry. A + damped force-converge journals the spent budget and writes NO ledger entry: + not in the unit worktree, not in the main checkout, nothing to carry.""" ignore_before_commit(project, "deferred-work.md") commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - - summary = engine.run() - - assert summary.done == 1 and summary.deferred == 0 and not summary.paused - rel = project.deferred_work.relative_to(project.project).as_posix() - assert git(project.project, "check-ignore", rel).strip() == rel - assert not verify.path_tracked(project.project, rel) - damped = [e for e in engine.journal.entries() if e["kind"] == "review-followup-damped"] - assert len(damped) == 1 and damped[0]["refiled"] - assert [e.title for e in _refiled_followups(project)] == [ - "Follow-up review still recommended for 1-1-a after the damping cap was spent" - ] - carried = [e for e in engine.journal.entries() if e["kind"] == "review-followup-carried"] - assert len(carried) == 1 and carried[0]["dw_ids"] == [_refiled_followups(project)[0].id] - # `git add -- ` refuses with rc 1: the row lands, the commit - # cannot, and that is recorded rather than raised. - uncommitted = [ - e for e in engine.journal.entries() if e["kind"] == "review-followup-carry-uncommitted" + script = [wt_dev_effect(project, "1-1-a")] + [ + wt_review_effect(project, "1-1-a", clean=False) for _ in range(3) ] - assert len(uncommitted) == 1 - - -def test_tracked_damped_followup_is_not_refiled_twice_by_the_carry(project): - """A tracked ledger delivers the row through the merge, so the carry re-reads - its own provenance and appends nothing. `append_entry` dedupes on `origin:` + - `source_spec:` against OPEN entries, which is what makes running the carry - unconditionally safe rather than needing a tracked/ignored predicate.""" - write_ledger(project, {}) - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - - summary = engine.run() - - assert summary.done == 1 and not summary.paused - assert verify.path_tracked( - project.project, project.deferred_work.relative_to(project.project).as_posix() - ) - assert len(_refiled_followups(project)) == 1 - carried = [e for e in engine.journal.entries() if e["kind"] == "review-followup-carried"] - assert len(carried) == 1 and carried[0]["dw_ids"] == [] - - -def test_a_deduped_followup_is_still_recorded_for_the_carry(project): - """The record is the INTENT, not a receipt for a new id. A story whose - follow-up was already open appends nothing, and the producer still records it, - because the record has to be durable before the append that may dedupe it — - otherwise a replay after a host loss can never reconstruct authorship. The - carry then dedupes in turn and journals an empty `dw_ids`, which the tracked - path already produces routinely.""" - write_ledger(project, {}) - deferredwork.append_entry( - project.deferred_work, - title="already recommended", - origin="review-budget-followup", - source_spec="spec-1-1-a.md", - reason="filed by an earlier run", - severity="low", - ) - git(project.project, "add", "-A") - git(project.project, "commit", "-q", "-m", "prior follow-up") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - + engine, _ = make_engine(project, script) # default wt_policy() → cap 1 summary = engine.run() - assert summary.done == 1 and not summary.paused + assert summary.done == 1 and summary.deferred == 0 and not summary.paused damped = [e for e in engine.journal.entries() if e["kind"] == "review-followup-damped"] - assert len(damped) == 1 and damped[0]["refiled"] is None - assert len(engine.state.tasks["1-1-a"].refiled_followups) == 1 - carried = [e for e in engine.journal.entries() if e["kind"] == "review-followup-carried"] - assert len(carried) == 1 and carried[0]["dw_ids"] == [] - # no duplicate: the pre-existing open row is the only one - assert len(_refiled_followups(project)) == 1 - - -def _host_loss_before_the_commit_save(engine, snap): - """A host loss inside `_commit`, before its `advance(COMMITTING)` + `_save()`. - - `snap` captures the bytes that were DURABLE at that instant. Restoring them - over whatever `run()`'s unwind-`finally` wrote is what makes this a SIGKILL - rather than a SIGINT: the engine's own teardown save would otherwise persist - the very record whose durability is under test, and the row would arrive for - a reason the fix has nothing to do with. - """ - - def commit_with_host_loss(_task): - snap["state"] = (engine.run_dir / "state.json").read_bytes() - raise RuntimeError("host died between the ledger write and the COMMITTING save") - - engine._commit = commit_with_host_loss - - -def test_host_loss_before_the_commit_save_still_carries_the_followup(project): - """The producer's record must be persisted BEFORE its ledger append. - - Nothing saves between `_record_review_budget_followup` and `_commit`'s - COMMITTING save, and that window spans every blocking `pre_commit_gate` - workflow — the shipped TEA plugin binds three, each a live session. Recording - after a successful append loses the row for good: the resumed run replays the - same review result in the same worktree, `append_entry` dedupes the row it - already wrote there to None, so the record is never made, the carry finds an - empty payload, and `close_unit_workspace` deletes the worktree holding the - only copy. - """ - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - snap: dict = {} - _host_loss_before_the_commit_save(engine, snap) - - assert engine.run().crashed - - worktree = Path(engine.state.tasks["1-1-a"].worktree_path) - # the row exists, but only inside the unit worktree's gitignored ledger - assert [e.id for e in _refiled_followups(project.rebased(worktree))] == ["DW-1"] - assert not project.deferred_work.exists() - - (engine.run_dir / "state.json").write_bytes(snap["state"]) - durable = load_state(engine.run_dir).tasks["1-1-a"] - assert durable.phase == Phase.REVIEW_VERIFY - assert durable.refiled_followups - - state = load_state(engine.run_dir) - state.clear_pause() - adapter = MockAdapter([]) - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=adapter, - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.crashed and not summary.paused - assert not worktree.is_dir() - assert [e.title for e in _refiled_followups(project)] == [ - "Follow-up review still recommended for 1-1-a after the damping cap was spent" - ] - - -def test_a_replayed_review_result_records_the_followup_once(project): - """The record is keyed on `origin:` + `source_spec:`, so the replay that - re-enters the damped path with the record already persisted appends no second - copy — and files no second ledger row.""" - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - _host_loss_before_the_commit_save(engine, {}) - - assert engine.run().crashed - # the unwind-finally persisted the record, so the replay re-enters the damped - # path with it already in hand — the case the keyed dedupe exists for - assert len(load_state(engine.run_dir).tasks["1-1-a"].refiled_followups) == 1 - - state = load_state(engine.run_dir) - state.clear_pause() - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=MockAdapter([]), - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.crashed - assert len(resumed.state.tasks["1-1-a"].refiled_followups) == 1 - assert len(_refiled_followups(project)) == 1 - - -def test_crashed_post_merge_followup_carry_replays_from_its_record(project): - """A story whose ONLY ledger write is a damped follow-up has both other carry - payloads empty, so the resume pass has to name this one to reach it: crash in - the merge-to-latch window and the row is otherwise stranded in a worktree that - is already gone.""" - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - crash_at_merge_back(engine, after="merge") - - assert engine.run().crashed - - crashed = load_state(engine.run_dir).tasks["1-1-a"] - assert crashed.phase == Phase.DONE and not crashed.isolated_ledger_carried - assert crashed.refiled_followups and not crashed.harvested_deferrals - assert not project.deferred_work.exists() - - state = load_state(engine.run_dir) - state.clear_pause() - adapter = MockAdapter([]) - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=adapter, - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.crashed and not summary.paused - assert adapter.sessions == [] - assert "resume-ledger-carry" in journal_kinds(resumed) - assert len(_refiled_followups(project)) == 1 - assert load_state(resumed.run_dir).tasks["1-1-a"].isolated_ledger_carried - - -def test_a_re_armed_story_does_not_carry_the_abandoned_attempt_s_followup(project, monkeypatch): - """The record is scoped to the attempt that made it, and `_dev_phase`'s - fresh-attempt clear is what enforces that. - - An escalation between the damped record and the commit leaves the unit - worktree mounted and unmerged; the re-drive then DISCARDS it, taking the only - copy of the row with it. Without the clear the record outlives its attempt, - and the next attempt's DONE leg files a follow-up against the commit that - actually landed — one whose review recommended nothing. `rearm_escalation` - already resets `followup_reviews_spent` for a fresh damping budget, so keeping - the record contradicts the re-arm on its own terms. - - Nothing upstream absorbs it either: the abandoned attempt's ledger write never - reached the main checkout, so `append_entry` has no open row to dedupe against - and a TRACKED ledger would commit the stale row rather than swallow it. - """ - ignore_before_commit(project, "deferred-work.md") - commit_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, _damping_script(project)) - - real_finalize = verify.finalize_commit - - def commit_fails(*_a, **_k): - raise verify.GitError("simulated commit failure") - - monkeypatch.setattr(verify, "finalize_commit", commit_fails) - - assert engine.run().paused - - escalated = load_state(engine.run_dir).tasks["1-1-a"] - assert escalated.phase == Phase.ESCALATED - assert escalated.refiled_followups # persisted by the producer, pre-append - assert not project.deferred_work.exists() # the row is only in the doomed worktree - - monkeypatch.setattr(verify, "finalize_commit", real_finalize) - assert ( - runs.rearm_escalation( - engine.run_dir, "1-1-a", isolated_redrive=True, resolution_recorded=True - ) - == "1-1-a" - ) - - state = load_state(engine.run_dir) - state.clear_pause() - resumed = Engine( - paths=project, - policy=engine.policy, - adapter=MockAdapter( - [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)] - ), - run_dir=engine.run_dir, - journal=engine.journal, - state=state, - ) - summary = resumed.run() - - assert summary.done == 1 and not summary.paused and not summary.crashed - # the re-drive converged on its own review: only the ABANDONED attempt damped - assert journal_kinds(resumed).count("review-followup-damped") == 1 - # the harm, asserted before its cause: no follow-up row for the commit that - # landed, and no carry claiming to have filed one - assert [e.title for e in _refiled_followups(project)] == [] - assert "review-followup-carried" not in journal_kinds(resumed) - assert not resumed.state.tasks["1-1-a"].refiled_followups + assert len(damped) == 1 and damped[0]["re_review_capped"] is False + assert "refiled" not in damped[0] + assert not project.deferred_work.exists() # no main-checkout row + assert "review-followup-carried" not in journal_kinds(engine) # ----------------------------------------------------------------- configured target @@ -5216,7 +4926,7 @@ def test_crashed_post_merge_board_advance_replays_from_its_record(project): assert crashed.phase == Phase.DONE and not crashed.isolated_ledger_carried # durable, and the ONLY payload that can reach the carry for this story assert crashed.board_advance_intended == "done" - assert not crashed.harvested_deferrals and not crashed.refiled_followups + assert not crashed.harvested_deferrals assert not crashed.story_closes_intended and not crashed.bundle_closes_intended assert sprintstatus.story_status(project.sprint_status, "1-1-a") == "ready-for-dev" @@ -5691,8 +5401,7 @@ def test_a_gitignored_board_story_finished_by_one_run_is_not_re_picked_by_the_ne def test_crashed_post_merge_story_close_replays_from_its_record(project): """A story whose ONLY ledger write is a declared close has every other carry - payload empty, so the resume pass has to name this one to reach it — the same - reachability the damped follow-up needed, for the fourth producer.""" + payload empty, so the resume pass has to name this one to reach it.""" ignore_before_commit(project, "deferred-work.md") write_ledger(project, {"DW-1": "open"}) commit_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -5708,7 +5417,7 @@ def test_crashed_post_merge_story_close_replays_from_its_record(project): assert crashed.phase == Phase.DONE and not crashed.isolated_ledger_carried # only the new disjunct can reach the carry assert crashed.story_closes_intended == ["DW-1"] - assert not crashed.harvested_deferrals and not crashed.refiled_followups + assert not crashed.harvested_deferrals assert _ledger_entry(project, "DW-1").open state = load_state(engine.run_dir) @@ -5738,7 +5447,7 @@ def test_a_re_armed_story_does_not_carry_a_withdrawn_declaration(project, monkey `_close_declared_deferred` reads `closes_deferred:` LIVE and reassigns `story_closes_intended` before its own early return, so it needs no - `_dev_phase` clear the way `refiled_followups` does: DONE is reachable only + `_dev_phase` clear: DONE is reachable only through `_finalize_commit_phase`, which always re-enters the producer. A human who resolves an escalation by WITHDRAWING the declaration must not have the abandoned attempt's ids closed on their behalf — the exact stale-snapshot case diff --git a/tests/test_model.py b/tests/test_model.py index 50fd5ac0..3ced6da2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -474,7 +474,6 @@ def test_board_advance_intended_keeps_none_distinct_from_a_status(): "ledger_changed_before_harvest", "harvested_deferrals", "bundle_closes_intended", - "refiled_followups", "story_closes_intended", "accepted_dev_session_index", "harvest_carry_commit_pending", @@ -483,7 +482,7 @@ def test_board_advance_intended_keeps_none_distinct_from_a_status(): def test_deferred_work_state_fields_round_trip_through_json(): - """All twelve fields are hand-enumerated in both serializers. Non-default + """All eleven fields are hand-enumerated in both serializers. Non-default values make a missing line on either side observable, while the JSON leg pins the on-disk container shape rather than only an in-memory dataclass copy.""" task = StoryTask( @@ -496,7 +495,6 @@ def test_deferred_work_state_fields_round_trip_through_json(): ledger_changed_before_harvest=True, harvested_deferrals=[{"origin": "spec-deferred abc", "title": "finding"}], bundle_closes_intended=["DW-3", "DW-7"], - refiled_followups=[{"origin": "review-budget-followup", "title": "follow-up"}], story_closes_intended=["DW-4"], accepted_dev_session_index=3, harvest_carry_commit_pending=True, @@ -512,9 +510,6 @@ def test_deferred_work_state_fields_round_trip_through_json(): assert restored.ledger_changed_before_harvest is True assert restored.harvested_deferrals == [{"origin": "spec-deferred abc", "title": "finding"}] assert restored.bundle_closes_intended == ["DW-3", "DW-7"] - assert restored.refiled_followups == [ - {"origin": "review-budget-followup", "title": "follow-up"} - ] assert restored.story_closes_intended == ["DW-4"] assert restored.accepted_dev_session_index == 3 assert restored.harvest_carry_commit_pending is True @@ -522,7 +517,7 @@ def test_deferred_work_state_fields_round_trip_through_json(): def test_deferred_work_state_fields_default_for_one_old_state_dict(): - """A state.json written before this package has none of the twelve keys. + """A state.json written before this package has none of the eleven keys. Every load must use ``d.get`` so resume reaches the old behavior instead of raising KeyError; one shared old document prevents testing only a subset.""" doc = StoryTask(story_key="1-1-a", epic=1).to_dict() @@ -537,7 +532,6 @@ def test_deferred_work_state_fields_default_for_one_old_state_dict(): assert restored.ledger_changed_before_harvest is False assert restored.harvested_deferrals == [] assert restored.bundle_closes_intended == [] - assert restored.refiled_followups == [] assert restored.story_closes_intended == [] assert restored.accepted_dev_session_index is None assert restored.harvest_carry_commit_pending is False @@ -563,21 +557,15 @@ def test_deferred_work_state_containers_do_not_alias_the_persisted_doc(): {"title": "original", "metadata": {"labels": ["review"]}}, ], bundle_closes_intended=["DW-1"], - refiled_followups=[{"title": "followup", "metadata": {"labels": ["review"]}}], ).to_dict() restored = StoryTask.from_dict(doc) restored.harvested_deferrals[0]["title"] = "mutated" restored.harvested_deferrals[0]["metadata"]["labels"].append("follow-up") restored.bundle_closes_intended.append("DW-2") - restored.refiled_followups[0]["title"] = "mutated" - restored.refiled_followups[0]["metadata"]["labels"].append("follow-up") assert doc["harvested_deferrals"] == [ {"title": "original", "metadata": {"labels": ["review"]}}, ] assert doc["bundle_closes_intended"] == ["DW-1"] - assert doc["refiled_followups"] == [ - {"title": "followup", "metadata": {"labels": ["review"]}}, - ] def test_deferred_work_state_container_defaults_are_not_shared(): @@ -585,10 +573,8 @@ def test_deferred_work_state_container_defaults_are_not_shared(): other = StoryTask(story_key="1-2-b", epic=1) one.harvested_deferrals.append({"title": "one"}) one.bundle_closes_intended.append("DW-1") - one.refiled_followups.append({"title": "one"}) assert other.harvested_deferrals == [] assert other.bundle_closes_intended == [] - assert other.refiled_followups == [] def test_restore_patch_round_trips(): diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 06a91b47..e042c880 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -406,6 +406,7 @@ "run_id", "run_type", "security_config_changed", + "seen_again", "sentinel", "sentinel_kind", "session_status", diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 93cf106c..97ae64ab 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -4489,11 +4489,11 @@ def test_migration_duplicate_refusal_clears_a_baseline_it_arrived_holding(projec # ------------------------------------------ review-budget commit-instead-of-rollback -def test_sweep_bundle_budget_exhausted_commits_and_refiles(project): +def test_sweep_bundle_budget_exhausted_commits_and_journals(project): """A bundle whose review keeps recommending a follow-up but is finalized (spec done, owned dw ids closed, verify green) is COMMITTED when the review - budget is exhausted — not rolled back. The lingering follow-up is re-filed as - a fresh open deferred-work entry.""" + budget is exhausted — not rolled back. The spent budget is journaled; no + deferred-work entry is filed.""" write_ledger(project, {"DW-1": "open"}) plan = triage_result( ["DW-1"], @@ -4519,15 +4519,16 @@ def test_sweep_bundle_budget_exhausted_commits_and_refiles(project): entries = ledger_entries(project) assert entries["DW-1"].status.startswith("done") # the worked item closed refiled = [e for e in entries.values() if e.open and "origin: review-budget-followup" in e.body] - assert len(refiled) == 1 + assert refiled == [] # journal-only: no follow-up entry filed kinds = {e["kind"] for e in engine.journal.entries()} assert "review-budget-committed" in kinds and "story-deferred" not in kinds def test_sweep_bundle_budget_followup_not_refiled_twice(project): """Re-review cap: when a bundle itself closes a `review-budget-followup` entry - and still won't converge, the work is committed but NOT re-filed again — a - second non-convergence should reach a human, not loop across sweeps.""" + (legacy and hand-filed rows still exist) and still won't converge, the work is + committed and the journal flags the repeat — a second non-convergence should + reach a human, not loop across sweeps.""" ledger = ( "# Deferred Work\n\n" "### DW-1: follow-up still recommended for dw-prior\n" @@ -4568,11 +4569,11 @@ def test_sweep_bundle_budget_followup_not_refiled_twice(project): assert len(capped) == 1 and capped[0]["re_review_capped"] is True -def test_sweep_bundle_followup_damped_commits_and_refiles(project): +def test_sweep_bundle_followup_damped_commits_and_journals(project): """Default damping cap (1): a bundle whose review keeps recommending a follow-up converges after ONE honored round instead of burning the whole review budget. - The lingering follow-up is re-filed once, the work is committed, and — the - steady state — the damped converge stays quiet (no review-budget ATTENTION).""" + The spent budget is journaled (no ledger entry), the work is committed, and — + the steady state — the damped converge stays quiet (no review-budget ATTENTION).""" write_ledger(project, {"DW-1": "open"}) plan = triage_result( ["DW-1"], @@ -4593,7 +4594,7 @@ def test_sweep_bundle_followup_damped_commits_and_refiles(project): entries = ledger_entries(project) assert entries["DW-1"].status.startswith("done") # the worked item closed refiled = [e for e in entries.values() if e.open and "origin: review-budget-followup" in e.body] - assert len(refiled) == 1 + assert refiled == [] # journal-only: no follow-up entry filed kinds = {e["kind"] for e in engine.journal.entries()} assert "review-followup-damped" in kinds assert "review-budget-committed" not in kinds and "story-deferred" not in kinds @@ -4603,9 +4604,10 @@ def test_sweep_bundle_followup_damped_commits_and_refiles(project): def test_sweep_bundle_damped_re_review_capped_notifies_not_refiles(project): """Re-review cap survives damping: when a bundle itself closes a - `review-budget-followup` entry and still won't converge, the damped force- - converge commits but does NOT re-file again — and, unlike an ordinary quiet - damped converge, it raises an ATTENTION notice so a human sees the repeat.""" + `review-budget-followup` entry (legacy and hand-filed rows still exist) and + still won't converge, the damped force-converge commits — and, unlike an + ordinary quiet damped converge, it raises an ATTENTION notice so a human sees + the repeat.""" ledger = ( "# Deferred Work\n\n" "### DW-1: follow-up still recommended for dw-prior\n" From 84fa8ca87194a623fd3ac28019cd9a9e7fa79c0e Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 17:35:04 -0700 Subject: [PATCH 02/35] sweep dw-resolve-session-root-context: DW-14, DW-35 via bmad-loop --- CHANGELOG.md | 9 + src/bmad_loop/bmadconfig.py | 2 + src/bmad_loop/cli.py | 33 ++- .../data/skills/bmad-loop-resolve/SKILL.md | 16 +- src/bmad_loop/resolve.py | 131 +++++++--- tests/test_bmadconfig.py | 14 ++ tests/test_cli.py | 175 +++++++++++++ tests/test_resolve.py | 235 ++++++++++++++++-- tests/test_resolve_skill_contract.py | 30 +++ 9 files changed, 595 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 077aeca3..ee5dbe57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ breaking changes may land in a minor release. ### Added +- **Interactive resolve context names both the BMAD project root and the run's code + root.** `bmad-loop resolve` warns before a divergent-root session launches, while + keeping the session project-rooted and directing code fixes and commits to the code + root. + - **A failed re-arm commits probe now journals `rearm-commits-probe-failed`** (DW-81). The warn-only probe that lists the commits an abandoned attempt left below the re-drive's new baseline used to swallow its `GitError` and write nothing — byte-identical to finding @@ -77,6 +82,10 @@ breaking changes may land in a minor release. ### Changed +- **Resolve context builds only the mode-specific details its consumer uses.** + Non-stories runs skip stories-root lookup, and stories sentinels report null frozen-spec + reachability without probing a spec they do not edit. + - **`bmad-loop diagnose --json` reports `schema_version: 3`.** Replacing a journal-entry value with a presence key is a payload break under the additive-only rule, and the redaction fixes above make two: a consumer reading `entry["question"]` on `decision-pending`, or an diff --git a/src/bmad_loop/bmadconfig.py b/src/bmad_loop/bmadconfig.py index 94fce0ce..a37f9567 100644 --- a/src/bmad_loop/bmadconfig.py +++ b/src/bmad_loop/bmadconfig.py @@ -199,6 +199,8 @@ def load_paths(project: Path) -> ProjectPaths: doc = yaml.safe_load(raw) or {} except yaml.YAMLError as e: raise BmadConfigError(f"invalid YAML in {config_path}: {e}") from e + if not isinstance(doc, dict): + raise BmadConfigError(f"{config_path} must contain a top-level mapping") impl = doc.get("implementation_artifacts") plan = doc.get("planning_artifacts") diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 2c9e0817..29c34a8c 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3116,9 +3116,31 @@ def cmd_resolve(args: argparse.Namespace) -> int: if args.interactive: adapters = _make_adapters(project, run_dir, pol) model = pol.adapter.resolved("dev").model + # The interactive session uses the CURRENT CLI project as cwd. Its code root + # must come from the CURRENT config too: both can have moved since state.json + # was written. This is best-effort observation only; the mandatory config + # re-read after the human conversation remains the authority for re-arm. + try: + pre_session_paths = bmadconfig.load_paths(project) + except (bmadconfig.BmadConfigError, OSError): + pre_session_code_root = state.code_root + else: + pre_session_code_root = pre_session_paths.repo_root _ctx_path, withheld, unreadable = resolve.build_context( - state, run_dir, story_key, isolation=pol.scm.isolation + state, + run_dir, + story_key, + isolation=pol.scm.isolation, + project_root=project, + code_root=pre_session_code_root, ) + if pre_session_code_root != project: + print( + f"warning: resolve session stays project-rooted at {project.as_posix()!r}; " + "code fixes and commits belong in the run's code root at " + f"{pre_session_code_root.as_posix()!r}", + file=sys.stderr, + ) print(f"launching resolve agent for {story_key} — converse, fix the spec, then exit…") try: produced = resolve.run_session( @@ -3248,6 +3270,15 @@ def cmd_resolve(args: argparse.Namespace) -> int: file=sys.stderr, ) else: + if args.interactive and paths.repo_root != pre_session_code_root: + print( + "error: the code root changed during the resolve session from " + f"{pre_session_code_root.as_posix()!r} to {paths.repo_root.as_posix()!r}; " + "the agent's guidance no longer names the tree the re-drive would use. " + "No re-arm was performed; reconcile the code change, then run resolve again.", + file=sys.stderr, + ) + return 1 # The SAME refusal `_resume_paused_run` makes, hoisted ahead of both writes # below — because aiming the mirror at the tree config.yaml names is only # correct for a configuration the orchestrator will actually run, and this is diff --git a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md index f6e5353f..095b4d50 100644 --- a/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md @@ -31,6 +31,8 @@ These environment variables are set: { "story_key": "6-4-cli-list-command", "run_id": "20260613-111429-6a14", + "project_root": "/abs/path/to/bmad-project", + "code_root": "/abs/path/to/code-repository", "spec_file": "/abs/path/to/_bmad-output/implementation-artifacts/spec-.md", "spec_reaches_the_redrive": true, "redrive_base_ref": "", @@ -47,13 +49,23 @@ These environment variables are set: } ``` +The interactive session's working directory is always `project_root`. That tree holds +the BMAD artifacts and specs you inspect or clarify. `code_root` is the tree where the +run's code and git work belong; it may be different. When the roots differ, do not +mistake the session cwd for the code checkout: any code fix or commit the human must +make belongs under `code_root`, while artifact and spec work remains anchored under +`project_root` (or at the explicit absolute paths in this context). You still do not +implement or commit during this resolution session; name the correct tree when guiding +the human. + **`spec_reaches_the_redrive` says whether your edit has a future.** The re-drive reads one tree; `spec_file` may name another. Under worktree isolation the run's mount is discarded before the re-drive reads anything, so a spec inside that mount is destroyed with it. When this field is `false`, every write to `spec_file` still SUCCEEDS and is then thrown away — worse than not editing at all, because the session -looks resolved. `null` means the task has no spec on record: there is nothing to edit -and step 4 does not apply. +looks resolved. `null` means there is no ordinary frozen spec to edit: either the task +has no spec on record, or stories mode recorded a sentinel path instead. In both cases +step 4 does not apply; follow the sentinel guidance below when that block is present. **`redrive_base_ref` tells you which of the two remedies applies.** Read it before you tell the human anything: a branch name and `HEAD` mean opposite things. diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 84458591..264eb04f 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -25,7 +25,7 @@ from .engine import _session_task_id from .escalation import critical_escalations from .journal import TASK_CYCLE_ARTIFACTS -from .model import RunState +from .model import RunState, StoryTask from .platform_util import safe_segment from .runs import ( redrive_base_ref, @@ -46,6 +46,38 @@ def context_path(run_dir: Path, story_key: str) -> Path: return _story_dir(run_dir, story_key) / "context.json" +def _rebase_recorded_project_path(path: Path, state: RunState, project_root: Path) -> Path: + """Move a project-owned persisted path with a renamed project, lexically. + + Run state intentionally keeps the launch-time project string, while the resolve + session runs from the live CLI project. Paths outside the recorded project are + shared/external and remain untouched. This is spelling arithmetic only: do not + introduce filesystem canonicalization at the context serialization boundary. + """ + recorded_project = Path(state.project) + if project_root == recorded_project: + return path + try: + relative = path.relative_to(recorded_project) + except ValueError: + return path + return project_root / relative + + +def _context_stories_root(task: StoryTask | None, state: RunState, project_root: Path) -> Path: + """Resolve the stories tree against the live project after a project move.""" + if task is not None and task.worktree_path: + recorded_mount = Path(task.worktree_path) + live_mount = _rebase_recorded_project_path(recorded_mount, state, project_root) + if live_mount != recorded_mount: + try: + if live_mount.is_dir(): + return live_mount + except OSError: + pass + return _rebase_recorded_project_path(task_stories_root(task, state), state, project_root) + + def resolution_path(run_dir: Path, story_key: str) -> Path: return _story_dir(run_dir, story_key) / "resolution.json" @@ -250,7 +282,13 @@ def _gather_escalations( def build_context( - state: RunState, run_dir: Path, story_key: str, *, isolation: str + state: RunState, + run_dir: Path, + story_key: str, + *, + isolation: str, + project_root: Path | None = None, + code_root: Path | None = None, ) -> tuple[Path, int, int]: """Write resolve//context.json for the resolve skill to read, and return it beside the number of already-answered escalations withheld from it and @@ -283,6 +321,13 @@ def build_context( seam that reports it.""" task = state.tasks.get(story_key) isolated_redrive = isolation == "worktree" + current_project_root = project_root if project_root is not None else Path(state.project) + current_code_root = code_root if code_root is not None else state.code_root + context_spec_path = ( + _rebase_recorded_project_path(task_spec_path(task, state), state, current_project_root) + if task and task.spec_file + else None + ) # Patch-restore availability (#2564): the shared `validate_restore_latch` # verdict, not a local copy of one leg. Any of them — worktree isolation (the # re-drive discards and re-mounts the unit's worktree), a spec-less escalation, @@ -292,13 +337,19 @@ def build_context( restore_supported = task is not None and ( validate_restore_latch(state, task, story_key, worktree_isolation=isolated_redrive) is None ) - # Which tree holds this run's STORY MANIFEST — the workspace root, answered by - # `task_stories_root` rather than by `task_spec_root`. The latter answers a - # write-confinement question about `spec_file` and falls back to the project for an - # out-of-mount spec; borrowing it here pointed the sentinel and the stories block at - # the main checkout while `stories_engine._stories_folder` was still the mount, so - # one `context.json` could name two trees. - stories_root = task_stories_root(task, state) + stories_ctx: dict[str, Any] | None = None + if state.source == "stories": + # Which tree holds this run's STORY MANIFEST — the workspace root, answered by + # `task_stories_root` rather than by `task_spec_root`. Sprint mode consumes no + # stories data, so it must not probe for a stories root at all. + stories_root = _context_stories_root(task, state, current_project_root) + stories_ctx = _stories_context( + state, story_key, stories_root, task, context_spec_path=context_spec_path + ) + # The persisted detection verdict is authoritative, matching re-arm. A real spec + # may legally have a sentinel-shaped basename, while a recorded sentinel remains a + # sentinel even when its file is unreadable or has disappeared. + sentinel = state.source == "stories" and task is not None and bool(task.sentinel_kind) # DW-11: hide what an earlier resolve cycle already answered. `start` is the task's # own watermark — 0 for a task never resolved, and for every pre-upgrade # `state.json`, which is the unfiltered pre-DW-11 walk. @@ -313,13 +364,20 @@ def build_context( context = { "story_key": story_key, "run_id": state.run_id, + # The interactive session deliberately stays rooted at the BMAD project, while + # code and git may live in the run's separately configured repository root. + # Serialize the caller's live snapshot without another canonicalization step: + # these are stable POSIX-form context strings, not a new path-resolution seam. + "project_root": current_project_root.as_posix(), + "code_root": current_code_root.as_posix(), # Absolute, matching the shape `bmad-loop-resolve/SKILL.md` documents: an # isolated unit's `spec_file` is persisted RELATIVE to the mounted worktree # (`model.StoryTask._serialized_worktree_path`) and the agent session runs # from the project root, where the main checkout carries the same # `_bmad-output/specs/...` layout — the raw value would name the wrong - # tree's copy. `task_spec_path` is the same re-anchor `rearm_escalation` - # writes through, so the agent edits the file the re-arm will flip. + # tree's copy. `task_spec_path` provides the persisted anchor; when the whole + # project moved, `_rebase_recorded_project_path` carries that project-owned + # spelling onto the live session root without resolving it through the OS. # as_posix() for the same reason `resolution_path` below uses it — the # context contract is one string on every OS — and because the value this # replaces was ALREADY posix under isolation: `_serialized_worktree_path` @@ -327,7 +385,7 @@ def build_context( # NON-isolated absolute case, which was previously emitted verbatim: on Windows # that changes `C:\\...\\spec.md` to `C:/.../spec.md`. Deliberate — one # spelling for every reader — and consumed by an agent, which accepts '/'. - "spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None), + "spec_file": context_spec_path.as_posix() if context_spec_path is not None else None, "baseline_commit": task.baseline_commit if task else None, "paused_reason": state.paused_reason, "escalations": escalations, @@ -341,13 +399,13 @@ def build_context( # every write succeed. `rearm_escalation` already journals # `rearm-spec-write-unreachable` on this same verdict; naming it here is what # lets the session act on it instead of learning it afterwards. - # Guarded on `task.spec_file` exactly as `spec_file` above is: a verdict about - # whether an edit SURVIVES is meaningless beside a `"spec_file": null`, and - # emitting one invited the session to act on a reachability answer for a file - # the same document says does not exist. Both fields are one claim. + # Guarded on `task.spec_file` as `spec_file` above is, and additionally on the + # stories sentinel the context just discovered: a verdict about whether a + # frozen-spec edit SURVIVES is meaningless when the file is absent, and a + # sentinel flow explicitly edits upstream intent rather than its sentinel. "spec_reaches_the_redrive": ( spec_reaches_the_redrive(task, state, isolated_redrive=isolated_redrive) - if task and task.spec_file + if task and task.spec_file and not sentinel else None ), # WHERE a correction has to land to be read. Emitted beside the verdict @@ -376,22 +434,27 @@ def build_context( # sentinel indicator, so it sees WHAT the story is meant to do and WHETHER the # frozen spec even exists yet (a sentinel has no plan to edit — resolve the # underlying ambiguity instead). Sprint mode leaves the context unchanged. - if state.source == "stories": - stories_ctx = _stories_context(state, story_key, stories_root) - if stories_ctx: - context["stories"] = stories_ctx + if stories_ctx: + context["stories"] = stories_ctx path = context_path(run_dir, story_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(context, indent=2), encoding="utf-8") return path, withheld, len(unreadable) -def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]: +def _stories_context( + state: RunState, + story_key: str, + root: Path, + task: StoryTask | None, + *, + context_spec_path: Path | None, +) -> dict[str, Any]: """The stories-mode extension of the resolve context: the spec folder, the manifest entry for the story (title/description/checkpoint flags/invoke_dev_with), - and — when the escalated spec is a fixed-slug pre-planning-halt sentinel — a - sentinel indicator with its kind and recorded blocking condition. Best-effort: - an unreadable manifest just yields the folder (resolve still runs).""" + and — when the task persisted a pre-planning-halt sentinel verdict — a sentinel + indicator using that recorded kind/path plus the best-effort blocking condition. + An unreadable manifest or sentinel never changes the recorded mode.""" from . import stories # `root`, not `Path(state.project)`: the caller resolved it with @@ -415,18 +478,20 @@ def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, A "done_checkpoint": entry.done_checkpoint, "invoke_dev_with": entry.invoke_dev_with, } - try: - st = stories.resolve_story_spec(folder, story_key) - except (OSError, UnicodeDecodeError): - st = None - if st is not None and st.kind == stories.KIND_SENTINEL and st.path is not None: + if task is not None and task.sentinel_kind: + sentinel_kind = task.sentinel_kind + sentinel_path = context_spec_path try: - condition = stories.recorded_blocking_condition(st.path.read_text(encoding="utf-8")) + condition = ( + stories.recorded_blocking_condition(sentinel_path.read_text(encoding="utf-8")) + if sentinel_path is not None + else "" + ) except (OSError, UnicodeDecodeError): condition = "" ctx["sentinel"] = { - "kind": st.sentinel_kind, - "path": st.path.as_posix(), + "kind": sentinel_kind, + "path": sentinel_path.as_posix() if sentinel_path is not None else None, "blocking_condition": condition, } return ctx diff --git a/tests/test_bmadconfig.py b/tests/test_bmadconfig.py index 99aa01d2..1da880d0 100644 --- a/tests/test_bmadconfig.py +++ b/tests/test_bmadconfig.py @@ -62,6 +62,20 @@ def test_load_paths_non_utf8_config_raises_bmad_config_error(project) -> None: bmadconfig.load_paths(project.project) +def test_load_paths_non_mapping_config_raises_bmad_config_error(project) -> None: + """A syntactically valid YAML sequence is still an invalid BMAD config. + + The typed boundary matters to best-effort observers such as interactive resolve: + they catch ``BmadConfigError`` and can fall back to the run's recorded roots. + """ + install_bmad_config(project) + cfg = project.project / "_bmad" / "bmm" / "config.yaml" + cfg.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + with pytest.raises(bmadconfig.BmadConfigError, match="top-level mapping"): + bmadconfig.load_paths(project.project) + + def test_rebased_reroots_project_and_artifacts(tmp_path: Path) -> None: src = tmp_path / "main" paths = ProjectPaths( diff --git a/tests/test_cli.py b/tests/test_cli.py index d70d8c0e..9d6ca38a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3246,6 +3246,181 @@ def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch): assert load_state(run_dir).tasks["s1"].phase == Phase.PENDING +def test_resolve_warns_about_divergent_roots_before_the_session(tmp_path, monkeypatch, capsys): + """The advisory names both trees before the interactive boundary while the + session still receives the project as its cwd root. + + Ablation: move the warning below `run_session` and the in-session assertion fails; + switch the launch root to `state.code_root` and the project assertion fails. + """ + from bmad_loop import resolve + from bmad_loop.journal import load_state, save_state + + run_dir = _escalated_run(tmp_path, "r1") + code_root = tmp_path / "code\nroot\x1b[31m" + state = load_state(run_dir) + state.repo_root = str(code_root) + save_state(run_dir, state) + context_roots: dict[str, Path] = {} + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + + def fake_context(*args, **kwargs): + context_roots["project"] = kwargs["project_root"] + context_roots["code"] = kwargs["code_root"] + return None, 0 + + monkeypatch.setattr(resolve, "build_context", fake_context) + + def fake_session(adapter, project, rd, story_key, **kwargs): + err = capsys.readouterr().err + assert "resolve session stays project-rooted" in err + assert repr(tmp_path.as_posix()) in err + assert repr(code_root.as_posix()) in err + assert "\x1b" not in err # terminal escape is rendered as the literal "\\x1b" + assert len(err.splitlines()) == 1 # embedded newline did not inject another line + assert "code fixes and commits belong" in err + assert project == tmp_path + return True + + monkeypatch.setattr(resolve, "run_session", fake_session) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + assert context_roots == {"project": tmp_path, "code": code_root} + + +def test_resolve_context_uses_live_project_after_project_rename(tmp_path, monkeypatch, capsys): + """The current CLI project is both session cwd and context root even when the + persisted launch-time project path names the pre-rename location.""" + from bmad_loop import resolve, runs + from bmad_loop.journal import load_state, save_state + + _write_bmad_config(tmp_path) + run_dir = _escalated_run(tmp_path, "r1") + old_project = tmp_path.parent / "project-before-rename" + state = load_state(run_dir) + state.project = str(old_project) + state.repo_root = str(old_project) + save_state(run_dir, state) + seen: dict[str, Path] = {} + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + + def fake_context(*args, **kwargs): + seen["project"] = kwargs["project_root"] + seen["code"] = kwargs["code_root"] + return None, 0 + + monkeypatch.setattr(resolve, "build_context", fake_context) + monkeypatch.setattr( + resolve, + "run_session", + lambda adapter, project, *a, **k: seen.setdefault("cwd", project) or True, + ) + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: key) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + assert seen == {"project": tmp_path, "code": tmp_path, "cwd": tmp_path} + assert str(old_project) not in capsys.readouterr().err + + +def test_resolve_context_uses_live_configured_code_root(project, monkeypatch, capsys): + """A paused run may record an old code root, but the pre-session context and + warning must name the config root the mandatory post-session re-stamp will use.""" + from bmad_loop import resolve, runs + + _run_dir, moved, recorded = _resolve_run_with_a_moved_code_root(project, monkeypatch) + seen: dict[str, Path] = {} + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + + def fake_context(*args, **kwargs): + seen["project"] = kwargs["project_root"] + seen["code"] = kwargs["code_root"] + return None, 0 + + monkeypatch.setattr(resolve, "build_context", fake_context) + + def fake_session(*args, **kwargs): + err = capsys.readouterr().err + assert repr(project.project.as_posix()) in err + assert repr(moved.as_posix()) in err + assert recorded.as_posix() not in err + return True + + monkeypatch.setattr(resolve, "run_session", fake_session) + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, key, **kwargs: key) + + argv = ["resolve", "--project", str(project.project), "r1", "--no-resume"] + assert cli.main(argv) == 0 + assert seen == {"project": project.project, "code": moved.resolve()} + + +def test_resolve_refuses_to_rearm_when_code_root_changes_during_session( + project, monkeypatch, capsys +): + """The context snapshot and the re-drive must name the same code tree. + + A config edit while the interactive conversation is open otherwise lets the + human follow the supplied guidance in the old tree before the command silently + re-arms and resumes in the new one. + """ + from bmad_loop import resolve, runs + from bmad_loop.journal import load_state + from bmad_loop.model import Phase + + install_bmad_config(project) + run_dir = _escalated_run(project.project, "r1") + moved = project.project / "code-after-session" + moved.mkdir() + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + + def move_code_root_during_session(*args, **kwargs): + _configure_repo_root(project, moved) + return True + + monkeypatch.setattr(resolve, "run_session", move_code_root_during_session) + monkeypatch.setattr( + runs, + "rearm_escalation", + lambda *a, **k: pytest.fail("re-armed after the context's code root went stale"), + ) + + argv = ["resolve", "--project", str(project.project), "r1", "--no-resume"] + assert cli.main(argv) == 1 + + state = load_state(run_dir) + assert state.tasks["s1"].phase == Phase.ESCALATED + err = capsys.readouterr().err + assert "code root changed during the resolve session" in err + assert project.project.as_posix() in err + assert moved.resolve().as_posix() in err + assert "No re-arm was performed" in err + + +def test_resolve_same_root_launches_without_a_divergence_warning(tmp_path, monkeypatch, capsys): + """Legacy and ordinary same-root runs stay quiet. The session call is the + positive control that the absent warning did not result from an early return. + + Ablation: make the warning unconditional and this fails on stderr. + """ + from bmad_loop import resolve + + _escalated_run(tmp_path, "r1") # empty repo_root: legacy fallback to project + launched: list[Path] = [] + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr( + resolve, + "run_session", + lambda adapter, project, *a, **k: launched.append(project) or True, + ) + + assert cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-resume"]) == 0 + + assert launched == [tmp_path] + assert "resolve session stays project-rooted" not in capsys.readouterr().err + + def test_resolve_passes_the_tasks_own_generation_to_the_session(tmp_path, monkeypatch): """`cmd_resolve` hands the resolve session the generation it read off the task — a real value, not a constant. The row seeds a NON-zero generation deliberately: diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 4cbcb129..e82c83e8 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -551,6 +551,8 @@ def test_build_context_gathers_critical_escalations(tmp_path): path = _context(state, run_dir, "6-4-cli-list-command", isolation="") ctx = json.loads(path.read_text(encoding="utf-8")) assert ctx["story_key"] == "6-4-cli-list-command" + assert ctx["project_root"] == tmp_path.as_posix() + assert ctx["code_root"] == tmp_path.as_posix() assert ctx["spec_file"] == spec.as_posix() assert ctx["baseline_commit"] == "abc123" details = [e["detail"] for e in ctx["escalations"]] @@ -562,6 +564,90 @@ def test_build_context_gathers_critical_escalations(tmp_path): assert "\\" not in ctx["resolution_path"] +def test_build_context_names_a_divergent_recorded_code_root(tmp_path): + """The session cwd remains the project, but the agent contract separately names + the persisted tree where this run's code and git work belong. Neither spelling is + canonicalized; only the stable POSIX serialization is applied. + + The same-root legacy fallback is covered by the preceding test, whose state has an + empty `repo_root` and therefore emits the project for both fields. + """ + code_root = tmp_path / "code" / ".." / "recorded-code" + run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md", repo_root=code_root) + + ctx = json.loads( + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + ) + assert ctx["project_root"] == tmp_path.as_posix() + assert ctx["code_root"] == code_root.as_posix() + assert ctx["project_root"] != ctx["code_root"] + + +def test_build_context_prefers_supplied_live_roots_over_recorded_launch_roots(tmp_path): + """A project rename and a paused-run config edit can move both live roots while + state.json still names launch-time locations. The CLI-supplied snapshot wins in + the payload; recorded roots remain only the observation-failure fallback.""" + recorded_project = tmp_path / "recorded-project" + recorded_code = tmp_path / "recorded-code" + live_project = tmp_path / "live-project" + live_code = tmp_path / "live-code" + run_dir, state, _ = _escalated_run( + recorded_project, + spec_file="/abs/spec.md", + repo_root=recorded_code, + ) + + path, _withheld = resolve.build_context( + state, + run_dir, + "6-4-cli-list-command", + isolation="", + project_root=live_project, + code_root=live_code, + ) + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["project_root"] == live_project.as_posix() + assert ctx["code_root"] == live_code.as_posix() + assert recorded_project.as_posix() not in {ctx["project_root"], ctx["code_root"]} + assert recorded_code.as_posix() not in {ctx["project_root"], ctx["code_root"]} + + +def test_build_context_rebases_project_owned_artifacts_after_project_rename(tmp_path): + """The live project root and every project-owned path in the payload move + together; otherwise the resolver is told its cwd is the renamed project while + its story manifest and frozen spec still point into the vanished old spelling. + """ + key = "6-4-cli-list-command" + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + folder = live_project / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "Live title", "description": "d"}]) + live_spec = folder / "stories" / f"{key}-live-title.md" + live_spec.parent.mkdir(parents=True, exist_ok=True) + live_spec.write_text("---\nstatus: in-review\n---\n", encoding="utf-8") + recorded_spec = recorded_project / live_spec.relative_to(live_project) + run_dir, state, _ = _escalated_run( + recorded_project, + spec_file=str(recorded_spec), + source="stories", + spec_folder="epic-1", + ) + + path, _withheld = resolve.build_context( + state, + run_dir, + key, + isolation="", + project_root=live_project, + code_root=live_project, + ) + + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["project_root"] == live_project.as_posix() + assert ctx["spec_file"] == live_spec.as_posix() + assert ctx["stories"]["story"]["title"] == "Live title" + + def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_path, monkeypatch): """`context.json` names the spec in the tree the RUN owns, absolute. @@ -2195,9 +2281,8 @@ def _rearmable(run_dir): def test_build_context_tolerates_non_utf8_present_spec(tmp_path): - """A non-UTF-8 PRESENT story spec makes resolve_story_spec's frontmatter read - raise UnicodeDecodeError; build_context must degrade to best-effort (folder-only) - stories context, not crash the resolve command.""" + """A non-UTF-8 ordinary story spec cannot turn into sentinel guidance merely + because its name or bytes are observed; persisted task state remains authoritative.""" key = "6-4-cli-list-command" stories_dir = tmp_path / "stories" stories_dir.mkdir(parents=True) @@ -2217,7 +2302,13 @@ def test_build_context_tolerates_non_utf8_sentinel(tmp_path): stories_dir = tmp_path / "stories" stories_dir.mkdir(parents=True) (stories_dir / f"{key}-unresolved.md").write_bytes(_BAD_UTF8) # undecodable sentinel - run_dir, state, _ = _escalated_run(tmp_path, source="stories", sentinel_kind="unresolved") + sentinel = stories_dir / f"{key}-unresolved.md" + run_dir, state, _ = _escalated_run( + tmp_path, + source="stories", + spec_file=str(sentinel), + sentinel_kind="unresolved", + ) path = _context(state, run_dir, key, isolation="") # must not raise ctx = json.loads(path.read_text(encoding="utf-8")) @@ -3069,6 +3160,8 @@ def test_build_context_keeps_the_withheld_count_out_of_the_payload(tmp_path): assert set(ctx) == { "story_key", "run_id", + "project_root", + "code_root", "spec_file", "baseline_commit", "paused_reason", @@ -3377,6 +3470,7 @@ def test_run_session_detects_resolution(tmp_path, monkeypatch): _context(state, run_dir, "6-4-cli-list-command", isolation="") def fake_subprocess_run(argv, cwd, env): + assert cwd == str(tmp_path) # supplied project is the process/session boundary cwd # simulate the agent writing the resolution marker resolve.resolution_path(run_dir, "6-4-cli-list-command").write_text("{}", encoding="utf-8") @@ -3539,13 +3633,46 @@ def test_build_context_stories_sentinel_indicator(tmp_path): "---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\nintent too vague\n", encoding="utf-8", ) - run_dir, state, _ = _escalated_run(tmp_path, spec_file=str(sentinel), source="stories") + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + ) state.spec_folder = "epic-1" ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) sent = ctx["stories"]["sentinel"] assert sent["kind"] == "unresolved" assert "intent too vague" in sent["blocking_condition"] + assert ctx["spec_reaches_the_redrive"] is None + + +def test_build_context_keeps_recorded_sentinel_mode_after_its_file_disappears(tmp_path): + """The persisted detection verdict survives an absent sentinel file. + + The missing file only removes the best-effort blocking-condition text; it must + not turn the next resolve session into an ordinary frozen-spec flow. + """ + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + sentinel = folder / "stories" / f"{key}-unresolved.md" + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind="unresolved", + ) + state.spec_folder = "epic-1" + + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + assert ctx["stories"]["sentinel"] == { + "kind": "unresolved", + "path": sentinel.as_posix(), + "blocking_condition": "", + } + assert ctx["spec_reaches_the_redrive"] is None def test_build_context_sprint_mode_has_no_stories_block(tmp_path): @@ -3557,6 +3684,82 @@ def test_build_context_sprint_mode_has_no_stories_block(tmp_path): assert "stories" not in ctx +def test_build_context_sprint_mode_does_not_resolve_a_stories_root(tmp_path, monkeypatch): + """A sprint context has no consumer for stories-root data, so it performs no + stories-only filesystem lookup. + + Ablation: move `task_stories_root` back above the source gate and this fails at + the planted seam rather than passing from an absent `stories` payload alone. + """ + run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") + monkeypatch.setattr( + resolve, + "task_stories_root", + lambda *_a, **_k: pytest.fail("stories root resolved for sprint context"), + ) + + ctx = json.loads( + _context(state, run_dir, "6-4-cli-list-command", isolation="").read_text(encoding="utf-8") + ) + assert "stories" not in ctx + + +@pytest.mark.parametrize("sentinel_kind", ["unresolved", "ambiguous"]) +def test_build_context_sentinel_does_not_probe_frozen_spec_reachability( + tmp_path, monkeypatch, sentinel_kind +): + """A stories sentinel is explicitly not a frozen spec, so reachability is null + without invoking the helper that answers whether a frozen-spec edit survives. + + Ablation: compute reachability before discovering the sentinel and the planted + helper fails; merely overwriting the result with null afterwards is insufficient. + """ + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + sentinel = folder / "stories" / f"{key}-{sentinel_kind}.md" + sentinel.write_text("---\nstatus: blocked\n---\n", encoding="utf-8") + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(sentinel), + source="stories", + sentinel_kind=sentinel_kind, + ) + state.spec_folder = "epic-1" + monkeypatch.setattr( + resolve, + "spec_reaches_the_redrive", + lambda *_a, **_k: pytest.fail("sentinel spec reachability was probed"), + ) + + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + assert "sentinel" in ctx["stories"] + assert ctx["stories"]["sentinel"]["kind"] == sentinel_kind + assert ctx["spec_reaches_the_redrive"] is None + + +def test_build_context_sentinel_shaped_ordinary_spec_keeps_reachability(tmp_path): + """A real stories spec may legally use a sentinel-shaped basename. Only the + persisted detection verdict selects sentinel mode, matching re-arm; the basename + alone must not erase ordinary frozen-spec reachability or add sentinel guidance.""" + key = "6-4-cli-list-command" + folder = tmp_path / "epic-1" + _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + spec = folder / "stories" / f"{key}-unresolved.md" + spec.write_text("---\nstatus: in-review\n---\n", encoding="utf-8") + run_dir, state, _ = _escalated_run( + tmp_path, + spec_file=str(spec), + source="stories", + sentinel_kind="", + ) + state.spec_folder = "epic-1" + + ctx = json.loads(_context(state, run_dir, key, isolation="").read_text(encoding="utf-8")) + assert "sentinel" not in ctx["stories"] + assert ctx["spec_reaches_the_redrive"] is True + + def test_build_context_leaves_an_out_of_mount_spec_unchanged(tmp_path): """Matrix row 5 graded at the layer that PUBLISHES the path to a human. @@ -3612,7 +3815,12 @@ def test_build_context_stories_block_names_the_same_tree_as_spec_file(tmp_path): rel = f"epic-1/stories/{key}-unresolved.md" run_dir, state, _ = _escalated_run( - tmp_path, run_id, spec_file=rel, source="stories", worktree_path=str(wt) + tmp_path, + run_id, + spec_file=rel, + source="stories", + sentinel_kind="unresolved", + worktree_path=str(wt), ) state.spec_folder = "epic-1" @@ -3639,17 +3847,18 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec There `task_spec_root` answers the PROJECT — a write-confinement decision — while the story manifest still lives in the mount, exactly where `stories_engine._stories_folder` - looks for it. + looks for it. Sentinel identity is no longer inferred from the decoy filenames; the + distinct manifest titles grade the stories-root choice instead. Ablation: revert `_stories_context`'s root to `task_spec_root(task, state)` and this - reddens on the blocking condition — it reports the decoy twin's.""" + reddens on the story title — it reports the decoy twin's.""" key = "6-4-cli-list-command" run_id = "20260613-111429-6a14" wt = tmp_path / ".bmad-loop" / "runs" / run_id / "worktrees" / "1" - for root, condition in ((wt, "the mount's real halt"), (tmp_path, "the decoy twin")): + for root, condition in ((wt, "the mount's real intent"), (tmp_path, "the decoy twin")): folder = root / "epic-1" - _stories_manifest(folder, [{"id": key, "title": "t", "description": "d"}]) + _stories_manifest(folder, [{"id": key, "title": condition, "description": "d"}]) (folder / "stories" / f"{key}-unresolved.md").write_text( f"---\nstatus: blocked\n---\n\n## Auto Run Result\n\nStatus: blocked\n{condition}\n", encoding="utf-8", @@ -3669,10 +3878,8 @@ def test_build_context_stories_block_stays_on_the_mount_for_an_out_of_mount_spec _context(state, run_dir, key, isolation="worktree").read_text(encoding="utf-8") ) assert ctx["spec_file"] == outside.as_posix() # unchanged: absolute passes through - sent = ctx["stories"]["sentinel"] - assert "the mount's real halt" in sent["blocking_condition"] - assert "decoy" not in sent["blocking_condition"] - assert Path(sent["path"]).is_relative_to(wt) # the mount, NOT task_spec_root's project + assert ctx["stories"]["story"]["title"] == "the mount's real intent" + assert "sentinel" not in ctx["stories"] # task never recorded a sentinel verdict def test_build_context_reports_whether_the_spec_reaches_the_redrive(tmp_path): diff --git a/tests/test_resolve_skill_contract.py b/tests/test_resolve_skill_contract.py index 396fee47..c17b0f82 100644 --- a/tests/test_resolve_skill_contract.py +++ b/tests/test_resolve_skill_contract.py @@ -84,6 +84,25 @@ def test_every_emitted_context_key_is_documented(skill_md): ) +def test_skill_routes_project_artifacts_and_code_work_to_their_distinct_roots(skill_md): + """Mentioning both keys is inert unless the skill explains the operational split. + + Each assertion grades a separate instruction the divergent-root resolution needs: + where the session starts, where BMAD artifact/spec work stays, and where code/git + work belongs. Removing the substantive guidance while leaving the schema keys + documented therefore fails this contract rather than passing the key census above. + """ + normalized = " ".join(skill_md.split()) + + assert "session's working directory is always `project_root`" in normalized + assert ( + "artifact and spec work remains anchored under `project_root` (or at the explicit " + "absolute paths in this context)" + ) in normalized + assert "When the roots differ" in normalized + assert "any code fix or commit the human must make belongs under `code_root`" in normalized + + def test_skill_branches_on_the_in_place_remedy(skill_md): """`spec_reaches_the_redrive: false` has TWO remedies, and the wrong one is lost work in the other direction. @@ -153,3 +172,14 @@ def test_skill_branches_on_spec_reachability(skill_md): assert "cut fresh from `redrive_base_ref`" in normalized # and the prohibition names whose job the landing is, rather than just refusing it assert "is the HUMAN's step" in normalized + + +def test_skill_explains_null_reachability_for_stories_sentinels(skill_md): + """A sentinel retains a path but deliberately has no frozen-spec verdict, so + the general null definition must not tell the agent that no path was recorded. + """ + normalized = " ".join(skill_md.split()) + + assert "no ordinary frozen spec to edit" in normalized + assert "stories mode recorded a sentinel path instead" in normalized + assert "follow the sentinel guidance below" in normalized From 26b5f0927e939842e25f49ff3538cc44dd7060a8 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 17:43:53 -0700 Subject: [PATCH 03/35] sweep dw-remove-dead-artifact-relpaths: DW-15 via bmad-loop --- CHANGELOG.md | 10 +++++--- src/bmad_loop/verify.py | 44 +++++-------------------------- tests/test_verify.py | 57 +++++------------------------------------ 3 files changed, 20 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5dbe57..1a8c784d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ breaking changes may land in a minor release. ### Changed +- **Remove the unused whole-artifact-folder exclusion helper** (DW-15). Proof-of-work + exclusions remain file-granular and rollback protection keeps its workspace-rooted + path derivation. + - **Resolve context builds only the mode-specific details its consumer uses.** Non-stories runs skip stories-root lookup, and stories sentinels report null frozen-spec reachability without probing a spec they do not edit. @@ -470,9 +474,9 @@ resolve` manufactures exactly that dual-key spec, inserting `baseline_revision` gate probe — the commit-identity lookup, both ancestry checks and `has_changes_since` — asked the BMAD project directory about it, so a marker only the project tree held satisfied proof of work and a correct attempt was refused forever. The four probes now share the git root, and so do the - three exclude sources that feed them. `artifact_relpaths` is deliberately untouched: it has no - production caller, and rollback protection builds its own list against the workspace root. No - effect where the two roots coincide, which is every other configuration. + three file-granular exclude sources that feed them. Rollback protection independently builds its + own list against the workspace root. No effect where the two roots coincide, which is every other + configuration. - **`bmad-loop resolve` advances the re-arm baseline in the code tree, and says so when it cannot** (#640). The advance read HEAD of the BMAD project directory rather than the git root, and diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 74554ef3..b35f77c2 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3164,36 +3164,6 @@ def set_frontmatter_field(path: Path, key: str, value: str, *, confine_root: Pat return True -def artifact_relpaths(paths: ProjectPaths) -> tuple[str, ...]: - """Repo-relative posix prefixes of the orchestrator-owned BMAD artifact - folders (the output root and the implementation/planning artifact dirs), - relative to ``paths.project``. Folders configured outside the project tree - are skipped — nothing to exclude there. - - NO PRODUCTION CALLER. Both consumers it was written for have moved: the - dev/bundle proof-of-work gate now composes its excludes file-granularly through - ``verify_dev_exclude_relpaths``, rooted on ``paths.repo_root`` where git runs - (#716), and rollback protection builds its own list against the workspace root in - ``RecoveryFlow.protected_relpaths``. Its ``paths.project`` anchor is therefore - inert rather than correct — do not cite it as evidence that project-rooting is - right for anything, and re-derive the root if a caller is ever added.""" - out: list[str] = [] - for folder in ( - paths.output_folder, - paths.implementation_artifacts, - paths.planning_artifacts, - ): - try: - rel = folder.relative_to(paths.project).as_posix() - except ValueError: - continue # configured outside the project tree; nothing to exclude here - # A folder == project root yields ".", which as an exclude prefix would - # disable change detection for the whole tree — drop it. - if rel and rel != ".": - out.append(rel) - return tuple(out) - - def verify_dev_exclude_relpaths( paths: ProjectPaths, spec_path: Path, @@ -3203,12 +3173,10 @@ def verify_dev_exclude_relpaths( ) -> tuple[str, ...]: """Repo-relative posix paths the dev/bundle proof-of-work gate excludes from its probe (`_changes_since`, via `_verify_shared_gates.proof_of_work_probe`) — - file-granularity, unlike `artifact_relpaths`' whole-folder - exclusion. `artifact_relpaths` has NO production caller left: rollback - protection builds its own list in `recovery_flow.protected_relpaths` against - `workspace.root`, and `Engine._protected_relpaths` merely delegates there. Do - not adopt it as a shortcut — it is still anchored on `paths.project`, which is - #716's root cause. Deliberately does NOT exclude `output_folder`: + deliberately file-granular. Rollback protection is a separate concern: it + builds its own list in `RecoveryFlow.protected_relpaths` against + `workspace.root`, and `Engine._protected_relpaths` merely delegates there. + Deliberately does NOT exclude `output_folder`: in the standard layout it is the parent directory of `implementation_artifacts`/ `planning_artifacts`, so excluding it as a directory prefix would swallow those two folders' content right back out of view via the same git-pathspec prefix @@ -3991,8 +3959,8 @@ def verify_dev_stories( # A plan-halt leg produced only its own spec (the plan), which proof-of-work # already excludes; skip it (extra_exclude=None) and record the plan spec. # Otherwise stories mode adds the spec folder's stories/ subdir + stories.yaml - # on top of the gate's own file-granular exclude — NOT the whole-folder - # artifact_relpaths, so a story whose entire authorized scope is ledger/spec + # on top of the gate's own file-granular exclude — NOT a whole-folder artifact + # exclusion, so a story whose entire authorized scope is ledger/spec # reconciliation doesn't register as a false "no changes". Engine-written # paths compose only on that live-gate leg; ``None`` must remain ``None`` for # plan halt rather than being combined with a tuple. diff --git a/tests/test_verify.py b/tests/test_verify.py index da463739..876dfe2a 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -3066,10 +3066,10 @@ def test_verify_dev_stories_ledger_only_counts_as_real_work(project): """T3 regression: a stories-mode story whose entire authorized diff is ledger/spec reconciliation under implementation_artifacts (e.g. deferred-work.md) must pass proof-of-work, not false-negative "no changes". Guards the file-granular - exclude port off #79 — the old whole-folder `artifact_relpaths` exclusion - swallowed the ledger, re-introducing KNOWN-BUG-ledger-only-story-false-no- - changes.md in stories mode (verify_dev_exclude_relpaths excludes only the - session's own spec + sprint-status, so sibling ledger content counts).""" + exclude port off #79 — the old whole-folder artifact exclusion swallowed the + ledger, re-introducing KNOWN-BUG-ledger-only-story-false-no-changes.md in stories + mode (verify_dev_exclude_relpaths excludes only the session's own spec + + sprint-status, so sibling ledger content counts).""" spec_folder = project.planning_artifacts / "epic-a" task = make_stories_task(project, "1") sp = write_story(spec_folder, "1", "x", "done", task.baseline_commit) @@ -6296,48 +6296,6 @@ def test_verify_dev_stories_refuses_bookkeeping_only_changes_under_the_monorepo_ ).ok -def test_artifact_relpaths_returns_in_repo_folders(project): - """The orchestrator-owned artifact folders, repo-relative posix.""" - rels = verify.artifact_relpaths(project) - assert "_bmad-output/implementation-artifacts" in rels - assert "_bmad-output/planning-artifacts" in rels - assert all(r and r != "." for r in rels) - - -def test_artifact_relpaths_drops_dot_when_folder_is_project_root(project): - """A folder configured == project root yields "."; it must be dropped so it - can't become a whole-tree exclude that disables the proof-of-work gate.""" - paths = dataclasses.replace(project, output_folder=project.project) - rels = verify.artifact_relpaths(paths) - assert "." not in rels and "" not in rels - # the real sub-dirs are still excluded; only the root-collapsing "." is dropped - assert "_bmad-output/implementation-artifacts" in rels - - -def test_has_changes_since_excludes_artifact_only_edit(project): - """A change confined to the artifact folders is not proof of dev work.""" - baseline = verify.rev_parse_head(project.project) - # root-level _bmad-output edit (bundle/ledger) + nested impl-artifact edit: - # both must be excluded, proving artifact_relpaths covers output_folder too. - (project.output_folder / "ledger.json").write_text("bookkeeping\n") - (project.implementation_artifacts / "spec-x.md").write_text("bookkeeping\n") - assert verify.has_changes_since(project.project, baseline) is True # unscoped - assert ( - verify.has_changes_since( - project.project, baseline, exclude=verify.artifact_relpaths(project) - ) - is False - ) - # a real source edit still counts - (project.project / "src.txt").write_text("real\n") - assert ( - verify.has_changes_since( - project.project, baseline, exclude=verify.artifact_relpaths(project) - ) - is True - ) - - def test_changes_since_reports_a_git_refusal_and_has_changes_since_collapses_it(project): """The two-function split, at its own layer: `_changes_since` answers the tri-state and `has_changes_since` is its fail-open collapse. @@ -6403,10 +6361,9 @@ def test_has_changes_since_subtracts_baseline_untracked(project): def test_verify_dev_exclude_relpaths_is_file_granular(project): - """Unlike artifact_relpaths (whole-folder), this excludes only the - sprint-status ledger and the session's own claimed spec file — sibling - artifact-folder content (deferred-work.md, other stories' specs) is left - un-excluded so it can register as real work.""" + """Excludes only the sprint-status ledger and the session's own claimed spec + file — sibling artifact-folder content (deferred-work.md, other stories' specs) + is left un-excluded so it can register as real work.""" sp = spec_path(project, "1-1-a") rels = verify.verify_dev_exclude_relpaths(project, sp, root=project.repo_root) assert "_bmad-output/implementation-artifacts/sprint-status.yaml" in rels From fc6502458f842e75885ea54b85cad41fff4aa106 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 18:13:31 -0700 Subject: [PATCH 04/35] sweep dw-peel-task-generation-suffix: DW-16 via bmad-loop --- src/bmad_loop/tui/data.py | 18 ++++++++---------- tests/test_tui_data.py | 30 +++++++++++++----------------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index 14f2ccf5..789156de 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -607,18 +607,16 @@ def _story_key_from_task_id(task_id: str, role: str) -> str: predates story-key stamping (#153 phase 1). The id is ``safe_segment(f"{story_key}-{part}-{seq}{gen}")`` where ``part`` is the role, or a workflow label for labeled plugin sessions, and ``gen`` is a ``-g`` re-arm - generation suffix emitted only above zero (#705). This parser handles the - unsuffixed shape ONLY: a ``-g1`` tail fails the ``seq.isdigit()`` test below and - returns the whole id. That is unreachable rather than latent-correct — every - session-start has carried ``story_key`` since #153 phase 1, so the entries this - fallback sees are exactly the ones that predate generations — but widen the - fallback and this is the assumption that breaks. So peel the trailing - ``-{part}-{seq}``: drop the numeric seq, then the recorded role when it - matches (the common case), else one more ``-`` group (best-effort, since a - label is not recoverable from the entry).""" + generation suffix emitted only above zero (#705). Peel one optional terminal + generation suffix, then peel the trailing ``-{part}-{seq}``: drop the numeric + seq, then the recorded role when it matches (the common case), else one more + ``-`` group (best-effort, since a label is not recoverable from the entry). + Malformed shapes return the original task id unchanged.""" + original = task_id + task_id = re.sub(r"-g[1-9][0-9]*\Z", "", task_id) head, sep, seq = task_id.rpartition("-") if not sep or not seq.isdigit(): - return task_id # not the expected shape — best we can do + return original # not the expected shape — best we can do if role and head.endswith(f"-{role}"): return head[: -(len(role) + 1)] parent = head.rpartition("-")[0] diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 65f528a1..f5ea159e 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -1133,20 +1133,9 @@ def test_story_key_from_task_id_grammar_including_the_generation_suffix(): `_session_task_id` composes `safe_segment(f"{story_key}-{part}-{seq}{gen}")`, and #705 added `gen` — a `-g` suffix emitted only above generation zero. That - changed the grammar this parser documents, and nothing recorded either half of it. - - The `-g1` row pins a DOCUMENTED LIMITATION, not a desired outcome: the suffix - fails `seq.isdigit()` and the whole id comes back as the story key. It is - unreachable today because every `session-start` has carried `story_key` since #153 - phase 1, so the entries this fallback actually sees predate generations entirely. - It is pinned precisely because that reasoning is an assumption about the CALLER: - widen the fallback to entries that can carry `-gN` and this row is where the - breakage surfaces, instead of a bogus `1-1-a-dev-1-g1` row appearing in the - active-agent view. - - Ablation: drop the `seq.isdigit()` term and the `-g1` row changes answer (it then - peels `g1` as if it were a sequence); change the emitted suffix shape in - `engine._session_task_id` and the last row reddens. + changed the grammar this parser documents. Only a final numeric generation + component is peeled; generation-like malformed or nonterminal components retain + the existing best-effort fallback behavior. """ # the ordinary unsuffixed shape: the recorded role is peeled with its seq assert data._story_key_from_task_id("1-1-a-dev-1", "dev") == "1-1-a" @@ -1156,6 +1145,13 @@ def test_story_key_from_task_id_grammar_including_the_generation_suffix(): # not the expected shape at all — returned verbatim assert data._story_key_from_task_id("nonsense", "dev") == "nonsense" - # generation-suffixed (#705): NOT parsed, returned whole. Unreachable today. - assert data._story_key_from_task_id("1-1-a-dev-1-g1", "dev") == "1-1-a-dev-1-g1" - assert data._story_key_from_task_id("1-1-a-dev-1-g12", "dev") == "1-1-a-dev-1-g12" + # generation-suffixed (#705): one terminal numeric generation is peeled + assert data._story_key_from_task_id("1-1-a-dev-1-g1", "dev") == "1-1-a" + assert data._story_key_from_task_id("1-1-a-dev-1-g12", "dev") == "1-1-a" + # malformed and nonterminal generation-like components are not suffixes + assert data._story_key_from_task_id("1-1-a-dev-1-g", "dev") == "1-1-a-dev-1-g" + assert data._story_key_from_task_id("1-1-a-dev-1-gx", "dev") == "1-1-a-dev-1-gx" + assert data._story_key_from_task_id("1-1-a-dev-1-g0", "dev") == "1-1-a-dev-1-g0" + assert data._story_key_from_task_id("1-1-a-dev-1-g01", "dev") == "1-1-a-dev-1-g01" + assert data._story_key_from_task_id("1-1-a-dev-1-g١", "dev") == "1-1-a-dev-1-g١" + assert data._story_key_from_task_id("1-1-a-dev-1-g1-extra", "dev") == "1-1-a-dev-1-g1-extra" From fa0b93409a2c0dc8f3dbe4664e813eaa271ecebe Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 18:25:59 -0700 Subject: [PATCH 05/35] sweep dw-document-spec-path-resolvers: DW-17, DW-18, DW-36 via bmad-loop --- CHANGELOG.md | 12 +++++++++--- src/bmad_loop/recovery_flow.py | 11 ++++++++++- src/bmad_loop/resolve.py | 2 +- src/bmad_loop/runs.py | 30 ++++++++++++++++++++++++++---- src/bmad_loop/tui/app.py | 4 ++-- src/bmad_loop/verify.py | 15 ++++++++++++--- tests/test_portability_guard.py | 6 +++--- tests/test_resolve.py | 8 ++++---- 8 files changed, 67 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8c784d..b2d645c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,9 +164,10 @@ breaking changes may land in a minor release. - **Re-arm writes the spec the run actually used, and reports every write it could not make** (#640). `StoryTask` persists `spec_file` relative to the worktree and re-arm resolved it against - the process cwd, where the main checkout carries the same layout — so the status flip and the - baseline re-stamp landed on the WRONG file while the worktree's real spec kept the escalated - attempt's sha. The recorded path is now re-anchored on the worktree before either write. + the process cwd, where the main checkout carries the same implementation-artifacts-relative + path — so the status flip and the baseline re-stamp landed on the WRONG file while the + worktree's real spec kept the escalated attempt's sha. The recorded path is now re-anchored on + the worktree before either write. Separately, both frontmatter writers answer a spec they cannot move with `False` rather than an exception and those returns were discarded; they now journal `rearm-baseline-restamp-skipped` and `rearm-spec-flip-skipped`, on genuine failure only — re-arm reads the status back, so an ordinary @@ -245,6 +246,11 @@ breaking changes may land in a minor release. ### Fixed +- **Document the three story-spec path resolvers and their distinct ownership + contracts** (DW-17, DW-18, DW-36). Persisted-task anchoring, session-reported + candidate probing, and exact attempt recovery binding now cross-reference one + another and spell out their different bare-basename results; production prose + also names the real implementation-artifacts-relative layout. - **An artifact the escalation walk cannot stat is recorded as unreadable rather than absent** (DW-11). `resolve._gather_escalations` classified each task-cycle artifact with `Path.is_file()` outside its guard, and that probe's answer to EACCES splits by interpreter. diff --git a/src/bmad_loop/recovery_flow.py b/src/bmad_loop/recovery_flow.py index 5b1aae21..a167788f 100644 --- a/src/bmad_loop/recovery_flow.py +++ b/src/bmad_loop/recovery_flow.py @@ -114,7 +114,16 @@ def protected_relpaths(self) -> tuple[str, ...]: return tuple(out) def _attempt_owned_spec(self, task: StoryTask) -> tuple[Path, str | None] | None: - """Resolve this attempt's bound spec and its exact Git exclusion. + """Bind recovery restoration to this attempt's spec and exact Git exclusion. + + This is the restore authority for the dispatched attempt, not an ordinary + path lookup. Operations on the tree recorded by a task use + ``runs.task_spec_path``, which anchors a bare basename directly on that tree. + Binding a reported or persisted spelling inside the current active + ``ProjectPaths`` uses ``verify.resolve_spec_path``, which chooses an existing + project candidate or falls back under implementation artifacts. Here a bare + basename probes both locations and is accepted only when exactly one trusted + regular-file candidate exists. Relative persisted paths may name either a project-relative file or a basename under the configured implementation-artifacts directory. The diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 264eb04f..3bcb4edf 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -374,7 +374,7 @@ def build_context( # isolated unit's `spec_file` is persisted RELATIVE to the mounted worktree # (`model.StoryTask._serialized_worktree_path`) and the agent session runs # from the project root, where the main checkout carries the same - # `_bmad-output/specs/...` layout — the raw value would name the wrong + # implementation-artifacts-relative path — the raw value would name the wrong # tree's copy. `task_spec_path` provides the persisted anchor; when the whole # project moved, `_rebase_recorded_project_path` carries that project-owned # spelling onto the live session root without resolving it through the OS. diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 388832fe..a85264f2 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -1,4 +1,16 @@ -"""Run-directory discovery and helpers shared by the CLI and the TUI.""" +"""Run-directory discovery and helpers shared by the CLI and the TUI. + +The public story-spec ownership and confinement seam consists of four helpers: + +* :func:`task_spec_path` anchors a persisted ``StoryTask.spec_file`` on the tree + that owned the task when it was recorded. +* :func:`task_spec_root` names the matching root used to confine writes to that + anchored spec. +* :func:`task_stories_root` locates the workspace tree from which the run reads + its stories folder; it is a read locator, not a spec-write confinement root. +* :func:`spec_reaches_the_redrive` reports whether an edit at the persisted-task + anchor will survive to the workspace used by the next attempt. +""" from __future__ import annotations @@ -2990,14 +3002,24 @@ def validate_restore_latch( def task_spec_path(task: StoryTask, state: RunState) -> Path: - """The recorded spec path, re-anchored on the tree it was persisted relative to. + """The persisted-task spec anchor, re-based on the tree it was recorded relative to. + + Use this when an operation must address the tree recorded by the task. A bare + basename is joined directly to :func:`task_spec_root`; it is NOT probed against the + project and has no implementation-artifacts fallback. To bind either a + session-reported path or a persisted spelling inside the current active + ``ProjectPaths``, use :func:`verify.resolve_spec_path`, which probes project-first + and then falls back under the implementation-artifacts directory. Recovery uses + ``recovery_flow.RecoveryFlow._attempt_owned_spec`` instead: for a bare basename it + probes both locations and accepts the binding only when exactly one trusted regular + file exists. `StoryTask._serialized_worktree_path` (`model.py`) persists a worktree-local spec RELATIVE to the mounted worktree root, and `from_dict` reads it back raw. Resolving that against the process cwd is not merely unreachable — it is actively wrong: `bmad-loop resolve` runs from the project root, where the MAIN CHECKOUT carries the - same `_bmad-output/specs/...` layout, so a bare `Path(task.spec_file)` names the main - checkout's copy of the story spec. `is_file()` then answers True, `confine_root` + same implementation-artifacts-relative path, so a bare `Path(task.spec_file)` names + the main checkout's copy of the story spec. `is_file()` then answers True, `confine_root` accepts it (it genuinely is under `project`), and the status flip and the baseline re-stamp both land on a file the run never used while the worktree's real spec is left on the escalated attempt's sha. diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 2b59c5a8..f4cfe1c9 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1071,8 +1071,8 @@ def _paused_spec(self, state: RunState) -> tuple[Path | None, str, bool]: raw value: `model.StoryTask._serialized_worktree_path` persists an isolated unit's spec RELATIVE to the mounted worktree root and `from_dict` reads it back raw, so a bare `Path(task.spec_file)` resolves against the TUI process cwd — - where the main checkout carries the very same `_bmad-output/specs/...` layout - and answers with the WRONG tree's copy of the story spec.""" + where the main checkout carries the same implementation-artifacts-relative + path and answers with the WRONG tree's copy of the story spec.""" task = self._paused_task(state) if task is None or not task.spec_file: return None, "", True diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index b35f77c2..e0f2fdde 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3257,9 +3257,18 @@ def spec_within_roots(spec_path: Path, paths: ProjectPaths) -> bool: def resolve_spec_path(spec_file: str, paths: ProjectPaths) -> Path: - """A session-reported ``spec_file`` as a concrete path: an absolute value passes - through untouched, a relative one is probed against ``paths.project`` and falls - back to ``paths.implementation_artifacts``. + """Probe a session-reported ``spec_file`` candidate into a concrete path. + + This lookup binds a reported or persisted spelling inside the current active + ``ProjectPaths``. The spelling may come directly from a disposable session or be + read from a task and rebound for a current or fresh workspace. An absolute value + passes through untouched. A relative value — including a bare basename — is probed + against ``paths.project`` first and falls back under + ``paths.implementation_artifacts``. When an operation must instead address the tree + recorded by the task, use :func:`runs.task_spec_path`, which anchors a bare basename + directly on that tree without this fallback. Recovery uses + ``recovery_flow.RecoveryFlow._attempt_owned_spec`` to bind restoration to exactly + one trusted regular-file candidate after probing both locations. Neither branch promises the result exists — the fallback is returned unprobed when the project candidate is not a file — so every caller re-tests diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index e042c880..656624e7 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -131,7 +131,7 @@ # Everywhere else the field arrives from `load_state`, and # `_serialized_worktree_path` persists an isolated unit's spec RELATIVE to the mount. # A bare `Path(...)` there resolves against the READER's cwd — the main checkout, -# which carries the same `_bmad-output/specs/...` layout and answers with the wrong +# which carries the same implementation-artifacts-relative path and answers with the wrong # tree's copy. That defect shipped in `tui/app.py::_paused_spec`, where it reached a # destructive write, and was then re-found one surface at a time in `resolve.py`, # `sweep.py`, `stories_engine.py` and `worktree_flow.py` across four review rounds. @@ -1974,8 +1974,8 @@ def test_spec_path_resolved_only_through_the_anchor(): loads state from disk must say WHICH tree the value is relative to. The four allowlisted files run inside that tree already; everything else — the TUI, the resolve-context builder, the sweep and stories engines, the read-model - projections — does not, and the main checkout carries an identical - ``_bmad-output/specs/...`` layout that answers a bare ``Path(...)`` with the wrong + projections — does not, and the main checkout carries the same + implementation-artifacts-relative path that answers a bare ``Path(...)`` with the wrong copy. That is not a hypothetical: it shipped in ``tui/app.py::_paused_spec``, where ``_do_replan`` then WROTE to the main checkout's file and the operator's replan silently did not happen. diff --git a/tests/test_resolve.py b/tests/test_resolve.py index e82c83e8..505a9bf2 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -654,8 +654,8 @@ def test_build_context_absolutizes_an_isolated_units_worktree_relative_spec(tmp_ `StoryTask._serialized_worktree_path` persists an isolated unit's `spec_file` RELATIVE to the mounted worktree and `from_dict` reads it back raw, so the raw value handed to the agent was a bare relpath. The `bmad-loop-resolve` session runs - from the PROJECT root, where the main checkout carries the very same - `_bmad-output/specs/...` layout — so that relpath resolved, silently, onto the + from the PROJECT root, where the main checkout carries the same + implementation-artifacts-relative path — so that relpath resolved, silently, onto the main checkout's twin, and the human and the agent edited a spec the run never used while `rearm_escalation` (which re-anchors through `task_spec_path`) flipped the worktree's. `build_context` now emits the same re-anchor the re-arm writes @@ -1321,8 +1321,8 @@ def test_rearm_writes_the_worktree_spec_not_the_main_checkouts_copy(monkeypatch, relative to the mounted worktree root — no worktree prefix — and `from_dict` reads it back raw, so a bare `Path(task.spec_file)` resolves against the process cwd. That is not merely unreachable, it is actively WRONG: `bmad-loop resolve` runs from - the project root, and the main checkout carries the very same - `_bmad-output/specs/...` layout. `is_file()` answered True on the wrong file, + the project root, and the main checkout carries the same + implementation-artifacts-relative path. `is_file()` answered True on the wrong file, `confine_root` accepted it (it genuinely is under `project`), and both the status flip AND the baseline re-stamp landed on a spec the run never used, while the worktree's real spec kept the escalated attempt's sha and the re-drive re-wedged. From c6f57ac71b7a65d80481303f556d9dc9bd6830c3 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 19:28:00 -0700 Subject: [PATCH 06/35] sweep dw-atomic-tui-replan: DW-33 via bmad-loop --- CHANGELOG.md | 3 + src/bmad_loop/devcontract.py | 35 ++++++++++ src/bmad_loop/tui/app.py | 3 +- tests/test_devcontract.py | 98 ++++++++++++++++++++++++++ tests/test_tui_app.py | 131 ++++++++++++++++++++++++++++++++++- 5 files changed, 267 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d645c9..10aa9339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,9 @@ breaking changes may land in a minor release. ### Fixed +- **Restore the original spec when a TUI replan cannot strip its stale result** + (DW-33), keeping the status reset and result removal atomic before resume. + - **Document the three story-spec path resolvers and their distinct ownership contracts** (DW-17, DW-18, DW-36). Persisted-task anchoring, session-reported candidate probing, and exact attempt recovery binding now cross-reference one diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 19f7aa87..e3819221 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -733,6 +733,41 @@ def strip_auto_run_result(spec_path: Path, *, confine_root: Path) -> bool: return True +def reset_spec_for_replan(spec_path: Path, *, confine_root: Path) -> bool: + """Reset a spec to ``draft`` and strip its stale result transactionally. + + The TUI exposes those two writes as one operator action. Capture the exact + preimage before the status write and restore it through the same confined + atomic writer if either stage faults after changing (or removing) the file, + so a failed replan never publishes only its first half. A reset that makes no + change is a refusal, not permission to strip the result independently. + + Rollback deliberately catches ``BaseException`` so an interrupt between the + two writes cannot leave a partial replan. A fault that leaves the preimage + untouched does not rewrite it. If the restore itself fails, that failure + escapes; otherwise the original stage failure is re-raised. + """ + original = spec_path.read_bytes() + try: + reset = reset_spec_status(spec_path, "draft", confine_root=confine_root) + if not reset: + if not spec_path.is_file(): + raise FileNotFoundError(f"replan spec vanished during status reset: {spec_path}") + return False + stripped = strip_auto_run_result(spec_path, confine_root=confine_root) + if not stripped and not spec_path.is_file(): + raise FileNotFoundError(f"replan spec vanished during result strip: {spec_path}") + except BaseException: + try: + unchanged = spec_path.read_bytes() == original + except OSError: + unchanged = False + if not unchanged: + _atomic_write_spec(spec_path, original.decode("utf-8"), confine_root=confine_root) + raise + return True + + # Provenance stamped into a synthesized `## Auto Run Result` section so a human # (or a later re-derivation) can tell an orchestrator-repaired marker from one # the skill wrote itself. Single line (no internal newlines) so the writer's diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index f4cfe1c9..c5277f64 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -839,8 +839,7 @@ def _do_replan(self, run_id: str, spec_path: Path, confine_root: Path) -> None: self.notify(f"replan: no spec at {spec_path} — not resuming", severity="error") return try: - reset = devcontract.reset_spec_status(spec_path, "draft", confine_root=confine_root) - devcontract.strip_auto_run_result(spec_path, confine_root=confine_root) + reset = devcontract.reset_spec_for_replan(spec_path, confine_root=confine_root) except (OSError, UnicodeDecodeError, verify.FrontmatterWriteError) as e: # FrontmatterWriteError is not an OSError: a spec whose `status:` is a # block scalar or a flow mapping reads fine and fails the WRITE. It diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index a91440b2..9816ead2 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -1062,6 +1062,104 @@ def test_strip_auto_run_result_skips_fenced_boundary_lines(tmp_path): assert "## Intent\n\nbody\n" in text +# --------------------------------------------------------- reset_spec_for_replan + + +def test_reset_spec_for_replan_does_not_rewrite_unchanged_reset_refusal(tmp_path, monkeypatch): + """A pre-write frontmatter refusal propagates without an unnecessary undo.""" + sp = tmp_path / "spec.md" + original = b"---\nstatus: |\n ready-for-dev\n---\n\nbody\n" + sp.write_bytes(original) + writes: list[tuple] = [] + monkeypatch.setattr( + devcontract, + "_atomic_write_spec", + lambda *args, **kwargs: writes.append((args, kwargs)), + ) + + with pytest.raises(verify.FrontmatterWriteError): + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert sp.read_bytes() == original + assert writes == [] + + +def test_reset_spec_for_replan_restores_when_reset_commits_then_interrupts(tmp_path, monkeypatch): + """An interrupt after reset's atomic replace still rolls the transaction back.""" + sp = tmp_path / "spec.md" + original = b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n## Auto Run Result\r\n" + sp.write_bytes(original) + real_reset = devcontract.reset_spec_status + interrupt = KeyboardInterrupt("interrupt after reset commit") + + def reset_then_interrupt(*args, **kwargs): + assert real_reset(*args, **kwargs) is True + raise interrupt + + monkeypatch.setattr(devcontract, "reset_spec_status", reset_then_interrupt) + + with pytest.raises(KeyboardInterrupt) as caught: + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert caught.value is interrupt + assert sp.read_bytes() == original + + +def test_reset_spec_for_replan_restores_when_reset_finds_spec_missing(tmp_path, monkeypatch): + """A vanished spec during reset is restored rather than treated as a refusal.""" + sp = tmp_path / "spec.md" + original = b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n## Auto Run Result\r\n" + sp.write_bytes(original) + + def vanish_during_reset(*args, **kwargs): + sp.unlink() + return False + + monkeypatch.setattr(devcontract, "reset_spec_status", vanish_during_reset) + + with pytest.raises(FileNotFoundError, match="vanished during status reset"): + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert sp.read_bytes() == original + + +def test_reset_spec_for_replan_restores_on_baseexception_from_strip(tmp_path, monkeypatch): + """The strip-stage guard is intentionally broader than ``Exception``.""" + + class StripAbort(BaseException): + pass + + sp = tmp_path / "spec.md" + original = b"---\nstatus: ready-for-dev\n---\n\n## Auto Run Result\n" + sp.write_bytes(original) + abort = StripAbort("abort result strip") + + def abort_strip(*args, **kwargs): + raise abort + + monkeypatch.setattr(devcontract, "strip_auto_run_result", abort_strip) + + with pytest.raises(StripAbort) as caught: + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert caught.value is abort + assert sp.read_bytes() == original + + +def test_reset_spec_for_replan_restores_when_strip_finds_spec_missing(tmp_path, monkeypatch): + """A vanished spec is a failed transaction, not a successful no-section strip.""" + sp = tmp_path / "spec.md" + original = b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n## Auto Run Result\r\n" + sp.write_bytes(original) + real_strip = devcontract.strip_auto_run_result + + def vanish_then_strip(*args, **kwargs): + sp.unlink() + return real_strip(*args, **kwargs) + + monkeypatch.setattr(devcontract, "strip_auto_run_result", vanish_then_strip) + + with pytest.raises(FileNotFoundError, match="vanished during result strip"): + devcontract.reset_spec_for_replan(sp, confine_root=tmp_path) + assert sp.read_bytes() == original + + def test_strip_auto_run_result_ignores_heading_in_longer_outer_fence(tmp_path): """Destructive-op guard: a `## Auto Run Result` fenced inside a 4-backtick block that contains a lone inner ``` line must be preserved (no-op). Line diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 1ec73aa5..54e0da6d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3561,6 +3561,131 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): assert strips == [(spec, project.project)] +async def test_plan_checkpoint_replan_restores_preimage_when_result_strip_fails( + project, monkeypatch +): + """The reset and result strip are one TUI transaction, including confinement. + + The real status reset commits first; the injected second-stage fault then forces + the helper to restore the byte-for-byte preimage. Using an isolated worktree also + pins that both the forward status write and rollback carry the caller's owning + root into the confined atomic writer. + + Ablation: delete the rollback write in ``reset_spec_for_replan`` and this reddens + on byte identity because the spec remains at ``status: draft``. + """ + from bmad_loop import devcontract + + calls: list[str] = [] + roots: list[Path] = [] + real_atomic_write = devcontract._atomic_write_spec + + writes = 0 + + def fail_second_atomic_write(p, text, **kw): + nonlocal writes + writes += 1 + roots.append(kw["confine_root"]) + if writes == 2: + raise OSError("injected result-strip write failure") + return real_atomic_write(p, text, **kw) + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr(devcontract, "_atomic_write_spec", fail_second_atomic_write) + wt = _unit_worktree(project.project) + _run_dir, spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + worktree_path=str(wt), + blocked_result="stale terminal result", + ) + original = ( + b"---\r\nstatus: ready-for-dev\r\n---\r\n\r\n# worktree plan for 1\r\n" + b"\r\n## Auto Run Result\r\n\r\n- Status: blocked\r\n\r\nstale terminal result\r\n" + ) + spec.write_bytes(original) + monkeypatch.chdir(project.project) + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until( + pilot, + lambda: any("injected result-strip write failure" in m for m in notifications(app)), + ) + assert app.is_running + assert spec.read_bytes() == original + assert calls == [] + assert roots == [wt, wt, wt] + + +async def test_plan_checkpoint_replan_does_not_strip_when_reset_refuses(project, monkeypatch): + """An unchanged reset must not independently commit the result-strip half.""" + calls: list[str] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + _run_dir, spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + spec_status="draft", + blocked_result="stale terminal result", + ) + original = spec.read_bytes() + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until(pilot, lambda: any("could not reset" in m for m in notifications(app))) + assert spec.read_bytes() == original + assert calls == [] + + +async def test_plan_checkpoint_replan_rollback_failure_stays_loud(project, monkeypatch): + """A failed undo escapes to the TUI error path and still cannot resume.""" + from bmad_loop import devcontract + + calls: list[str] = [] + writes = 0 + real_atomic_write = devcontract._atomic_write_spec + + def fail_rollback(p, text, **kw): + nonlocal writes + writes += 1 + if writes == 2: + raise OSError("injected rollback failure") + return real_atomic_write(p, text, **kw) + + def fail_strip(p, **kw): + raise OSError("injected result-strip failure") + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr(devcontract, "_atomic_write_spec", fail_rollback) + monkeypatch.setattr(devcontract, "strip_auto_run_result", fail_strip) + _run_dir, _spec = _stories_paused_run( + project.project, + stage="plan-checkpoint", + blocked_result="stale terminal result", + ) + + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until( + pilot, + lambda: any("injected rollback failure" in m for m in notifications(app)), + ) + assert app.is_running + assert calls == [] + + def _unit_worktree(root: Path, run_id: str = "20260611-100000-aaaa", unit: str = "1") -> Path: """The UNRESOLVED spelling of where `workspace.open_unit_workspace` mounts a unit. @@ -3625,7 +3750,10 @@ def spy_strip(p, **kw): monkeypatch.setattr(devcontract, "strip_auto_run_result", spy_strip) wt = _unit_worktree(project.project) _run_dir, spec = _stories_paused_run( - project.project, stage="plan-checkpoint", worktree_path=str(wt) + project.project, + stage="plan-checkpoint", + worktree_path=str(wt), + blocked_result="stale terminal result", ) twin = project.project / spec.relative_to(wt) untouched = twin.read_bytes() @@ -3637,6 +3765,7 @@ def spy_strip(p, **kw): await pilot.click(await ready(pilot, "#act-replan")) await until(pilot, lambda: calls == ["20260611-100000-aaaa"]) assert verify.read_frontmatter(spec)["status"] == "draft" + assert "## Auto Run Result" not in spec.read_text(encoding="utf-8") assert twin.read_bytes() == untouched assert roots == [wt, wt] From 60ab6feb2c6c158b3af610838c35524e69e8c2c2 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 22:39:06 -0700 Subject: [PATCH 07/35] sweep dw2-isolation-flip-mount-state: DW-41, DW-42, DW-45 via bmad-loop --- src/bmad_loop/engine.py | 88 ++-- src/bmad_loop/model.py | 24 ++ src/bmad_loop/recovery_flow.py | 19 +- src/bmad_loop/sweep.py | 34 +- src/bmad_loop/verify.py | 148 +++++-- src/bmad_loop/workspace.py | 64 ++- src/bmad_loop/worktree_flow.py | 85 +++- tests/test_engine_worktree.py | 733 +++++++++++++++++++++++++++++++-- tests/test_model.py | 119 ++++++ tests/test_recovery_flow.py | 33 +- tests/test_sweep.py | 186 ++++++++- tests/test_verify.py | 71 ++++ tests/test_verify_worktree.py | 12 + tests/test_worktree_flow.py | 39 +- 14 files changed, 1484 insertions(+), 171 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index a81fd926..7f98f7df 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1434,54 +1434,21 @@ def _discard_unit_for_restart(self, task: StoryTask) -> None: `attempt_dirty` and `_rollback_cleanup_plan` both read as "nothing here is this attempt's to remove", and the same one `sweep`'s migration refusal already uses. """ - discard_worktree( - self.paths.repo_root, task.worktree_path, task.branch, run_dir=self.run_dir - ) + discard_worktree(self.paths.repo_root, task.worktree_path, "", run_dir=self.run_dir) # before the clears below: the relativization is measured against this field task.release_mount_owned_state() task.worktree_path = "" task.branch = "" def _release_orphaned_mount(self, task: StoryTask) -> None: - """Give up a mount live policy has stopped treating as isolated, and say so. - - Reached when `isolation` flipped `worktree -> none` across a resume: policy is - re-read every resume and a change is journaled, never refused, so a task can - arrive still recording the previous attempt's mount while execution happens in - the MAIN workspace. `_finish_inflight` re-anchors `spec_file` INTO that mount - first and unconditionally — the anchor must precede the `isolated` gate, - because the relative spelling resolves against the main checkout, which carries - the identical layout, and `recovery_flow` would restore over the operator's own - copy. Every non-isolated leg that then proceeds has to UNDO that anchor, or it - consumes a `spec_file` absolutized into a tree this run will not enter again: - `_dispatched_spec_for_attempt` resolves it `strict=True`, raises, and leaves the - attempt unbound, and an explicit-spec prompt meets the snapshot gate with - nothing bound. - - FOUR call sites, not one. The restart arm carried this first, but the three - continuation arms — the spec-approval `DEV_VERIFY` leg, the recorded-result - `_resumable_session` leg and the `COMMITTING` finalizer — each finish their work - and `return` without ever reaching it, so they were left consuming the anchored - path. A helper rather than a hoist above the arm dispatch: the restart arm asks - `_refuse_gated_story` FIRST and that can raise `RunPaused`, and the anchored - spelling is load-bearing until an arm commits to acting. Releasing above the - dispatch would undo it for a task that never proceeds. - - The BASELINE goes with the spec: `baseline_commit`/`baseline_untracked` were - measured inside the mount, and handing them to `_rollback_or_pause` against the - main checkout makes a unit's empty untracked snapshot read every untracked file - in the operator's own checkout as this attempt's debris. The CLAIM goes too — - `worktree_path` is how `runs` answers which tree owns the state this task has - already persisted (`task_spec_root`, `task_stories_root`), so keeping it set - anchors those readers on a tree the run has left. Clearing it is not deleting - the tree: the directory stays where it is and the journal names it, and - `workspace.open_unit_workspace` reclaims it if a later flip back to `worktree` - needs its deterministic path. - - Does NOT fix `redrive_base_ref` / `spec_reaches_the_redrive`, and never could: - those describe the re-drive rather than the attempt, and `bmad-loop resolve` - asks them in a SEPARATE process before this resume runs. They take the live - isolation mode as a parameter instead — see `runs.redrive_base_ref`. + """Release mount ownership for a restart that will run in main. + + Accepted continuations never call this helper: their persisted mount owns + the verified work regardless of live policy, so they reopen, finish, merge, + and only then tear down normally. This is restart-only, after the story gate + permits replacement work. The spec binding, attempt snapshot, baselines, + worktree claim, and branch claim all leave together; the directory itself is + retained and journaled for recovery or a later deterministic remount. """ if not task.worktree_path: return @@ -1638,28 +1605,19 @@ def _finish_inflight(self) -> None: if task.terminal: continue if task.worktree_path: - # Re-anchor BEFORE the `isolated` gate, because that gate is live - # policy (`self._isolated`) while the relative spelling is persisted - # state: `model._serialized_worktree_path` relativizes whenever - # `worktree_path` is set, and `from_dict` reads it back raw. Two arms - # below then act on a task whose paths `reopen_unit` never - # re-absolutized — an `isolation` flip across a resume (policy is - # re-read and only journaled, never refused), and the restart arm, - # which discards the mount and clears `worktree_path` before it saves. - # Either way the raw value resolves against the MAIN checkout, which - # carries the same layout, so `recovery_flow._attempt_owned_spec` finds - # exactly one candidate, `spec_within_roots` accepts it, and the - # snapshot restore rewrites the operator's own copy. Anchoring here - # names the tree that actually owned the attempt; when that tree is - # gone the binding is unresolvable and recovery refuses it loudly. + # Portable spec paths are persisted relative to their recorded mount. + # Re-anchor before dispatching recovery: accepted continuations reopen + # that mount regardless of live policy, while a restart must release + # ownership before it can begin in main or a replacement worktree. task.rebase_spec_paths_on(Path(task.worktree_path)) - isolated = self._isolated and task.worktree_path - if isolated and task.defer_reason is not None: + mounted = bool(task.worktree_path) + restart_isolated = self._isolated and mounted + if mounted and task.defer_reason is not None: # _defer records its reason before carrying harvested findings. # A read/commit fault (or host loss before the terminal advance) # can therefore leave a rejected result in the same DEV_VERIFY + # spec_file shape as a verified spec-approval pause. A persisted - # defer reason on a nonterminal isolated task is that interrupted + # defer reason on a nonterminal mounted task is that interrupted # decision's intent; finish it before any session-replay arm. self.journal.append("resume-defer", story_key=task.story_key) unit = self._reopen_unit(task) @@ -1674,7 +1632,7 @@ def _finish_inflight(self) -> None: # paused at the spec-approval gate (or, in stories mode, a # plan-checkpoint awaiting implementation — _resume_after_dev_verify # dispatches the right leg): dev verified on disk. - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -1705,7 +1663,7 @@ def _finish_inflight(self) -> None: continuation = functools.partial( self._review_and_commit, task, resume_result=result ) - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -1727,7 +1685,7 @@ def _finish_inflight(self) -> None: # and finalize_commit tolerates both the pre- and post-squash # crash states (#115). self.journal.append("resume-commit", story_key=task.story_key) - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -1767,7 +1725,7 @@ def _finish_inflight(self) -> None: self.journal.append( "resume-restart", story_key=task.story_key, phase=str(task.phase) ) - if isolated: + if restart_isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) else: @@ -1777,7 +1735,7 @@ def _finish_inflight(self) -> None: # recovery refuse loudly instead of rewriting the main checkout. self._release_orphaned_mount(task) - if not isolated and task.baseline_commit: + if not restart_isolated and task.baseline_commit: # latch resolved_redrive so the corrected spec stays protected # through every reset of this re-drive, not just this first one task.resolved_redrive = task.resolved_redrive or task.rearmed @@ -2056,7 +2014,7 @@ def _run_story(self, task: StoryTask) -> None: ctx = self._emit("pre_story", task) if self._vetoed(ctx, task): return - if self._isolated: + if self._isolated or task.worktree_path: self._run_isolated(task, self._drive_story) else: # in-place (non-isolated) ready gate: a plugin (e.g. a shared-mode diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 16bb3af0..1cf99d53 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -587,6 +587,30 @@ def rebase_spec_paths_on(self, root: Path) -> None: self.spec_file = _rebased_on(self.spec_file, root) self.dispatched_spec_file = _rebased_on(self.dispatched_spec_file, root) + def relativize_project_local_accepted_spec(self, project: Path) -> None: + """Make a canonical project-local accepted spec portable to a worktree. + + Only the accepted ``spec_file`` is normalized. Prior-attempt dispatch path + and snapshot fields remain byte-for-byte untouched until fresh attempt + binding replaces them after the mount opens. Relative values already carry + the intended authority, while external, missing, unresolvable, and + symlink-external absolute values keep their original spelling. Resolving + both sides before containment prevents a lexical in-project path through an + outward symlink from being redirected into a replacement checkout. + """ + raw = self.spec_file + if not raw or not Path(raw).is_absolute(): + return + try: + project_root = project.resolve(strict=True) + target = Path(raw).resolve(strict=True) + relative = target.relative_to(project_root) + except (OSError, RuntimeError, ValueError): + return + if not target.is_file(): + return + self.spec_file = relative.as_posix() + @classmethod def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot = d.get("dispatched_spec_snapshot") diff --git a/src/bmad_loop/recovery_flow.py b/src/bmad_loop/recovery_flow.py index a167788f..8579fa19 100644 --- a/src/bmad_loop/recovery_flow.py +++ b/src/bmad_loop/recovery_flow.py @@ -41,6 +41,11 @@ PRESERVE_REF_PROBE_LIMIT = 100 +def attempt_preserve_ref_name(run_id: str, tip: str) -> str: + """Canonical commits-only recovery branch for one run and pinned tip.""" + return f"attempt-preserve/{safe_ref_segment(run_id)}-{tip[:8]}" + + class _OwnedSpecAuthorityError(RuntimeError): """A previously canonical owned-spec name became unsafe to restore.""" @@ -1194,14 +1199,14 @@ def preserve_attempt_commits(self, task: StoryTask, *, allow_pause: bool) -> Non # blind). These two calls carried no guard at all, so until #343 a plain # git *timeout* — which `_run_git` does translate, and which every sibling # here already treats as routine — crashed the rollback outright; OSError - # joins it because the translation stops at timeouts. The `not commits` - # return sits inside the try to keep the original call order: HEAD is still - # only read once there is something to park. + # joins it because the translation stops at timeouts. Pin HEAD before + # enumerating so the range and the recovery ref describe the same observed + # tip even if the checkout moves between those operations. try: - commits = verify.commits_above(workspace.root, baseline) + head = verify.rev_parse_head(workspace.root) + commits = verify.commits_above(workspace.root, baseline, head) if not commits: return - head = verify.rev_parse_head(workspace.root) # the tip the ref parks at except (verify.GitError, OSError) as exc: self.journal.append( "attempt-preserve-enumerate-failed", story_key=task.story_key, error=str(exc) @@ -1216,13 +1221,13 @@ def preserve_attempt_commits(self, task: StoryTask, *, allow_pause: bool) -> Non # an exotic/overlong id can't blow the ref-name limit, fail `git branch`, and # drop the recovery ref (which on a re-drive would then reset past the work # anyway). - slug = safe_ref_segment(self.state.run_id) try: ref = verify.preserve_commits( workspace.root, baseline, - f"attempt-preserve/{slug}-{head[:8]}", + attempt_preserve_ref_name(self.state.run_id, head), commits=commits, + revision=head, ) except (verify.GitError, OSError): ref = None # branch creation failed — treat as a preservation failure diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index bc66cb07..a2a2a924 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -837,29 +837,19 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: COMMITTING window IS recovered, though — same as the base engine's resume-commit arm (#115).""" if task.worktree_path: - # The same re-anchor `Engine._finish_inflight` makes, for the same reason - # and in the same position — ABOVE the `isolated` gate. Sweep does not - # inherit it: `SweepEngine` replaces `_loop` wholesale and `Engine._loop` - # is the only caller of `_finish_inflight`, so nothing on this path had - # re-absolutized the persisted spelling. Both legs below need it. The - # restart arm discards the mount and clears `worktree_path` before the - # caller saves, which would strand the mount-RELATIVE value beside an - # empty `worktree_path` (`_serialized_worktree_path` only relativizes - # while that field is set); and the gate is live policy, so an - # `isolation` flip across a resume drops the `elif task.baseline_commit` - # and the two non-isolated arms onto never-re-anchored paths. Either way - # the raw value resolves against the MAIN checkout — same layout, so - # `recovery_flow._attempt_owned_spec` finds one candidate, - # `spec_within_roots` accepts it, and the snapshot restore rewrites the - # operator's own copy. + # Sweep replaces Engine._loop, so it performs Engine._finish_inflight's + # mount-relative re-anchor itself. Accepted receipts reopen this mount + # regardless of live policy; restart is the only path allowed to release + # or discard its ownership before future work begins. task.rebase_spec_paths_on(Path(task.worktree_path)) - isolated = self._isolated and task.worktree_path + mounted = bool(task.worktree_path) + restart_isolated = self._isolated and mounted if task.phase == Phase.COMMITTING: # the gate+advance save landed pre-death; finish the commit # instead of rolling verified bundle work back (see # Engine._finalize_commit_phase for the re-drive contract). self.journal.append("resume-commit", story_key=task.story_key) - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -878,7 +868,7 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: and self._accepted_dev_session_matches(task) ): self._save() - if isolated: + if mounted: unit = self._reopen_unit(task) prev = self.workspace self.workspace = unit.workspace @@ -890,10 +880,14 @@ def _recover_inflight_bundle(self, task: StoryTask) -> bool: else: self._resume_after_dev_verify(task) return True - if isolated: + if restart_isolated: # drop the half-built worktree; _run_story mounts a fresh one self._discard_unit_for_restart(task) - elif task.baseline_commit: + elif mounted: + # Live in-place policy applies to the replacement attempt, not to an + # incomplete attempt's mount-owned baselines, paths, and claims. + self._release_orphaned_mount(task) + if not restart_isolated and task.baseline_commit: # latch resolved_redrive so the corrected spec + restored diff stay # protected through every reset of this re-drive, not just this # first one; cause="resolved" keeps a human-initiated re-arm diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index e0f2fdde..554400fc 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -583,6 +583,19 @@ def rev_parse_head(repo: Path) -> str: return out +def rev_parse_revision(repo: Path, revision: str) -> str: + """Resolve ``revision`` to one pinned commit sha. + + Callers that will mutate refs must not carry a moving branch name across the + mutation boundary. ``^{commit}`` also refuses non-commit objects instead of + handing a later worktree/reset operation an object with different semantics. + """ + rc, out, detail = _git_out(repo, "rev-parse", "--verify", f"{revision}^{{commit}}") + if rc != 0: + raise GitError(f"git rev-parse --verify {revision} failed in {repo}: {detail}") + return out + + def last_commit_for(repo: Path, path: Path) -> str: """Sha of the most recent commit touching ``path``, or ``""`` when no commit does (an untracked or deleted-without-history file) or the path lies outside @@ -1467,34 +1480,39 @@ def path_ignored(repo: Path, path: Path) -> bool: return proc.returncode == 0 -def commits_above(repo: Path, baseline: str) -> list[str]: - """Commit shas reachable from HEAD but not from ``baseline`` — the commits an - attempt added on top of its pre-attempt baseline, in ``git rev-list`` order (do - not assume a strict newest-first / HEAD-first ordering across merges or clock - skew; callers that need the tip should read HEAD directly). Empty when HEAD is - at or behind baseline. Raises GitError on a git failure (a bad baseline is a +def commits_above(repo: Path, baseline: str, revision: str = "HEAD") -> list[str]: + """Commit shas reachable from ``revision`` but not from ``baseline`` — the + commits an attempt added on top of its pre-attempt baseline, in ``git rev-list`` + order (do not assume a strict newest-first ordering across merges or clock skew; + callers that need the tip should resolve it directly). Empty when the revision + is at or behind baseline. Raises GitError on a git failure (a bad baseline is a real error, never quietly "no commits"). Reads stdout ALONE (`_git_out`): git exits 0 while still warning on stderr, and against the merged stream that warning is a phantom sha handed to - :func:`preserve_commits` — "Empty when HEAD is at or behind baseline" stops + :func:`preserve_commits` — "empty when the revision is at/below baseline" stops holding on any host whose git config warns (#442).""" - rc, out, detail = _git_out(repo, "rev-list", f"{baseline}..HEAD") + rc, out, detail = _git_out(repo, "rev-list", f"{baseline}..{revision}") if rc != 0: - raise GitError(f"git rev-list {baseline}..HEAD failed in {repo}: {detail}") + raise GitError(f"git rev-list {baseline}..{revision} failed in {repo}: {detail}") return [line for line in out.splitlines() if line] def preserve_commits( - repo: Path, baseline: str, ref_name: str, commits: list[str] | None = None + repo: Path, + baseline: str, + ref_name: str, + commits: list[str] | None = None, + *, + revision: str = "HEAD", ) -> str | None: - """Park the commits an attempt made above ``baseline`` under a branch at HEAD + """Park the commits an attempt made above ``baseline`` under a branch at ``revision`` so a following ``git reset --hard baseline`` cannot orphan them — they survive `git gc` and are recoverable by name, not just via the reflog. Returns - ``ref_name`` on success; ``None`` when there is nothing to preserve (HEAD at/ - below baseline) or the branch could not be created (the caller must then refuse - to reset rather than silently destroy committed work). ``-f`` because a retry - within the same run may re-preserve the same head under the same name. + ``ref_name`` on success; ``None`` when there is nothing to preserve (the + revision is at/below baseline). Creation failures raise, so the caller must + refuse to reset rather than silently destroy committed work. ``-f`` because a + retry within the same run may re-preserve the same tip under the same name. ``commits`` lets a caller that already ran :func:`commits_above` pass the result in to skip a second ``git rev-list`` subprocess; ``None`` self-fetches (keeps the @@ -1505,12 +1523,12 @@ def preserve_commits( of this module), so a caller can never mistake a preservation failure for a harmless no-op and reset past committed work.""" if commits is None: - commits = commits_above(repo, baseline) + commits = commits_above(repo, baseline, revision) if not commits: return None - rc, out = _git(repo, "branch", "-f", ref_name, "HEAD") + rc, out = _git(repo, "branch", "-f", ref_name, revision) if rc != 0: - raise GitError(f"git branch -f {ref_name} HEAD failed in {repo}: {out}") + raise GitError(f"git branch -f {ref_name} {revision} failed in {repo}: {out}") return ref_name @@ -2132,6 +2150,23 @@ def delete_branch(repo: Path, name: str, force: bool = False) -> None: raise GitError(f"git branch -d {name} failed in {repo}: {out}") +def reset_branch_if_tip(repo: Path, name: str, revision: str, expected_tip: str) -> None: + """Move a branch to a pinned revision only while its tip is unchanged. + + ``git update-ref `` is the compare-and-swap primitive: a + concurrently advanced branch makes the command fail rather than losing the + rival commit. The caller resolves both shas before destructive follow-up. + """ + ref = f"refs/heads/{name}" + # A refs/heads name can itself be symbolic. The default update-ref behavior + # dereferences it, which could reset the target branch (including main) instead + # of the attempt-local story ref. --no-deref replaces that name itself while + # preserving the expected-old CAS for ordinary and symbolic refs. + rc, out = _git(repo, "update-ref", "--no-deref", ref, revision, expected_tip) + if rc != 0: + raise GitError(f"git update-ref {ref} {revision} {expected_tip} failed in {repo}: {out}") + + def worktree_add( repo: Path, path: Path, branch: str, base: str | None = None, *, create: bool = True ) -> None: @@ -2202,22 +2237,85 @@ def worktree_prune(repo: Path) -> None: def worktree_list(repo: Path) -> list[Path]: """Paths of every worktree attached to `repo` (the main checkout first). - Reads stdout ALONE (`_git_out`) so the record parse does not depend on no - stderr line ever starting with ``"worktree "``. The advisories measured for + Reads stdout alone through NUL-delimited porcelain so paths may contain + newlines and the record parse does not depend on no stderr line ever starting + with ``"worktree "``. The advisories measured for #442 — an unknown `core.fsyncMethod` value and its family — do NOT start that way, so the `startswith` filter screens them out and this parse was correct by accident rather than by construction; the filter stays as a second, independent screen.""" - rc, out, detail = _git_out(repo, "worktree", "list", "--porcelain") - if rc != 0: + proc = _run_git( + ["git", "-C", str(repo), "worktree", "list", "--porcelain", "-z"], + repo, + ) + if proc.returncode != 0: + detail = (proc.stdout + proc.stderr).strip() raise GitError(f"git worktree list failed in {repo}: {detail}") paths = [] - for line in out.splitlines(): - if line.startswith("worktree "): - paths.append(Path(line[len("worktree ") :])) + for field in proc.stdout.split("\0"): + if field.startswith("worktree "): + paths.append(Path(field[len("worktree ") :])) return paths +def worktree_is_registered(repo: Path, path: Path) -> bool: + """Whether ``path`` is this repository's exact live linked worktree. + + Directory existence is insufficient for recovery: a deleted ``.git`` marker + below the main checkout makes git silently discover the parent repository, + while a replacement repository at the same path can have its own valid + toplevel. Require all three identities to agree: the persisted path is not a + symlink, the main repository still lists it, and git invoked there reports + both that exact toplevel and the main repository's common git directory. + + Ordinary git refusal reads as ``False`` so the recovery caller can escalate + with its recorded-mount message. Spawn/timeout faults raised by ``_git_out`` + remain typed and fail loud. + """ + if path.is_symlink(): + return False + try: + candidate = path.resolve(strict=True) + except (OSError, RuntimeError): + return False + registered = False + for listed in worktree_list(repo): + try: + if listed.resolve(strict=True) == candidate: + registered = True + break + except (OSError, RuntimeError): + continue + if not registered: + return False + + def git_path(root: Path, raw: str) -> Path: + value = Path(raw) + return (value if value.is_absolute() else root / value).resolve(strict=True) + + def path_out(root: Path, *args: str) -> tuple[int, str]: + proc = _run_git(["git", "-C", str(root), *args], root) + # Git terminates this scalar with one newline. Removing exactly that + # delimiter preserves whitespace/newlines that belong to the path itself. + return proc.returncode, proc.stdout.removesuffix("\n") + + rc, top = path_out(candidate, "rev-parse", "--show-toplevel") + if rc != 0: + return False + rc, mounted_common = path_out(candidate, "rev-parse", "--git-common-dir") + if rc != 0: + return False + rc, repo_common = path_out(repo, "rev-parse", "--git-common-dir") + if rc != 0: + return False + try: + return Path(top).resolve(strict=True) == candidate and git_path( + candidate, mounted_common + ) == git_path(repo, repo_common) + except (OSError, RuntimeError): + return False + + def dirty_paths(repo: Path) -> dict[str, str]: """Repo-relative posix path -> two-char porcelain XY status for every dirty entry in `repo`'s working tree. Excludes the orchestrator's own working dir diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index 338868bf..d5af41bf 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -24,6 +24,7 @@ from . import verify from .bmadconfig import ProjectPaths from .platform_util import safe_ref_segment, safe_segment +from .recovery_flow import attempt_preserve_ref_name # Per-unit worktrees live under the run dir (.bmad-loop/runs//worktrees/), # which `bmad-loop init` already gitignores — so unit checkouts never show up as @@ -112,10 +113,11 @@ def open_unit_workspace( """Mount a fresh worktree for `unit_key` and return its rebased workspace. The worktree is mounted under the run dir (see unit_worktrees_dir), not under - .git/. The unit branch is cut from `base` (the target branch's HEAD). When the - branch already exists (branch_per=run re-mounting the shared run branch - across serial units) it is re-checked-out from its own HEAD instead, so it - keeps the commits earlier units already landed on it. + .git/. A new unit branch is cut from a pinned resolution of ``base``. Existing + run-scoped branches reattach at their own pinned tip so earlier landed units + remain reachable. Existing story-scoped branches are abandoned-attempt state: + commits unique to their named tip are parked under ``attempt-preserve/*``, then + the story branch is compare-and-swap reset to the pinned base before remount. """ branch = unit_branch_name(run_id, unit_key, branch_per) unresolved_wt = unit_worktrees_dir(run_dir) / safe_segment(unit_key) @@ -125,6 +127,26 @@ def open_unit_workspace( raise verify.GitError( f"cannot resolve worktree mount path for {unit_key} ({unresolved_wt}): {e}" ) from e + # Pin every moving input before the first mutation. A story branch is an + # attempt-local name: reclaim preserves any commits unique to its old tip, + # then resets it to the requested base with compare-and-swap semantics. A run + # branch is cumulative and deliberately keeps its own tip across remounts. + pinned_base = verify.rev_parse_revision(repo_root, base) + branch_tip: str | None = None + if verify.branch_exists(repo_root, branch): + branch_tip = verify.rev_parse_revision(repo_root, f"refs/heads/{branch}") + if branch_tip is not None and branch_per == "story": + commits = verify.commits_above(repo_root, pinned_base, branch_tip) + if commits: + preserve_ref = attempt_preserve_ref_name(run_id, branch_tip) + verify.preserve_commits( + repo_root, + pinned_base, + preserve_ref, + commits=commits, + revision=branch_tip, + ) + verify.reset_branch_if_tip(repo_root, branch, pinned_base, branch_tip) wt.parent.mkdir(parents=True, exist_ok=True) # Reclaim whatever still occupies this unit's mount point before adding. # `wt` and `branch` are both DETERMINISTIC in (run_id, unit_key, run_dir), so a @@ -139,17 +161,35 @@ def open_unit_workspace( # keeps that preservation intact for the in-place run and spends the orphan only # when a mount actually needs its path. # - # The BRANCH is deliberately not passed: `discard_worktree` would force-delete it, - # and under `branch_per=run` this name is the SHARED run branch carrying commits - # earlier units already landed. Dropping only the worktree frees the checkout that - # blocks `worktree_add` while leaving those commits reachable, so the - # `branch_exists` fork below still re-mounts the branch from its own HEAD. + # The BRANCH is deliberately not passed: `discard_worktree` would force-delete it. + # A run-scoped name carries commits earlier units landed; a story-scoped name has + # already been safely reset above. Dropping only the worktree frees the checkout + # that blocks `worktree_add` without introducing a second ref mutation here. discard_worktree(repo_root, str(wt), "", run_dir=run_dir) - if verify.branch_exists(repo_root, branch): + if branch_tip is not None: verify.worktree_add(repo_root, wt, branch, create=False) + if branch_per == "story": + try: + mounted_tip = verify.rev_parse_head(wt) + current_tip = verify.rev_parse_revision(repo_root, f"refs/heads/{branch}") + if mounted_tip != pinned_base or current_tip != pinned_base: + raise verify.GitError( + f"story branch {branch} moved after reclaim reset: " + f"expected {pinned_base}, mounted {mounted_tip}, current {current_tip}" + ) + except (verify.GitError, OSError): + # The branch may have moved after the reset CAS but before checkout. + # Drop only the mount we just created; the rival ref is evidence and + # must not be reset or deleted by this failure cleanup. + discard_worktree(repo_root, str(wt), "", run_dir=run_dir) + raise else: - verify.worktree_add(repo_root, wt, branch, base=base, create=True) - baseline = verify.rev_parse_head(wt) + verify.worktree_add(repo_root, wt, branch, base=pinned_base, create=True) + # A story checkout was already verified against the pinned base above. Do + # not re-read its symbolic HEAD after that boundary: a rival ref move in this + # final window would record unverified history as the attempt baseline even + # though the mounted index and files still represent ``pinned_base``. + baseline = pinned_base if branch_per == "story" else verify.rev_parse_head(wt) return UnitWorkspace( workspace=Workspace(root=wt, paths=paths.rebased(wt)), repo_root=repo_root, diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 96644a77..14dc2a48 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1482,11 +1482,57 @@ def _board_seed(self, worktree: Path) -> tuple[str, ...]: return () return (rel,) + def _accepted_spec_seed( + self, + task: StoryTask, + worktree: Path, + *, + project_relative_only: bool = False, + ) -> tuple[str, ...]: + """Copy an accepted project-local spec a tracked checkout did not deliver. + + The isolation-flip normalizer gives a main-checkout absolute spec a + project-relative spelling before this point. If the accepted file is + ignored or untracked, ``git worktree add`` cannot carry it, so binding the + new attempt would silently fall back to the bare story key. Seed only a + canonical regular file inside the project, only when the mounted checkout + lacks its corresponding path. Absolute/external spellings pass through; + prior-attempt binding fields remain authoritative until fresh binding + replaces them after the mount is provisioned. + """ + raw = task.spec_file + if not raw or Path(raw).is_absolute(): + return () + try: + project = self.paths.project.resolve(strict=True) + source = ( + self.paths.project / raw + if project_relative_only + else verify.resolve_spec_path(raw, self.paths) + ).resolve(strict=True) + relative = source.relative_to(project) + destination = (worktree / relative).resolve(strict=False) + mounted_root = worktree.resolve(strict=True) + destination.relative_to(mounted_root) + except (OSError, RuntimeError, ValueError): + return () + if not source.is_file() or destination.is_file(): + return () + return (relative.as_posix(),) + def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> None: """Run one unit's `drive` body in a fresh per-unit worktree, then merge it back into the target branch. `drive` either returns (DONE/DEFERRED → integrate) or raises RunPaused (spec-approval gate / escalation → leave the worktree mounted for resume/inspection, integration skipped).""" + # A sprint run can accept an absolute spec in the main checkout before + # isolation is enabled. Convert only that canonical project-local accepted + # artifact to the portable spelling the new mount can own. This must happen + # before worktree creation and must not disturb an existing attempt binding, + # whose path/snapshot authority belongs to its prior workspace. + accepted_spec_before = task.spec_file + task.relativize_project_local_accepted_spec(self.paths.project) + accepted_spec_relocated = task.spec_file != accepted_spec_before try: unit = self._open_unit_workspace( self.paths.repo_root, @@ -1565,6 +1611,13 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N # behind — each decides its own exclusions; see the methods. seeds.extend(self._ledger_seed(unit.path)) seeds.extend(self._board_seed(unit.path)) + seeds.extend( + self._accepted_spec_seed( + task, + unit.path, + project_relative_only=accepted_spec_relocated, + ) + ) # plugins (e.g. the Unity engine) may prime an isolated checkout with # gitignored paths they need — e.g. an MCP-generated skill tree + client # config so the worktree's Editor MCP is reachable. Aggregate every loaded @@ -1615,6 +1668,13 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N "worktree-seed-dropped", story_key=task.story_key, entries=undelivered_seeds ) + if accepted_spec_relocated and not _is_file(unit.path / str(task.spec_file)): + self.escalate_unit( + task, + f"accepted spec for {task.story_key} disappeared before it could be " + "delivered to the replacement worktree", + ) + trees = [p.skill_tree for p in profiles] # The wheel's own bundled skills, journal-only like worktree-seed-dropped but # under their own kind so a user seed that spells a skill rel can neither forge @@ -2280,10 +2340,31 @@ def reopen_unit(self, task: StoryTask) -> UnitWorkspace: be mounted — if it was pruned out from under us we cannot safely reuse it, so escalate rather than run a session in a missing directory.""" wt = Path(task.worktree_path) - if not wt.is_dir(): + try: + mounted = wt.is_dir() and verify.worktree_is_registered(self.paths.repo_root, wt) + except verify.GitError as exc: + self.escalate_unit( + task, + f"cannot verify recorded worktree for {task.story_key} ({wt}): {exc}", + ) + if not mounted: + self.escalate_unit( + task, + f"worktree for {task.story_key} is gone or unopenable ({wt}); " + "cannot resume in place", + ) + try: + mounted_branch = verify.current_branch(wt) + except verify.GitError as exc: + self.escalate_unit( + task, + f"cannot verify recorded worktree branch for {task.story_key} ({wt}): {exc}", + ) + if mounted_branch != task.branch: self.escalate_unit( task, - f"worktree for {task.story_key} is gone ({wt}); cannot resume in place", + f"worktree for {task.story_key} is on {mounted_branch!r}, not recorded " + f"branch {task.branch!r}; cannot resume in place", ) # Spec paths are persisted relative to the worktree (model.to_dict) so # state stays portable; re-absolutize both accepted/result ownership and diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 7a04cf4f..4a591048 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -237,6 +237,119 @@ def test_worktree_happy_path_merges_to_target(project): assert "worktree-teardown-degraded" not in kinds +def test_local_absolute_ignored_accepted_spec_is_seeded_and_bound_in_mount(project): + """Relativizing an accepted spec also delivers it to a tracked-only checkout.""" + rel = "_bmad-output/implementation-artifacts/accepted-untracked.md" + ignore_before_commit(project, rel) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + accepted = project.project / rel + accepted.parent.mkdir(parents=True, exist_ok=True) + accepted.write_bytes(b"accepted operator bytes\n") + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + task = StoryTask("1-1-a", 1, spec_file=str(accepted)) + engine.state.tasks[task.story_key] = task + observed: dict[str, object] = {} + + def bind_then_defer(current): + engine._bind_dispatched_spec_for_attempt(current) + observed["path"] = current.dispatched_spec_file + observed["snapshot"] = current.dispatched_spec_snapshot + observed["root"] = engine.workspace.root + current.phase = Phase.DEFERRED + current.defer_reason = "test complete" + + engine._run_isolated(task, bind_then_defer) + + mounted_root = observed["root"] + assert isinstance(mounted_root, Path) + assert observed["path"] == str(mounted_root / rel) + assert observed["snapshot"] == b"accepted operator bytes\n" + + +def test_prior_dispatch_does_not_block_fresh_mounted_spec_binding(project): + """A none-to-worktree re-drive relocates accepted input, then replaces old authority.""" + rel = "_bmad-output/implementation-artifacts/accepted-rearm.md" + ignore_before_commit(project, rel) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + accepted = project.project / rel + accepted.parent.mkdir(parents=True, exist_ok=True) + accepted.write_bytes(b"ignored accepted bytes\n") + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + old_dispatch = str(project.project / ".bmad-loop" / "runs" / "old" / "spec.md") + old_snapshot = b"prior dispatch bytes\x00" + task = StoryTask( + "1-1-a", + 1, + spec_file=str(accepted), + dispatched_spec_file=old_dispatch, + dispatched_spec_snapshot=old_snapshot, + ) + engine.state.tasks[task.story_key] = task + observed: dict[str, object] = {} + + def bind_fresh_attempt(current): + observed["prior_path"] = current.dispatched_spec_file + observed["prior_snapshot"] = current.dispatched_spec_snapshot + engine._bind_dispatched_spec_for_attempt(current) + observed["accepted"] = current.spec_file + observed["bound"] = current.dispatched_spec_file + observed["snapshot"] = current.dispatched_spec_snapshot + observed["root"] = engine.workspace.root + current.phase = Phase.DEFERRED + current.defer_reason = "test complete" + + engine._run_isolated(task, bind_fresh_attempt) + + mounted_root = observed["root"] + assert isinstance(mounted_root, Path) + assert observed["prior_path"] == old_dispatch + assert observed["prior_snapshot"] == old_snapshot + assert observed["accepted"] == rel + assert observed["bound"] == str(mounted_root / rel) + assert observed["snapshot"] == b"ignored accepted bytes\n" + + +def test_relocated_accepted_spec_disappearance_does_not_bind_fallback(project): + """A normalized absolute spec keeps its exact project-path authority. + + Ablation: resolve the seed through the ordinary relative fallback and omit + the mounted-file gate; the nested fallback is bound after the accepted source + disappears between normalization and seeding. + """ + from bmad_loop.engine import RunPaused + + rel = "_bmad-output/implementation-artifacts/accepted-race.md" + ignore_before_commit(project, rel) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + accepted = project.project / rel + accepted.parent.mkdir(parents=True, exist_ok=True) + accepted.write_bytes(b"accepted operator bytes\n") + fallback = project.implementation_artifacts / rel + fallback.parent.mkdir(parents=True, exist_ok=True) + fallback.write_bytes(b"unrelated fallback bytes\n") + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + task = StoryTask("1-1-a", 1, spec_file=str(accepted)) + engine.state.tasks[task.story_key] = task + real_open = engine._worktree_flow._open_unit_workspace + + def open_then_remove_source(*args, **kwargs): + unit = real_open(*args, **kwargs) + accepted.unlink() + return unit + + engine._worktree_flow._open_unit_workspace = open_then_remove_source + drove: list[bool] = [] + + with pytest.raises(RunPaused, match="accepted spec.*disappeared"): + engine._run_isolated(task, lambda _task: drove.append(True)) + + assert drove == [] + assert task.phase == Phase.ESCALATED + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_missing_upstream_skill_seed_escalates_before_dispatch_and_records_mount(project, tmp_path): """A shared install outside the repo passes main's through-link resolution but @@ -1317,15 +1430,19 @@ def test_started_merge_replay_failure_does_not_carry_harvest(project, monkeypatc commit_sprint(project, {"1-1-a": "ready-for-dev"}) engine, _ = make_engine(project, []) engine.state.target_branch = "main" - worktree = engine.run_dir / "worktrees" / "1-1-a" - worktree.mkdir(parents=True) - source = rev_parse_head(project.project) + from bmad_loop.workspace import open_unit_workspace + + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + source = rev_parse_head(unit.path) task = StoryTask( story_key="1-1-a", epic=1, phase=Phase.DONE, - worktree_path=str(worktree), - branch="bmad-loop/test-run/1-1-a", + worktree_path=str(unit.path), + branch=unit.branch, + baseline_commit=unit.baseline, commit_sha=source, harvested_deferrals=[_harvest_record()], ) @@ -1974,6 +2091,151 @@ def test_worktree_reopen_reabsolutizes_both_spec_ownership_paths(project, tmp_pa assert task.dispatched_spec_file == outside_dispatched +def test_reopen_rejects_existing_directory_that_is_not_the_recorded_worktree(project, monkeypatch): + """A plain directory below main must not make git fall back to main on resume.""" + from bmad_loop.engine import RunPaused + + engine, _ = make_engine(project, [], policy=wt_policy()) + fake = engine.run_dir / "worktrees" / "plain-directory" + fake.mkdir(parents=True) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(fake), + branch="bmad-loop/test-run/1-1-a", + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="gone or unopenable"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + assert engine.workspace.root == project.project + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_reopen_rejects_symlink_alias_to_recorded_worktree(project, monkeypatch): + """A retargetable alias is not the exact persisted mount ownership claim.""" + from bmad_loop.engine import RunPaused + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=wt_policy()) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + alias = unit.path.parent / "recorded-alias" + alias.symlink_to(unit.path, target_is_directory=True) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(alias), + branch=unit.branch, + baseline_commit=unit.baseline, + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="gone or unopenable"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + + +@pytest.mark.parametrize("checkout", ["wrong-branch", "detached"]) +def test_reopen_rejects_registered_mount_on_wrong_recorded_branch(project, monkeypatch, checkout): + """Registration is not ownership when the linked checkout changed branch.""" + from bmad_loop.engine import RunPaused + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=wt_policy()) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + if checkout == "wrong-branch": + git(unit.path, "checkout", "-q", "-b", "operator-branch") + else: + git(unit.path, "checkout", "-q", "--detach") + assert verify.worktree_is_registered(project.project, unit.path) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(unit.path), + branch=unit.branch, + baseline_commit=unit.baseline, + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="not recorded branch"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + + +@pytest.mark.parametrize("damage", ["missing-marker", "foreign-repository"]) +def test_reopen_rejects_corrupted_but_registered_mount(project, monkeypatch, damage): + """Toplevel and common-dir identity fail closed before any continuation.""" + from bmad_loop.engine import RunPaused + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=wt_policy()) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + if damage == "missing-marker": + (unit.path / ".git").unlink() + else: + shutil.rmtree(unit.path) + unit.path.mkdir() + git(unit.path, "init", "-q") + git(unit.path, "config", "user.email", "test@example.com") + git(unit.path, "config", "user.name", "Test User") + git(unit.path, "commit", "--allow-empty", "-q", "-m", "foreign root") + assert unit.path.resolve() in [path.resolve() for path in worktree_list(project.project)] + assert not verify.worktree_is_registered(project.project, unit.path) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(unit.path), + branch=unit.branch, + baseline_commit=unit.baseline, + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="gone or unopenable"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + + def test_restart_arm_anchors_spec_ownership_before_it_discards_the_mount(project, monkeypatch): """The restart arm destroys the only tree that can resolve the persisted spelling. @@ -2304,25 +2566,16 @@ def _stop(*_a, **_k): assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) -def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, monkeypatch): - """Every non-isolated leg undoes the re-anchor, not just the restart arm. +def test_isolation_flip_keeps_the_mount_for_an_accepted_continuation(project, monkeypatch): + """Recorded ownership wins over live policy until accepted work is integrated. - `_finish_inflight` re-anchors `spec_file` INTO the recorded mount unconditionally, - above the `isolated` gate. Three arms below then reach the MAIN workspace on their - non-isolated legs and `return` without ever reaching the restart arm that first - carried the release — the spec-approval `DEV_VERIFY` continuation graded here, the - recorded-result `_resumable_session` continuation and the `COMMITTING` finalizer. - Left unreleased, each continues with `spec_file` absolutized into a mount the run - has already left: `_dispatched_spec_for_attempt` resolves that `strict=True`, - raises, and leaves the attempt unbound, and an explicit-spec prompt meets the - snapshot gate with nothing bound. + The worktree path is persisted state while ``self._isolated`` is live policy for + the next attempt. A verified DEV_VERIFY continuation must therefore reopen the + exact mount after ``worktree -> none`` instead of releasing its accepted spec and + running in main. - Graded at the moment the continuation RUNS, not on the saved state — the defect is - what the arm consumes, and a later save could launder it. That is also why this - cannot be folded into the restart-arm row above: that one never enters an arm. - - Ablation: drop `self._release_orphaned_mount(task)` from the `DEV_VERIFY` else-leg - and `seen["spec_file"]` becomes the absolute path into the mount. + Ablation: gate the DEV_VERIFY reopen on ``self._isolated`` and the observed paths + are released to main (and ``isolation-flip-orphaned-worktree`` is journaled). """ commit_sprint(project, {"1-1-a": "ready-for-dev"}) in_place = Policy( @@ -2333,17 +2586,23 @@ def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, engine, _ = make_engine(project, [], policy=in_place) assert not engine._isolated # the premise: live policy says in-place - mount = project.project / ".bmad-loop" / "runs" / "test-run" / "worktrees" / "1-1-a" + from bmad_loop.workspace import open_unit_workspace + + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + mount = unit.path rel = "_bmad-output/accepted.md" (mount / rel).parent.mkdir(parents=True, exist_ok=True) (mount / rel).write_text("# spec\n", encoding="utf-8") task = StoryTask("1-1-a", 1, phase=Phase.DEV_VERIFY) - task.worktree_path = str(mount) # the persisted mount the live policy ignores + task.worktree_path = str(mount) # persisted ownership survives the policy flip + task.branch = unit.branch task.spec_file = rel # persisted RELATIVE, as `_serialized_worktree_path` writes it task.dispatched_spec_file = rel task.dispatched_spec_snapshot = b"pre-launch bytes" - task.baseline_commit = rev_parse_head(project.project) + task.baseline_commit = unit.baseline task.baseline_untracked = [] engine.state.tasks["1-1-a"] = task @@ -2362,14 +2621,11 @@ def test_isolation_flip_releases_the_mount_on_the_continuation_arms_too(project, engine._finish_inflight() assert seen, "the DEV_VERIFY continuation arm never ran" - # the arm acts on the spelling the MAIN workspace re-probes, not the orphan's - assert seen["spec_file"] == rel - assert seen["spec_file"] != str(mount / rel) - assert seen["dispatched"] is None # the attempt died with its tree - assert seen["worktree_path"] == "" # the claim is dropped BEFORE the arm acts - assert seen["baseline_commit"] is None # unit operands never reach the main checkout - assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) - assert mount.is_dir() # released, not deleted + assert seen["spec_file"] == str(mount / rel) + assert seen["dispatched"] == str(mount / rel) + assert seen["worktree_path"] == str(mount) + assert seen["baseline_commit"] == rev_parse_head(project.project) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(engine) def test_open_unit_workspace_reclaims_the_orphan_holding_its_mount_path(project): @@ -2414,6 +2670,265 @@ def test_open_unit_workspace_reclaims_the_orphan_holding_its_mount_path(project) assert (second.path / "landed.txt").read_text(encoding="utf-8") == "earlier unit\n" +def test_story_remount_preserves_named_tip_and_restarts_from_pinned_base(project): + """Story branches are attempt-local while the preserve ref keeps abandoned work.""" + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + (first.path / "abandoned.txt").write_text("old attempt\n", encoding="utf-8") + git(first.path, "add", "abandoned.txt") + git(first.path, "commit", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + + (project.project / "advanced.txt").write_text("new base\n", encoding="utf-8") + git(project.project, "add", "advanced.txt") + git(project.project, "commit", "-q", "-m", "advance requested base") + pinned_base = rev_parse_head(project.project) + + second = open_unit_workspace(*args) + + preserve_ref = f"attempt-preserve/test-run-{old_tip[:8]}" + assert rev_parse_head(second.path) == pinned_base + assert not (second.path / "abandoned.txt").exists() + assert (second.path / "advanced.txt").read_text(encoding="utf-8") == "new base\n" + assert git(project.project, "rev-parse", preserve_ref) == old_tip + + +def test_story_remount_without_unique_commits_still_moves_to_advanced_base(project): + """Reset is unconditional for story scope, not coupled to preservation work. + + Ablation: nest ``reset_branch_if_tip`` under ``if commits`` and this leaves the + replacement at the original base because there is no abandoned commit to park. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + old_base = rev_parse_head(first.path) + + (project.project / "advanced-without-abandoned.txt").write_text("new base\n") + git(project.project, "add", "advanced-without-abandoned.txt") + git(project.project, "commit", "-q", "-m", "advance requested base") + advanced = rev_parse_head(project.project) + + second = open_unit_workspace(*args) + + assert old_base != advanced + assert rev_parse_head(second.path) == advanced + assert (second.path / "advanced-without-abandoned.txt").read_text() == "new base\n" + + +def test_new_story_workspace_uses_base_sha_pinned_before_ref_moves(project, monkeypatch): + """A moving requested branch cannot change the operation's selected snapshot.""" + from bmad_loop.workspace import open_unit_workspace + + real_resolve = verify.rev_parse_revision + observed: dict[str, str] = {} + + def resolve_then_advance(repo, revision): + pinned = real_resolve(repo, revision) + if revision == "main" and "pinned" not in observed: + observed["pinned"] = pinned + (project.project / "late-base-move.txt").write_text("too late\n") + git(project.project, "add", "late-base-move.txt") + git(project.project, "commit", "-q", "-m", "concurrent base move") + observed["advanced"] = rev_parse_head(project.project) + return pinned + + monkeypatch.setattr(verify, "rev_parse_revision", resolve_then_advance) + unit = open_unit_workspace( + project.project, + project, + "test-run", + "1-1-pinned", + "main", + "story", + project.project / ".bmad-loop" / "runs" / "test-run", + ) + + assert observed["pinned"] != observed["advanced"] + assert rev_parse_head(unit.path) == observed["pinned"] + assert not (unit.path / "late-base-move.txt").exists() + + +def test_story_remount_preservation_failure_keeps_old_tip_and_mount(project, monkeypatch): + """Preservation must complete before either the story ref or mount is discarded. + + INVERSE ablation: catch the preservation error and continue to reset/discard; + then the old branch tip and mounted directory assertions fail. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + monkeypatch.setattr( + verify, + "preserve_commits", + lambda *_a, **_k: (_ for _ in ()).throw(verify.GitError("preserve refused")), + ) + + with pytest.raises(verify.GitError, match="preserve refused"): + open_unit_workspace(*args) + + assert git(project.project, "rev-parse", first.branch) == old_tip + assert first.path.is_dir() + + +def test_story_remount_cas_failure_keeps_concurrent_tip_and_mount(project, monkeypatch): + """A rival branch move after preservation is never overwritten by reclaim. + + INVERSE ablation: remove the expected-old operand from ``update-ref`` and the + concurrently created tip is reset to main instead of surviving. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + real_reset = verify.reset_branch_if_tip + rival: dict[str, str] = {} + + def move_then_reset(repo, name, revision, expected_tip): + git(first.path, "commit", "--allow-empty", "-q", "-m", "concurrent story move") + rival["tip"] = rev_parse_head(first.path) + real_reset(repo, name, revision, expected_tip) + + monkeypatch.setattr(verify, "reset_branch_if_tip", move_then_reset) + + with pytest.raises(verify.GitError, match="update-ref"): + open_unit_workspace(*args) + + assert git(project.project, "rev-parse", first.branch) == rival["tip"] + assert git(project.project, "rev-parse", f"attempt-preserve/test-run-{old_tip[:8]}") == old_tip + assert first.path.is_dir() + + +def test_story_remount_rejects_branch_move_after_reset_before_checkout(project, monkeypatch): + """The second race window cannot smuggle a rival tip into the replacement.""" + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + pinned = rev_parse_head(project.project) + real_add = verify.worktree_add + + def move_then_add(repo, path, branch, base=None, *, create=True): + if not create: + git(repo, "update-ref", f"refs/heads/{branch}", old_tip, pinned) + real_add(repo, path, branch, base, create=create) + + monkeypatch.setattr(verify, "worktree_add", move_then_add) + + with pytest.raises(verify.GitError, match="moved after reclaim reset"): + open_unit_workspace(*args) + + assert git(project.project, "rev-parse", first.branch) == old_tip + assert not first.path.exists() + + +def test_story_remount_does_not_reread_head_after_tip_validation(project, monkeypatch): + """A final moving HEAD read cannot replace the already-validated baseline. + + Ablation: restore ``baseline = rev_parse_head(wt)`` and the injected rival + move lands between validation and that read, returning the rival as baseline. + """ + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + args = (project.project, project, "test-run", "1-1-a", "main", "story", run_dir) + first = open_unit_workspace(*args) + git(first.path, "commit", "--allow-empty", "-q", "-m", "abandoned story attempt") + old_tip = rev_parse_head(first.path) + pinned = rev_parse_head(project.project) + real_head = verify.rev_parse_head + reads = 0 + + def move_on_redundant_head_read(repo): + nonlocal reads + if Path(repo).resolve() == first.path.resolve(): + reads += 1 + if reads == 2: + git(project.project, "update-ref", f"refs/heads/{first.branch}", old_tip, pinned) + return real_head(repo) + + monkeypatch.setattr(verify, "rev_parse_head", move_on_redundant_head_read) + + second = open_unit_workspace(*args) + + assert reads == 1 + assert second.baseline == pinned + + +def test_restart_discard_retains_shared_run_branch_history(project): + """Restart teardown frees the mount without deleting a cumulative run ref.""" + from bmad_loop.workspace import open_unit_workspace + + engine, _ = make_engine(project, [], policy=wt_policy(branch_per="run")) + args = ( + project.project, + project, + "test-run", + "1-1-a", + "main", + "run", + engine.run_dir, + ) + first = open_unit_workspace(*args) + (first.path / "landed-before-restart.txt").write_text("retained\n") + git(first.path, "add", "landed-before-restart.txt") + git(first.path, "commit", "-q", "-m", "landed before restart") + landed_tip = rev_parse_head(first.path) + task = StoryTask( + "1-1-a", + 1, + worktree_path=str(first.path), + branch=first.branch, + baseline_commit=first.baseline, + ) + + engine._discard_unit_for_restart(task) + second = open_unit_workspace(*args) + + assert rev_parse_head(second.path) == landed_tip + assert (second.path / "landed-before-restart.txt").read_text() == "retained\n" + + +def test_story_remount_bad_base_fails_before_discard(project): + """An unresolvable requested base leaves the existing story workspace intact.""" + from bmad_loop.workspace import open_unit_workspace + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + first = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", run_dir + ) + tip = rev_parse_head(first.path) + + with pytest.raises(verify.GitError, match="missing-base"): + open_unit_workspace( + project.project, + project, + "test-run", + "1-1-a", + "missing-base", + "story", + run_dir, + ) + + assert rev_parse_head(first.path) == tip + assert first.path.is_dir() + + def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) gated = Policy( @@ -2435,9 +2950,14 @@ def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): state = load_state(engine.run_dir) state.clear_pause() adapter = MockAdapter([wt_review_effect(project, "1-1-a", clean=True)]) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) resumed = Engine( paths=project, - policy=gated, + policy=in_place, adapter=adapter, run_dir=engine.run_dir, journal=engine.journal, @@ -2452,6 +2972,144 @@ def test_worktree_spec_approval_pause_resumes_in_same_worktree(project): assert worktree_clean(project.project) +@pytest.mark.parametrize("role", ["dev", "review"]) +def test_isolation_flip_replays_recorded_session_in_mount_and_lands_it(project, monkeypatch, role): + """Completed dev/review results retain their recorded workspace through merge. + + Ablation: gate the recorded-result reopen on live isolation and each row writes + the continuation marker in main directly, leaving no unit merge evidence. + """ + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) + engine, _ = make_engine(project, [], policy=in_place) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + phase = Phase.DEV_RUNNING if role == "dev" else Phase.REVIEW_RUNNING + task = StoryTask( + "1-1-a", + 1, + phase=phase, + attempt=1, + review_cycle=1, + baseline_commit=unit.baseline, + worktree_path=str(unit.path), + branch=unit.branch, + ) + task.record_session( + SessionRecord( + task_id=f"1-1-a-{role}-1", + role=role, + status="completed", + result_json={"workflow": f"recorded-{role}"}, + ) + ) + engine.state.tasks[task.story_key] = task + observed: list[Path] = [] + + def continue_in_recorded_workspace(*_args, **_kwargs): + observed.append(engine.workspace.root) + marker = engine.workspace.root / f"continued-{role}.txt" + marker.write_text(f"{role}\n", encoding="utf-8") + git(engine.workspace.root, "add", marker.name) + git(engine.workspace.root, "commit", "-q", "-m", f"continue {role}") + task.phase = Phase.DONE + + target = "_drive_story" if role == "dev" else "_review_and_commit" + monkeypatch.setattr(engine, target, continue_in_recorded_workspace) + + engine._finish_inflight() + + assert observed == [unit.path] + assert (project.project / f"continued-{role}.txt").read_text(encoding="utf-8") == f"{role}\n" + assert "unit-merged" in journal_kinds(engine) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(engine) + assert not unit.path.exists() + + +def test_isolation_flip_finishes_mounted_defer_before_teardown(project): + """A persisted defer decision is completed where its rejected work lives.""" + from bmad_loop.workspace import open_unit_workspace + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none", keep_failed=False), + ) + engine, _ = make_engine(project, [], policy=in_place) + engine.state.target_branch = "main" + unit = open_unit_workspace( + project.project, project, "test-run", "1-1-a", "main", "story", engine.run_dir + ) + task = StoryTask( + "1-1-a", + 1, + phase=Phase.DEV_VERIFY, + baseline_commit=unit.baseline, + worktree_path=str(unit.path), + branch=unit.branch, + defer_reason="accepted rejection", + ) + engine.state.tasks[task.story_key] = task + rejected = unit.path / "rejected-work.txt" + rejected.write_text("preserve in failed patch\n", encoding="utf-8") + + engine._finish_inflight() + + assert task.phase == Phase.DEFERRED + assert "resume-defer" in journal_kinds(engine) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(engine) + assert not unit.path.exists() + patch = engine.run_dir / "failed" / "1-1-a" / "changes.patch" + assert "rejected-work.txt" in patch.read_text(encoding="utf-8") + assert "preserve in failed patch" in patch.read_text(encoding="utf-8") + + +def test_isolation_flip_missing_mount_escalates_before_continuation(project, monkeypatch): + """Missing recorded ownership never falls back to a continuation in main. + + Ablation: select reopening from live isolation and the finalizer spy runs in the + main checkout instead of the task escalating. + """ + from bmad_loop.engine import RunPaused + + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) + engine, _ = make_engine(project, [], policy=in_place) + engine.state.target_branch = "main" + task = StoryTask( + "1-1-a", + 1, + phase=Phase.COMMITTING, + worktree_path=str(engine.run_dir / "worktrees" / "gone"), + branch="bmad-loop/test-run/1-1-a", + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="is gone"): + engine._finish_inflight() + + assert finalized == [] + assert task.phase == Phase.ESCALATED + assert engine.workspace.root == project.project + + def test_worktree_crash_restart_discards_stale_worktree(project): """A unit interrupted before the spec gate is restarted fresh: the stale worktree is discarded and a new one mounted, not stacked on top.""" @@ -2540,9 +3198,14 @@ def test_worktree_resume_committing_finishes_and_merges(project): state = load_state(engine.run_dir) state.clear_pause() adapter = MockAdapter([]) + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) resumed = Engine( paths=project, - policy=wt_policy(), + policy=in_place, adapter=adapter, run_dir=engine.run_dir, journal=engine.journal, diff --git a/tests/test_model.py b/tests/test_model.py index 3ced6da2..2840a243 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from conftest import refuse_to_resolve from bmad_loop.model import ( SWEEP_REFUSED_DIRTY, @@ -380,6 +381,124 @@ def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): assert task.dispatched_spec_file is None +def test_project_local_absolute_accepted_spec_becomes_canonical_relative(tmp_path): + project = tmp_path / "project" + spec = project / "artifacts" / "spec.md" + spec.parent.mkdir(parents=True) + spec.write_text("spec\n", encoding="utf-8") + (project / "hop").mkdir() + raw = str(project / "hop" / ".." / "artifacts" / "spec.md") + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == "artifacts/spec.md" + + +def test_prior_attempt_binding_survives_accepted_spec_relocation(tmp_path): + """Only accepted-spec portability changes before fresh attempt binding.""" + project = tmp_path / "project" + spec = project / "spec.md" + project.mkdir() + spec.write_text("spec\n", encoding="utf-8") + old_dispatch = str(tmp_path / "old-worktree" / "spec.md") + old_snapshot = b"prior attempt bytes\x00" + task = StoryTask( + story_key="1-1-a", + epic=1, + spec_file=str(spec), + dispatched_spec_file=old_dispatch, + dispatched_spec_snapshot=old_snapshot, + ) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == "spec.md" + assert task.dispatched_spec_file == old_dispatch + assert task.dispatched_spec_snapshot == old_snapshot + + +def test_external_and_symlink_external_accepted_specs_keep_their_spelling(tmp_path): + project = tmp_path / "project" + external = tmp_path / "external" + project.mkdir() + external.mkdir() + spec = external / "spec.md" + spec.write_text("spec\n", encoding="utf-8") + link = project / "linked" + link.symlink_to(external, target_is_directory=True) + + for raw in (str(spec), str(link / "spec.md")): + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + task.relativize_project_local_accepted_spec(project) + assert task.spec_file == raw + + +def test_relative_accepted_spec_keeps_its_exact_spelling(): + """A relative value already carries the intended dispatch authority. + + Ablation: delete the absolute-path guard and ``./pyproject.toml`` is normalized + to ``pyproject.toml``. + """ + raw = "./pyproject.toml" + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(Path.cwd()) + + assert task.spec_file == raw + + +def test_missing_absolute_accepted_spec_is_unchanged(tmp_path): + """A missing target has no canonical containment fact to transfer. + + INVERSE ablation: resolve the target non-strictly; the missing in-project + spelling is rewritten despite having no accepted artifact. + """ + project = tmp_path / "project" + project.mkdir() + raw = str(project / "missing.md") + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == raw + + +def test_non_file_absolute_accepted_spec_is_unchanged(tmp_path): + """A contained directory cannot gain relative fallback authority. + + Ablation: remove the regular-file guard and the accepted directory is + rewritten to a relative spelling that can probe an unrelated artifact root. + """ + project = tmp_path / "project" + directory = project / "artifacts" / "spec.md" + directory.mkdir(parents=True) + raw = str(directory) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == raw + + +def test_resolution_fault_accepted_spec_is_unchanged(tmp_path, monkeypatch): + """Uncertain canonical containment fails safe with the exact original spelling. + + INVERSE ablation: fall back to lexical containment after the resolution error; + the faulted in-project absolute value is rewritten. + """ + project = tmp_path / "project" + project.mkdir() + faulted = project / "faulted.md" + refuse_to_resolve(monkeypatch, faulted) + raw = str(faulted) + task = StoryTask(story_key="1-1-a", epic=1, spec_file=raw) + + task.relativize_project_local_accepted_spec(project) + + assert task.spec_file == raw + + def test_dispatched_spec_snapshot_round_trips_byte_exactly(): snapshot = b"---\r\nstatus: ready-for-dev\r\n---\r\n\xffoperator intent\r\n" task = StoryTask(story_key="1-1-a", epic=1, dispatched_spec_snapshot=snapshot) diff --git a/tests/test_recovery_flow.py b/tests/test_recovery_flow.py index 877c3a7a..a72fae3c 100644 --- a/tests/test_recovery_flow.py +++ b/tests/test_recovery_flow.py @@ -1876,6 +1876,35 @@ def test_preserve_attempt_commits_parks_committed_work(project): assert task.preserve_ref == ref # #333: the ref reaches run state, not just the journal +def test_preserve_attempt_commits_pins_the_observed_head(project, monkeypatch): + """Range enumeration and ref creation use one HEAD observed before either.""" + repo = project.project + flow = _make_flow(workspace=Workspace.default(project)) + task = _task(repo) + baseline = task.baseline_commit + assert baseline is not None + git(repo, "checkout", "-q", "-b", "unrelated", baseline) + git(repo, "commit", "--allow-empty", "-q", "-m", "unrelated commit") + unrelated = rev_parse_head(repo) + git(repo, "checkout", "-q", "-b", "attempt", baseline) + git(repo, "commit", "--allow-empty", "-q", "-m", "attempt commit") + intended = rev_parse_head(repo) + real_commits_above = verify.commits_above + + def move_then_enumerate(repo, baseline, revision="HEAD"): + assert revision == intended + git(repo, "checkout", "-q", "unrelated") + return real_commits_above(repo, baseline, revision) + + monkeypatch.setattr(verify, "commits_above", move_then_enumerate) + + flow.preserve_attempt_commits(task, allow_pause=True) + + assert rev_parse_head(repo) == unrelated + assert task.preserve_ref is not None + assert git(repo, "rev-parse", task.preserve_ref) == intended + + def test_preserve_attempt_commits_noop_without_commits(project): ws = Workspace.default(project) flow = _make_flow(workspace=ws) @@ -1968,8 +1997,8 @@ def boom(*a, **k): def test_preserve_attempt_commits_pauses_when_head_read_fails(project, monkeypatch): - # The second unguarded call (#343): the range enumerated fine, but the tip the - # ref would park at could not be read — still work we cannot park. + # The pinned-tip read fails before range enumeration; uncertainty still means + # there may be work above baseline that cannot safely be reset away. repo = project.project ws = Workspace.default(project) flow = _make_flow(workspace=ws) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 97ae64ab..cbd27ba4 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -10,6 +10,7 @@ from conftest import ( _OK, _file_exists_cmd, + _spec_baseline, attach_profile, bundle_dev_effect, bundle_dev_escalates, @@ -1205,6 +1206,30 @@ def effect(spec): return effect +def wt_bundle_review(project, name="fix"): + """Follow-up review effect that resolves the accepted spec from ``spec.cwd``.""" + + def effect(spec): + wt = project.rebased(spec.cwd) + sp = wt.implementation_artifacts / f"spec-dw-{name}.md" + baseline = _spec_baseline(sp) + write_spec(sp, "done", baseline) + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": f"dw-{name}", + "spec_file": str(sp), + "baseline_commit": baseline, + "status": "done", + "followup_review_recommended": False, + "escalations": [], + }, + ) + + return effect + + def bundle_plan(dw_ids=("DW-1",), name="fix"): return triage_result( list(dw_ids), @@ -1768,6 +1793,64 @@ def review_after_sync(spec): assert saved.pre_harvest_ledger_captured is False +def test_isolation_flip_resumes_accepted_bundle_in_mount_and_integrates(project, monkeypatch): + """A sweep receipt owns its recorded tree until sync, review, commit, and merge. + + Ablation: gate accepted DEV_VERIFY reopening on live isolation and review sees + main's still-open ledger instead of the accepted close in the mounted workspace. + """ + write_ledger(project, {"DW-1": "open"}) + plan = bundle_plan() + isolated = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="worktree", rollback_on_failure=True), + ) + engine, _ = make_sweep( + project, + [triage_effect(plan), wt_bundle_dev(project)], + policy=isolated, + ) + + def crash_before_accepted_sync(task, result_json): + raise RuntimeError("host died before accepted sync") + + monkeypatch.setattr(engine, "_post_dev_accepted_sync", crash_before_accepted_sync) + assert engine.run().crashed + crashed = load_state(engine.run_dir).tasks["dw-fix"] + mount = Path(crashed.worktree_path) + assert crashed.phase == Phase.DEV_VERIFY and mount.is_dir() + + engine.policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="none", rollback_on_failure=True), + ) + seen: list[tuple[Path, str]] = [] + review = wt_bundle_review(project) + + def review_mounted_close(spec): + wt = project.rebased(spec.cwd) + seen.append((spec.cwd, ledger_entries(wt)["DW-1"].status)) + return review(spec) + + resumed, adapter = resume_sweep(project, engine, [review_mounted_close]) + summary = resumed.run() + + assert not summary.crashed and not summary.paused + assert [session.role for session in adapter.sessions] == ["review"] + assert seen and seen[0][0] == mount and seen[0][1].startswith("done") + assert "change for dw-fix" in (project.project / "src.txt").read_text(encoding="utf-8") + assert ledger_entries(project)["DW-1"].status.startswith("done") + assert "unit-merged" in journal_kinds(resumed) + assert "isolation-flip-orphaned-worktree" not in journal_kinds(resumed) + assert not mount.exists() + + def test_resume_dev_verify_bundle_after_repair_preserves_acceptance(project, monkeypatch): """A verify-green repair remains accepted across a DEV_VERIFY crash.""" write_ledger(project, {"DW-1": "open"}) @@ -4843,13 +4926,21 @@ def test_resume_committing_bundle_finishes_commit(project): plan = triage_result( ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}] ) + isolated = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="worktree", rollback_on_failure=True), + ) engine, _ = make_sweep( project, [ triage_effect(plan), - bundle_dev_effect(project, "fix", ["DW-1"]), - bundle_review_effect(project, "fix"), + wt_bundle_dev(project), + wt_bundle_review(project), ], + policy=isolated, ) def crashing_emit(stage, *args, **kwargs): @@ -4863,7 +4954,16 @@ def crashing_emit(stage, *args, **kwargs): crashed = load_state(engine.run_dir).tasks["dw-fix"] assert crashed.phase == Phase.COMMITTING assert not crashed.commit_sha # stamped only by the DONE save that never ran + mount = Path(crashed.worktree_path) + assert mount.is_dir() + engine.policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + review=ReviewPolicy(enabled=True, trigger="always"), + dev=DevPolicy(skill="bmad-dev-auto"), + scm=ScmPolicy(isolation="none", rollback_on_failure=True), + ) resumed, adapter = resume_sweep(project, engine, []) summary = resumed.run() @@ -4875,6 +4975,88 @@ def crashing_emit(stage, *args, **kwargs): assert "resume-commit" in journal assert "resume-restart" not in journal assert ledger_entries(project)["DW-1"].status.startswith("done") + assert "change for dw-fix" in (project.project / "src.txt").read_text(encoding="utf-8") + assert "unit-merged" in journal_kinds(resumed) + assert not mount.exists() + + +def test_sweep_isolation_flip_restart_releases_mount_state_without_main_rollback( + project, monkeypatch +): + """Rejected/incomplete work restarts in main without carrying mount operands. + + Ablation: omit the mounted restart release and the rollback spy receives the + unit baseline while the mount claim remains attached. + """ + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none", rollback_on_failure=True), + ) + engine, _ = make_sweep(project, [], policy=in_place) + mount = engine.run_dir / "worktrees" / "dw-fix" + mount.mkdir(parents=True) + task = StoryTask( + "dw-fix", + 0, + phase=Phase.DEV_RUNNING, + worktree_path=str(mount), + branch="bmad-loop/sweep-run/dw-fix", + baseline_commit=verify.rev_parse_head(project.project), + baseline_untracked=[], + spec_file="_bmad-output/implementation-artifacts/spec-dw-fix.md", + dispatched_spec_file="_bmad-output/implementation-artifacts/spec-dw-fix.md", + dispatched_spec_snapshot=b"bound", + ) + engine.state.tasks[task.story_key] = task + rolled: list[str] = [] + monkeypatch.setattr(engine, "_rollback_or_pause", lambda _task, cause: rolled.append(cause)) + + assert engine._recover_inflight_bundle(task) is False + + assert rolled == [] + assert task.phase == Phase.PENDING + assert task.worktree_path == "" and task.branch == "" + assert task.baseline_commit is None and task.baseline_untracked is None + assert task.dispatched_spec_file is None and task.dispatched_spec_snapshot is None + assert task.spec_file == "_bmad-output/implementation-artifacts/spec-dw-fix.md" + assert mount.is_dir() # released and journaled, not destroyed + assert "isolation-flip-orphaned-worktree" in journal_kinds(engine) + + +def test_sweep_missing_recorded_mount_escalates_without_finalizing_in_main(project, monkeypatch): + """A mounted COMMITTING receipt has no in-place fallback when its tree is gone. + + Ablation: gate the reopen on live isolation and the finalizer spy runs against + main rather than the bundle escalating. + """ + from bmad_loop.engine import RunPaused + + in_place = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + ) + engine, _ = make_sweep(project, [], policy=in_place) + task = StoryTask( + "dw-fix", + 0, + phase=Phase.COMMITTING, + worktree_path=str(engine.run_dir / "worktrees" / "gone"), + branch="bmad-loop/sweep-run/dw-fix", + ) + engine.state.tasks[task.story_key] = task + finalized: list[Path] = [] + monkeypatch.setattr( + engine, "_finalize_commit_phase", lambda _task: finalized.append(engine.workspace.root) + ) + + with pytest.raises(RunPaused, match="is gone"): + engine._recover_inflight_bundle(task) + + assert finalized == [] + assert task.phase == Phase.ESCALATED + assert engine.workspace.root == project.project def test_regenerated_intent_when_bundle_file_missing(project): diff --git a/tests/test_verify.py b/tests/test_verify.py index 876dfe2a..e37d7020 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -6765,6 +6765,77 @@ def test_commits_above_lists_attempt_commits_newest_first(project): assert commits == [head] +def test_pinned_revision_preservation_does_not_follow_checkout_head(project): + """A named story tip, not whichever branch the caller has checked out, is parked.""" + repo = project.project + baseline = verify.rev_parse_head(repo) + git(repo, "checkout", "-q", "-b", "story-tip") + (repo / "story.txt").write_text("story\n", encoding="utf-8") + git(repo, "add", "story.txt") + git(repo, "commit", "-q", "-m", "story work") + story_tip = verify.rev_parse_head(repo) + git(repo, "checkout", "-q", "main") + (repo / "main.txt").write_text("main\n", encoding="utf-8") + git(repo, "add", "main.txt") + git(repo, "commit", "-q", "-m", "main moved") + main_tip = verify.rev_parse_head(repo) + + commits = verify.commits_above(repo, baseline, story_tip) + ref = verify.preserve_commits( + repo, + baseline, + "attempt-preserve/run-story", + commits=commits, + revision=story_tip, + ) + + assert ref == "attempt-preserve/run-story" + assert git(repo, "rev-parse", ref) == story_tip + assert git(repo, "rev-parse", ref) != main_tip + + +def test_reset_branch_if_tip_refuses_a_concurrent_move(project): + """CAS failure leaves the rival tip intact. + + INVERSE ablation: replace ``update-ref `` with an unconditional + branch force-update and this test loses ``rival_tip``. + """ + repo = project.project + baseline = verify.rev_parse_head(repo) + git(repo, "branch", "story", baseline) + git(repo, "checkout", "-q", "story") + git(repo, "commit", "--allow-empty", "-q", "-m", "old story tip") + old_tip = verify.rev_parse_head(repo) + git(repo, "commit", "--allow-empty", "-q", "-m", "rival advances") + rival_tip = verify.rev_parse_head(repo) + git(repo, "checkout", "-q", "main") + + with pytest.raises(verify.GitError): + verify.reset_branch_if_tip(repo, "story", baseline, old_tip) + + assert git(repo, "rev-parse", "story") == rival_tip + + +def test_reset_branch_if_tip_does_not_follow_symbolic_story_ref(project): + """A story ref must never redirect its reset onto the branch it names. + + Ablation: remove ``--no-deref`` and update-ref resets ``main`` through the + symbolic story ref instead of replacing the named ref itself. + """ + repo = project.project + baseline = verify.rev_parse_head(repo) + (repo / "advance-symbolic-main.txt").write_text("main stays here\n", encoding="utf-8") + git(repo, "add", "advance-symbolic-main.txt") + git(repo, "commit", "-q", "-m", "advance main before symbolic reset") + main_tip = verify.rev_parse_head(repo) + git(repo, "symbolic-ref", "refs/heads/story", "refs/heads/main") + + verify.reset_branch_if_tip(repo, "story", baseline, main_tip) + + assert verify.rev_parse_head(repo) == main_tip + assert git(repo, "rev-parse", "refs/heads/story") == baseline + + def test_preserve_commits_survives_reset_and_gc(project): """The parked ref keeps committed attempt work reachable through the exact destructive sequence safe_rollback performs (reset --hard baseline) and a gc.""" diff --git a/tests/test_verify_worktree.py b/tests/test_verify_worktree.py index a28ff8ed..c79686bd 100644 --- a/tests/test_verify_worktree.py +++ b/tests/test_verify_worktree.py @@ -6,6 +6,7 @@ """ import subprocess +import sys import pytest from conftest import git, make_git_noisy, refuse_to_resolve @@ -117,6 +118,17 @@ def noisy_run(cmd, **kwargs): assert [p.resolve() for p in verify.worktree_list(repo)] == [repo.resolve()] +@pytest.mark.skipif(sys.platform == "win32", reason="Win32 forbids newlines in filenames") +def test_worktree_list_preserves_newlines_in_paths(project, tmp_path): + """NUL-delimited porcelain keeps a valid newline inside one path record.""" + repo = project.project + wt = tmp_path / "wt\nline" + verify.worktree_add(repo, wt, "newline-path", "main") + + assert wt.resolve() in [path.resolve() for path in verify.worktree_list(repo)] + assert verify.worktree_is_registered(repo, wt) + + def test_worktree_add_create_defaults_to_head(project, tmp_path): """create=True with no `base` cuts the branch from HEAD (git's own default) instead of passing None into git and crashing.""" diff --git a/tests/test_worktree_flow.py b/tests/test_worktree_flow.py index c780d9a1..b84a4d02 100644 --- a/tests/test_worktree_flow.py +++ b/tests/test_worktree_flow.py @@ -121,7 +121,9 @@ def _pause(reason, story_key="", *, cause=None): raise _Pause(reason, story_key) flow = WorktreeFlow( - paths=paths if paths is not None else SimpleNamespace(repo_root=tmp_path), + paths=( + paths if paths is not None else SimpleNamespace(repo_root=tmp_path, project=tmp_path) + ), policy=policy if policy is not None else _policy(), state=( state @@ -428,6 +430,41 @@ def test_ensure_target_branch_detached_head_pauses(project): # --------------------------------------------------------------- run / escalate +def test_run_isolated_relativizes_local_accepted_spec_before_open(tmp_path): + """Mount creation observes the portable spelling, never the main absolute path. + + Ablation: move normalization below ``_open_unit_workspace`` and the spy sees the + main-checkout absolute value. + """ + project = tmp_path / "project" + artifacts = project / "_bmad-output" / "implementation-artifacts" + artifacts.mkdir(parents=True) + spec = artifacts / "spec-1-1.md" + spec.write_text("spec\n", encoding="utf-8") + paths = ProjectPaths( + project=project, + implementation_artifacts=artifacts, + planning_artifacts=project / "_bmad-output" / "planning-artifacts", + ) + task = StoryTask(story_key="1-1", epic=1, spec_file=str(spec)) + observed: list[str | None] = [] + + def stop_after_observation(*_args, **_kwargs): + observed.append(task.spec_file) + raise verify.GitError("stop after observing pre-open state") + + flow = _make_flow( + tmp_path, + paths=paths, + state=SimpleNamespace(target_branch="main", run_id="run-1", tasks={}), + open_unit_workspace=stop_after_observation, + ) + + flow.run_isolated(task, lambda _task: pytest.fail("drive must not run")) + + assert observed == ["_bmad-output/implementation-artifacts/spec-1-1.md"] + + def test_run_isolated_defers_on_open_failure(tmp_path): def boom(*a, **k): raise verify.GitError("branch held by a kept-failed unit") From fb521a149f6215cf813241a43eec7c3643776815 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 22:48:51 -0700 Subject: [PATCH 08/35] sweep dw2-path-assertion-test-hardening: DW-43, DW-44 via bmad-loop --- tests/test_model.py | 10 +++++----- tests/test_stories_engine.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_model.py b/tests/test_model.py index 2840a243..978e718f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -335,7 +335,7 @@ def test_dispatched_spec_file_defaults_none_for_legacy_state(): assert StoryTask.from_dict(doc).dispatched_spec_file is None -def test_rebase_spec_paths_on_reanchors_both_ownership_fields(): +def test_rebase_spec_paths_on_reanchors_both_ownership_fields(tmp_path): """The read-side inverse of `_serialized_worktree_path`, on both fields at once. `to_dict` relativizes `spec_file` and `dispatched_spec_file` together, so a @@ -343,7 +343,7 @@ def test_rebase_spec_paths_on_reanchors_both_ownership_fields(): values are already anchored (a spec outside the mount persists verbatim) and must pass through, which is also what makes the call idempotent. """ - mount = Path("/repo/.bmad-loop/runs/r1/worktrees/1-1-a") + mount = tmp_path / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1-1-a" task = StoryTask( story_key="1-1-a", epic=1, @@ -362,7 +362,7 @@ def test_rebase_spec_paths_on_reanchors_both_ownership_fields(): assert task.dispatched_spec_file == str(mount / "_out/dispatched.md") -def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): +def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(tmp_path): """An out-of-mount spec and an unbound field are both already correct. `_serialized_worktree_path` keeps a path verbatim exactly when @@ -372,10 +372,10 @@ def test_rebase_spec_paths_on_leaves_absolute_and_empty_values_untouched(): becoming the mount root: `Path("")` is `.`, so a bare join would answer the tree root, which is a write target, not a spec. """ - outside = str(Path("/elsewhere/spec.md")) + outside = str(tmp_path / "outside" / "spec.md") task = StoryTask(story_key="1-1-a", epic=1, spec_file=outside) - task.rebase_spec_paths_on(Path("/repo/wt")) + task.rebase_spec_paths_on(tmp_path / "alternate-root" / "wt") assert task.spec_file == outside assert task.dispatched_spec_file is None diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 63eac7f0..a5bea118 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1069,11 +1069,12 @@ def test_plan_checkpoint_pause_journals_the_mount_anchored_spec(project): # this assertion no row in the repo observed ANY `gates.notify` body, so every # notification site could be reverted to a bare `task.spec_file` with the suite green. # - # Ablation: revert `_pause_plan_checkpoint`'s notify to `task.spec_file` and this - # reddens — the bare relpath appears and the anchored path does not. + # INVERSE ablation: replace only `_pause_plan_checkpoint`'s notification + # `_operator_spec_path(task)` call with `task.spec_file`; this focused test + # reddens because the bare relpath appears and the anchored path does not. attention = (engine.run_dir / "ATTENTION").read_text(encoding="utf-8") assert str(wt / rel) in attention - assert f"review {rel}," not in attention # not the un-anchored spelling + assert f"review the planned spec {rel}," not in attention # -------- MAJOR-B: a spec_checkpoint story can never commit without a plan review From 0182d0626db7139d6bd4b1cb0306806681541200 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 01:20:08 -0700 Subject: [PATCH 09/35] Fix session-authored park assertions --- CHANGELOG.md | 10 +- docs/FEATURES.md | 2 +- src/bmad_loop/adapters/generic.py | 85 +++++++++- src/bmad_loop/devcontract.py | 29 ++++ src/bmad_loop/engine.py | 88 +---------- src/bmad_loop/model.py | 28 ---- src/bmad_loop/verify.py | 71 +++------ tests/conftest.py | 4 + tests/test_devcontract.py | 64 ++++++++ tests/test_engine.py | 250 ++++-------------------------- tests/test_generic_tmux.py | 79 ++++++++++ tests/test_model.py | 54 +------ tests/test_verify.py | 104 +++++-------- 13 files changed, 369 insertions(+), 499 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10aa9339..e390e773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -343,14 +343,14 @@ argument` and failed the story; a `ts` key did not raise and instead silently re an environment fault, and escalated — budget untouched, re-armable. `bmad-loop confirm --reverify` reports it as a refusal too. A command that merely times out is unchanged: still an ordinary fixable retry. -- Require a dispatch-time expectation before an `awaiting-operator` park skips proof-of-work, - so a re-drive cannot verify green by inheriting an earlier in-run park; inherited parks with +- Require the current session's genuine `awaiting-operator` Auto Run Result marker before a + park skips proof-of-work, so previous-run, out-of-band, and re-armed specs cannot inherit + waiver authority through retained frontmatter or operator actions; unasserted parks with real changes still pass (#335, #676). Journal each waived artifact-gate pass as `park-proof-of-work-skipped`, with `zero_diff` reporting no non-excluded residue (`true`), residue (`false`), or an unanswerable probe (`null`). The record does not mean the park - committed; use the later `story-awaiting-operator` event for that. Cross-run, out-of-band, - and re-armed parks remain deferred. On upgrade, an in-flight legacy park defaults ineligible - and may retry; re-running the story is sufficient. + committed; use the later `story-awaiting-operator` event for that. Missing, repaired, legacy, + and malformed assertions fail closed to the ordinary proof gate. - Anchor the TUI's paused-spec read and its `Request replan` write on the tree the run owns. Under isolation both resolved against the main checkout, so the review modals showed that copy of the spec and the replan reset it — reporting success while the run's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0ceb1e84..7c58fcde 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -64,7 +64,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only for a park this attempt could newly **elect**: the orchestrator records at dispatch whether the story's bound spec was already at `awaiting-operator`, and a session that merely inherits an earlier attempt's park declaration is held to the ordinary diff requirement, so a re-drive that does nothing does not verify green on the park it inherited. That expectation is read from the run's own binding for the story, which bounds what it catches: a park inherited from a **previous run**, or one written into the spec out of band, reaches a dispatch with nothing bound and is eligible; so does a story re-armed out of an escalation, since re-arming reopens the spec at `ready-for-dev` without clearing its `operator_actions:`. Both remain open and are tracked as deferred work. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the status the session left, so an inherited park that did real work passes as before. Within that scope the skip still covers **every** park, including one that produced nothing and listed plausible actions: the action gate tests that the list is non-empty, never what is in it — so a park that clears this artifact gate with the waiver in force is journaled as `park-proof-of-work-skipped` with a `zero_diff` flag saying which kind of park got through. Read that record for exactly what it says: a waiver refused by a later check inside the same gate leaves no entry, but the stages **after** it — your `[verify]` commands, deterministic review verification and repair, pre-commit workflows, and the commit — can still reject the attempt, and the entry stands regardless. It answers "which attempts cleared the artifact gate without proving work", never "which parks committed". The committed half is the post-commit `story-awaiting-operator` journal record, appended only after the commit lands and carrying its sha; correlate the two on the story key plus journal order — a committed park's waiver is the last `park-proof-of-work-skipped` for that story before that event. The waiver record does carry `attempt`; the terminal event does not, which is exactly why no attempt-keyed join is promised — and adding one would not help, since the attempt current at commit can be higher than the one on the waiver. Do **not** read `review-skipped-awaiting-operator` as that half: it is written when a park _enters_ the commit path, ahead of the review verification, the repair loop, the pre-commit workflows and the commit itself, every one of which can still reject it. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and `park_asserted: true` in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced `## Auto Run Result` marker reporting `awaiting-operator`; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or `operator_actions:` while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as `park-proof-of-work-skipped`; `zero_diff` reports whether the waived gate would have found non-excluded residue (`true` means none, `false` means some, `null` means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later `story-awaiting-operator` event. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and a row at `done` or `awaiting-operator` is the orchestrator's own bookkeeping — not a defect to fix, and not proof that the work is verified, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 4071976f..3d383dc5 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -28,7 +28,7 @@ import time from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, cast from .. import devcontract, gates, runs from ..bmadconfig import ProjectPaths @@ -1269,6 +1269,12 @@ def read_usage(self, result: SessionResult) -> TokenUsage | None: time.sleep(RESULT_POLL_S) +class _SessionStarter(Protocol): + """Next concrete adapter in the dev mixin's cooperative MRO.""" + + def start_session(self, spec: SessionSpec) -> SessionHandle: ... + + class _DevSynthesisMixin(_ResultFileMixin): """Result synthesis for the generic ``bmad-build-auto`` skill, shared by every transport that drives it (tmux today; see GenericDevAdapter for the @@ -1327,6 +1333,71 @@ def _configure_dev_knobs(self) -> None: # apply — this budget is not a counter and touches no stall counters). self._contract_nudge_sent: set[str] = set() self._contract_nudge_enabled = self.policy.limits.dev_contract_nudge + # Marker identities present immediately before each real session launch. + # The adapter, not whole-file mtime, owns this attempt-relative evidence: + # touching another part of a parked spec must not make its retained marker + # look session-authored. None means launch capture was incomplete and + # therefore fails closed for the waiver. + self._launch_auto_run_results: dict[str, dict[str, tuple[int, str]] | None] = {} + + @staticmethod + def _marker_path_key(path: Path) -> str: + try: + return str(path.resolve()) + except OSError: + return str(path.absolute()) + + def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None: + """Snapshot real result markers before the child can write its spec.""" + paths: list[Path] = [] + complete = True + if spec.expected_spec: + expected = Path(spec.expected_spec) + paths = [expected if expected.is_absolute() else Path(spec.cwd) / expected] + else: + for artifacts in self._artifact_dirs(spec.cwd): + try: + paths.extend(artifacts.glob("*.md")) + except OSError: + complete = False + + captured: dict[str, tuple[int, str]] = {} + for path in paths: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + continue + except (OSError, UnicodeDecodeError): + complete = False + continue + fingerprint = devcontract.auto_run_result_fingerprint(text) + if fingerprint[0]: + captured[self._marker_path_key(path)] = fingerprint + self._launch_auto_run_results[spec.task_id] = captured if complete else None + + def start_session(self, spec: SessionSpec) -> SessionHandle: + self._capture_launch_auto_run_results(spec) + # The mixin is shared by two unrelated concrete transports. Keep the + # cooperative MRO dispatch rather than naming either host explicitly; + # the protocol gives Pyright the host contract without adding a runtime + # base that could alter method resolution. + return cast(_SessionStarter, super()).start_session(spec) + + def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> bool: + """Whether the live marker differs from this session's launch marker.""" + if spec.task_id not in self._launch_auto_run_results: + # Direct read-back callers predate launch capture; production always + # enters through start_session. Preserve that diagnostic/test seam. + return True + captured = self._launch_auto_run_results[spec.task_id] + if captured is None: + return False + try: + current = devcontract.auto_run_result_fingerprint(spec_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + return False + launch = captured.get(self._marker_path_key(spec_path), (0, "")) + return current != launch def _probe_alive(self, handle: SessionHandle) -> bool | None: """Liveness of the session's native surface (tmux window, server @@ -1418,10 +1489,13 @@ def _known_spec_synth_result( observation and the M1 launch-snapshot gate all still apply — scoped to the one legitimate path instead of a shared directory. - No launch-snapshot gate is needed on the marker branch itself: the + No whole-file launch-snapshot gate is needed on the marker branch itself: the pre-review-launch strip (`Engine._reset_spec_for_review`) REMOVES the marker, so a spec carrying one again has necessarily changed bytes since the snapshot and the gate would be a no-op (`_snapshot_verdict` → NEUTRAL). + Marker-level launch capture still runs for every real session: it prevents + an unrelated post-launch touch from lending a retained park marker to the + new attempt. Note this deliberately does NOT fall back to the scan when the expected spec yields nothing: a session that did not write the spec it owed produced no @@ -1447,7 +1521,12 @@ def _synthesize_from(self, spec_path: Path, spec: SessionSpec) -> devcontract.Sy story_key = spec.env.get("BMAD_LOOP_STORY_KEY") or None raw_dw_ids = (spec.env.get("BMAD_LOOP_DW_IDS") or "").split(",") dw_ids = [tok for tok in (i.strip() for i in raw_dw_ids) if tok] - return devcontract.synthesize_result(spec_path, story_key=story_key, dw_ids=dw_ids or None) + return devcontract.synthesize_result( + spec_path, + story_key=story_key, + dw_ids=dw_ids or None, + park_marker_session_authored=self._park_marker_session_authored(spec_path, spec), + ) def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None: """Mid-session status-transition observation (#276 M2), called each diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index e3819221..19c5e90b 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -159,6 +159,24 @@ def parse_auto_run_result(text: str) -> AutoRunResult: return AutoRunResult(present=True, status=status, detail=body.strip()) +def auto_run_result_fingerprint(text: str) -> tuple[int, str]: + """Identity of the current last real Auto Run Result marker. + + The count distinguishes a newly appended marker even when its text repeats + the previous result verbatim; the digest distinguishes an in-place rewrite. + Fenced examples remain invisible through the shared heading reader. + """ + matches = _section_headings(text) + if not matches: + return (0, "") + last = matches[-1] + section = text[last.start() : _next_heading_start(text, last.end())] + return ( + len(matches), + hashlib.sha256(section.encode("utf-8"), usedforsecurity=False).hexdigest(), + ) + + # ------------------------------------------------ deferred review findings # # Since BMAD-METHOD #2640 the dev primitive records findings triaged as `defer` @@ -334,6 +352,7 @@ def synthesize_result( story_key: str | None, dw_ids: list[str] | None = None, plan_halt: bool = False, + park_marker_session_authored: bool = True, ) -> SynthResult: """Build the legacy result dict from the generic skill's on-disk spec. @@ -410,6 +429,16 @@ def synthesize_result( "baseline_commit": baseline, "status": status, "escalations": escalations, + # Attempt ownership comes only from the current session's terminal marker. + # Frontmatter-only fallback is deliberately insufficient, and a marker + # written later by the orchestrator's missing-marker repair cannot + # retroactively authorize the session that omitted it. + "park_asserted": ( + arr.present + and arr.status == AWAITING_OPERATOR + and ORCHESTRATOR_SYNTH_NOTE not in arr.detail + and park_marker_session_authored + ), } if dw_ids: result["dw_ids"] = list(dw_ids) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 7f98f7df..edfe1177 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2267,15 +2267,6 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # never hidden along with the orchestrator's own append. A fixable # retry rebases it onto the tree that retry deliberately keeps. task.baseline_ledger_digest = self._ledger_digest() - # Whether this phase may newly ELECT a park, on the same anchor and - # for the same reason as the baseline above: the proof-of-work skip - # this authorizes is measured from that baseline, so the expectation - # and the diff it guards have to be captured at one instant. A fixable - # repair therefore inherits the phase's answer (it deliberately keeps - # the previous session's tree, park declaration included, so - # re-observing per attempt would make every repair of a malformed park - # ineligible), and a crash-replayed attempt keeps the persisted one. - task.park_eligible = self._park_eligible_at_dispatch(task) feedback: Path | None = None while True: replayed = resume_result is not None @@ -3526,76 +3517,6 @@ def _operator_park_enabled(self) -> bool: branch happens to sit.""" return self.policy.operator.enabled - def _park_eligible_at_dispatch(self, task: StoryTask) -> bool: - """Whether the attempt about to be dispatched could newly ELECT a park — - the orchestrator-side half of :func:`verify.verify_dev`'s two-part - proof-of-work skip selector (#335, #676). - - The skip used to be selected entirely by state a fresh session can - INHERIT: ``operator_park`` (a policy flag) plus the spec's own - ``awaiting-operator`` status, which an earlier attempt may already have - written. A re-drive over such a spec therefore selected #676's relaxation - while having done nothing at all, and verified green on someone else's - park declaration. This is the fact that cannot be inherited: at the moment - the phase is dispatched, was the story's bound spec ALREADY parked? - - ``False`` when parking is off (the skip is unreachable anyway, so this - costs no read), when the bound spec already reads ``awaiting-operator``, - and on the two genuinely unobservable shapes: a recorded ``spec_file`` - that no longer resolves to a trusted regular file, and one whose read - raises ``OSError`` (journaled ``spec-read-failed``). Those fail closed onto - the ordinary gated path, where an honest park with a real diff still - passes. - - An UNPARSEABLE spec is deliberately not in that list, and the distinction - is worth stating because it looks like a gap. ``read_frontmatter`` - degrades malformed YAML and non-UTF-8 to ``{}`` rather than raising, so - ``status_of`` reads ``""`` and this returns True. That is correct rather - than merely tolerated: an unparseable spec demonstrably does not say - "parked", and ``verify_dev``'s own gate reads the very same ``{}``, so - ``parked`` is False there too and the skip is unreachable on that leg no - matter what this answers. Only OSError and an unresolvable binding are - uncertainty about a spec that *does* say something. - - ``True`` when nothing is bound at all — the ordinary case, not a fallback. - Note precisely what that tests: ``task.spec_file`` is an IN-RUN binding, - set only after a session returns and its artifacts verify, so "unbound" - means "this task object has no binding", NOT "no earlier park exists on - disk". A story whose spec was parked by a previous RUN, or edited into the - park status out of band, presents as unbound here and is eligible. The - residual is recorded as a deferred finding on this change's spec rather - than closed silently; closing it means keying eligibility on the spec the - story resolves to rather than on the task's binding, which is a wider - change than the one this gate makes. - - Called only from ``_dev_phase``'s ``resume_result is None`` block, beside - the baseline capture — see the comment there for why the anchor is the - PHASE and not the attempt. Reuses ``_dispatched_spec_for_attempt`` for the - symlink/roots checks rather than re-deriving them: a second, laxer - resolution here would be a second answer to "which file is this attempt's - spec", and recovery already owns that question. - - Consequence worth knowing before touching either caller: that resolver is - now invoked TWICE per dev phase — once here at phase entry, and once by - the binder inside the attempt loop. They are two observations of the same - path at different instants and neither may be folded into the other (this - one must precede the first attempt; the binder's must be the one that - promotes). Any test that counts calls to it has to say which observation - it means — ``test_transient_initial_binding_fault_does_not_promote_after_bare_prompt`` - pins this one out for exactly that reason. - """ - if not self._operator_park_enabled(): - return False - if not task.spec_file: - return True - bound = self._dispatched_spec_for_attempt(task) - if bound is None: - return False - fm = self._observed_frontmatter(Path(bound), task.story_key, "park-eligibility") - if fm is None: - return False - return verify.status_of(fm) != verify.AWAITING_OPERATOR - def _dev_review_enabled(self) -> bool: """Spec-status/sprint semantics for verify_dev and the sprint sync. The generic skill always self-finalizes to ``done`` (no in-review handoff), so @@ -5336,11 +5257,6 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): result_json, review_enabled=self._dev_review_enabled(), operator_park=self._operator_park_enabled(), - # The dispatch-time half of the park's proof-of-work skip selector, - # read from the task rather than re-observed: it was captured on this - # phase's fresh entry, and re-deriving it now would answer about the - # spec the session just finished writing (#676). - park_eligible=task.park_eligible, engine_written=self._harvest_gate_exclude(task), ) # The record marks the WAIVED GATE, so it keys on the waiver itself @@ -6364,7 +6280,9 @@ def _operator_park_instruction(self) -> str: "part an agent CAN do, commit it, then finalize the spec frontmatter " "to status: awaiting-operator and enumerate what is owed under an " "operator_actions: key — a YAML list of strings, one imperative " - "instruction each, non-empty. Never use the blocked status for this: " + "instruction each, non-empty. The final Auto Run Result must also " + "report the matching status: awaiting-operator. Never use the blocked " + "status for this: " "blocked halts the whole run, and this story is finished as far as " "you can take it." ) diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 1cf99d53..1bc3f8ff 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -304,20 +304,6 @@ class StoryTask: # owes, and nothing re-derives it once the session that wrote the spec is # gone). operator_actions: list[str] = field(default_factory=list) - # Whether THIS dev phase was in a position to newly elect a park: captured - # once, on the fresh entry into `Engine._dev_phase` (`resume_result is None`), - # from the same instant and the same condition as `baseline_commit` — so the - # expectation and the diff it guards share one anchor. False when the bound - # spec was ALREADY at `awaiting-operator` on entry (an earlier attempt's park - # is on disk, so a park observed afterwards may be inherited rather than - # elected), when parking is disabled, or when the spec could not be read at - # all (fail closed). It gates exactly one thing: `verify_dev`'s proof-of-work - # skip on the park leg (#335, #676). Every other park gate still selects on the - # observed status alone, so an ineligible park with a real diff still passes. - # Deliberately per-PHASE, not per-attempt: a fixable repair keeps the previous - # session's tree, so re-observing would make every repair of a malformed park - # ineligible and fail it on the gate it just re-armed. - park_eligible: bool = False defer_reason: str | None = None # the recovery ref this attempt's work was parked on by the last auto-rollback # — an `attempt-preserve/*` branch (commits above baseline) or, when the tree @@ -466,7 +452,6 @@ def to_dict(self) -> dict[str, Any]: ), "commit_sha": self.commit_sha, "operator_actions": self.operator_actions, - "park_eligible": self.park_eligible, "defer_reason": self.defer_reason, "preserve_ref": self.preserve_ref, "preserve_partial": self.preserve_partial, @@ -678,19 +663,6 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": dispatched_spec_snapshot=dispatched_spec_snapshot, commit_sha=d.get("commit_sha"), operator_actions=[str(a) for a in d.get("operator_actions", [])], - # `is True`, not `bool(...)`, and this is the one field on this task - # where the difference is load-bearing. Every sibling bool above - # merely restores bookkeeping; this one AUTHORIZES a gate to be - # waived, so its failure direction is not symmetric — a wrong False - # costs one retryable proof-of-work refusal, a wrong True re-opens - # the inheritance hole the field exists to close (#335, #676). Under - # `bool()` every truthy non-boolean grants the waiver, and the - # likeliest one is the string "false" (a hand-edited state.json, a - # bridge that stringifies JSON scalars): `bool("false")` is True. - # Only a real JSON `true` may authorize; anything else — absent, - # null, a string, a number — fails closed onto the ordinary gated - # path, where an honest park with a real diff still passes. - park_eligible=d.get("park_eligible") is True, defer_reason=d.get("defer_reason"), preserve_ref=d.get("preserve_ref"), preserve_partial=bool(d.get("preserve_partial", False)), diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 554400fc..2376e7bb 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3726,7 +3726,6 @@ def verify_dev( review_enabled: bool = True, *, operator_park: bool = False, - park_eligible: bool = False, engine_written: tuple[str, ...] = (), ) -> VerifyOutcome: """Verify a dev session's on-disk artifacts against its result.json claims. @@ -3748,14 +3747,13 @@ def verify_dev( a terminal the gate knows, so it fails the ordinary status check and the session is retried with that mismatch as feedback. - The proof-of-work gate is skipped on a park that this attempt was in a - position to newly ELECT — ``skip_proof = parked and park_eligible``, a - two-part selector. ``parked`` is what the session left behind (the observed - spec status, plus the policy flag); ``park_eligible`` is what the orchestrator - knew at dispatch (:meth:`Engine._park_eligible_at_dispatch`, captured on the - fresh entry into ``Engine._dev_phase`` from the same instant and the same - condition as ``task.baseline_commit``): the story's bound spec did NOT already - read ``awaiting-operator``. Both halves are load-bearing. The skip exists + The proof-of-work gate is skipped only when the observed park intersects a + strict current-session result assertion — ``skip_proof = parked and + rj.get("park_asserted") is True``. ``parked`` comes from the independently + observed spec status plus policy. ``park_asserted`` is minted by + :func:`devcontract.synthesize_result` only from the last genuine, non-fenced + ``## Auto Run Result`` marker whose status is ``awaiting-operator``. Both + halves are load-bearing. The skip exists because a park's whole output can legitimately be its own spec's park declaration plus the board sync, both of which proof-of-work already excludes, so demanding a diff read a correct park as "no changes since baseline commit" @@ -3769,32 +3767,15 @@ def verify_dev( (``Engine._finalize_commit_phase``), and a reset discards that too, onto an ``attempt-preserve/*`` ref. - What the eligibility half defends is narrow and worth naming exactly. Before - it, the relaxation was selected entirely by state a fresh session could - INHERIT rather than produce: a spec an earlier attempt left at - ``awaiting-operator`` still reads ``awaiting-operator`` to the next session - that does nothing at all, so a re-drive over that spec selected the skip and - verified green on someone else's declaration, relaxing #676's skip for an - attempt that produced nothing. Requiring the - orchestrator's own dispatch-time answer means the leg that skips proof-of-work - is the leg that actually authored the park. It does NOT defend against a - session that elects a park it did not earn — one that writes the frontmatter, - lists plausible actions and implements nothing is eligible by construction and - still passes, because the actions gate tests list non-emptiness and never - content. It is a check on WHICH ATTEMPT owns the park, not on whether the park - is honest, and it is captured per PHASE rather than per attempt: a fixable - repair deliberately keeps the previous session's tree, so re-observing would - make every repair of a malformed park ineligible and fail it on the gate it - just re-armed. - - An INELIGIBLE park is not refused — it is merely held to proof-of-work like - any other terminal. The park's status pair, ``operator_actions`` - non-emptiness, workflow tag, baseline match and sprint pair all keep selecting - on the observed status alone, so an inherited park carrying a real diff passes - exactly as before; only the residue-free one now owes the diff it never - produced. - - Nothing else relaxes on the eligible leg either — the ``operator_actions`` + The assertion establishes attempt ownership, not honesty or a second status + authority. A frontmatter-only fallback, a legacy result, a malformed value, + or a marker carrying the orchestrator's missing-marker repair note cannot + authorize the waiver. Those parks are not otherwise refused: they take the + ordinary proof-of-work arm, so one carrying a real diff still passes. Crash + and fixable-retry replay preserve the already synthesized result rather than + deriving authority from retained frontmatter or ``operator_actions``. + + Nothing else relaxes on the asserted leg either — the ``operator_actions`` gate above still refuses a park that enumerates nothing, and the workflow-tag, status, baseline-match and sprint-pair gates all still run. Two of those four are not independent evidence on this leg, and saying so is the point: the @@ -3805,8 +3786,8 @@ def verify_dev( this gate runs, so it confirms the orchestrator's own write landed rather than anything the session did. What still binds a park to the attempt the orchestrator actually launched is the workflow tag, the baseline match, the - non-empty actions list — and now the dispatch-time eligibility, which is the - only one of the four the session cannot influence at all. Baseline-match also + non-empty actions list and the independent result-marker assertion. + Baseline-match also accepts a claim NEWER than the recorded baseline whenever it is a HEAD-reachable descendant, and the comment guarding that branch names the compensating control: such a commit "may have arrived in the shared checkout @@ -3879,12 +3860,10 @@ def verify_dev( actions = _operator_actions_gate(fm, task.story_key) if actions is not None: return actions - # The two-part selector: the session's observed park AND the orchestrator's - # dispatch-time answer that this phase could newly elect one. Deliberately a - # separate name from `parked` — every other park gate below still keys on - # `parked` alone, and collapsing the two would silently widen this expectation - # from "may skip proof-of-work" to "may park at all" (#335, #676). - skip_proof = parked and park_eligible + # The two-part selector: the independently observed park AND the strict + # current-session marker assertion. Every other park gate below still keys on + # `parked` alone; the result assertion authorizes only this waiver (#335, #676). + skip_proof = parked and rj.get("park_asserted") is True # With review disabled, the dev session runs its own internal review and # finalizes straight to done; otherwise it hands off at in-review. A park @@ -3897,12 +3876,12 @@ def verify_dev( expected_status=( AWAITING_OPERATOR if parked else ("in-review" if review_enabled else "done") ), - # Proof-of-work is the one gate an ELECTED park skips (``extra_exclude=None``, + # Proof-of-work is the one gate an ASSERTED park skips (``extra_exclude=None``, # the callee-blessed spelling): such a park's whole residue can legitimately # be the spec and the board, both already excluded (#676). The park paragraph # in this function's docstring carries the reasoning and, more importantly, - # what the skip does NOT relax. An inherited park (`park_eligible=False`) - # takes the ordinary arm and owes a diff like every other terminal. + # what the skip does NOT relax. An unasserted park takes the ordinary arm + # and owes a diff like every other terminal. extra_exclude=None if skip_proof else engine_written, # Same tuple, no gate: when the skip fires the probe still runs, purely so # the accepted park's zero-diff answer can be journaled (#676). diff --git a/tests/conftest.py b/tests/conftest.py index 340b266a..dbfb7600 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1141,6 +1141,7 @@ def dev_effect( write_src: bool = True, closes_deferred: object = None, operator_actions: object = None, + park_asserted: object = OMIT, deferred=None, ): """Simulate a successful bmad-dev-auto session: it self-finalizes the spec @@ -1199,6 +1200,9 @@ def effect(spec: SessionSpec) -> SessionResult: "verification": [], "escalations": [], "followup_review_recommended": followup_review, + "park_asserted": ( + final_status == "awaiting-operator" if park_asserted is OMIT else park_asserted + ), }, ) diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index 9816ead2..7bf0430c 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -182,6 +182,7 @@ def test_synth_success_maps_baseline_revision(tmp_path): assert rj["spec_file"] == str(sp) assert rj["baseline_commit"] == "abc123def456abc123def456abc123def456abcd" assert rj["escalations"] == [] + assert rj["park_asserted"] is False assert "dw_ids" not in rj @@ -368,6 +369,7 @@ def test_synth_awaiting_operator_is_terminal_and_folds_actions(tmp_path): rj = out.result_json assert rj is not None and rj["status"] == "awaiting-operator" assert rj["operator_actions"] == ["buy example.com", "publish the TXT record"] + assert rj["park_asserted"] is True def test_synth_awaiting_operator_synthesizes_no_escalation(tmp_path): @@ -381,6 +383,68 @@ def test_synth_awaiting_operator_synthesizes_no_escalation(tmp_path): assert out.result_json["escalations"] == [] assert "followup_review_recommended" not in out.result_json + assert out.result_json["park_asserted"] is False + + +def test_synth_park_assertion_uses_only_the_last_real_marker(tmp_path): + """A fenced example and an older genuine park cannot authorize a later result.""" + sp = _spec( + tmp_path / "s.md", + status="awaiting-operator", + auto_run=None, + actions="['do it']", + body_extra=( + "\n```md\n## Auto Run Result\n\nStatus: awaiting-operator\n```\n" + "\n## Auto Run Result\n\nStatus: awaiting-operator\n" + "\n## Auto Run Result\n\nStatus: done\n" + ), + ) + + assert ( + devcontract.synthesize_result(sp, story_key="1-1-a").result_json["park_asserted"] is False + ) + + +def test_synth_repaired_park_marker_cannot_assert_session_ownership(tmp_path): + sp = _spec( + tmp_path / "s.md", + status="awaiting-operator", + auto_run=None, + actions="['do it']", + body_extra=( + "\n## Auto Run Result\n\nStatus: awaiting-operator\n\n" + f"{devcontract.ORCHESTRATOR_SYNTH_NOTE}\n" + ), + ) + + assert ( + devcontract.synthesize_result(sp, story_key="1-1-a").result_json["park_asserted"] is False + ) + + +def test_synth_preexisting_genuine_park_marker_cannot_assert_session_ownership(tmp_path): + sp = _spec( + tmp_path / "s.md", + status="awaiting-operator", + auto_run="awaiting-operator", + actions="['do it']", + ) + + rj = devcontract.synthesize_result( + sp, + story_key="1-1-a", + park_marker_session_authored=False, + ).result_json + + assert rj["park_asserted"] is False + + +def test_auto_run_result_fingerprint_detects_an_identical_appended_marker(): + marker = "## Auto Run Result\n\nStatus: awaiting-operator\n" + + assert devcontract.auto_run_result_fingerprint(marker) != ( + devcontract.auto_run_result_fingerprint(marker + "\n" + marker) + ) @pytest.mark.parametrize( diff --git a/tests/test_engine.py b/tests/test_engine.py index f93e8f1d..398ecf71 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1521,14 +1521,6 @@ def transient_first_fault(bound_task): return real_resolve(bound_task) monkeypatch.setattr(engine, "_dispatched_spec_for_attempt", transient_first_fault) - # The phase-entry park-eligibility read is a SECOND, unrelated consumer of the - # same resolver (`_park_eligible_at_dispatch`, DW-1) and would otherwise absorb - # the injected fault, handing the binder a clean second observation and - # inverting exactly what this row measures. Pin it out so `observations` counts - # the binder alone — this test is about prompt construction and recovery - # ownership, not about whether the story could newly elect a park. - monkeypatch.setattr(engine, "_park_eligible_at_dispatch", lambda _task: False) - assert engine._dev_phase(task) assert observations == 1 @@ -1888,6 +1880,7 @@ def _dev_verify_crash_state(project, engine, spec_status: str) -> tuple[str, Pat "baseline_commit": baseline, "escalations": [], "followup_review_recommended": False, + "park_asserted": False, }, ) ) @@ -3138,59 +3131,10 @@ def test_park_without_usable_actions_is_repaired_not_committed(project): assert "story-awaiting-operator" not in kinds and "story-done" in kinds -def test_dispatch_over_an_already_parked_spec_is_not_park_eligible(project): - """DW-1's engine half: the proof-of-work skip is authorized by an expectation - the orchestrator records at dispatch, and a story whose bound spec ALREADY - reads `awaiting-operator` cannot newly elect a park — whatever the session - that runs next leaves behind, the declaration on disk when it launched was - someone else's. - - The answer is captured on the fresh entry into `_dev_phase`, on the same - `resume_result is None` condition as `baseline_commit`, and persisted, so a - crash-replayed attempt reads back the same expectation rather than - re-deriving one from the tree the replayed session already wrote. - - Ablation (measured): drop the `!= AWAITING_OPERATOR` test and this fails — - the re-drive becomes eligible and #676's relaxation applies to a session that - inherited its park. Note what this row does NOT detect: moving the capture out - of the `resume_result is None` block leaves it green, because the parked spec - is on disk before the phase starts and attempt 1 therefore observes the same - status either way. The anchor is pinned one row down, by - `test_park_eligibility_is_captured_once_per_phase_not_per_attempt`, which is - the row that reddens on that mutation.""" - write_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, [dev_effect(project, "1-1-a")], policy=_park_policy()) - recorded = spec_path(project, "1-1-a") - write_spec( - recorded, "awaiting-operator", rev_parse_head(project.project), operator_actions=ACTIONS - ) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - engine.state.tasks[task.story_key] = task - - engine._dev_phase(task) - - assert task.park_eligible is False - assert load_state(engine.run_dir).tasks["1-1-a"].park_eligible is False - - def test_inherited_park_is_refused_end_to_end_through_the_engine(project): - """The JOIN, which both halves being pinned separately does not cover: that - `_verify_dev_artifacts` actually forwards `task.park_eligible` into - `verify_dev`. Its sibling row stops at the flag, and every refusal row in - `test_verify.py` hand-passes `park_eligible=False` straight into the gate — so - the one wiring point between them was untested, and the whole fix could be - reverted there with the suite green. - - Driven through the engine's own binding lifecycle: the story's spec_file is - bound to a spec ALREADY at `awaiting-operator`, so eligibility is reached via - the bound branch (every other `engine.run()`-level park row reaches it - unbound, and therefore eligible). The re-driven session writes no code and - re-declares the same park — the inherited-park shape — and must NOT verify - green. - - Ablation: replace `park_eligible=task.park_eligible` with the literal `True` - in `_verify_dev_artifacts` and this row fails; without it that mutation passes - the entire suite.""" + """A markerless/inherited result must reach the strict selector through the + engine and owe ordinary proof-of-work. Setting its synthesized assertion true + makes the residue-free park verify, so the negative gate discriminates.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "awaiting-operator"}) engine, _ = make_engine( project, @@ -3201,6 +3145,7 @@ def test_inherited_park_is_refused_end_to_end_through_the_engine(project): final_status="awaiting-operator", operator_actions=ACTIONS, write_src=False, + park_asserted=False, ) ] * 3, @@ -3221,93 +3166,16 @@ def test_inherited_park_is_refused_end_to_end_through_the_engine(project): with pytest.raises(RunPaused): engine._dev_phase(task) - assert task.park_eligible is False reasons = [e["reason"] for e in engine.journal.entries() if e["kind"] == "dev-decision"] assert reasons and all(r == "no changes in worktree since baseline commit" for r in reasons) # the waiver never fired, so nothing was journaled as a skipped gate assert "park-proof-of-work-skipped" not in [e["kind"] for e in engine.journal.entries()] -def test_dispatch_with_no_bound_spec_is_park_eligible(project): - """The ordinary case, not a fallback: a story's first attempt has no - `spec_file` yet, so there is no earlier declaration for it to inherit and the - #676 relaxation must remain available. Fail-CLOSED applies to uncertainty - about a spec that exists, not to the absence of one.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - - assert engine._park_eligible_at_dispatch(StoryTask(story_key="1-1-a", epic=1)) is True - - -def test_park_eligibility_fails_closed_on_an_unresolvable_binding(project): - """The OTHER fail-closed arm, and a genuinely separate one: this is the - `bound is None` refusal from `_dispatched_spec_for_attempt` (a symlinked - binding, the shape it exists to refuse), not the later `fm is None` OSError - arm its sibling row covers. A spec_file that will not resolve to a trusted - regular file is a spec whose status the orchestrator does not know, and an - unknown status must not authorize waiving proof-of-work. - - Ablation: invert this arm to `return True` and this row fails while the whole - rest of the suite stays green — nothing else reaches it, which is why it - needed its own row rather than sharing the unreadable-spec one.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - real = spec_path(project, "1-1-a") - write_spec(real, "ready-for-dev", rev_parse_head(project.project)) - link = real.parent / "spec-1-1-a-symlink.md" - link.symlink_to(real) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(link)) - - # the binding resolves to nothing usable, even though the TARGET is a - # perfectly readable non-parked spec — it is the binding that is untrusted - assert engine._dispatched_spec_for_attempt(task) is None - assert engine._park_eligible_at_dispatch(task) is False - - -def test_park_eligibility_fails_closed_on_an_unreadable_spec(project): - """Observation degrades, and here degrading means denying the relaxation: a - bound spec the orchestrator cannot read is a spec whose status it does not - know, and an unknown status must not authorize skipping proof-of-work. The - skip is what would be lost, not the park — an honest park with a real diff - still passes the ordinary gate. - - Silent it is not: the read goes through `_observed_frontmatter`, so the skip - lands a `spec-read-failed` entry naming this site.""" - engine, _ = make_engine(project, [], policy=_park_policy()) - recorded = spec_path(project, "1-1-a") - write_spec(recorded, "ready-for-dev", rev_parse_head(project.project)) - task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded)) - - def boom(_path): - raise OSError("spec vanished mid-read") - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(verify, "read_frontmatter", boom) - assert engine._park_eligible_at_dispatch(task) is False - - failures = [e for e in engine.journal.entries() if e["kind"] == "spec-read-failed"] - assert [e["site"] for e in failures] == ["park-eligibility"] - - -def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): - """A fixable repair deliberately keeps the previous session's tree, so the - malformed park it is repairing is on disk when it launches. Re-observing - eligibility per ATTEMPT would therefore make every such repair ineligible, - and its fix — one frontmatter block, which proof-of-work already excludes — - would fail the gate it just re-armed. The expectation is anchored to the - phase, on the same `resume_result is None` condition as `baseline_commit`, - precisely so the expectation and the diff it guards cannot disagree. - - Both sessions run with `write_src=False`, which is what makes this row - evidence: the tree never holds any code residue, so the ONLY thing that can - carry the repair past proof-of-work is the retained eligibility. - - Ablation (measured, not assumed): move `task.park_eligible = ...` out of the - `resume_result is None` block and into `_dev_phase`'s per-attempt branch, and - attempt 2 re-observes the parked spec attempt 1 left behind, turns ineligible, - and its `dev-decision` reads exactly `no changes in worktree since baseline - commit` -> DEFER. Note what the row then fails ON: the defer's spec-restore - finds the binding unusable and raises `RunPaused`, so the visible surface is a - pause, not the assertion below. The refusal is the cause and the journal - records it; the pause is its consequence.""" +def test_fixable_retry_uses_the_repair_sessions_park_assertion(project): + """A fixable retry keeps the prior tree but authorizes only from the repair + session's own result. Its genuine assertion carries a residue-free corrected + park through without re-observing retained frontmatter.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, adapter = make_engine( project, @@ -3338,7 +3206,6 @@ def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): assert engine._dev_phase(task) is True - assert task.park_eligible is True assert len(adapter.sessions) == 2 # the malformed park, then its repair # the repair's park was ACCEPTED with the gate waived, on a tree that holds no # code at all — the whole point of retaining the phase's answer @@ -3346,27 +3213,9 @@ def test_park_eligibility_is_captured_once_per_phase_not_per_attempt(project): assert [(e["attempt"], e["zero_diff"]) for e in records] == [(2, True)] -def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): - """Crash replay: the host died after the dev session finished and before its - result was consumed, so `_finish_inflight` resets the task to PENDING and - re-enters `_dev_phase` with the recorded result instead of a session - (`engine.py`'s `resumable` arm). The fresh-entry block is skipped wholesale on - that path, which is exactly why eligibility is captured there — a replayed - attempt must read back the answer the DEAD phase recorded, never derive a new - one from the tree the session it is replaying already wrote. - - The setup makes the two answers differ: the spec on disk is ALREADY parked - (the replayed session's own work), so a re-observation at this point returns - False and the residue-free tree would then owe proof-of-work it cannot show. - The persisted `True` is the only thing that carries the park through. - - Ablation: move `task.park_eligible = self._park_eligible_at_dispatch(task)` - out of `_dev_phase`'s `if resume_result is None:` block and this fails — the - replay re-observes its own parked spec, turns ineligible, and the park is - refused for "no changes in worktree since baseline commit". This is the row - the sibling capture-once test explicitly does NOT cover: that one measures a - second ATTEMPT inside a live phase, this one a replayed phase with no - attempt of its own.""" +def test_replayed_attempt_reuses_the_persisted_park_assertion(project): + """Crash replay consumes the persisted synthesized result without launching + or re-deriving authority from the parked spec now on disk.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, adapter = make_engine(project, [], policy=_park_policy()) baseline = rev_parse_head(project.project) @@ -3379,8 +3228,6 @@ def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): task.spec_file = str(sp) task.baseline_commit = baseline task.baseline_untracked = untracked - # what the dead phase recorded at ITS dispatch, when the spec was unparked - task.park_eligible = True engine.state.tasks[task.story_key] = task result_json = { "workflow": "auto-dev", @@ -3389,6 +3236,7 @@ def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): "baseline_commit": baseline, "escalations": [], "followup_review_recommended": False, + "park_asserted": True, } # the persisted record the resume arm replays FROM — `_accept_current_dev_session` # latches it as the accepted tree owner, so its task_id has to be the one the @@ -3408,7 +3256,6 @@ def test_replayed_attempt_reuses_the_persisted_park_eligibility(project): # the recorded result replaced the session: nothing was dispatched, so the # only observation available was the persisted one assert adapter.sessions == [] - assert task.park_eligible is True records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True)] @@ -3448,9 +3295,10 @@ def test_no_waiver_record_when_a_later_check_inside_the_gate_rejects_the_park(pr task.spec_file = str(sp) task.baseline_commit = baseline task.baseline_untracked = sorted(verify.untracked_files(project.project)) - task.park_eligible = True - - outcome = engine._verify_dev_artifacts(task, {"workflow": "auto-dev", "spec_file": str(sp)}) + outcome = engine._verify_dev_artifacts( + task, + {"workflow": "auto-dev", "spec_file": str(sp), "park_asserted": True}, + ) # refused for the sprint pair, NOT for proof-of-work: the waiver did fire assert not outcome.ok and outcome.retryable @@ -3505,55 +3353,15 @@ def test_the_waiver_record_stands_when_a_later_stage_rejects_the_park(project): assert len(adapter.sessions) == 2 -def test_eligible_phase_waives_on_a_different_spec_than_it_was_authorized_over(project): - """CHARACTERIZATION of behavior this change deliberately LEAVES OPEN — not a - guarantee, and not a gate. It is the shipped answer to the intent's I/O row - "Eligible phase, attempt resolves a DIFFERENT spec", and it is folded into the - first `deferred` entry on this change's spec ("the same authorization is also - never re-validated against spec IDENTITY"). A later change that closes that - deferral is EXPECTED to rewrite this row rather than be blocked by it. - - What it pins: `park_eligible` is a PHASE-level authorization answering one - question about ONE observation — was `task.spec_file` already parked at the - instant the phase was dispatched? Here it was not (the binding is a - `ready-for-dev` spec), so the phase is eligible. The session then returns a - result naming a DIFFERENT spec that was already at `awaiting-operator` before - the phase began, and writes no code at all. The authorization is not - re-validated against that identity, so the waiver is spent on an inherited - park declaration the phase was never authorized over, the residue-free tree is - accepted, and the attempt is journaled as a waived gate. - - ENGINE layer, and the choice is forced rather than preferred: `verify_dev` - takes `park_eligible` as a bare argument and has no notion of the phase - binding at all, so at that layer "the authorization was computed about another - spec" is not expressible — a caller can only assert the value it just passed - in. Only `_dev_phase` holds both halves: it computes eligibility from - `task.spec_file` at fresh entry and later hands the session's own - `result_json["spec_file"]` to the gate. Nothing between the two compares them, - which is precisely the finding. (No binding or roots gate refuses the - construction: the foreign spec sits inside `implementation_artifacts`, so - `spec_within_roots` admits it, and no verify gate reads `result.json`'s - `story_key`.) - - Ablation, measured rather than predicted: bind eligibility to the returned - spec identity in `verify_dev` with - `skip_proof = parked and park_eligible and - (not task.spec_file or str(spec_path) == task.spec_file)` — and this row fails - with `ScriptExhausted: no scripted result for session 1-1-a-dev-2`. Note the - surface it fails on, because it is a consequence and not the assertion below: - the waiver no longer fires, the residue-free tree owes a diff it never - produced, `verify_dev` refuses it with "no changes in worktree since baseline - commit", and the engine asks for the retry the one-entry script cannot supply. - The refusal is the cause and the `dev-decision` journal entry records it; the - exhausted script is only how the retry becomes visible. That failure is the - expected shape of CLOSING the deferral, which is why this row is - characterization rather than warranty — close it and rewrite this row.""" +def test_asserted_result_selects_the_spec_named_by_that_result(project): + """The assertion belongs to the synthesized result, while the parked half is + independently observed from the result's claimed spec path.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) baseline = rev_parse_head(project.project) - # the phase's binding: NOT parked, so the dispatch-time answer is "eligible" + # the phase's prior binding is intentionally a different spec bound = spec_path(project, "1-1-a") write_spec(bound, "ready-for-dev", baseline) - # somebody else's park, on disk before this phase ever starts + # the result names this separately parked spec inherited = spec_path(project, "1-2-b") write_spec(inherited, "awaiting-operator", baseline, operator_actions=ACTIONS) @@ -3569,12 +3377,11 @@ def resolves_the_other_spec(_spec): "baseline_commit": baseline, "escalations": [], "followup_review_recommended": False, + "park_asserted": True, }, ) - # exactly ONE scripted session: the shipped behavior accepts on the first - # attempt, and under the ablation below the engine's request for a second is - # the whole signal + # exactly one result owns the assertion and claims the parked spec engine, adapter = make_engine(project, [resolves_the_other_spec], policy=_park_policy()) task = StoryTask(story_key="1-1-a", epic=1, spec_file=str(bound)) engine.state.tasks[task.story_key] = task @@ -3582,10 +3389,9 @@ def resolves_the_other_spec(_spec): assert engine._dev_phase(task) is True assert len(adapter.sessions) == 1 # accepted on the first attempt, never retried - # authorized over the bound spec... - assert task.park_eligible is True + # the bound spec remains unchanged... assert read_frontmatter(bound)["status"] == "ready-for-dev" - # ...and spent on the other one, which the gate then rebound the task to + # ...and the result's spec becomes the accepted task binding assert task.spec_file == str(inherited) records = [e for e in engine.journal.entries() if e["kind"] == "park-proof-of-work-skipped"] assert [(e["attempt"], e["zero_diff"]) for e in records] == [(1, True)] @@ -6260,6 +6066,9 @@ def test_expected_spec_withheld_from_labeled_workflow_session(project): ) PARK_HEAD = "If this story's acceptance criteria include actions only a HUMAN" PARK_NEVER_BLOCKED = "Never use the blocked status" +PARK_RESULT_ASSERTION = ( + "final Auto Run Result must also report the matching status: awaiting-operator" +) PARK_TAIL = "blocked halts the whole run, and this story is finished as far as you can take it." LEDGER_BOARD_JOIN = "their status and resolution. sprint-status.yaml is owned by the orchestrator" BOARD_REDIRECT_JOIN = "not proof that the work is verified. If the story cannot be finished" @@ -6448,6 +6257,7 @@ def test_board_clause_rides_every_dev_leg_ahead_of_the_park_clause(project, tmp_ for prompt in (bare, explicit, restore, repair): assert BOARD_OWNED in prompt assert PARK_HEAD in prompt + assert PARK_RESULT_ASSERTION in prompt assert prompt.index(BOARD_OWNED) < prompt.index(PARK_HEAD) assert BOARD_PARK_JOIN in prompt # the board→park separator itself assert prompt.endswith(PARK_TAIL) # nothing may be appended after the park diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index d7737e1c..a76afa38 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -3924,10 +3924,28 @@ def test_frontmatter_fallback_synthesizes_on_second_stable_stop(tmp_path, monkey assert rj["baseline_commit"] == "abc123" assert rj["synthesized_from_frontmatter"] is True assert rj["escalations"] == [] + assert rj["park_asserted"] is False # the harvest pass writes no breadcrumb assert len(_breadcrumbs(adapter)) == 1 +def test_frontmatter_fallback_park_cannot_assert_session_ownership(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + markerless_park = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n" + ) + (impl / "spec-3-1-foo.md").write_text(markerless_park) + + assert adapter._result_json(_dev_handle(), _dev_spec(tmp_path), wait=True) is None + rj = adapter._result_json(_dev_handle(), _dev_spec(tmp_path), wait=True) + + assert rj["status"] == "awaiting-operator" + assert rj["park_asserted"] is False + assert rj["synthesized_from_frontmatter"] is True + + def test_frontmatter_fallback_stamps_story_key_and_dw_ids(tmp_path, monkeypatch): """The fallback shares _synthesize_from with the marker path, so bundle dev sessions get their exported dw ids stamped for verify_dev_bundle.""" @@ -4735,6 +4753,67 @@ def test_expected_spec_synthesizes_its_own_marker_spec(tmp_path, monkeypatch): assert rj["spec_file"] == str(ours) +@pytest.mark.parametrize("known_spec", [False, True], ids=["scan", "known-spec"]) +def test_prelaunch_park_marker_survives_unrelated_touch_without_asserting_ownership( + tmp_path, monkeypatch, known_spec +): + """A whole-file mtime bump cannot lend a retained park marker to this session.""" + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + parked = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + ours.write_text(parked) + launch_floor = ours.stat().st_mtime_ns + 1 + spec = _dev_spec(tmp_path) + if known_spec: + spec = dataclasses.replace(spec, expected_spec=str(ours)) + + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + # Rewrite only the body before the retained marker, then lift whole-file mtime + # past launch. The old implementation treated that mtime as marker provenance. + ours.write_text(parked.replace("# Story", "# Story\n\nUnrelated post-launch touch.")) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["status"] == "awaiting-operator" + assert rj["park_asserted"] is False + + +def test_new_session_marker_asserts_park_after_launch_capture(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text("---\nstatus: in-progress\nbaseline_revision: abc123\n---\n\n# Story\n") + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is True + + def test_expected_spec_ignores_foreign_markerless_spec(tmp_path, monkeypatch): """Same regression through the #224 missing-marker fallback, which #261 predates but which added a second identical mtime-only scan of the shared dir. A foreign diff --git a/tests/test_model.py b/tests/test_model.py index 978e718f..fbe7060d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -189,57 +189,15 @@ def test_followup_review_recommended_defaults_false_for_legacy_state(): assert StoryTask.from_dict(doc).followup_review_recommended is False -def test_park_eligible_round_trips(): - """The dispatch-time expectation gating the park's proof-of-work skip is - captured once per dev phase, so it has to survive the crash/resume boundary — - a replayed attempt that re-derived it would answer about the spec the session - it is replaying already parked.""" - task = StoryTask(story_key="1-1-a", epic=1, park_eligible=True) - assert StoryTask.from_dict(task.to_dict()).park_eligible is True - - -def test_park_eligible_defaults_false_for_legacy_state(): - """And it defaults to the FAIL-CLOSED value, which is the load-bearing half: a - run resumed from a state.json written before the field existed has no recorded - answer, and the absent one must deny the skip rather than grant it. Defaulting - True would make every legacy resume the exact DW-1 hole this field closes.""" +def test_legacy_park_eligible_state_loads_but_is_not_persisted(): + """Retired authorization state is tolerated but cannot influence new runs.""" doc = StoryTask(story_key="1-1-a", epic=1).to_dict() - del doc["park_eligible"] # state.json from before the field existed - assert StoryTask.from_dict(doc).park_eligible is False + doc["park_eligible"] = True + loaded = StoryTask.from_dict(doc) -@pytest.mark.parametrize( - "stored", - ["false", "true", "", 0, 1, None, [], ["x"], {}], - ids=["str-false", "str-true", "str-empty", "int-0", "int-1", "null", "list", "list-x", "dict"], -) -def test_park_eligible_only_a_real_boolean_true_authorizes_the_waiver(stored): - """`from_dict` reads this one field STRICTLY, and the asymmetry is the reason. - Every sibling bool on the task restores bookkeeping; this one authorizes the - dev gate's proof-of-work check to be WAIVED, so a wrong `False` costs one - retryable refusal while a wrong `True` re-opens the inheritance hole the field - exists to close. - - Under the ordinary `bool(...)` spelling every truthy non-boolean grants that - waiver, and the likeliest one is the string `"false"` — a hand-edited - state.json, or any bridge that stringifies JSON scalars — for which - `bool("false")` is True. The `"true"`/`1` rows are here for the same reason - from the other side: reading them as authorization would be GUESSING that a - non-boolean meant yes, and fail-closed does not guess. - - Ablation: restore `bool(d.get("park_eligible", False))` and the `str-false`, - `str-true`, `int-1` and `list-x` rows all fail.""" - doc = StoryTask(story_key="1-1-a", epic=1).to_dict() - doc["park_eligible"] = stored - assert StoryTask.from_dict(doc).park_eligible is False - - -def test_park_eligible_round_trips_the_authorized_value(): - """The other direction, so strictness is not mistaken for "always False": a - real JSON `true` — the only value `to_dict` ever writes — survives.""" - doc = StoryTask(story_key="1-1-a", epic=1, park_eligible=True).to_dict() - assert doc["park_eligible"] is True - assert StoryTask.from_dict(doc).park_eligible is True + assert not hasattr(loaded, "park_eligible") + assert "park_eligible" not in loaded.to_dict() def test_verify_outcome_park_fields_are_absent_by_default(): diff --git a/tests/test_verify.py b/tests/test_verify.py index e37d7020..80e8bb28 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -40,8 +40,11 @@ def make_task(paths, story_key="1-1-a"): return task -def dev_result(sp): - return {"workflow": "auto-dev", "spec_file": str(sp)} +def dev_result(sp, *, park_asserted: object = False): + result = {"workflow": "auto-dev", "spec_file": str(sp)} + if park_asserted is not OMIT: + result["park_asserted"] = park_asserted + return result def _codec_rejects_bad_byte() -> bool: @@ -853,11 +856,9 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): (awaiting-operator, awaiting-operator) either way — so the flag must not reach the outcome, and the `True` leg is what would catch a future edit that let it. - `park_eligible=True` is the engine-side half of the selector the skip now - needs: the orchestrator's answer, recorded at dispatch, that this phase could - newly ELECT a park rather than inherit one (DW-1). Without it this row fails - on proof-of-work — which is exactly what - `test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work` asserts. + `park_asserted=True` is the current session's independent marker assertion. + Without it this row fails on proof-of-work — exactly what the strict-value + sibling asserts. `park_zero_diff` is the accepted skip's record: the tree really was residue-free, and the outcome says so instead of the skip passing silently (DW-6).""" task, sp = _residue_free( @@ -867,10 +868,9 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=review_enabled, operator_park=True, - park_eligible=True, ) assert out.ok @@ -878,14 +878,14 @@ def test_verify_dev_park_with_no_code_residue_passes(project, review_enabled): assert out.park_proof_skipped is True and out.park_zero_diff is True -@pytest.mark.parametrize("park_eligible", [False, True]) +@pytest.mark.parametrize("park_asserted", [False, True]) @pytest.mark.parametrize("operator_park", [False, True]) @pytest.mark.parametrize( "status, sprint, review_enabled", [("in-review", "review", True), ("done", "done", False)], ) def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( - project, status, sprint, review_enabled, operator_park, park_eligible + project, status, sprint, review_enabled, operator_park, park_asserted ): """The control for the row above, and the reason that row proves anything: the SAME residue-free tree at an ordinary terminal must still be refused. Without @@ -906,13 +906,9 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( `test_engine.py` that are about harvest, not about park. A run with parking enabled but a session that finished ordinarily must still owe a diff. - `park_eligible` is parametrized for the identical reason, one selector later: - the skip is now `parked and park_eligible`, so the engine-side half is the - other input that could widen it past the park. Rewriting it as - `None if park_eligible` — the dispatch-time expectation alone, ignoring the - observed status — is green everywhere without this dimension, and it would let - every ordinary session on a story that had never parked skip proof-of-work - entirely. Neither half selects the skip on its own. + `park_asserted` is parametrized for the identical reason, one selector later: + the assertion alone must not widen the waiver past an observed park. Neither + half selects the skip on its own. Ablation: delete the `if extra_exclude is not None and task.baseline_commit:` proof-of-work block in `_verify_shared_gates` and all four rows fail on @@ -923,10 +919,9 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=park_asserted), review_enabled=review_enabled, operator_park=operator_park, - park_eligible=park_eligible, ) assert not out.ok and out.retryable @@ -935,7 +930,12 @@ def test_verify_dev_residue_free_non_park_still_fails_proof_of_work( assert out.park_proof_skipped is False and out.park_zero_diff is None -def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): +@pytest.mark.parametrize( + "park_asserted", + [OMIT, False, None, 0, 1, "true"], + ids=["missing", "false", "null", "zero", "one", "truthy-string"], +) +def test_verify_dev_unasserted_park_with_no_residue_owes_proof_of_work(project, park_asserted): """DW-1, and the reason the row above needs its new argument: the skip used to be selected entirely by state a fresh session can INHERIT — the policy flag plus the spec's own status. A spec an earlier attempt left at @@ -943,18 +943,12 @@ def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): nothing at all, so a re-drive over it selected #676's relaxation and verified green on someone else's park declaration. - `park_eligible=False` is the orchestrator saying "the bound spec was ALREADY - parked when I dispatched this". The park is not refused for being inherited — - it is merely held to proof-of-work like every other terminal, and this tree has - none to show. Note the reason: the ordinary proof-of-work message, not a - park-specific refusal, because the eligibility flag gates the SKIP and nothing - else. - - This row and `test_verify_dev_park_with_no_code_residue_passes` differ in - exactly one argument over byte-identical state, which is what makes either one - evidence. Ablation: rewrite the selector as `skip_proof = parked` (drop the - `and park_eligible`) and this fails on `assert not out.ok` while its twin stays - green — the pre-DW-1 behavior exactly.""" + Missing, false, null, numeric, and truthy-string assertions are all held to + proof-of-work like every other terminal, and this tree has none to show. The + ordinary proof-of-work message proves the assertion gates only the waiver. + + Ablation: rewrite the selector as `skip_proof = parked` and every row fails on + `assert not out.ok` while the asserted twin stays green.""" task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) @@ -962,10 +956,9 @@ def test_verify_dev_ineligible_park_with_no_residue_owes_proof_of_work(project): out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=park_asserted), review_enabled=False, operator_park=True, - park_eligible=False, ) assert not out.ok and out.retryable @@ -982,8 +975,8 @@ def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): on its own and passes — status pair, actions list, workflow tag, baseline match and sprint pair all still select on the OBSERVED status exactly as before. - This is the row that would catch the over-correction: making `park_eligible` - select the park's status pair as well (rather than only the skip) turns a + This is the row that catches making `park_asserted` select the park's status + pair as well (rather than only the skip), which turns a legitimate repair-then-park into a status mismatch, and refuses work that was actually done. `park_zero_diff` stays None because no skip fired — a passing park is not automatically a recorded one.""" @@ -995,7 +988,6 @@ def test_verify_dev_ineligible_park_with_a_real_diff_still_passes(project): dev_result(sp), review_enabled=False, operator_park=True, - park_eligible=False, ) assert out.ok @@ -1018,10 +1010,9 @@ def test_verify_dev_elected_park_with_code_residue_records_a_non_zero_diff(proje out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1064,10 +1055,9 @@ def boom(*_a, **_kw): out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1115,10 +1105,9 @@ def test_verify_dev_park_zero_diff_is_unknown_when_git_refuses_the_probe(project out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1181,10 +1170,9 @@ def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(projec out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert out.ok @@ -1218,10 +1206,9 @@ def test_verify_dev_park_zero_diff_excludes_the_orchestrators_own_writes(project out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, engine_written=("ledger.md",), ) @@ -1248,13 +1235,11 @@ def test_verify_dev_park_still_faces_the_workflow_tag_gate(project): task, sp = _residue_free( project, status=verify.AWAITING_OPERATOR, sprint=verify.AWAITING_OPERATOR ) - rj = {"workflow": "quick-dev", "spec_file": str(sp)} + rj = {"workflow": "quick-dev", "spec_file": str(sp), "park_asserted": True} - # park_eligible=True so the skip really is in place: without it proof-of-work + # park_asserted=True so the skip really is in place: without it proof-of-work # would also refuse this tree and the row would pass for a compound reason. - out = verify.verify_dev( - task, project, rj, review_enabled=False, operator_park=True, park_eligible=True - ) + out = verify.verify_dev(task, project, rj, review_enabled=False, operator_park=True) assert not out.ok and out.retryable assert "auto-dev" in out.reason @@ -1281,16 +1266,15 @@ def test_verify_dev_park_still_faces_the_baseline_match_gate(project): baseline="deadbeef" * 5, ) - # Same reason as the workflow-tag row above: with park_eligible left False the + # Same reason as the workflow-tag row above: with park_asserted left False the # tree would also owe proof-of-work, and baseline-match would stop being the # only thing that could refuse here. out = verify.verify_dev( task, project, - dev_result(sp), + dev_result(sp, park_asserted=True), review_enabled=False, operator_park=True, - park_eligible=True, ) assert not out.ok and out.retryable @@ -7376,13 +7360,7 @@ def test_engine_written_is_keyword_only_on_all_dev_verifiers(): parameter = inspect.signature(fn).parameters["engine_written"] assert parameter.kind is inspect.Parameter.KEYWORD_ONLY assert "operator_park" in inspect.signature(verify.verify_dev).parameters - # The park skip's second selector (DW-1). Keyword-only for the same reason - # `engine_written` is: `verify_dev`'s positional tail is `review_enabled`, and - # a positional eligibility flag would be one transposed argument away from - # silently authorizing the skip on every leg. - park_eligible = inspect.signature(verify.verify_dev).parameters["park_eligible"] - assert park_eligible.kind is inspect.Parameter.KEYWORD_ONLY - assert park_eligible.default is False + assert "park_eligible" not in inspect.signature(verify.verify_dev).parameters # --------------------------------------------------- the git support floor (GIT_FLOOR) From 21c1d9b5b925c389ec8d07e65bf6c2a156d1495a Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 01:52:36 -0700 Subject: [PATCH 10/35] Harden park marker provenance --- src/bmad_loop/adapters/generic.py | 39 +++++-- src/bmad_loop/devcontract.py | 2 +- tests/test_devcontract.py | 23 ++-- tests/test_generic_tmux.py | 172 ++++++++++++++++++++++++++++++ tests/test_opencode_http.py | 27 +++++ 5 files changed, 244 insertions(+), 19 deletions(-) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 3d383dc5..e1538fec 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -1336,9 +1336,11 @@ def _configure_dev_knobs(self) -> None: # Marker identities present immediately before each real session launch. # The adapter, not whole-file mtime, owns this attempt-relative evidence: # touching another part of a parked spec must not make its retained marker - # look session-authored. None means launch capture was incomplete and - # therefore fails closed for the waiver. - self._launch_auto_run_results: dict[str, dict[str, tuple[int, str]] | None] = {} + # look session-authored. A task-level None means directory enumeration was + # incomplete; a path-level None means that one launch file was unreadable. + # Both fail closed at the affected scope without letting an unrelated bad + # Markdown file suppress a newly created, readable story spec. + self._launch_auto_run_results: dict[str, dict[str, tuple[int, str] | None] | None] = {} @staticmethod def _marker_path_key(path: Path) -> str: @@ -1361,18 +1363,19 @@ def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None: except OSError: complete = False - captured: dict[str, tuple[int, str]] = {} + captured: dict[str, tuple[int, str] | None] = {} for path in paths: + key = self._marker_path_key(path) try: text = path.read_text(encoding="utf-8") except FileNotFoundError: continue except (OSError, UnicodeDecodeError): - complete = False + captured[key] = None continue fingerprint = devcontract.auto_run_result_fingerprint(text) if fingerprint[0]: - captured[self._marker_path_key(path)] = fingerprint + captured[key] = fingerprint self._launch_auto_run_results[spec.task_id] = captured if complete else None def start_session(self, spec: SessionSpec) -> SessionHandle: @@ -1386,9 +1389,9 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> bool: """Whether the live marker differs from this session's launch marker.""" if spec.task_id not in self._launch_auto_run_results: - # Direct read-back callers predate launch capture; production always - # enters through start_session. Preserve that diagnostic/test seam. - return True + # Production always enters through start_session. A direct diagnostic + # read-back has no attempt-relative evidence and therefore fails closed. + return False captured = self._launch_auto_run_results[spec.task_id] if captured is None: return False @@ -1396,8 +1399,22 @@ def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> b current = devcontract.auto_run_result_fingerprint(spec_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError): return False - launch = captured.get(self._marker_path_key(spec_path), (0, "")) - return current != launch + key = self._marker_path_key(spec_path) + if key in captured: + launch = captured[key] + if launch is None: + return False + # Appending another marker is authorship even when its text repeats; + # an in-place rewrite is authorship when the final section changes. + # Deleting older sections while retaining the same final marker is not. + return current[0] > launch[0] or (current[0] == launch[0] and current[1] != launch[1]) + + # A marker moved or copied from another launch path is inherited evidence, + # not a marker authored by this attempt. A genuinely new marker whose text + # happens to collide also fails closed; byte identity cannot prove authorship. + if current in (fingerprint for fingerprint in captured.values() if fingerprint): + return False + return current[0] > 0 def _probe_alive(self, handle: SessionHandle) -> bool | None: """Liveness of the session's native surface (tmux window, server diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 19c5e90b..6b229e4c 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -352,7 +352,7 @@ def synthesize_result( story_key: str | None, dw_ids: list[str] | None = None, plan_halt: bool = False, - park_marker_session_authored: bool = True, + park_marker_session_authored: bool = False, ) -> SynthResult: """Build the legacy result dict from the generic skill's on-disk spec. diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index 7bf0430c..24ec2063 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -363,7 +363,11 @@ def test_synth_awaiting_operator_is_terminal_and_folds_actions(tmp_path): auto_run="awaiting-operator", actions="['buy example.com', 'publish the TXT record']", ) - out = devcontract.synthesize_result(sp, story_key="1-1-a") + out = devcontract.synthesize_result( + sp, + story_key="1-1-a", + park_marker_session_authored=True, + ) assert out.status_consistent rj = out.result_json @@ -422,7 +426,7 @@ def test_synth_repaired_park_marker_cannot_assert_session_ownership(tmp_path): ) -def test_synth_preexisting_genuine_park_marker_cannot_assert_session_ownership(tmp_path): +def test_synth_genuine_park_marker_defaults_to_unasserted_without_session_provenance(tmp_path): sp = _spec( tmp_path / "s.md", status="awaiting-operator", @@ -430,11 +434,7 @@ def test_synth_preexisting_genuine_park_marker_cannot_assert_session_ownership(t actions="['do it']", ) - rj = devcontract.synthesize_result( - sp, - story_key="1-1-a", - park_marker_session_authored=False, - ).result_json + rj = devcontract.synthesize_result(sp, story_key="1-1-a").result_json assert rj["park_asserted"] is False @@ -447,6 +447,15 @@ def test_auto_run_result_fingerprint_detects_an_identical_appended_marker(): ) +def test_auto_run_result_fingerprint_detects_an_in_place_marker_rewrite(): + before = "## Auto Run Result\n\nStatus: done\n" + after = "## Auto Run Result\n\nStatus: awaiting-operator\n" + + assert devcontract.auto_run_result_fingerprint(before) != ( + devcontract.auto_run_result_fingerprint(after) + ) + + @pytest.mark.parametrize( "actions, expected, why", [ diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index a76afa38..ba2dc70f 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -4814,6 +4814,178 @@ def test_new_session_marker_asserts_park_after_launch_capture(tmp_path, monkeypa assert rj is not None and rj["park_asserted"] is True +def test_marker_readback_without_launch_capture_fails_closed(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + + rj = adapter._result_json(_dev_handle(), spec, wait=False) + + assert rj is not None and rj["park_asserted"] is False + + +def test_launch_capture_precedes_transport_that_writes_park_marker(tmp_path, monkeypatch): + """A child that finishes during transport startup still owns its new marker.""" + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text("---\nstatus: in-progress\nbaseline_revision: abc123\n---\n\n# Story\n") + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + + def launch(_adapter, _spec): + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + return _dev_handle(launched_ns=launch_floor) + + monkeypatch.setattr(generic.GenericAdapter, "start_session", launch) + + handle = adapter.start_session(spec) + rj = adapter._result_json(handle, spec, wait=False) + + assert rj is not None and rj["park_asserted"] is True + + +def test_in_place_marker_rewrite_asserts_session_ownership(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_text( + "---\nstatus: done\nbaseline_revision: abc123\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: done\nFinished.\n" + ) + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is True + + +def test_deleting_older_marker_does_not_assert_retained_last_marker(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + final_marker = "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + prefix = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + ) + ours.write_text(prefix + "## Auto Run Result\n\nStatus: done\nOld.\n\n" + final_marker) + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text(prefix + final_marker) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is False + + +def test_moved_launch_marker_does_not_assert_session_ownership(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + old = impl / "spec-old.md" + ours = impl / "spec-3-1-foo.md" + old.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + launch_floor = old.stat().st_mtime_ns + 1 + spec = _dev_spec(tmp_path) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + old.rename(ours) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is False + + +def test_unrelated_unreadable_markdown_does_not_poison_new_spec_capture(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + (impl / "notes.md").write_bytes(b"\xff") + ours = impl / "spec-3-1-foo.md" + launch_floor = (impl / "notes.md").stat().st_mtime_ns + 1 + spec = _dev_spec(tmp_path) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is True + + +def test_unreadable_launch_spec_fails_closed_after_becoming_readable(tmp_path, monkeypatch): + adapter, impl = make_dev_adapter(tmp_path) + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + ours = impl / "spec-3-1-foo.md" + ours.write_bytes(b"\xff") + launch_floor = ours.stat().st_mtime_ns + 1 + spec = dataclasses.replace(_dev_spec(tmp_path), expected_spec=str(ours)) + monkeypatch.setattr( + generic.GenericAdapter, + "start_session", + lambda _adapter, _spec: _dev_handle(launched_ns=launch_floor), + ) + handle = adapter.start_session(spec) + + ours.write_text( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + + rj = adapter._result_json(handle, spec, wait=False) + assert rj is not None and rj["park_asserted"] is False + + def test_expected_spec_ignores_foreign_markerless_spec(tmp_path, monkeypatch): """Same regression through the #224 missing-marker fallback, which #261 predates but which added a second identical mtime-only scan of the shared dir. A foreign diff --git a/tests/test_opencode_http.py b/tests/test_opencode_http.py index 0088f48a..669348c7 100644 --- a/tests/test_opencode_http.py +++ b/tests/test_opencode_http.py @@ -2680,6 +2680,33 @@ def test_e2e_dev_synthesizes_terminal_spec(tmp_path, fake_opencode): assert_server_gone(rec) +def test_e2e_dev_retained_park_marker_is_not_reowned_by_unrelated_rewrite(tmp_path, fake_opencode): + """The HTTP transport also captures marker provenance before prompting.""" + launcher, rec = fake_opencode + adapter, impl = make_dev_adapter(tmp_path, binary=str(launcher)) + spec_path = impl / "spec-3-1-foo.md" + parked = ( + "---\nstatus: awaiting-operator\nbaseline_revision: abc123\n" + "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" + "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" + ) + spec_path.write_text(parked) + spec = make_dev_spec( + tmp_path, + rec, + "completed", + spec_path, + spec_text=parked.replace("# Story", "# Story\n\nUnrelated session edit."), + ) + + result = adapter.run(spec) + + assert result.status == "completed" + assert result.result_json["status"] == "awaiting-operator" + assert result.result_json["park_asserted"] is False + assert_server_gone(rec) + + def test_e2e_dev_stories_mode_resolves_by_id(tmp_path, fake_opencode, monkeypatch): """Folder+id dispatch (BMAD_LOOP_SPEC_FOLDER): the story spec is resolved at its deterministic id-keyed path — never via the mtime scan.""" From 35fd2ff044d3e7575a1d67f42ca360645ceefd71 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 08:28:01 -0700 Subject: [PATCH 11/35] sweep dw2-session-authored-park-assertion: DW-46, DW-47 via bmad-loop --- src/bmad_loop/devcontract.py | 21 ++++++++++++++------- tests/test_runs.py | 8 ++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 6b229e4c..e1ade5f8 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -9,13 +9,20 @@ that turns that on-disk spec into the legacy result dict that verify.py / escalation.py already consume, so the rest of the pipeline stays unchanged. -DOCTRINE — never trust prose for a gate. The frontmatter `status:` read straight -off disk is authoritative; the `## Auto Run Result` prose is only used to route -the blocked→PAUSE decision and to carry a human-readable detail. Where the two -disagree we surface it (`status_consistent=False`) so the caller can fail safe -(treat a mismatch as a retry rather than silently proceeding). Every real -deterministic gate (git baseline, worktree-changed, sprint advancement, dw_id -match) still runs in verify.py against actual on-disk state. +DOCTRINE — never let terminal prose override populated frontmatter status. The +frontmatter `status:` read straight off disk is authoritative whenever it is +present. A genuine `## Auto Run Result` marker has three narrow roles: its +status/detail route the blocked→PAUSE decision; its status is a compatibility +fallback that may populate the synthesized result when frontmatter is blank or +missing; and proof that the current session authored the marker supplies +attempt ownership for `park_asserted`. From that compatibility result, `done` +alone may be reconciled onto disk, `blocked` routes to PAUSE, and +`awaiting-operator` remains subject to the on-disk frontmatter/status gate. +Where the marker and populated frontmatter disagree we surface it +(`status_consistent=False`) so the caller can fail safe (treat a mismatch as a +retry rather than silently proceeding). Every real deterministic gate (git +baseline, worktree-changed, sprint advancement, dw_id match) still runs in +verify.py against actual on-disk state. """ from __future__ import annotations diff --git a/tests/test_runs.py b/tests/test_runs.py index dffba09d..82c252ab 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2782,7 +2782,8 @@ def test_restamp_code_root_aims_the_mirror_the_rearm_reads(tmp_path, recorded): _SPEC_WITH_ARR = ( - "---\ntitle: t\nstatus: blocked\n---\n\n## Intent\n\nbody\n" + "---\ntitle: t\nstatus: blocked\noperator_actions:\n" + " - publish the TXT record\n---\n\n## Intent\n\nbody\n" "\n## Auto Run Result\n\n- Status: blocked\n\nboom\n" ) @@ -2822,7 +2823,10 @@ def test_rearm_plain_mode_sets_ready_for_dev_and_clears_stale_latch(tmp_path): task = load_state(run_dir).tasks["1-1-a"] assert task.phase == Phase.PENDING assert task.restore_patch is None # stale latch cleared - assert "status: ready-for-dev" in spec.read_text() + text = spec.read_text() + assert "status: ready-for-dev" in text + assert verify.operator_actions_of(verify.read_frontmatter(spec)) == ("publish the TXT record",) + assert "## Auto Run Result" not in text # stale attempt authority stripped entry = [e for e in Journal(run_dir).entries() if e["kind"] == "story-escalation-resolved"][-1] assert entry["restore"] is False From 9af10d9794c1e24ec7a4054c06e777459f55cf94 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 09:56:34 -0700 Subject: [PATCH 12/35] sweep dw2-proof-probe-consistency: DW-48, DW-49, DW-50 via bmad-loop --- docs/FEATURES.md | 1 + src/bmad_loop/engine.py | 16 +++- src/bmad_loop/model.py | 9 ++ src/bmad_loop/stories_engine.py | 25 +++++- src/bmad_loop/verify.py | 100 ++++++++++++--------- tests/test_engine.py | 23 ++++- tests/test_portability_guard.py | 57 ++++++++++++ tests/test_stories_engine.py | 115 +++++++++++++++++++++++- tests/test_verify.py | 153 ++++++++++++++++++++++++++++++++ 9 files changed, 449 insertions(+), 50 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7c58fcde..dc601e0e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -65,6 +65,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342). - Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and `park_asserted: true` in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced `## Auto Run Result` marker reporting `awaiting-operator`; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or `operator_actions:` while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as `park-proof-of-work-skipped`; `zero_diff` reports whether the waived gate would have found non-excluded residue (`true` means none, `false` means some, `null` means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later `story-awaiting-operator` event. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. +- A successful stories-mode plan halt is likewise journaled at the artifact-gate boundary as `plan-halt-proof-of-work-skipped`. Its `zero_diff` uses the same tri-state projection (`true` = the waived gate found no non-excluded residue, `false` = it found changes, `null` = Git could not answer), including the stories manifest/spec and orchestrator-written exclusions the live gate would have used. The record is emitted only after the `ready-for-dev` artifact verification passes and never substitutes for the independent `result.json` `plan_halt: true` marker that authorizes the halt; an absent marker or an earlier gate failure produces no waiver record. - Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. - A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. - Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and a row at `done` or `awaiting-operator` is the orchestrator's own bookkeeping — not a defect to fix, and not proof that the work is verified, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index edfe1177..9766efc0 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -4896,8 +4896,8 @@ def _legacy_ledger_changed_before_harvest(self, task: StoryTask) -> bool: session can still have authored ledger-only work, and path-granular Git evidence distinguishes that existing diff from the engine harvest about to run. External ledgers cannot satisfy the project proof gate. An - attribution probe that raises keeps the path excluded, so uncertainty - never credits the engine's own append as session work. + attribution probe that raises or returns unknown keeps the path excluded, + so uncertainty never credits the engine's own append as session work. """ if not task.baseline_commit: return False @@ -4913,12 +4913,20 @@ def _legacy_ledger_changed_before_harvest(self, task: StoryTask) -> bool: except ValueError: return False try: - return verify.path_changed_since( + changed = verify._changes_since( root, task.baseline_commit, - rel, + literal_path=rel, baseline_untracked=task.baseline_untracked, ) + if changed is None: + self.journal.append( + "legacy-ledger-attribution-failed", + story_key=task.story_key, + error="git could not determine whether the ledger changed", + ) + return False + return changed except (verify.GitError, OSError, RuntimeError) as e: self.journal.append( "legacy-ledger-attribution-failed", diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 1bc3f8ff..93790bc2 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -949,6 +949,13 @@ class VerifyOutcome: # A waived gate is recorded whatever the probe managed to say; `None` is a # truthful field value, not a reason to withhold the record. park_zero_diff: bool | None = None + # Stories plan halts waive the same proof-of-work gate for a different + # reason: the accepted output is the plan spec itself. Keep their observation + # independent of the park-only pair above — the result.json `plan_halt` + # marker remains the authority for which leg ran, while this field reports + # only what the waived gate would have found. `True` / `False` / `None` have + # the same no-diff / diff / unknown meanings as `park_zero_diff`. + plan_halt_zero_diff: bool | None = None @classmethod def passed( @@ -956,11 +963,13 @@ def passed( *, park_proof_skipped: bool = False, park_zero_diff: bool | None = None, + plan_halt_zero_diff: bool | None = None, ) -> "VerifyOutcome": return cls( ok=True, park_proof_skipped=park_proof_skipped, park_zero_diff=park_zero_diff, + plan_halt_zero_diff=plan_halt_zero_diff, ) @classmethod diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 7d38ffde..1097a6ef 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -519,7 +519,7 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): # rearm clears it by recorded kind, not by re-deriving from the basename. task.sentinel_kind = state.sentinel_kind self._journal_sentinel_detected(task.story_key, state) - return verify.verify_dev_stories( + outcome = verify.verify_dev_stories( task, self.workspace.paths, result_json, @@ -528,6 +528,29 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None): plan_halt=plan_halt, engine_written=self._harvest_gate_exclude(task), ) + # The marker remains the independent authority that this was a deliberate + # plan halt. Journal the proof waiver only after every artifact gate passes; + # `zero_diff` is an observation of the skipped gate, never an input to it. + waiver_already_recorded = ( + plan_halt + and outcome.ok + and any( + entry.get("kind") == "plan-halt-proof-of-work-skipped" + and entry.get("story_key") == task.story_key + and entry.get("attempt") == task.attempt + and entry.get("generation", 0) == task.generation + for entry in self.journal.entries() + ) + ) + if plan_halt and outcome.ok and not waiver_already_recorded: + self.journal.append( + "plan-halt-proof-of-work-skipped", + story_key=task.story_key, + attempt=task.attempt, + generation=task.generation, + zero_diff=outcome.plan_halt_zero_diff, + ) + return outcome def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | None) -> bool: # A plan-halt leg produced only the plan (spec at ready-for-dev); there is diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 2376e7bb..5f79b27d 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -786,6 +786,7 @@ def _changes_since( baseline: str, exclude: tuple[str, ...] = (), *, + literal_path: str | None = None, baseline_untracked: list[str] | None = None, include_untracked: bool = True, ) -> bool | None: @@ -801,6 +802,11 @@ def _changes_since( ``observe_skipped_proof`` arm) files "the gate would have found changes" about a question git never answered. + ``literal_path`` selects the exact-path form used by + :func:`path_changed_since`; ``None`` selects the whole-tree form. Both forms + share this one quiet-diff invocation and the same untracked-fault handling, + while preserving their established pathspec and baseline-snapshot semantics. + This is the body BOTH proof arms reach, and by only one route: the `proof_of_work_probe` closure in :func:`_verify_shared_gates`, which is what actually makes "the observation measures exactly what the gate would have" @@ -810,19 +816,29 @@ def _changes_since( body decides is what an unanswerable git call looks like; each arm then reads that `None` under its own policy. - :func:`has_changes_since` is the fail-open COLLAPSE of this tri-state, kept for - the gates that want it — it folds `None` into `True` and is what a caller - should reach for unless it can act on "git would not answer".""" - rc, _ = _git(repo, "diff", "--quiet", baseline, "--", ".", *_exclude_specs(exclude)) + :func:`has_changes_since` and :func:`path_changed_since` are the fail-open + COLLAPSES of this tri-state — each folds `None` into `True` at its public + boolean boundary.""" + pathspecs = ( + (f":(literal){literal_path}",) + if literal_path is not None + else (".", *_exclude_specs(exclude)) + ) + rc, _ = _git(repo, "diff", "--quiet", baseline, "--", *pathspecs) if rc not in (0, 1): return None if rc != 0: return True if not include_untracked: return False - created = untracked_files(repo) + try: + created = untracked_files(repo) + except GitError: + return None if baseline_untracked is not None: created -= set(baseline_untracked) + if literal_path is not None: + return literal_path in created created = {p for p in created if not _path_under_any(p, exclude)} return bool(created) @@ -843,19 +859,19 @@ def path_changed_since( counting every ordinary untracked path. Ignored paths are absent from :func:`untracked_files` and therefore cannot become proof of work here. - Any non-zero diff result fails open toward "changed", matching what the - proof-of-work gate does with :func:`_changes_since`'s unanswerable `None` (and - what :func:`has_changes_since` collapses it to). The literal pathspec is + Both a diff refusal and an untracked-enumeration fault fail open toward + "changed", matching :func:`has_changes_since`. The literal pathspec is required for operator-configured ledger paths containing Git wildmatch - characters. + characters. The tri-state body owns that pathspec so this caller cannot drift + from whole-tree proof handling. """ - rc, _ = _git(repo, "diff", "--quiet", baseline, "--", f":(literal){rel}") - if rc != 0: - return True - untracked = untracked_files(repo) - if rel not in untracked: - return False - return baseline_untracked is None or rel not in set(baseline_untracked) + answer = _changes_since( + repo, + baseline, + literal_path=rel, + baseline_untracked=baseline_untracked, + ) + return True if answer is None else answer def attempt_dirty( @@ -3498,15 +3514,11 @@ def _verify_shared_gates( gate would have passed and one it would have refused are otherwise indistinguishable after the fact. - Exactly one of the two skipping legs asks for it, and the asymmetry is - deliberate rather than an omission: only sprint mode's PARK passes it. - ``verify_dev_stories``' plan halt skips the gate and observes nothing, because - it already has an independent cross-check a park has no equivalent for — a - clean plan-halt carries ``devcontract``'s ``plan_halt`` marker in its - result.json (``rj.get("plan_halt") is not True`` refuses the leg outright), so - a died-mid-flight ``ready-for-dev`` cannot reach the skip in the first place. A - park's status is self-asserted with no such marker, which is why it is the leg - that needs a record of what the waived gate would have found. + Both skipping legs ask for it. Sprint mode's park and stories mode's plan halt + have independent selectors — the park's session-authored assertion and the + plan halt's strict ``result_json`` marker — while the observation records only + what each waived gate would have found. It never replaces either selector and + never changes acceptance. The two parameters are MUTUALLY EXCLUSIVE by construction: ``extra_exclude`` gates and ``observe_skipped_proof`` observes, and the arms below are ``if`` / @@ -3996,7 +4008,10 @@ def verify_dev_stories( and baseline gates still run, and ``task.spec_file`` is still recorded. A ``plan_halt`` leg also requires the ``result_json`` to carry the ``plan_halt`` marker ``devcontract`` emits on a clean plan-halt, so a died-mid-flight - ``ready-for-dev`` can't be mistaken for a successful plan. + ``ready-for-dev`` can't be mistaken for a successful plan. A passing halt + returns what the skipped proof gate would have found as + ``VerifyOutcome.plan_halt_zero_diff``; that observation never affects the + marker cross-check or the outcome. """ # Deferred to avoid a verify<->stories import cycle: stories imports # read_frontmatter/status_of from this module at top level, so verify must not @@ -4042,34 +4057,33 @@ def verify_dev_stories( else: expected = "in-review" if review_enabled else "done" - # A plan-halt leg produced only its own spec (the plan), which proof-of-work - # already excludes; skip it (extra_exclude=None) and record the plan spec. - # Otherwise stories mode adds the spec folder's stories/ subdir + stories.yaml - # on top of the gate's own file-granular exclude — NOT a whole-folder artifact - # exclusion, so a story whose entire authorized scope is ledger/spec - # reconciliation doesn't register as a false "no changes". Engine-written - # paths compose only on that live-gate leg; ``None`` must remain ``None`` for - # plan halt rather than being combined with a tuple. + # Stories mode adds the spec folder's stories/ subdir + stories.yaml on top of + # the gate's own file-granular exclude — NOT a whole-folder artifact exclusion, + # so a story whose entire authorized scope is ledger/spec reconciliation + # doesn't register as a false "no changes". A plan-halt leg produced only its + # own spec (the plan), so it skips the gate but passes this same tuple to the + # observer: the journal answer must measure exactly the gate that was waived, + # including engine-written paths. + stories_exclude = _stories_relpaths(paths.repo_root, spec_folder) + engine_written gate = _verify_shared_gates( spec_path, rj, task, paths, expected_status=expected, - extra_exclude=( - None - if plan_halt - # Rooted where the proof-of-work gate invokes git (`paths.repo_root`), - # not on `paths.project`: a pathspec relative to the other root matches - # nothing and the exclusion evaporates without an error (#716). - else _stories_relpaths(paths.repo_root, spec_folder) + engine_written - ), + # Rooted where the proof-of-work gate invokes git (`paths.repo_root`), not + # on `paths.project`: a pathspec relative to the other root matches nothing + # and the exclusion evaporates without an error (#716). + extra_exclude=None if plan_halt else stories_exclude, + observe_skipped_proof=stories_exclude if plan_halt else None, ) if gate.outcome is not None: return gate.outcome task.spec_file = str(spec_path) - return VerifyOutcome.passed() + return VerifyOutcome.passed( + plan_halt_zero_diff=(gate.skipped_proof_zero_diff if plan_halt else None) + ) def _stories_relpaths(root: Path, spec_folder: Path) -> tuple[str, ...]: diff --git a/tests/test_engine.py b/tests/test_engine.py index 398ecf71..e4e3bf52 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -16340,7 +16340,10 @@ def crash_after_session_save(stage, *args, **kwargs): ] -def test_legacy_replay_does_not_credit_its_new_harvest_as_session_work(project): +@pytest.mark.parametrize("untracked_fault", [False, True], ids=["answered", "unknown"]) +def test_legacy_replay_does_not_credit_its_new_harvest_as_session_work( + project, monkeypatch, untracked_fault +): """Missing attribution must not let the upgrade's own append satisfy proof.""" write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, _ = make_engine( @@ -16369,6 +16372,18 @@ def crash_after_session_save(stage, *args, **kwargs): _downgrade_harvest_state_to_legacy(engine) resumed, adapter = resume_engine(project, engine, []) + untracked_calls = 0 + real_untracked_files = verify.untracked_files + + def fault_once(repo): + nonlocal untracked_calls + untracked_calls += 1 + if untracked_calls == 1: + raise verify.GitError("untracked enumeration failed") + return real_untracked_files(repo) + + if untracked_fault: + monkeypatch.setattr(verify, "untracked_files", fault_once) summary = resumed.run() assert summary.deferred == 1 and not summary.done and not summary.crashed @@ -16378,6 +16393,12 @@ def crash_after_session_save(stage, *args, **kwargs): decisions = [e for e in resumed.journal.entries() if e["kind"] == "dev-decision"] assert [decision["action"] for decision in decisions] == ["defer"] assert "no changes in worktree" in decisions[0]["reason"] + if untracked_fault: + assert untracked_calls >= 2 # attribution faulted; the later proof gate re-probed + failed = [ + e for e in resumed.journal.entries() if e["kind"] == "legacy-ledger-attribution-failed" + ] + assert failed and "could not determine" in failed[-1]["error"] def test_retry_replay_recovers_session_ledger_attribution_after_prior_harvest(project): diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 656624e7..c6398ced 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -335,6 +335,7 @@ "followup_damped", "followup_review_recommended", "frm", + "generation", "graceful", "harvest_attempt", "head", @@ -1889,6 +1890,62 @@ def test_no_git_invocation_outside_verify(): ) +def test_proof_quiet_diff_is_owned_by_the_central_tri_state_probe(): + """Production has one proof-of-work quiet-diff body across the source tree. + Whole-tree and literal public callers route through `_changes_since`; + `attempt_dirty` retains the one separate quiet diff whose contract is rollback + ownership, not proof of work. + + Ablation: restore `path_changed_since`'s inline `_git(..., "diff", + "--quiet", ...)` body and this fails twice — the unexpected owner appears and + the literal caller no longer calls the tri-state probe. + """ + quiet_diff_owners: list[tuple[str, str]] = [] + tri_state_callers: Counter[tuple[str, str]] = Counter() + + class Visitor(ast.NodeVisitor): + def __init__(self, rel: str) -> None: + self.rel = rel + self.functions: list[str] = [] + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.functions.append(node.name) + self.generic_visit(node) + self.functions.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Call(self, node: ast.Call) -> None: + owner = self.functions[-1] if self.functions else "" + callee = ( + node.func.id + if isinstance(node.func, ast.Name) + else node.func.attr if isinstance(node.func, ast.Attribute) else None + ) + if callee == "_changes_since": + tri_state_callers[(self.rel, owner)] += 1 + if ( + callee == "_git" + and len(node.args) >= 3 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value == "diff" + and isinstance(node.args[2], ast.Constant) + and node.args[2].value == "--quiet" + ): + quiet_diff_owners.append((self.rel, owner)) + self.generic_visit(node) + + for source in SRC.rglob("*.py"): + rel = source.relative_to(SRC).as_posix() + Visitor(rel).visit(ast.parse(source.read_text(encoding="utf-8"))) + + assert Counter(quiet_diff_owners) == Counter( + {("verify.py", "_changes_since"): 1, ("verify.py", "attempt_dirty"): 1} + ) + assert tri_state_callers[("verify.py", "has_changes_since")] == 1 + assert tri_state_callers[("verify.py", "path_changed_since")] == 1 + + def _verify_command_offenders(findings) -> list[tuple[str, int, str]]: """The review-gate chokepoint as a filter: a ``verify_commands_outcome`` call is sanctioned only in a ``VERIFY_COMMANDS_CHOKEPOINT`` file AND only from diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index a5bea118..7b3590e1 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -17,7 +17,7 @@ write_spec, ) -from bmad_loop import stories +from bmad_loop import stories, verify from bmad_loop.adapters.base import SessionResult from bmad_loop.adapters.mock import MockAdapter from bmad_loop.engine import Engine @@ -987,6 +987,10 @@ def test_plan_checkpoint_pause_then_resume_implements(project): assert leg1.prompt.endswith("Halt after planning.") assert leg1.env["BMAD_LOOP_PLAN_HALT"] == "1" assert _kinds(engine.journal, "plan-halt") + (waiver,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert waiver["story_key"] == "1" + assert waiver["zero_diff"] is True + assert "plan_halt" not in waiver # the result marker remains a separate fact assert _kinds(engine.journal, "checkpoint-pause")[-1]["checkpoint"] == "plan" resumed, radapter = resume_engine(project, engine, [stories_checkpoint_effect()]) @@ -1000,6 +1004,115 @@ def test_plan_checkpoint_pause_then_resume_implements(project): assert "BMAD_LOOP_PLAN_HALT" not in leg2.env +@pytest.mark.parametrize( + "result_json", + [ + {"workflow": "auto-dev"}, + {"workflow": "wrong-workflow", "plan_halt": True}, + ], + ids=["marker-absent", "earlier-gate-fails"], +) +def test_refused_plan_halt_does_not_journal_a_proof_waiver(project, result_json): + """Only a passing artifact outcome earns the plan-halt waiver record. + + Ablation: move the journal append before `verify_dev_stories`, or key it only + on a truthy marker, and the relevant row observes a record for a refused leg. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + + outcome = engine._verify_dev_artifacts(task, result_json) + + assert not outcome.ok + assert not _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + + +@pytest.mark.parametrize("zero_diff", [False, None], ids=["residue", "unknown"]) +def test_accepted_plan_halt_journals_the_non_clean_proof_observation( + project, monkeypatch, zero_diff +): + """The engine carries the verifier's full tri-state into the accepted-halt + record; neither residue nor an unanswerable probe may be rewritten as clean. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + + if zero_diff is False: + (project.repo_root / "src.txt").write_text("planning residue\n", encoding="utf-8") + else: + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + + outcome = engine._verify_dev_artifacts( + task, + {"workflow": "auto-dev", "plan_halt": True}, + ) + + assert outcome.ok + (record,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert record["zero_diff"] is zero_diff + + +def test_accepted_plan_halt_journal_excludes_engine_written(project, monkeypatch): + """The journal projects the gate after excluding orchestrator-owned residue. + + Ablation: stop composing ``engine_written`` into the stories observation and + this reports ``zero_diff: false`` even though the session itself wrote no code. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + (project.repo_root / "engine-owned.txt").write_text( + "orchestrator bookkeeping\n", encoding="utf-8" + ) + monkeypatch.setattr(engine, "_harvest_gate_exclude", lambda _task: ("engine-owned.txt",)) + + outcome = engine._verify_dev_artifacts( + task, + {"workflow": "auto-dev", "plan_halt": True}, + ) + + assert outcome.ok + (record,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert record["zero_diff"] is True + + +def test_replayed_plan_halt_does_not_duplicate_proof_waiver(project): + """Crash replay preserves the first observation for one session generation. + + The first verification journals a clean waiver. A host death before the + accepted-session save can replay the same result after unrelated residue has + appeared; that replay must not append a conflicting second audit record. + """ + setup_stories(project, [entry("1", spec_checkpoint=True)]) + engine, _adapter = make_engine(project, []) + baseline = rev_parse_head(project.repo_root) + task = StoryTask("1", 0, baseline_commit=baseline, generation=2) + write_spec(story_spec(project, "1"), "ready-for-dev", baseline) + result_json = {"workflow": "auto-dev", "plan_halt": True} + + first = engine._verify_dev_artifacts(task, result_json) + (project.repo_root / "late-residue.txt").write_text("later\n", encoding="utf-8") + replay = engine._verify_dev_artifacts(task, result_json) + + assert first.ok and first.plan_halt_zero_diff is True + assert replay.ok and replay.plan_halt_zero_diff is False + (record,) = _kinds(engine.journal, "plan-halt-proof-of-work-skipped") + assert record["generation"] == 2 + assert record["zero_diff"] is True + + def test_operator_spec_path_anchors_an_isolated_units_spec(project): """The pause notice is the FIRST surface an operator meets — before any dashboard. diff --git a/tests/test_verify.py b/tests/test_verify.py index 80e8bb28..07f9f4df 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -250,6 +250,25 @@ def test_path_changed_since_detects_one_tracked_path(project): assert verify.path_changed_since(project.project, baseline, "src.txt") is True +def test_path_changed_since_treats_tracked_pathspec_magic_literally(project): + """A tracked exact-path probe must not interpret brackets as pathspec magic. + + Ablation: remove the `:(literal)` prefix in `_changes_since` and Git reports + the modified bracketed path clean, so both assertions fail. + """ + repo = project.project + rel = "tracked[1].txt" + (repo / rel).write_text("baseline\n", encoding="utf-8") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "tracked literal path baseline") + baseline = verify.rev_parse_head(repo) + + (repo / rel).write_text("changed\n", encoding="utf-8") + + assert verify._changes_since(repo, baseline, literal_path=rel) is True + assert verify.path_changed_since(repo, baseline, rel) is True + + def test_path_changed_since_respects_the_untracked_baseline(project): baseline = verify.rev_parse_head(project.project) (project.project / "ledger[1].md").write_text("finding\n", encoding="utf-8") @@ -268,6 +287,30 @@ def test_path_changed_since_respects_the_untracked_baseline(project): ) +def test_path_changed_since_routes_the_literal_path_through_the_tri_state(project, monkeypatch): + """The literal-path public boundary owns only the fail-open collapse; the + centralized tri-state probe owns the quiet diff and its literal pathspec. + + Ablation: restore an inline `_git(..., "diff", "--quiet", ...)` body in + `path_changed_since` and this fails because `_changes_since` is never called. + """ + seen = {} + + def probe(repo, baseline, exclude=(), **kwargs): + seen.update(repo=repo, baseline=baseline, exclude=exclude, kwargs=kwargs) + return None + + monkeypatch.setattr(verify, "_changes_since", probe) + + assert verify.path_changed_since(project.project, "baseline", "ledger[1].md") is True + assert seen == { + "repo": project.project, + "baseline": "baseline", + "exclude": (), + "kwargs": {"literal_path": "ledger[1].md", "baseline_untracked": None}, + } + + def test_attempt_dirty_excludes_untracked_artifact(project): """A new untracked spec under an orchestrator-owned artifact folder is not the dev attempt's dirtiness when that folder is excluded — but counts otherwise.""" @@ -1138,6 +1181,27 @@ def test_verify_dev_proof_of_work_gate_still_fails_open_on_a_refused_probe(proje assert out.park_proof_skipped is False and out.park_zero_diff is None +def test_verify_dev_proof_gate_fails_open_on_untracked_enumeration_fault(project, monkeypatch): + """A clean tracked diff followed by a failed untracked enumeration is an + unanswerable proof, not an escalation. The ordinary gate keeps its established + fail-open policy and accepts the attempt. + + Ablation: delete `_changes_since`'s `except GitError: return None` and this + changes from a passing outcome to a raised/escalated Git fault. + """ + task, sp = _residue_free(project, status="done", sprint="done") + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + + out = verify.verify_dev(task, project, dev_result(sp), review_enabled=False) + + assert out.ok + assert out.park_proof_skipped is False and out.park_zero_diff is None + + def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(project): """The SECOND documented cause of `zero_diff: null`, and the one a reader is likeliest to mistake for the first: not a git fault, but an attempt carrying @@ -3102,9 +3166,15 @@ def test_stories_relpaths_is_empty_when_resolution_is_uncertain(project, monkeyp def test_verify_dev_stories_plan_halt_expects_ready_for_dev(project): # plan-halt leg: the spec is at ready-for-dev (the plan), not done, and there # is NO code change — proof-of-work is skipped and the plan spec is recorded. + # Post-baseline stories bookkeeping is excluded from the observation too. spec_folder = project.planning_artifacts / "epic-a" task = make_stories_task(project, "1") sp = write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + (spec_folder / "stories.yaml").write_text("stories: []\n", encoding="utf-8") + write_story(spec_folder, "2", "sibling", "ready-for-dev", task.baseline_commit) + (project.repo_root / "engine-owned.txt").write_text( + "orchestrator bookkeeping\n", encoding="utf-8" + ) out = verify.verify_dev_stories( task, project, @@ -3112,9 +3182,66 @@ def test_verify_dev_stories_plan_halt_expects_ready_for_dev(project): spec_folder=spec_folder, review_enabled=False, plan_halt=True, + engine_written=("engine-owned.txt",), ) assert out.ok # no code change required for a plan assert task.spec_file == str(sp) + assert out.plan_halt_zero_diff is True + + +def test_verify_dev_stories_plan_halt_observes_a_non_zero_diff(project): + """The skipped gate is observed even when it would have passed: the marker + still authorizes the halt, while the independent observation reports residue. + + Ablation: replace the skipped-proof observation with a constant clean answer + and this fails without changing plan-halt acceptance. + """ + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + (project.repo_root / "src.txt").write_text("changed during planning\n", encoding="utf-8") + + out = verify.verify_dev_stories( + task, + project, + {"workflow": "auto-dev", "plan_halt": True}, + spec_folder=spec_folder, + review_enabled=False, + plan_halt=True, + ) + + assert out.ok + assert out.plan_halt_zero_diff is False + + +def test_verify_dev_stories_plan_halt_untracked_fault_is_unknown(project, monkeypatch): + """A bookkeeping probe fault cannot reject an otherwise valid plan halt; the + returned observation is unknown so the engine can journal JSON null. + + Ablation: delete `_changes_since`'s untracked `GitError` normalization and + this still passes only if the outer skipped-proof observer catches it; remove + that catch as well and the fault escapes. The helper-level sibling pins the + normalization itself. + """ + spec_folder = project.planning_artifacts / "epic-a" + task = make_stories_task(project, "1") + write_story(spec_folder, "1", "x", "ready-for-dev", task.baseline_commit) + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + out = verify.verify_dev_stories( + task, + project, + {"workflow": "auto-dev", "plan_halt": True}, + spec_folder=spec_folder, + review_enabled=False, + plan_halt=True, + ) + + assert out.ok + assert out.plan_halt_zero_diff is None def test_verify_dev_stories_plan_halt_rejects_non_plan_status(project): @@ -3148,6 +3275,7 @@ def test_verify_dev_stories_plan_halt_requires_marker(project): plan_halt=True, ) assert not out.ok and "no plan_halt marker" in out.reason + assert out.plan_halt_zero_diff is None def test_plan_halt_status_matches_devcontract(): @@ -6312,6 +6440,31 @@ def test_changes_since_reports_a_git_refusal_and_has_changes_since_collapses_it( assert verify.has_changes_since(project.project, head) is False +@pytest.mark.parametrize("literal_path", [None, "src.txt"], ids=["whole-tree", "literal-path"]) +def test_changes_since_reports_untracked_enumeration_fault_as_unknown( + project, monkeypatch, literal_path +): + """Tracked diff and untracked enumeration are halves of one tri-state answer. + Once the clean tracked half succeeds, a `GitError` from the untracked half is + unknown too; both public boolean boundaries then fail open to changed. + + Ablation: delete `_changes_since`'s `except GitError: return None` and both + rows raise instead of reaching either fail-open boundary. + """ + baseline = verify.rev_parse_head(project.project) + + def boom(_repo): + raise verify.GitError("untracked enumeration failed") + + monkeypatch.setattr(verify, "untracked_files", boom) + + assert verify._changes_since(project.project, baseline, literal_path=literal_path) is None + if literal_path is None: + assert verify.has_changes_since(project.project, baseline) is True + else: + assert verify.path_changed_since(project.project, baseline, literal_path) is True + + def test_has_changes_since_subtracts_baseline_untracked(project): """Untracked files already on disk when the baseline snapshot was taken are not this session's work. `None` deliberately keeps counting all of them — From b9207ca23f1af0fb59034d8fc3acdaa6c8cf9362 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 1 Sep 2026 10:09:12 -0700 Subject: [PATCH 13/35] sweep dw2-document-optional-baseline-claim: DW-51 via bmad-loop --- CHANGELOG.md | 4 ++++ src/bmad_loop/verify.py | 15 ++++++++++----- tests/test_verify.py | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e390e773..f89f2c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,10 @@ breaking changes may land in a minor release. ### Fixed +- **Document spec baseline frontmatter as an optional `verify_dev` attestation** + (DW-51), with no-claim acceptance and no-work refusal regressions anchored to + the orchestrator-recorded baseline. + - **Restore the original spec when a TUI replan cannot strip its stale result** (DW-33), keeping the status reset and result removal atomic before resume. diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 5f79b27d..b4785049 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -3744,11 +3744,16 @@ def verify_dev( Checks the claimed spec exists, carries the fixed ``auto-dev`` workflow tag, sits at the expected status (``in-review`` when a separate review session - follows, ``done`` when review is disabled), records a baseline matching the - orchestrator's, has produced changes since that baseline (every leg but the - park — see ``operator_park`` below), and that the story's sprint-status was - advanced to the matching stage. Returns a retryable VerifyOutcome on any - mismatch, escalates on git failure, passes otherwise. + follows, ``done`` when review is disabled), has produced changes (every leg + but the park — see ``operator_park`` below), and that the story's + sprint-status was advanced to the matching stage. Returns a retryable + VerifyOutcome on any mismatch, escalates on git failure, passes otherwise. + + The spec's baseline frontmatter is an OPTIONAL attestation: a usable + ``baseline_revision`` or legacy ``baseline_commit`` claim is checked against + the accepted orchestrator baseline, while absence of both claims is accepted. + Absence does not waive proof-of-work; without a claim, changes are still + measured from the orchestrator-recorded ``task.baseline_commit``. ``operator_park`` (``[operator] enabled``, engine-supplied) adds one more accepted spec/sprint pair: ``(awaiting-operator, awaiting-operator)``, the diff --git a/tests/test_verify.py b/tests/test_verify.py index 07f9f4df..f6d0ac92 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -6667,6 +6667,25 @@ def test_verify_dev_baseline_gate_reads_the_skills_baseline_revision_key(project assert verify.verify_dev(task, project, dev_result(sp)).ok +def test_verify_dev_accepts_an_optional_baseline_claim_but_still_requires_work(project): + """A missing baseline claim is valid, but it does not disable proof-of-work: + the orchestrator-owned task baseline remains the measurement authority.""" + write_sprint(project, {"1-1-a": "review"}) + task = make_task(project) + sp = spec_path(project, "1-1-a") + write_spec(sp, "in-review", OMIT) + body = sp.read_text() + assert "baseline_revision:" not in body and "baseline_commit:" not in body + + no_work = verify.verify_dev(task, project, dev_result(sp)) + assert not no_work.ok and "no changes in worktree since baseline commit" in no_work.reason + + (project.project / "src.txt").write_text("real work\n") + git(project.project, "add", "src.txt") + git(project.project, "commit", "-q", "-m", "real work after task baseline") + assert verify.verify_dev(task, project, dev_result(sp)).ok + + def test_verify_dev_baseline_gate_prefers_the_fresh_revision_over_a_stale_legacy_key(project): """#716: a spec carrying BOTH keys is what `runs.rearm_escalation` produces — it inserts `baseline_revision` and never removes a pre-existing From 7ba1bda069e1ee7ede2ad2502eeee500ff798311 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 11:34:20 -0700 Subject: [PATCH 14/35] test(generic): bump marker mtimes by a tick the filesystem can record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The park-marker rows rewrite the spec and advance its mtime by one nanosecond above the launch capture. ext4 stores that; NTFS keeps ~100ns ticks, so on Windows the increment rounds away, the rewritten marker does not read as newer than the launch floor, the readback fails closed and `_result_json` returns None — nine rows asserting `rj is not None`. Bump by a whole second instead, which clears every granularity these tests can land on and matches the bump this file already uses elsewhere. The negative rows still distinguish what they did: the full file passes unchanged on a filesystem that did record the nanosecond. --- tests/test_generic_tmux.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index ba2dc70f..1a457deb 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -34,6 +34,14 @@ from bmad_loop.policy import LimitsPolicy, NotifyPolicy, Policy from bmad_loop.signals import HookEvent +# A bump the filesystem can actually record. NTFS stores ~100ns ticks (FAT, 2s), +# so a 1ns increment rounds away on Windows and the rewritten marker never reads +# as newer than the launch capture — the readback then fails closed and +# `_result_json` returns None. ext4 keeps the nanosecond, which is why a 1ns bump +# only ever reddened the Windows legs. A whole second clears every granularity +# these tests can meet, and matches the bump already used elsewhere in this file. +_MTIME_TICK_NS = 10**9 + HAVE_TMUX = sys.platform != "win32" and shutil.which("tmux") is not None # The read-back decodes artifacts as UTF-8. A spec truncated mid-write (the CLI was @@ -4782,7 +4790,7 @@ def test_prelaunch_park_marker_survives_unrelated_touch_without_asserting_owners # Rewrite only the body before the retained marker, then lift whole-file mtime # past launch. The old implementation treated that mtime as marker provenance. ours.write_text(parked.replace("# Story", "# Story\n\nUnrelated post-launch touch.")) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["status"] == "awaiting-operator" @@ -4808,7 +4816,7 @@ def test_new_session_marker_asserts_park_after_launch_capture(tmp_path, monkeypa "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" ) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["park_asserted"] is True @@ -4845,7 +4853,7 @@ def launch(_adapter, _spec): "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" ) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) return _dev_handle(launched_ns=launch_floor) monkeypatch.setattr(generic.GenericAdapter, "start_session", launch) @@ -4878,7 +4886,7 @@ def test_in_place_marker_rewrite_asserts_session_ownership(tmp_path, monkeypatch "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" ) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["park_asserted"] is True @@ -4904,7 +4912,7 @@ def test_deleting_older_marker_does_not_assert_retained_last_marker(tmp_path, mo handle = adapter.start_session(spec) ours.write_text(prefix + final_marker) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["park_asserted"] is False @@ -4930,7 +4938,7 @@ def test_moved_launch_marker_does_not_assert_session_ownership(tmp_path, monkeyp handle = adapter.start_session(spec) old.rename(ours) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["park_asserted"] is False @@ -4955,7 +4963,7 @@ def test_unrelated_unreadable_markdown_does_not_poison_new_spec_capture(tmp_path "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" ) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["park_asserted"] is True @@ -4980,7 +4988,7 @@ def test_unreadable_launch_spec_fails_closed_after_becoming_readable(tmp_path, m "operator_actions:\n - publish the TXT record\n---\n\n# Story\n\n" "## Auto Run Result\n\nStatus: awaiting-operator\nParked.\n" ) - os.utime(ours, ns=(launch_floor + 1, launch_floor + 1)) + os.utime(ours, ns=(launch_floor + _MTIME_TICK_NS, launch_floor + _MTIME_TICK_NS)) rj = adapter._result_json(handle, spec, wait=False) assert rj is not None and rj["park_asserted"] is False From 89bf2e3dbd0c583a6ead3349afc046d531fb535e Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 15:39:59 -0700 Subject: [PATCH 15/35] test(resolve,cli): carry build_context's third member through this slice's rows The unreadable-artifact count added below this slice widens build_context to a triple; the rows added here still unpacked a pair. --- tests/test_cli.py | 10 +++++----- tests/test_resolve.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9d6ca38a..3ae9e29b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3267,7 +3267,7 @@ def test_resolve_warns_about_divergent_roots_before_the_session(tmp_path, monkey def fake_context(*args, **kwargs): context_roots["project"] = kwargs["project_root"] context_roots["code"] = kwargs["code_root"] - return None, 0 + return None, 0, 0 monkeypatch.setattr(resolve, "build_context", fake_context) @@ -3307,7 +3307,7 @@ def test_resolve_context_uses_live_project_after_project_rename(tmp_path, monkey def fake_context(*args, **kwargs): seen["project"] = kwargs["project_root"] seen["code"] = kwargs["code_root"] - return None, 0 + return None, 0, 0 monkeypatch.setattr(resolve, "build_context", fake_context) monkeypatch.setattr( @@ -3335,7 +3335,7 @@ def test_resolve_context_uses_live_configured_code_root(project, monkeypatch, ca def fake_context(*args, **kwargs): seen["project"] = kwargs["project_root"] seen["code"] = kwargs["code_root"] - return None, 0 + return None, 0, 0 monkeypatch.setattr(resolve, "build_context", fake_context) @@ -3372,7 +3372,7 @@ def test_resolve_refuses_to_rearm_when_code_root_changes_during_session( moved = project.project / "code-after-session" moved.mkdir() monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0, 0)) def move_code_root_during_session(*args, **kwargs): _configure_repo_root(project, moved) @@ -3408,7 +3408,7 @@ def test_resolve_same_root_launches_without_a_divergence_warning(tmp_path, monke _escalated_run(tmp_path, "r1") # empty repo_root: legacy fallback to project launched: list[Path] = [] monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {"dev": object()}) - monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0)) + monkeypatch.setattr(resolve, "build_context", lambda *a, **k: (None, 0, 0)) monkeypatch.setattr( resolve, "run_session", diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 505a9bf2..7fe14608 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -597,7 +597,7 @@ def test_build_context_prefers_supplied_live_roots_over_recorded_launch_roots(tm repo_root=recorded_code, ) - path, _withheld = resolve.build_context( + path, _withheld, _unreadable = resolve.build_context( state, run_dir, "6-4-cli-list-command", @@ -633,7 +633,7 @@ def test_build_context_rebases_project_owned_artifacts_after_project_rename(tmp_ spec_folder="epic-1", ) - path, _withheld = resolve.build_context( + path, _withheld, _unreadable = resolve.build_context( state, run_dir, key, From 520118b909b6e4c9298b50e1c2a42794b74e9e8a Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 15:58:28 -0700 Subject: [PATCH 16/35] fix(resolve,engine): aim the re-arm's spec writes and the defer's carry at the live tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sites where a persisted value stood in for the tree actually in hand. resolve: `build_context` publishes a `spec_file` rebased onto the LIVE CLI project, because `state.project` is the launch-time spelling and nothing re-stamps it — there is no project counterpart to `restamp_code_root`. `rearm_escalation` still resolved `task_spec_path` against the recorded project, so after a project move the agent edited one file and the re-arm flipped another; in the real shape the recorded tree is gone, the flip silently no-ops, and the re-drive wedges on the escalated attempt's status with the escalation spent. The rebase helper moves to `runs` (both sides need one answer, and `resolve` already imports it), and `rearm_escalation` takes the live project the way `build_context` does. Scope is the write target and its confinement root, which move together or the writers silently drop the confined arm. engine: `_finish_inflight` reopens a recorded mount regardless of live policy, but `_defer` gated `_carry_harvested_deferrals` on `self._isolated` alone. On a `worktree -> none` flip a deferring continuation reset the main repo, skipped the carry, and lost the unit's harvested findings when `_integrate_unit` deleted the mount. The gate is now the same pair `_run_story` already uses. --- CHANGELOG.md | 9 ++- src/bmad_loop/cli.py | 5 ++ src/bmad_loop/engine.py | 25 +++++- src/bmad_loop/resolve.py | 27 ++----- src/bmad_loop/runs.py | 91 ++++++++++++++++++++-- src/bmad_loop/tui/app.py | 6 ++ tests/test_cli.py | 129 ++++++++++++++++++++++++++++--- tests/test_engine_worktree.py | 114 +++++++++++++++++++++++++++ tests/test_runs.py | 141 ++++++++++++++++++++++++++++++++++ tests/test_tui_app.py | 35 +++++---- 10 files changed, 529 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89f2c1c..fefac3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ breaking changes may land in a minor release. - **Interactive resolve context names both the BMAD project root and the run's code root.** `bmad-loop resolve` warns before a divergent-root session launches, while keeping the session project-rooted and directing code fixes and commits to the code - root. + root. The re-arm writes the same tree the context published: `runs.rearm_escalation` + takes the live project root from `resolve` and the TUI, so a moved project no longer + has the agent edit one copy of the spec while the re-arm flips another. - **A failed re-arm commits probe now journals `rearm-commits-probe-failed`** (DW-81). The warn-only probe that lists the commits an abandoned attempt left below the re-drive's @@ -324,6 +326,11 @@ argument` and failed the story; a `ts` key did not raise and instead silently re raw bundle story keys because the value fell through to `scrub_json` — the identity on a list of identifier-shaped strings — while the singular `story_key` beside it was already aliased; a non-list value on any key-list field now fails closed instead of taking that same path. +- Carry an isolated unit's harvested ledger findings when a defer runs under a + recorded mount after `[scm] isolation` was edited to `none` mid-pause. Resume reopens + the mount regardless of live policy, but the defer routed on live policy alone: it + reset the main repo, skipped the carry, and the findings died with the deleted + worktree. `_defer` now routes on the tree in hand, matching `_run_story`. - Stop a second resolve cycle re-presenting escalations the human already answered (DW-11). Only a re-arm that accepted a `resolution.json` watermarks the session trail; later cycles show what came after it and print how many were withheld. A diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 29c34a8c..00943c05 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3316,6 +3316,11 @@ def cmd_resolve(args: argparse.Namespace) -> int: restore_patch=restore_patch, isolated_redrive=pol.scm.isolation == "worktree", resolution_recorded=resolution_recorded, + # The tree this invocation is acting in, which is also the tree + # `build_context` published a `spec_file` from. `state.project` is where the + # run was LAUNCHED and nothing re-stamps it, so a moved project would have + # the agent edit one file and the re-arm flip another. + project_root=project, ) except runs.RearmError as e: print(f"error: {e}", file=sys.stderr) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 9766efc0..36aa6982 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -6742,7 +6742,30 @@ def _record_defer(self, task: StoryTask, reason: str, note: str | None = None) - def _defer(self, task: StoryTask, reason: str) -> None: task.defer_reason = reason - if self._isolated: + # `self._isolated` is LIVE policy (`_worktree_flow.isolated` reads + # `scm.isolation`), and it is not the whole question. `_finish_inflight` + # decides its arms on `mounted = bool(task.worktree_path)` and reopens a + # recorded mount REGARDLESS of live policy — an accepted continuation owns + # the verified work in that tree, so it finishes there and merges. A run + # flipped `"worktree" -> "none"` while paused therefore reaches this method + # with the workspace swapped onto a mount, while `_isolated` answers False: + # the in-place arm below then rolls the MAIN repo back and never carries the + # harvest, and `_integrate_unit` deletes the mount on the way out. The + # findings that unit harvested are gone, and a ledger row nothing will ever + # re-file is invisible to every later sweep. + # + # `or task.worktree_path` and not `task.worktree_path` alone: isolation can be + # live with no mount yet recorded (a defer before `run_isolated` stores the + # path), and that shape must keep the isolated arm rather than fall through to + # a main-repo reset. A restart in the other direction never reaches here with a + # stale path — `_release_orphaned_mount` clears it before replacement work + # begins, which is what keeps this test about the tree actually in hand. + # + # The same pair, spelled the same way, already decides `_run_story`'s arms. The + # two are one question — does this task's work live in a mount — and a defer + # that answered it differently from the dispatch that produced the work is the + # shape this fixes. + if self._isolated or task.worktree_path: # the failed work lives in the unit's worktree; the diff is captured # and the worktree kept/dropped by _integrate_unit. Don't touch the # tree here (no reset into the main repo — there's nothing to undo). diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index 3bcb4edf..f2706361 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -28,6 +28,7 @@ from .model import RunState, StoryTask from .platform_util import safe_segment from .runs import ( + rebase_recorded_project_path, redrive_base_ref, spec_reaches_the_redrive, task_spec_path, @@ -46,36 +47,18 @@ def context_path(run_dir: Path, story_key: str) -> Path: return _story_dir(run_dir, story_key) / "context.json" -def _rebase_recorded_project_path(path: Path, state: RunState, project_root: Path) -> Path: - """Move a project-owned persisted path with a renamed project, lexically. - - Run state intentionally keeps the launch-time project string, while the resolve - session runs from the live CLI project. Paths outside the recorded project are - shared/external and remain untouched. This is spelling arithmetic only: do not - introduce filesystem canonicalization at the context serialization boundary. - """ - recorded_project = Path(state.project) - if project_root == recorded_project: - return path - try: - relative = path.relative_to(recorded_project) - except ValueError: - return path - return project_root / relative - - def _context_stories_root(task: StoryTask | None, state: RunState, project_root: Path) -> Path: """Resolve the stories tree against the live project after a project move.""" if task is not None and task.worktree_path: recorded_mount = Path(task.worktree_path) - live_mount = _rebase_recorded_project_path(recorded_mount, state, project_root) + live_mount = rebase_recorded_project_path(recorded_mount, state, project_root) if live_mount != recorded_mount: try: if live_mount.is_dir(): return live_mount except OSError: pass - return _rebase_recorded_project_path(task_stories_root(task, state), state, project_root) + return rebase_recorded_project_path(task_stories_root(task, state), state, project_root) def resolution_path(run_dir: Path, story_key: str) -> Path: @@ -324,7 +307,7 @@ def build_context( current_project_root = project_root if project_root is not None else Path(state.project) current_code_root = code_root if code_root is not None else state.code_root context_spec_path = ( - _rebase_recorded_project_path(task_spec_path(task, state), state, current_project_root) + rebase_recorded_project_path(task_spec_path(task, state), state, current_project_root) if task and task.spec_file else None ) @@ -376,7 +359,7 @@ def build_context( # from the project root, where the main checkout carries the same # implementation-artifacts-relative path — the raw value would name the wrong # tree's copy. `task_spec_path` provides the persisted anchor; when the whole - # project moved, `_rebase_recorded_project_path` carries that project-owned + # project moved, `rebase_recorded_project_path` carries that project-owned # spelling onto the live session root without resolving it through the OS. # as_posix() for the same reason `resolution_path` below uses it — the # context contract is one string on every OS — and because the value this diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index a85264f2..ec5e0684 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3001,6 +3001,35 @@ def validate_restore_latch( return None +def rebase_recorded_project_path(path: Path, state: RunState, project_root: Path) -> Path: + """Move a project-owned persisted path with a renamed project, lexically. + + Run state intentionally keeps the LAUNCH-TIME project string — nothing re-stamps + `state.project` the way `restamp_code_root` re-stamps the code root — while both + surfaces that resolve an escalation run from the LIVE CLI project. Paths outside + the recorded project are shared/external and remain untouched. This is spelling + arithmetic only: do not introduce filesystem canonicalization here, at either + caller's boundary. + + Lives in `runs` rather than in `resolve` because BOTH sides of one gesture need + the identical answer, and `resolve` already imports this module (the reverse is a + cycle). `resolve.build_context` hands the agent a `spec_file` to edit; + `rearm_escalation` flips the status of the file it then re-drives from. Rebasing + one and not the other is not a cosmetic split — after a project move the agent + edits the live copy while the re-arm writes a path under a directory that no + longer exists, so the flip silently no-ops and the re-drive wedges on the + escalated attempt's status. + """ + recorded_project = Path(state.project) + if project_root == recorded_project: + return path + try: + relative = path.relative_to(recorded_project) + except ValueError: + return path + return project_root / relative + + def task_spec_path(task: StoryTask, state: RunState) -> Path: """The persisted-task spec anchor, re-based on the tree it was recorded relative to. @@ -3165,6 +3194,25 @@ def task_stories_root(task: StoryTask | None, state: RunState) -> Path: return mount +def _live_spec_path(task: StoryTask, state: RunState, project_root: Path) -> Path: + """`task_spec_path` carried onto the tree the caller is acting in. + + The pair below is the WRITE side of `rearm_escalation`: the file it flips and + re-stamps, and the root every writer confines that edit to. They move together + because `task_spec_root` is the confinement claim about the very path + `task_spec_path` produces — rebasing one alone would hand the writers a path + outside their own root, and all four of them answer that by silently dropping to + the unconfined arm (see `task_spec_root` for why that matters). + """ + return rebase_recorded_project_path(task_spec_path(task, state), state, project_root) + + +def _live_spec_root(task: StoryTask, state: RunState, project_root: Path) -> Path: + """`task_spec_root` carried onto the tree the caller is acting in — the confine + root for the path `_live_spec_path` names. See there.""" + return rebase_recorded_project_path(task_spec_root(task, state), state, project_root) + + def _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: """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. @@ -3902,6 +3950,7 @@ def rearm_escalation( restore_patch: str | None = None, isolated_redrive: bool, resolution_recorded: bool, + project_root: Path | None = None, ) -> str: """Re-arm an escalation-paused story so the next resume re-drives it. @@ -3987,6 +4036,31 @@ def rearm_escalation( and both non-interactive callers know by construction that no session ran. Do not unlink the marker here either — the TUI's Re-arm button is gated on its presence. + `project_root` is the LIVE CLI project, and it exists so this function writes the + file `resolve.build_context` told the agent to edit. `state.project` is the + LAUNCH-TIME project and nothing re-stamps it (unlike `state.code_root`, which + `restamp_code_root` re-points just before both callers reach here), so after a + project move `task_spec_path` resolves under a directory that no longer exists: + the status flip and the baseline re-stamp both silently no-op — every writer + answers an absent path with `False` rather than an exception — while the agent's + correction sits in the live tree the re-drive actually reads. The re-drive then + wedges on the escalated attempt's status and the escalation is spent. + `build_context` already rebases the `spec_file` it publishes, through the very + helper used here, so the two sides now name one file. + + `None` means "the project this run recorded", which is byte-for-byte today's + behavior and the correct answer for every run whose project has not moved — the + default is safe in a way `isolated_redrive`'s would not be, because it does not + stand in for a fact only the caller holds; it names the same tree the caller + would pass. It is optional for that reason and to match `build_context`'s own + signature, which takes the live roots the same way. + + Scope is the WRITE TARGET and its confinement root, not the reachability verdicts + beside them. Those read `task.spec_file`, which is relative for every run that + records one under the project, and answer without consulting `state.project` at + all; the two that can consult it degrade toward WARNING on an unresolvable path, + which is the safe direction and already their documented contract. + The generation bump stays UNCONDITIONAL beside the gated stamp: it answers session-id reuse (#705), which an abandoned attempt needs exactly as much as a resolved one. @@ -4019,6 +4093,11 @@ def rearm_escalation( if err is not None: raise RearmError(err) + # The tree this gesture is ACTING IN, for the paths below that WRITE. `state.project` + # is where the run was launched and nothing re-stamps it, so after a project move it + # names a directory that is no longer there. See the `project_root` note above. + live_project = project_root if project_root is not None else Path(state.project) + journal = Journal(run_dir) # Read before the unconditional overwrite below: they describe the restore # attempt this re-arm is abandoning, and the residue block needs both. @@ -4095,7 +4174,7 @@ def rearm_escalation( # proof the tree is untouched. try: if task.spec_file: - spec_path = task_spec_path(task, state) + spec_path = _live_spec_path(task, state, live_project) # 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 @@ -4303,7 +4382,9 @@ 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=_live_spec_root(task, state, live_project), ) # `set_frontmatter_status` answers "nothing to change" with `False` # for FOUR causes, not three — its own docstring lists them: no file, @@ -4410,7 +4491,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=_live_spec_root(task, state, live_project) ) except verify.FrontmatterWriteError as e: # The spec reads fine but carries `status:` in a shape no line @@ -4578,7 +4659,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 = _live_spec_path(task, state, live_project) 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 @@ -4613,7 +4694,7 @@ def rearm_escalation( spec_path, "baseline_revision", task.baseline_commit, - confine_root=task_spec_root(task, state), + confine_root=_live_spec_root(task, state, live_project), ) 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 c5277f64..f1d50021 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -972,6 +972,12 @@ def _do_rearm( run_dir, story_key, isolated_redrive=isolation == "worktree", + # The live project this dashboard was launched against, matching + # `cli.cmd_resolve`: the re-arm's spec writes have to land in the tree + # the operator is looking at, not in the one the run recorded at launch. + # `self.project` rather than `paths.project` because the `load_paths` + # arm above may have degraded without binding `paths` at all. + project_root=self.project, # DW-11. This gesture runs no resolve session, so it accepted nothing: # the escalation watermark must not advance. A `resolution.json` on # disk is NOT evidence to the contrary here — `_restore_recorded` diff --git a/tests/test_cli.py b/tests/test_cli.py index 3ae9e29b..5055d45c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2575,6 +2575,47 @@ def test_resolve_no_interactive_rearms_and_resumes(tmp_path, monkeypatch, capsys assert "ready-for-dev" in spec.read_text() +def test_resolve_rearms_the_spec_in_the_project_it_was_invoked_on(tmp_path, monkeypatch): + """`--project` names the tree this invocation acts in, and the re-arm's spec writes + have to land there. + + `state.project` is the LAUNCH-TIME project and nothing re-stamps it — `resolve` + aims the CODE root through `runs.restamp_code_root` before it re-arms, and there is + no project counterpart. So after a project move `task_spec_path` resolved under a + directory that is no longer the one the operator (and `build_context`, which + rebases the `spec_file` it publishes) is working in. In the real shape the old tree + is gone and the flip silently no-ops, so the re-drive wedges on the escalated + attempt's status with the escalation already spent. + + Both trees carry a copy on purpose: with only the live one present, "the live spec + flipped" could not tell a correctly-aimed write from a wrongly-aimed one that + happened to fall through to the same path. + + Ablation: drop `project_root=project` from `cmd_resolve`'s `rearm_escalation` call + and this reddens on both assertions at once.""" + from bmad_loop.journal import load_state, save_state + + live_project = tmp_path / "project-after-rename" + recorded_project = tmp_path / "project-before-rename" + for root in (live_project, recorded_project): + root.mkdir() + (root / "spec.md").write_text("---\nstatus: in-review\n---\n", encoding="utf-8") + recorded_spec = recorded_project / "spec.md" + live_spec = live_project / "spec.md" + run_dir = _escalated_run(live_project, "r1", spec_file=str(recorded_spec)) + state = load_state(run_dir) + state.project = str(recorded_project) # the launch-time spelling state keeps + save_state(run_dir, state) + + rc = cli.main( + ["resolve", "--project", str(live_project), "r1", "--no-interactive", "--no-resume"] + ) + + assert rc == 0 + assert "ready-for-dev" in live_spec.read_text(encoding="utf-8") + assert "ready-for-dev" not in recorded_spec.read_text(encoding="utf-8") + + # ------------------------------------ resolve aims the code root before it re-arms # `_resume_paused_run` re-stamps the persisted code root because the engine it arms @@ -2625,7 +2666,13 @@ def test_resolve_restamps_the_code_root_before_it_rearms(project, monkeypatch, c seen: list = [] def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): seen.append(load_state(rd).code_root) return key @@ -2751,7 +2798,13 @@ def test_resolve_echoes_this_rearms_stale_restore_events(tmp_path, monkeypatch, Journal(run_dir).append("stale-restore-excluded", story_key="s1", files=["FROM-LAST-TIME.txt"]) def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): journal = Journal(rd) journal.append("stale-restore-excluded", story_key=key, patch="a.patch", files=["new.txt"]) @@ -2793,7 +2846,13 @@ def test_resolve_echoes_the_rearm_baseline_records(tmp_path, monkeypatch, capsys _escalated_run(tmp_path, "r1") def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): journal = Journal(rd) journal.append( @@ -2846,7 +2905,13 @@ def test_resolve_restamp_echo_warns_on_both_legs(tmp_path, monkeypatch, capsys): def rearm_with(restore: bool): def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): Journal(rd).append( "rearm-baseline-restamped", @@ -2902,7 +2967,13 @@ def test_resolve_survives_a_corrupt_journal(tmp_path, monkeypatch, capsys, outco from bmad_loop.journal import JOURNAL_FILE def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): if outcome == "rearm-error": raise runs.RearmError("cannot re-open story spec /x/spec.md") @@ -2940,7 +3011,13 @@ def test_resolve_echoes_a_skipped_restamp(tmp_path, monkeypatch, capsys): _escalated_run(tmp_path, "r1") def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): Journal(rd).append( "rearm-baseline-restamp-skipped", @@ -3007,7 +3084,13 @@ def test_resolve_echoes_the_residue_even_when_the_rearm_aborts(tmp_path, monkeyp _escalated_run(tmp_path, "r1") def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): # journalled first, exactly as the real residue pass is ordered Journal(rd).append( @@ -3077,7 +3160,13 @@ def test_resolve_holds_the_resume_when_the_correction_cannot_reach_the_redrive( def rearm_journalling(kind, **fields): def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): Journal(rd).append(kind, story_key=key, **fields) return key @@ -3146,7 +3235,13 @@ def test_resolve_appends_the_next_step_imperative(tmp_path, monkeypatch, capsys) _escalated_run(tmp_path, "r1") def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): journal = Journal(rd) journal.append( # table row with a next_step @@ -3197,7 +3292,13 @@ def test_resolve_echoes_the_commits_probe_failure(tmp_path, monkeypatch, capsys) baseline = "b" * 40 def fake_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): Journal(rd).append( "rearm-commits-probe-failed", @@ -4338,7 +4439,13 @@ def fake_session(adapter, project, rd, story_key, *, generation, model=""): seen: list[bool] = [] def recording_rearm( - rd, key, *, restore_patch=None, isolated_redrive=False, resolution_recorded=False + rd, + key, + *, + restore_patch=None, + isolated_redrive=False, + resolution_recorded=False, + project_root=None, ): seen.append(isolated_redrive) return key diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 4a591048..99d5b66c 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1153,6 +1153,120 @@ def test_carry_harvest_dedupe_stays_status_agnostic(project): assert task.harvest_carry_commit_pending is False # nothing novel, so no latch +def _in_place_policy(): + """`wt_policy`'s mirror: the live mode a mid-pause `isolation = "none"` edit + leaves behind, with everything else identical so the two rows differ in one + field only.""" + return Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(isolation="none"), + limits=LimitsPolicy(), + ) + + +def test_defer_under_a_recorded_mount_carries_the_harvest_after_an_isolation_flip( + project, monkeypatch +): + """`_defer` routes on the tree in hand, not on live policy alone. + + `_finish_inflight` picks its arms on `mounted = bool(task.worktree_path)` and + reopens a recorded mount REGARDLESS of live policy — an accepted continuation owns + the verified work in that tree. So a run whose `scm.isolation` was edited + `"worktree" -> "none"` while it was paused re-enters this decision with the + workspace swapped onto a mount while `self._isolated` answers False. Gated on + policy alone, the in-place arm then reset the MAIN repo and skipped + `_carry_harvested_deferrals` entirely, and `_integrate_unit` deleted the mount on + the way out: the unit's harvested findings had no durable home left, and a ledger + row nothing will re-file is invisible to every later sweep. + + `rolled` is the positive control and the discriminator — this row would also pass + on an engine that simply did nothing, so it pins WHICH arm ran, not merely that the + harvest survived. + + Ablation: restore the bare `if self._isolated:` gate and this reddens on an empty + ledger, with the main-repo rollback recorded instead.""" + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=_in_place_policy()) + assert engine._isolated is False # MEASURED: live policy really says in place + task = StoryTask( + story_key="1-1-a", + epic=1, + phase=Phase.REVIEW_VERIFY, # the phase the budget-exhausted defer fires from + worktree_path=str(project.project / ".bmad-loop" / "runs" / "test-run" / "wt" / "1-1-a"), + baseline_commit=rev_parse_head(project.project), + harvested_deferrals=[_harvest_record()], + ) + engine.state.tasks[task.story_key] = task + rolled: list[str] = [] + monkeypatch.setattr(engine, "_rollback_or_pause", lambda t: rolled.append(t.story_key)) + + engine._defer(task, "review did not converge within budget") + + assert [entry.title for entry in _main_harvest_entries(project)] == [_HARVEST_CARRY["summary"]] + assert [event["dw_ids"] for event in _harvest_carry_events(engine)] == [["DW-1"]] + assert rolled == [] # no reset into the main repo under a live mount + assert task.phase == Phase.DEFERRED + + +def test_defer_with_no_recorded_mount_still_takes_the_in_place_arm(project, monkeypatch): + """The other half of the widened gate. `or task.worktree_path` must not swallow the + ordinary in-place defer, whose whole job is the rollback the isolated arm skips — + an in-place task carries `""`, and nothing else about this row differs from its + sibling above. + + Ablation: widen the gate to an unconditional `True` (or drop the `worktree_path` + truthiness test so `Path("")` logic creeps back in) and this reddens on an + un-rolled-back tree and a harvest carried where none should be.""" + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, [], policy=_in_place_policy()) + task = StoryTask( + story_key="1-1-a", + epic=1, + phase=Phase.REVIEW_VERIFY, + baseline_commit=rev_parse_head(project.project), + harvested_deferrals=[_harvest_record()], + ) + assert task.worktree_path == "" # MEASURED: the discriminator is the empty one + engine.state.tasks[task.story_key] = task + rolled: list[str] = [] + monkeypatch.setattr(engine, "_rollback_or_pause", lambda t: rolled.append(t.story_key)) + + engine._defer(task, "review did not converge within budget") + + assert rolled == ["1-1-a"] # the in-place reset DID run + assert _harvest_carry_events(engine) == [] # and no unit ledger was carried + assert task.phase == Phase.DEFERRED + + +def test_defer_under_live_isolation_with_no_mount_yet_keeps_the_isolated_arm(project, monkeypatch): + """Isolation can be LIVE with no mount recorded — a defer reached before + `run_isolated` stores the path. That shape must keep the isolated arm rather than + fall through to a main-repo reset, which is why the gate is + `self._isolated OR worktree_path` and not the path alone. + + Ablation: narrow the gate to `if task.worktree_path:` and this reddens on a + rollback that should never have run.""" + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, []) # wt_policy: isolation IS live + assert engine._isolated is True + task = StoryTask( + story_key="1-1-a", + epic=1, + phase=Phase.REVIEW_VERIFY, + baseline_commit=rev_parse_head(project.project), + harvested_deferrals=[_harvest_record()], + ) + engine.state.tasks[task.story_key] = task + rolled: list[str] = [] + monkeypatch.setattr(engine, "_rollback_or_pause", lambda t: rolled.append(t.story_key)) + + engine._defer(task, "review did not converge within budget") + + assert rolled == [] + assert [entry.title for entry in _main_harvest_entries(project)] == [_HARVEST_CARRY["summary"]] + + def test_tracked_harvest_carry_commit_failure_propagates(project, monkeypatch): """A tracked ledger persistence fault cannot be reported as a completed carry.""" project.deferred_work.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_runs.py b/tests/test_runs.py index 82c252ab..640be2d6 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2864,6 +2864,147 @@ def test_rearm_aborts_when_the_spec_status_cannot_be_reopened(tmp_path): assert task.restore_patch is None # the latch never landed either +def _renamed_project_pair(tmp_path): + """A run whose `state.project` names the tree it LAUNCHED in, plus a live tree + holding the same spec at the same relative position — the shape a project move + leaves behind. + + Both copies exist deliberately, and that is what makes the rows below falsifiable: + a real rename deletes the old tree, so a re-arm aimed at it would merely no-op and + "the live spec is unflipped" could not tell a wrong target from no write at all. + With both present, exactly one file changes and the assertion names which. + """ + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + for root in (recorded_project, live_project): + root.mkdir() + (root / "spec.md").write_text(_SPEC_WITH_ARR, encoding="utf-8") + recorded_spec = recorded_project / "spec.md" + run = escalated_run( + recorded_project, + "r1", + story_key="1-1-a", + attempt=2, + spec_file=str(recorded_spec), + ) + return run.run_dir, recorded_spec, live_project / "spec.md", live_project + + +def test_rearm_flips_the_spec_in_the_live_project_after_a_rename(tmp_path): + """`resolve.build_context` publishes a `spec_file` REBASED onto the live CLI + project, because `state.project` is the launch-time spelling and nothing re-stamps + it — `restamp_code_root` moves the CODE root, and there is no project counterpart. + The re-arm resolved the same task through `task_spec_path` against that recorded + project, so after a rename the agent edited one file and the re-arm flipped + another. In the real shape the recorded tree is gone, so the flip silently no-ops + (every writer answers an absent path with `False`), the baseline re-stamp is + skipped, and the re-drive wedges on the escalated attempt's status with the + escalation spent. + + Both halves are asserted, because a fix that wrote BOTH files would satisfy the + first alone. + + Ablation: revert `_live_spec_path` to a bare `task_spec_path` and this reddens on + the live spec's unchanged status. `_live_spec_root` is graded by the row below + instead, and deliberately: ablating the ROOT alone is invisible here, because every + writer answers an out-of-root path by silently dropping to the unconfined arm — + the write still lands, it just loses #593's O_NOFOLLOW walk. That silent degrade is + the hazard `task_spec_root`'s own docstring names, so the invariant has to be + asserted directly rather than inferred from an outcome that cannot see it.""" + run_dir, recorded_spec, live_spec, live_project = _renamed_project_pair(tmp_path) + + runs.rearm_escalation( + run_dir, + "1-1-a", + isolated_redrive=False, + resolution_recorded=False, + project_root=live_project, + ) + + assert verify.status_of(verify.read_frontmatter(live_spec)) == "ready-for-dev" + assert verify.status_of(verify.read_frontmatter(recorded_spec)) != "ready-for-dev" + assert "## Auto Run Result" not in live_spec.read_text(encoding="utf-8") + assert "## Auto Run Result" in recorded_spec.read_text(encoding="utf-8") + + +def test_live_spec_root_still_confines_the_live_spec_path(tmp_path): + """The pair moves together or not at all. `task_spec_root` is the confinement claim + about the very path `task_spec_path` produces, so rebasing one alone hands the four + writers of these bytes a path outside their own root — which none of them refuses. + They drop to the plain write and #593's O_NOFOLLOW walk of the parent components is + gone, with no signal anywhere. Nothing downstream can observe that, so the + containment is asserted here, at the seam that has to preserve it. + + Ablation: revert either helper to its un-rebased original and this reddens — the + root one on containment, the path one on the root's own identity.""" + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + for root in (recorded_project, live_project): + root.mkdir() + (root / "spec.md").write_text(_SPEC_WITH_ARR, encoding="utf-8") + run = escalated_run( + recorded_project, + "r1", + story_key="1-1-a", + spec_file=str(recorded_project / "spec.md"), + ) + task = run.state.tasks["1-1-a"] + + spec_path = runs._live_spec_path(task, run.state, live_project) + spec_root = runs._live_spec_root(task, run.state, live_project) + + assert spec_path == live_project / "spec.md" + assert spec_root == live_project + assert spec_path.is_relative_to(spec_root) # the confined arm stays reachable + + +def test_rearm_without_a_live_project_writes_the_tree_the_run_recorded(tmp_path): + """The default is byte-for-byte today's behavior, and that is the whole argument + for it being optional: `None` does not stand in for a fact only the caller holds + (the way a defaulted `isolated_redrive` would), it names the same tree the caller + would have passed on every run whose project has not moved. + + Ablation: make the parameter required, or default it to anything but + `state.project`, and this reddens.""" + run_dir, recorded_spec, live_spec, _live_project = _renamed_project_pair(tmp_path) + + runs.rearm_escalation(run_dir, "1-1-a", isolated_redrive=False, resolution_recorded=False) + + assert verify.status_of(verify.read_frontmatter(recorded_spec)) == "ready-for-dev" + assert verify.status_of(verify.read_frontmatter(live_spec)) != "ready-for-dev" + + +def test_rearm_leaves_a_spec_outside_the_recorded_project_alone(tmp_path): + """`rebase_recorded_project_path` is spelling arithmetic over the recorded project + PREFIX, so an artifact dir configured outside the project tree — the shared + external layout `[stories] source` allows, and the one shape + `_spec_is_shared_with_the_redrive` treats as reachable under isolation — is not + project-owned and must pass through untouched. Rebasing it would relocate a file + every checkout already shares onto a tree that does not contain it. + + Ablation: drop the `relative_to` guard's `except ValueError: return path` arm and + this reddens on an unflipped external spec.""" + external = tmp_path / "shared-artifacts" + external.mkdir() + spec = external / "spec.md" + spec.write_text(_SPEC_WITH_ARR, encoding="utf-8") + recorded_project = tmp_path / "project-before-rename" + recorded_project.mkdir() + live_project = tmp_path / "project-after-rename" + live_project.mkdir() + run = escalated_run(recorded_project, "r1", story_key="1-1-a", attempt=2, spec_file=str(spec)) + + runs.rearm_escalation( + run.run_dir, + "1-1-a", + isolated_redrive=False, + resolution_recorded=False, + project_root=live_project, + ) + + assert verify.status_of(verify.read_frontmatter(spec)) == "ready-for-dev" + + def test_rearm_resets_followup_reviews_spent(tmp_path): """A human-resolved re-drive gets a fresh damping budget: rearm_escalation zeroes followup_reviews_spent alongside review_cycle, so the clean rebuild diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 54e0da6d..d6ec979d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4778,9 +4778,15 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, 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. + The LIVE PROJECT rides the same argument list and for the same reason: nothing + re-stamps `state.project`, so a re-arm left to the recorded value writes the spec + into the tree this dashboard is no longer looking at. `self.project` rather than + `paths.project`, because the `load_paths` arm above may degrade without binding + `paths` at all. + 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. + answers. Drop `project_root=self.project` and it reddens on the second list. """ from bmad_loop import resolve, runs @@ -4788,15 +4794,17 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, bmad.mkdir(parents=True, exist_ok=True) (bmad / "policy.toml").write_text('[scm]\nisolation = "worktree"\n', encoding="utf-8") seen: list[bool] = [] + roots: list[object] = [] + + def fake_rearm(rd, sk, *, isolated_redrive, resolution_recorded, project_root=None): + seen.append(isolated_redrive) + roots.append(project_root) + return "ready-for-dev" + 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, resolution_recorded: seen.append(isolated_redrive) - or "ready-for-dev", - ) + monkeypatch.setattr(runs, "rearm_escalation", fake_rearm) run_dir, _spec = _stories_paused_run( project.project, stage="escalation", @@ -4812,6 +4820,7 @@ async def test_escalation_rearm_hands_the_rearm_the_live_isolation_mode(project, await _open_review(app, pilot, EscalationModal) await pilot.click(await ready(pilot, "#act-rearm")) await until(pilot, lambda: seen == [True]) + assert roots == [project.project] # the live tree, not the recorded one # ...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") @@ -4965,7 +4974,7 @@ async def test_escalation_rearm_surfaces_a_failed_baseline_advance(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False, project_root=None): Journal(rd).append( "rearm-baseline-advance-failed", story_key=sk, @@ -5025,7 +5034,7 @@ async def test_escalation_rearm_aims_the_code_root_before_it_rearms(project, mon monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") seen: list = [] - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False, project_root=None): seen.append(load_state(rd).code_root) return "ready-for-dev" @@ -5172,7 +5181,7 @@ async def test_escalation_rearm_surfaces_the_kinds_it_used_to_drop(project, monk monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False, project_root=None): journal = Journal(rd) journal.append( "stale-restore-commits", @@ -5309,7 +5318,7 @@ async def test_escalation_rearm_holds_the_resume_it_folds_in(project, monkeypatc monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False, project_root=None): Journal(rd).append( "rearm-spec-write-unreachable", story_key=sk, @@ -5381,7 +5390,7 @@ async def test_escalation_rearm_echoes_residue_when_the_rearm_aborts(project, mo monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False, project_root=None): # exactly the real ordering: residue journalled, THEN the abort Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] @@ -5442,7 +5451,7 @@ async def test_escalation_rearm_survives_a_corrupt_journal(project, monkeypatch) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") - def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False): + def fake_rearm(rd, sk, *, isolated_redrive=False, resolution_recorded=False, project_root=None): Journal(rd).append( "stale-restore-commits", story_key=sk, old_baseline="f" * 40, commits=["c1"] ) From b9e1e5a300e30000cbbd712524945796c951ed0b Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 17:11:29 -0700 Subject: [PATCH 17/35] fix(deferredwork,engine): file the finding when a seen-again match goes stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_harvest_spec_deferrals` decides the cross-spec dedupe against a ledger SNAPSHOT and excludes the matched finding from its append on the strength of that match, then stamps the sighting later, under `mark_seen_again_many`'s lock. A rival that closed or archived the entry in that window left `_find_entry` returning a done entry, which the mark stamped anyway — so the sighting landed on a closed row, no open entry was filed, and the recurrence was recorded nowhere. `mark_seen_again_many` now rechecks `entry.open` inside the hold and returns the ids whose match went stale alongside the applied flags and the published text. The two ways a flag can be False are no longer interchangeable: a replay whose line is already present has a live sighting and must file nothing, while a missing or no-longer-open id has none. `entry.open` deliberately, not `not entry.done` — an unparseable status is neither, and the question is only whether this is still the open entry the caller matched. The harvest folds the stale ids' findings back into both the append and `harvested_deferrals` (records ahead of the write that files them, as before, so the isolation carry can re-file them), narrows `seen_again` to the entries that actually took a sighting, and journals `spec-deferral-sighting-stale`. The row builder and the record absorption are shared by both passes so the recovered rows are byte-identical to the ones the snapshot scan would have filed. --- CHANGELOG.md | 11 +++ src/bmad_loop/deferredwork.py | 48 +++++++--- src/bmad_loop/engine.py | 163 ++++++++++++++++++++++++---------- tests/test_deferredwork.py | 78 +++++++++++++--- tests/test_engine.py | 51 +++++++++++ 5 files changed, 281 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fefac3bd..dc7db911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,17 @@ breaking changes may land in a minor release. ### Fixed +- **A `seen-again:` match that goes stale inside the ledger lock no longer swallows the + recurrence.** `_harvest_spec_deferrals` decides the cross-spec dedupe against a ledger + snapshot and excludes the matched finding from its append, then stamps the sighting later + under `deferredwork.mark_seen_again_many`'s lock. A rival that closed or archived the entry + in that window got the line stamped onto a done entry while no open entry was filed — the + finding was then recorded neither as a sighting nor as an entry. The primitive now rechecks + `entry.open` inside the hold and returns the ids whose match went stale; the harvest files + those findings after all, persists their records ahead of the append so the isolation carry + can re-file them, keeps `seen_again` naming only entries that actually took a sighting, and + journals `spec-deferral-sighting-stale`. + - **Document spec baseline frontmatter as an optional `verify_dev` attestation** (DW-51), with no-claim acceptance and no-work refusal regressions anchored to the orchestrator-recorded baseline. diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 97c0793c..427054a7 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -1020,20 +1020,35 @@ def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: def mark_seen_again_many( path: Path, dw_ids: Sequence[str], date: str, note: str -) -> tuple[list[bool], str | None]: +) -> tuple[list[bool], str | None, list[str]]: """Stamp `seen-again: ()` under each entry's status line, in ONE - read and ONE atomic write. Returns one applied flag per id, plus the text it - published — None when it wrote nothing (every id missing, or already carrying - this exact line). + read and ONE atomic write. Returns one applied flag per id, the text it + published — None when it wrote nothing — and the ids whose match went STALE + inside the hold. The writer half of the format doc's dedupe rule (deferred-work-format.md): a finding that matches an existing entry is recorded as a sighting on that entry, never as a duplicate entry. Idempotent per (id, date, note): a replay stamping the same line is skipped, so a flag reads "this call inserted it", - not "the line is there". A missing id is False rather than an error — the - caller matched against a snapshot, and a rival may have archived the entry - since. A missing ledger applies nothing and takes no lock, like - :func:`mark_done_many`'s absent-file arm. + not "the line is there". + + ⚠️ The two ways a flag can be False are NOT interchangeable, which is why the + stale ids come back separately. A replay whose line is already present has a + live sighting on a live entry and must file nothing. An id that is missing or + NO LONGER OPEN has no sighting anywhere: the caller matched it against a + snapshot read before this lock and a rival closed or archived it in between, + so its finding is recorded work again rather than a duplicate and the caller + must file it. Stamping a done entry instead — while the caller, having already + excluded the finding from its append, files nothing — drops the recurrence in + silence, which is the whole reason the recheck happens inside the hold and not + against the caller's snapshot. + + ``entry.open``, not ``not entry.done``: a status the format cannot parse is + neither, and the only question here is whether this is still the open entry + the caller matched — so the predicate has to be the one the caller used. + + A missing ledger applies nothing, takes no lock, and reports every id stale, + like :func:`mark_done_many`'s absent-file arm. ONE locked read->edit->write (#286/#469): each insert lands on the text the previous one produced (:func:`_find_entry` re-parses the evolving text, so @@ -1045,27 +1060,32 @@ def mark_seen_again_many( _require_iso_date(date) line = f"seen-again: {date} ({_one_line(note)})" if not dw_ids: - return [], None + return [], None, [] if not path.is_file(): - return [False for _ in dw_ids], None + return [False for _ in dw_ids], None, list(dw_ids) with ledger_lock(path): if not path.is_file(): - return [False for _ in dw_ids], None + return [False for _ in dw_ids], None, list(dw_ids) text = path.read_text(encoding="utf-8") applied: list[bool] = [] + stale: list[str] = [] for dw_id in dw_ids: entry = _find_entry(text, dw_id) - if entry is None or line in entry.body: + if entry is None or not entry.open: + applied.append(False) + stale.append(dw_id) + continue + if line in entry.body: applied.append(False) continue text = _insert_after_status(text, entry, line) applied.append(True) if not any(applied): - return applied, None + return applied, None, stale atomic_write_text(path, text) # Returned from INSIDE the hold: this is the published text by # construction, not a read-back that a rival could have moved. - return applied, text + return applied, text, stale _MARK_DONE_TAIL_RE = re.compile( diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 36aa6982..6820d7de 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -586,6 +586,26 @@ class _LedgerAnchor(StrEnum): NO_RESET_CONTENT = "no-reset-content" +def _harvest_row( + finding: devcontract.DeferredFinding, +) -> tuple[str, str, str, str | None, str | None]: + """One harvest row — `(origin, title, reason, location, severity)` — for a + finding this harvest may file. + + Shared by the snapshot scan and by the stale-sighting recovery below, so a + finding whose `seen-again:` match died inside the ledger lock files the + byte-identical row it would have filed had the match never existed. Two + spellings of this tuple would diverge on exactly the rare path nothing + exercises.""" + return ( + f"{HARVEST_ORIGIN} {finding.fingerprint}", + finding.summary, + finding.evidence or finding.summary, + finding.location or None, + finding.severity or None, + ) + + class Engine: # The engine that installed the process-wide stop handlers. Signal handling is # single-owner per process; only this engine reinstalls/restores them. Run @@ -3946,6 +3966,53 @@ def _harvest_spec_path(self, task: StoryTask, result_json: dict | None) -> Path return None return verify.resolve_spec_path(str(spec_file), self.workspace.paths) + def _absorb_harvest_records( + self, + task: StoryTask, + rows: Sequence[tuple[str, str, str, str | None, str | None]], + spec_name: str, + ) -> None: + """Fold harvest rows into ``task.harvested_deferrals`` under the stable-union + rule, and checkpoint when the union actually grew. + + Persist the full intended set, not only newly-filed rows: a replay can + dedupe every append while a later isolation carry still needs the data. The + union is stable across a retained retry/review chain — a later pass may + replace the frontmatter list, but every earlier accepted finding is still + present in an ignored unit ledger and must survive final carry — so the key + is ``(origin, source_spec)`` and a repeat is dropped rather than doubled. + + The isolation carry reads only persisted records after a hard loss, so the + checkpoint has to precede the ledger write that files these rows. That holds + for both callers: the snapshot scan's rows, and the rows recovered when a + `seen-again:` match went stale inside the mark's lock — the latter still land + ahead of the append, which is the write that files them. Checkpoint every + expansion, including later passes where ``harvest_wrote_ledger`` is already + latched and its separate pre-write save will be skipped.""" + known = { + (str(item.get("origin", "")), str(item.get("source_spec", ""))) + for item in task.harvested_deferrals + } + changed = False + for origin, title, reason, location, severity in rows: + key = (origin, spec_name) + if key in known: + continue + task.harvested_deferrals.append( + { + "origin": origin, + "title": title, + "reason": reason, + "location": location, + "severity": severity, + "source_spec": spec_name, + } + ) + known.add(key) + changed = True + if changed: + self._save() + def _harvest_spec_deferrals( self, task: StoryTask, result_json: dict | None ) -> VerifyOutcome | None: @@ -3981,7 +4048,15 @@ def _harvest_spec_deferrals( fresh, consistent with ``_apply_append``'s open-only scan. A matched finding is also excluded from ``harvested_deferrals``, so the isolated carry cannot re-file the duplicate. - """ + + That exclusion is provisional, because the match is decided against a + SNAPSHOT and the stamp happens later under the ledger lock. A rival that + closes or archives the entry in between leaves the sighting with nowhere to + land, and the finding — already dropped from the append on the strength of + the match — would vanish entirely. ``mark_seen_again_many`` therefore + rechecks ``entry.open`` inside its hold and reports the ids whose match went + stale; those findings are folded back into both the append and the records + here, and the loss is journaled as ``spec-deferral-sighting-stale``.""" if not self._generic_dev(): return spec_path = self._harvest_spec_path(task, result_json) @@ -4091,8 +4166,14 @@ def _harvest_spec_deferrals( # entries never match; a recurrence after a close files fresh, exactly # as `_apply_append`'s open-only scan treats its own dedupe. Matched # findings are also excluded from `harvested_deferrals`, so the - # isolated carry cannot re-file the duplicate. + # isolated carry cannot re-file the duplicate — provisionally: this + # reads a SNAPSHOT, and the stale arm below restores any finding whose + # match did not survive to the mark's lock. seen_again_ids: list[str] = [] + # Every finding behind each matched id, so a match that goes stale inside + # `mark_seen_again_many`'s lock can still be filed. Dropping the finding + # here — as this loop used to — makes that recovery impossible. + matched: dict[str, list[devcontract.DeferredFinding]] = {} harvestable: list[devcontract.DeferredFinding] = [] for finding in findings: origin = f"{HARVEST_ORIGIN} {finding.fingerprint}" @@ -4117,21 +4198,16 @@ def _harvest_spec_deferrals( ) if match is None: harvestable.append(finding) - elif match.id not in seen_again_ids: + continue + matched.setdefault(match.id, []).append(finding) + if match.id not in seen_again_ids: seen_again_ids.append(match.id) # (origin, title, reason, location, severity), one row per entry this # harvest may file. The malformed loss is aggregated per spec so a bad # sibling never suppresses a valid finding and never disappears silently. pending: list[tuple[str, str, str, str | None, str | None]] = [ - ( - f"{HARVEST_ORIGIN} {finding.fingerprint}", - finding.summary, - finding.evidence or finding.summary, - finding.location or None, - finding.severity or None, - ) - for finding in harvestable + _harvest_row(finding) for finding in harvestable ] if malformed: self.journal.append( @@ -4154,39 +4230,7 @@ def _harvest_spec_deferrals( ) ) - # Persist the full intended set, not only newly-filed rows. A replay can - # dedupe every append while a later isolation carry still needs the data. - # Keep a stable union across a retained retry/review chain: a later pass - # may replace the frontmatter list, but every earlier accepted finding is - # still present in an ignored unit ledger and must survive final carry. - current_records = [ - { - "origin": origin, - "title": title, - "reason": reason, - "location": location, - "severity": severity, - "source_spec": spec_name, - } - for origin, title, reason, location, severity in pending - ] - known = { - (str(item.get("origin", "")), str(item.get("source_spec", ""))) - for item in task.harvested_deferrals - } - records_changed = False - for record in current_records: - key = (str(record["origin"]), str(record["source_spec"])) - if key not in known: - task.harvested_deferrals.append(record) - known.add(key) - records_changed = True - if records_changed: - # The isolation carry reads only persisted records after a hard - # loss. Checkpoint every stable-union expansion before a ledger - # append, including later passes where harvest_wrote_ledger is - # already latched and its separate pre-write save will be skipped. - self._save() + self._absorb_harvest_records(task, pending, spec_name) if seen_again_ids: # Latch + save BEFORE the write, exactly as the append path does: a @@ -4197,18 +4241,45 @@ def _harvest_spec_deferrals( if not task.harvest_wrote_ledger: task.harvest_wrote_ledger = True self._save() - marked_published = deferredwork.mark_seen_again_many( + _, marked_published, stale = deferredwork.mark_seen_again_many( ledger, seen_again_ids, self._today(), f"spec-deferral harvest of {spec_name}", - )[1] + ) if marked_published is not None: # Re-anchor the pre-harvest restore's CAS on what the mark # published; a following append overwrites this with its own # published text (its locked read includes these marks). task.post_engine_ledger_digest = _digest_of(marked_published) self._save() + if stale: + # The match was OPEN in the snapshot read above, and gone or + # closed by the time the mark took the ledger lock. The sighting + # landed nowhere, and the finding was already excluded from + # `pending` on the strength of that match — so without this it is + # lost in silence, recorded neither as a sighting nor as an entry. + # A recurrence after a close files fresh (`_apply_append` dedupes + # open entries only), which is exactly what the scan above would + # have decided had it run inside the lock. + recovered = [ + _harvest_row(finding) for dw_id in stale for finding in matched.get(dw_id, ()) + ] + self.journal.append( + "spec-deferral-sighting-stale", + story_key=task.story_key, + spec=spec_name, + items=stale, + refiled=len(recovered), + ) + pending.extend(recovered) + # Records before the ledger write that files them, exactly as the + # first pass ordered it: the isolation carry reads only what was + # persisted, and the append below is still ahead of us. + self._absorb_harvest_records(task, recovered, spec_name) + # Keep the harvest record honest: `seen_again` below names the + # entries that actually took a sighting, and a stale one took none. + seen_again_ids = [i for i in seen_again_ids if i not in set(stale)] specs: list[deferredwork.EntrySpec] = [] deduped = 0 diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 6297805d..c7ad825b 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -150,10 +150,10 @@ def test_mark_done_missing_entry(tmp_path): def test_mark_seen_again_many_inserts_after_status(tmp_path): path = write_ledger(tmp_path) - applied, published = mark_seen_again_many( + applied, published, stale = mark_seen_again_many( path, ["DW-1"], "2026-08-31", "spec-deferral harvest of spec-2-2-b.md" ) - assert applied == [True] + assert applied == [True] and stale == [] text = path.read_text(encoding="utf-8") assert published == text assert "status: open\nseen-again: 2026-08-31 (spec-deferral harvest of spec-2-2-b.md)" in text @@ -166,28 +166,86 @@ def test_mark_seen_again_many_is_idempotent_on_replay(tmp_path): path = write_ledger(tmp_path) assert mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x")[0] == [True] snapshot = path.read_text(encoding="utf-8") - applied, published = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x") - assert applied == [False] and published is None + applied, published, stale = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x") + # the line is already there: a live sighting on a live entry, NOT a stale match + assert applied == [False] and published is None and stale == [] assert path.read_text(encoding="utf-8") == snapshot # a different sighting (new date) is a new line, not a dupe to skip assert mark_seen_again_many(path, ["DW-1"], "2026-09-01", "harvest of x")[0] == [True] assert path.read_text(encoding="utf-8").count("seen-again:") == 3 # DW-3 owns one -def test_mark_seen_again_many_missing_id_is_false(tmp_path): +def test_mark_seen_again_many_missing_id_is_false_and_stale(tmp_path): path = write_ledger(tmp_path) - applied, published = mark_seen_again_many(path, ["DW-99", "DW-1"], "2026-08-31", "harvest of x") - assert applied == [False, True] and published is not None + applied, published, stale = mark_seen_again_many( + path, ["DW-99", "DW-1"], "2026-08-31", "harvest of x" + ) + # an id that is simply gone had nowhere to take the sighting either — the + # caller matched it against a snapshot and must file its finding after all + assert applied == [False, True] and published is not None and stale == ["DW-99"] assert "seen-again: 2026-08-31 (harvest of x)" in path.read_text(encoding="utf-8") - # a missing ledger applies nothing and creates nothing + # a missing ledger applies nothing, creates nothing, and reports every id stale missing = tmp_path / "absent" / "deferred-work.md" - assert mark_seen_again_many(missing, ["DW-1"], "2026-08-31", "x") == ([False], None) + assert mark_seen_again_many(missing, ["DW-1"], "2026-08-31", "x") == ( + [False], + None, + ["DW-1"], + ) assert not missing.exists() +def test_mark_seen_again_many_reports_a_closed_match_as_stale(tmp_path): + """A rival's close between the caller's snapshot and this lock leaves the + sighting nowhere to land, and the caller has already excluded the finding from + its append on the strength of that match. Stamping the done entry anyway would + drop the recurrence in silence, so report the id instead of writing to it.""" + path = write_ledger(tmp_path) + # DW-2 is done in the fixture, standing in for an entry closed since the + # caller's snapshot; DW-3 is open and must still take its line in the same call. + applied, published, stale = mark_seen_again_many( + path, ["DW-2", "DW-3"], "2026-08-31", "harvest of x" + ) + + assert applied == [False, True] and stale == ["DW-2"] + text = path.read_text(encoding="utf-8") + assert published == text + entries = {e.id: e for e in parse_ledger(text)} + assert "seen-again: 2026-08-31" not in entries["DW-2"].body + assert "seen-again: 2026-08-31 (harvest of x)" in entries["DW-3"].body + + +def test_mark_seen_again_many_writes_nothing_when_every_match_is_stale(tmp_path): + path = write_ledger(tmp_path) + snapshot = path.read_text(encoding="utf-8") + + assert mark_seen_again_many(path, ["DW-2", "DW-99"], "2026-09-01", "x") == ( + [False, False], + None, + ["DW-2", "DW-99"], + ) + assert path.read_text(encoding="utf-8") == snapshot + + +def test_mark_seen_again_many_treats_an_unparseable_status_as_stale(tmp_path): + """``entry.open``, not ``not entry.done``. A status the format cannot read is + neither open nor done, and the only question here is whether this is still the + open entry the caller matched — so the predicate has to be the caller's.""" + path = write_ledger(tmp_path, LEDGER.replace("status: open\n\n", "status: opne\n\n", 1)) + snapshot = path.read_text(encoding="utf-8") + entries = {e.id: e for e in parse_ledger(snapshot)} + assert not entries["DW-1"].open and not entries["DW-1"].done # neither, by design + + assert mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x") == ( + [False], + None, + ["DW-1"], + ) + assert path.read_text(encoding="utf-8") == snapshot + + def test_mark_seen_again_many_sanitizes_the_note_to_one_line(tmp_path): path = write_ledger(tmp_path) - applied, _ = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest\nof spec-x.md") + applied, _, _ = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest\nof spec-x.md") assert applied == [True] text = path.read_text(encoding="utf-8") assert "seen-again: 2026-08-31 (harvest of spec-x.md)" in text diff --git a/tests/test_engine.py b/tests/test_engine.py index e4e3bf52..7a136ab6 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -14194,6 +14194,57 @@ def test_harvest_files_fresh_when_the_match_is_done(project): assert event["dw_ids"] == ["DW-2"] and event["seen_again"] == [] +def test_harvest_files_the_finding_when_the_seen_again_match_goes_stale(project, monkeypatch): + """TOCTOU: the match is decided against a ledger snapshot, the sighting is + stamped later under the ledger lock. A rival that closes the entry in between + leaves the sighting nowhere to land — and the finding was already excluded from + the append on the strength of that match, so without the in-lock recheck the + recurrence is lost with no entry and no sighting anywhere. + + The real primitive still runs; only the ledger is mutated ahead of it, which is + the race window itself rather than a stub of the code under test.""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])], + policy=_harvest_policy(), + ) + real_mark = deferredwork.mark_seen_again_many + + def close_the_match_first(path, dw_ids, date, note): + path.write_text( + path.read_text(encoding="utf-8").replace("status: open", "status: done 2026-06-05"), + encoding="utf-8", + ) + return real_mark(path, dw_ids, date, note) + + monkeypatch.setattr(deferredwork, "mark_seen_again_many", close_the_match_first) + + assert engine.run().done == 1 + + entries = _harvest_entries(project) + # the recurrence is filed fresh, exactly as a match already done at snapshot + # time would have been (`_apply_append` dedupes open entries only) + assert [e.id for e in entries] == ["DW-1", "DW-2"] + assert entries[1].open and entries[1].title == HARVEST_A["summary"] + assert "seen-again: " not in entries[0].body # never stamped on the closed entry + (stale_event,) = [ + e for e in engine.journal.entries() if e["kind"] == "spec-deferral-sighting-stale" + ] + assert stale_event["items"] == ["DW-1"] and stale_event["refiled"] == 1 + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + # `seen_again` names entries that actually took a sighting; a stale id took none + assert event["dw_ids"] == ["DW-2"] and event["seen_again"] == [] + # and the record is persisted, so the isolated carry can re-file it too + assert [r["origin"] for r in engine.state.tasks["1-1-a"].harvested_deferrals] == [ + f"spec-deferred {fp}" + ] + + def test_ledger_digest_collapses_absent_and_empty_only(): assert _digest_of(None) == _digest_of("") assert _digest_of("# Deferred Work\n") != _digest_of(None) From 024ceee4eb21b5c1084db079d8aa0d9c4a645bea Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 17:11:43 -0700 Subject: [PATCH 18/35] docs(features): match the review-budget bullet to the shipped behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded-review-loop bullet promised that when the follow-up-review damping cap is spent the lingering recommendation "is re-filed to the deferred-work ledger". `_journal_review_budget_spent` does the opposite by design: it journals the spent budget (`review-followup-damped`, or `review-budget-committed` on plain exhaustion) and files nothing, because the DW-55/64/90 class showed such rows re-litigate a converged story's review rather than record work anyone chose to defer. FEATURES.md is a behavior contract, so state what ships. The one path that still files is the review TIMEOUT salvage, under its own `review-timeout-salvage` origin — named here so the correction cannot be read as "the orchestrator never re-files a follow-up". --- CHANGELOG.md | 8 ++++++++ docs/FEATURES.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7db911..bacf7a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,14 @@ breaking changes may land in a minor release. ### Fixed +- **`docs/FEATURES.md` no longer promises a ledger entry the review-budget damping does not + file.** The bounded-review-loop bullet said a lingering follow-up recommendation is re-filed + to the deferred-work ledger; `_journal_review_budget_spent` journals the spent budget and + deliberately files nothing, on the damping path and on plain budget exhaustion alike (the + DW-55/64/90 class showed such rows re-litigate a converged story's review). FEATURES.md is a + behavior contract, so the bullet now matches the shipped behavior and names the review + _timeout_ salvage as the one path that does still file. + - **A `seen-again:` match that goes stale inside the ledger lock no longer swallows the recurrence.** `_harvest_spec_deferrals` decides the cross-spec dedupe against a ledger snapshot and excludes the matched finding from its append, then stamps the sighting later diff --git a/docs/FEATURES.md b/docs/FEATURES.md index dc601e0e..ea18aeaf 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -54,7 +54,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - The follow-up review is a re-invocation of the dev primitive on the `done` spec — a fresh-context session with no anchoring bias from the implementer (BMAD-METHOD#2508 routes a `done` spec to a fresh step-04 review pass), so there is no separate review skill. - Parallel adversarial layers resolved from the skill's `customize.toml` (defaults: Blind Hunter (Adversarial-General back when the layer named the standalone skill), Edge-Case-Hunter, Verification-Gap — the third added by BMAD-METHOD#2550 — and the inline Intent Alignment Auditor added by #2560) → verify findings against code → triage → auto-apply patches → log → defer ambiguity → commit. Which skills a layer invokes is a property of the installed primitive, not a catalog pinned here, so `bmad-loop validate` derives the prerequisite set per tree and both topologies run unchanged: on pre-6.11 sources the hunter layers hand off to upstream `bmad-review*` skills and those are checked for as bmm prerequisites; on 6.11 sources every layer is a self-contained prompt — the edge-case-hunter and verification-gap layers read the primitive's own `review-prompts/*.md`, the blind-hunter and intent-alignment ones carry their prompt inline — so no review skill is required at all. -- Bounded review loop (`limits.max_review_cycles`, default 3 cycles); done when the pass finishes `done` and no longer recommends a follow-up. A second guard, `limits.max_followup_reviews` (default 1), damps the structurally non-convergent case: a finalized pass that keeps recommending its own follow-up is honored only this many times, after which the round converges (verify + commit) and the lingering recommendation is re-filed to the deferred-work ledger instead of burning cycles to the hard cap. `0` never honors a pass's own recommendation. (Upstream BMAD-METHOD#2580 has since made the flag convergent by construction — a severity-weighted score over the pass's patched findings rather than a judgment — so the damping guard is now belt-and-suspenders; it stays as the orchestrator-side bound, which #2580 explicitly leaves to the driver.) +- Bounded review loop (`limits.max_review_cycles`, default 3 cycles); done when the pass finishes `done` and no longer recommends a follow-up. A second guard, `limits.max_followup_reviews` (default 1), damps the structurally non-convergent case: a finalized pass that keeps recommending its own follow-up is honored only this many times, after which the round converges (verify + commit) and the lingering recommendation is journaled — `review-followup-damped`, carrying the cap and whether the re-review cap fired with it — instead of burning cycles to the hard cap. `0` never honors a pass's own recommendation. **No ledger entry is filed** on this path or on plain budget exhaustion (`review-budget-committed`): the DW-55/64/90 class showed such rows re-litigate a converged story's review rather than record work anyone chose to defer, and a follow-up that matters resurfaces through review of later work, not through a process ticket. (The one path that still files is the review _timeout_ salvage, under its own `review-timeout-salvage` origin.) (Upstream BMAD-METHOD#2580 has since made the flag convergent by construction — a severity-weighted score over the pass's patched findings rather than a judgment — so the damping guard is now belt-and-suspenders; it stays as the orchestrator-side bound, which #2580 explicitly leaves to the driver.) - Optional (`[review].enabled`, default `true`): set `false` to skip the follow-up review session. The dev pass's own inline review (same layers, in-context) is then the only review and it finalizes the story to `done` — one session per story instead of two. Verify commands still gate the commit. Applies to story runs and deferred-work sweeps alike. - Trigger (`[review].trigger`, default `recommended`): when review is enabled, decides _when_ the follow-up pass runs. `recommended` runs it only when the primitive sets `followup_review_recommended` on a `done` spec (it self-reviews inline and computes the flag from a severity-weighted score over the final pass's patched findings — flag introduced by BMAD-METHOD#2505, scoring by #2580). `always` runs it on every story (pre-0.7.0 behavior). From 0c7a75c9fedea018218bc5d0255738d6bffc189a Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 18:01:00 -0700 Subject: [PATCH 19/35] fix(generic): catch the symlink-loop RuntimeError in the marker key builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_marker_path_key` wrapped `Path.resolve()` in `except OSError:`, but on the 3.11 support floor a symlink loop raises `RuntimeError` — not an OSError — and on 3.13 it raises nothing at all. So the fallback was inert on the floor and unreachable on the dev interpreter: one looped `*.md` under an artifact dir aborted `_capture_launch_auto_run_results` before the transport started, and with it every unpinned generic dev session, which globs the whole directory. Catch `(OSError, RuntimeError)`, the shape every other `resolve()` guard in the package already uses. The loop entry then reads as one unreadable marker (`read_text` raises ELOOP on every interpreter) and the readable spec beside it is still captured. Two tests: the unit row injects the RuntimeError through a Path subclass so it means the same thing on every interpreter CI runs; the launch row builds a real `a -> b -> a` loop and is non-vacuous on 3.11 only, for the reason above. --- CHANGELOG.md | 7 +++++ src/bmad_loop/adapters/generic.py | 7 ++++- tests/test_generic_tmux.py | 51 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bacf7a23..f07150a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,13 @@ breaking changes may land in a minor release. ### Fixed +- **A looped `*.md` symlink under an artifact dir no longer aborts every unpinned dev + session on Python 3.11.** `_marker_path_key`'s `resolve()` guard caught only `OSError`, + but a symlink loop raises `RuntimeError` on the support floor (3.13 resolves it + silently), so the launch-time marker capture died before the transport started. The + guard now catches `(OSError, RuntimeError)` like every other `resolve()` in the package, + and the loop entry reads as one unreadable marker. + - **`docs/FEATURES.md` no longer promises a ledger entry the review-budget damping does not file.** The bounded-review-loop bullet said a lingering follow-up recommendation is re-filed to the deferred-work ledger; `_journal_review_budget_spent` journals the spent budget and diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index e1538fec..dc8017fd 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -1344,9 +1344,14 @@ def _configure_dev_knobs(self) -> None: @staticmethod def _marker_path_key(path: Path) -> str: + # `(OSError, RuntimeError)`, like every other `resolve()` guard in this + # package: on the 3.11 support floor a symlink LOOP raises RuntimeError, + # not an OSError (3.13 resolves it silently), and a bare `except OSError` + # let one looped `*.md` under an artifact dir abort the launch capture — + # and with it every unpinned dev session — before the transport started. try: return str(path.resolve()) - except OSError: + except (OSError, RuntimeError): return str(path.absolute()) def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None: diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 1a457deb..8124bee4 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -4838,6 +4838,57 @@ def test_marker_readback_without_launch_capture_fails_closed(tmp_path, monkeypat assert rj is not None and rj["park_asserted"] is False +def test_marker_path_key_falls_back_when_resolve_raises_runtime_error(tmp_path): + """`Path.resolve()` on a symlink loop raises RuntimeError on the 3.11 support + floor — NOT an OSError — and raises nothing at all on 3.13. So a real loop cannot + exercise the fallback on the dev interpreter, and a `except OSError:` guard is + inert on the floor: the fault is injected here instead, so the row means the same + thing on every interpreter CI runs. + + Ablation: narrow the guard back to `except OSError:` and this reddens on both + interpreters, on the RuntimeError escaping the key builder.""" + + class LoopedPath(type(Path())): + def resolve(self, strict=False): + raise RuntimeError("Symlink loop from 'a.md'") + + looped = LoopedPath(tmp_path / "impl" / "a.md") + assert GenericDevAdapter._marker_path_key(looped) == str(looped.absolute()) + + +@pytest.mark.skipif(sys.platform == "win32", reason="symlink creation needs a privilege") +def test_launch_capture_survives_a_looped_artifact_symlink(tmp_path, monkeypatch): + """One looped `*.md` under the artifact dir is one unreadable marker, not an + aborted launch. Every unpinned dev session (no `expected_spec`) globs the whole + directory here, before the transport starts, so a stray loop used to block the + run outright on 3.11. The loop entry is captured as `None` (`read_text` raises + ELOOP on every interpreter) and the readable spec beside it is still captured. + + Non-vacuous only on the floor: 3.13 resolves the loop silently, so the abort + this guards against cannot be produced there. Its sibling above injects the + RuntimeError directly for that reason.""" + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\nbaseline_revision: abc123\n---\n\n" + "## Auto Run Result\n\nStatus: done\nFinished.\n" + ) + (impl / "a.md").symlink_to("b.md") + (impl / "b.md").symlink_to("a.md") + monkeypatch.setattr( + generic.GenericAdapter, "start_session", lambda _adapter, _spec: _dev_handle() + ) + + adapter.start_session(_dev_spec(tmp_path)) # must not raise + + captured = adapter._launch_auto_run_results["3-1-dev-1"] + assert captured is not None # the directory enumeration itself completed + # both loop members are unreadable markers; the real spec beside them survives + assert list(captured.values()).count(None) == 2 + assert [fp for fp in captured.values() if fp is not None] == [ + devcontract.auto_run_result_fingerprint((impl / "spec-3-1-foo.md").read_text()) + ] + + def test_launch_capture_precedes_transport_that_writes_park_marker(tmp_path, monkeypatch): """A child that finishes during transport startup still owns its new marker.""" adapter, impl = make_dev_adapter(tmp_path) From 4fe4efd2664ce6ddbe483ed40b50116f393ae8ad Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 18:01:00 -0700 Subject: [PATCH 20/35] fix(engine): pick the defer notice's arm on the same mounted-task pair as the defer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_defer` routes on `self._isolated or task.worktree_path`, but the recovery note `_record_defer` emits two lines later still selected on live isolation alone. A run flipped `"worktree" -> "none"` while paused therefore reached the defer with the workspace swapped onto its mount and printed the in-place `git -C merge --ff-only ` — aimed at a directory `_integrate_unit` deletes on the way out with `keep_failed` off — and with it on, omitted the branch holding the latest failed work. `preserve_ref` is set on that path whenever an earlier in-worktree dev-retry rollback parked one (#333: the ref is not isolation-scoped), so both facts are live at once. The selector now uses the same pair. The isolated arm's rule is unchanged: name the ref, never offer a fast-forward that would land a discarded attempt. --- CHANGELOG.md | 7 +++++++ src/bmad_loop/engine.py | 11 +++++++++-- tests/test_engine_worktree.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f07150a0..05e7c1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,6 +255,13 @@ breaking changes may land in a minor release. guard now catches `(OSError, RuntimeError)` like every other `resolve()` in the package, and the loop entry reads as one unreadable marker. +- **The defer notice under a recorded mount names the kept branch instead of an + in-place merge.** `_defer_recovery_note` selected on live `scm.isolation` alone, so a + run flipped `"worktree" -> "none"` while paused advertised + `git -C merge --ff-only ` against the worktree `_integrate_unit` was about + to delete, and hid the kept branch when `keep_failed` was on. It now selects on the same + live-isolation-or-recorded-mount pair as the defer arm itself. + - **`docs/FEATURES.md` no longer promises a ledger entry the review-budget damping does not file.** The bounded-review-loop bullet said a lingering follow-up recommendation is re-filed to the deferred-work ledger; `_journal_review_budget_spent` journals the spent budget and diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 6820d7de..5ffe7324 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -6767,8 +6767,15 @@ def _defer_recovery_note(self, task: StoryTask) -> str: half instead of hiding a ref that does exist. The pointer is a name, not a promise: `scm.preserve_keep` prunes the oldest refs at a later run's start, and nothing here re-validates it (this must stay git-free — `status` reads - `state.json` only).""" - if self._isolated: + `state.json` only). + + Selects on the same pair as `_defer` — live isolation OR a recorded mount — + not on live policy alone. A run flipped `"worktree" -> "none"` while paused + reaches the defer with the workspace swapped onto its mount, so the in-place + arm would advertise `git -C merge --ff-only` against a directory + `_integrate_unit` is about to delete (`keep_failed` off), and hide the kept + branch when it is on.""" + if self._isolated or task.worktree_path: note = "" if self.policy.scm.keep_failed and task.branch: note = f" — failed work kept on branch `{task.branch}`" diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 99d5b66c..2640546d 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1209,6 +1209,36 @@ def test_defer_under_a_recorded_mount_carries_the_harvest_after_an_isolation_fli assert task.phase == Phase.DEFERRED +def test_defer_recovery_note_under_a_recorded_mount_names_the_branch_not_a_merge(project): + """The notice `_record_defer` emits two lines after `_defer` picked its arm must + pick the SAME arm. Selected on live policy alone, the flipped-policy defer above + printed the in-place `git -C merge --ff-only ` — a command aimed at + a directory `_integrate_unit` deletes on the way out with `keep_failed` off, and + with it on, a notice that never names the branch holding the latest failed work. + An earlier in-worktree dev-retry rollback parks `preserve_ref` on the shared + refs (#333: the ref is not isolation-scoped), so both facts are live at once. + + Ablation: restore the bare `if self._isolated:` gate in `_defer_recovery_note` + and this reddens on the merge line, then on the missing branch.""" + engine, _ = make_engine(project, [], policy=_in_place_policy()) + assert engine._isolated is False # MEASURED: live policy really says in place + assert engine.policy.scm.keep_failed is True + ref = "attempt-preserve/test-run-0badc0de" + task = StoryTask( + story_key="1-1-a", + epic=1, + worktree_path=str(project.project / ".bmad-loop" / "runs" / "test-run" / "wt" / "1-1-a"), + branch="bmad-loop/1-1-a", + preserve_ref=ref, + ) + + note = engine._defer_recovery_note(task) + + assert "merge --ff-only" not in note + assert "failed work kept on branch `bmad-loop/1-1-a`" in note + assert f"an earlier rolled-back attempt is parked at `{ref}`" in note + + def test_defer_with_no_recorded_mount_still_takes_the_in_place_arm(project, monkeypatch): """The other half of the widened gate. `or task.worktree_path` must not swallow the ordinary in-place defer, whose whole job is the rollback the isolated arm skips — From a42f1c98dea86eedaf6128875eae57d97da4ef49 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 18:23:02 -0700 Subject: [PATCH 21/35] fix(verify): gate `worktree list -z` on git 2.36; keep the newline parse on the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isolation-flip sweep replaced the newline-delimited `worktree list --porcelain` parse with `-z`, a switch git added in 2.36. The 2.34 support floor — Ubuntu 22.04's stock 2.34.1 — rejects it outright (`error: unknown switch `z'`, exit 129, measured in an ubuntu:22.04 container), so on the floor every isolated-task resume reached `worktree_is_registered`, got a `GitError`, and escalated instead of reopening its recorded mount, while orphan reconciliation silently skipped its cleanup. The floor is documented as a support floor, not a capability one: no command bmad-loop issues may need more. `worktree_list` now asks `git_below_floor` for 2.36 and issues `-z` only where git offers it; below that — or when git will not say what it is — the parse the floor supports is used, and the one shape it cannot represent (a newline inside a worktree path) reads as a truncated record for that entry alone. --- CHANGELOG.md | 6 ++++++ src/bmad_loop/verify.py | 31 ++++++++++++++++++++++--------- tests/test_verify_worktree.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e7c1d0..0b897e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,12 @@ breaking changes may land in a minor release. ### Fixed +- **`worktree list -z` is gated on git 2.36; the 2.34 support floor keeps the newline + parse.** The NUL-delimited listing was issued unconditionally, and Ubuntu 22.04's stock + 2.34.1 rejects the switch (exit 129), so every isolated-task resume escalated instead of + reopening its recorded mount and orphan reconciliation skipped its cleanup. Below 2.36 — + or when git will not say what it is — the pre-existing newline parse is used. + - **A looped `*.md` symlink under an artifact dir no longer aborts every unpinned dev session on Python 3.11.** `_marker_path_key`'s `resolve()` guard caught only `OSError`, but a symlink loop raises `RuntimeError` on the support floor (3.13 resolves it diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index b4785049..f0a1f99f 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -2250,25 +2250,38 @@ def worktree_prune(repo: Path) -> None: pass +# `git worktree list --porcelain -z` arrived in git 2.36; the 2.34 support floor +# (Ubuntu 22.04's stock git) rejects the switch outright — `error: unknown switch +# `z'`, exit 129 (measured in an ubuntu:22.04 container, git 2.34.1). The floor is +# documented as a SUPPORT floor, not a capability one: no command bmad-loop issues +# may need more than it, so the NUL parse is gated and the newline parse kept +# beneath it rather than the floor raised. +_WORKTREE_LIST_NUL_GIT = (2, 36) + + def worktree_list(repo: Path) -> list[Path]: """Paths of every worktree attached to `repo` (the main checkout first). - Reads stdout alone through NUL-delimited porcelain so paths may contain - newlines and the record parse does not depend on no stderr line ever starting - with ``"worktree "``. The advisories measured for - #442 — an unknown `core.fsyncMethod` value and its family — do NOT start that - way, so the `startswith` filter screens them out and this parse was correct by - accident rather than by construction; the filter stays as a second, independent - screen.""" + Reads stdout alone, through NUL-delimited porcelain where git offers it + (`_WORKTREE_LIST_NUL_GIT`), so paths may contain newlines and the record parse + does not depend on no stderr line ever starting with ``"worktree "``. Below that + version — and when git will not say what it is — the newline-delimited parse the + floor supports is used instead: the one thing it cannot represent is a newline + inside a worktree path, which then reads as a truncated record for that entry + alone. The advisories measured for #442 — an unknown `core.fsyncMethod` value + and its family — do NOT start with ``"worktree "``, so the `startswith` filter + screens them out and this parse was correct by accident rather than by + construction; the filter stays as a second, independent screen.""" + nul = git_below_floor(repo, _WORKTREE_LIST_NUL_GIT) is None proc = _run_git( - ["git", "-C", str(repo), "worktree", "list", "--porcelain", "-z"], + ["git", "-C", str(repo), "worktree", "list", "--porcelain", *(["-z"] if nul else [])], repo, ) if proc.returncode != 0: detail = (proc.stdout + proc.stderr).strip() raise GitError(f"git worktree list failed in {repo}: {detail}") paths = [] - for field in proc.stdout.split("\0"): + for field in proc.stdout.split("\0" if nul else "\n"): if field.startswith("worktree "): paths.append(Path(field[len("worktree ") :])) return paths diff --git a/tests/test_verify_worktree.py b/tests/test_verify_worktree.py index c79686bd..6db9b867 100644 --- a/tests/test_verify_worktree.py +++ b/tests/test_verify_worktree.py @@ -118,6 +118,39 @@ def noisy_run(cmd, **kwargs): assert [p.resolve() for p in verify.worktree_list(repo)] == [repo.resolve()] +@pytest.mark.parametrize("answer", ["git version 2.34.1", "no version reported"]) +def test_worktree_list_keeps_the_newline_parse_below_git_2_36( + project, tmp_path, monkeypatch, answer +): + """`worktree list --porcelain -z` is a git 2.36 switch; the 2.34 support floor + rejects it (`error: unknown switch `z'`, exit 129 — measured on Ubuntu 22.04's + stock 2.34.1). Gated the other way every isolated-task resume reached + `worktree_is_registered`, got a `GitError`, and escalated instead of reopening + its recorded mount, and orphan reconciliation silently skipped its cleanup. + An unreadable version answer takes the same arm: the generous failure here is + the parse that works everywhere, not the one that needs the newer git. + + Ablation: make the `nul` gate unconditionally True and the argv assertion + reddens; split on `\\0` regardless of the gate and the listing reddens.""" + repo = project.project + wt = tmp_path / "plain" + verify.worktree_add(repo, wt, "plain-path", "main") + monkeypatch.setattr(verify, "git_below_floor", lambda _repo, _floor: answer) + real = verify._run_git + seen: list[list[str]] = [] + + def spy(args, cwd, **kw): + seen.append(list(args)) + return real(args, cwd, **kw) + + monkeypatch.setattr(verify, "_run_git", spy) + + listed = [path.resolve() for path in verify.worktree_list(repo)] + + assert seen and "-z" not in seen[-1] and "--porcelain" in seen[-1] + assert listed == [repo.resolve(), wt.resolve()] + + @pytest.mark.skipif(sys.platform == "win32", reason="Win32 forbids newlines in filenames") def test_worktree_list_preserves_newlines_in_paths(project, tmp_path): """NUL-delimited porcelain keeps a valid newline inside one path record.""" From 871b117c5c038ea47ef87046724b6e6d22a8daac Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 18:46:52 -0700 Subject: [PATCH 22/35] fix(tui,runs): anchor the escalation modal's spec on the tree the re-arm writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_do_rearm` hands `rearm_escalation` `project_root=self.project`, so after a project move the re-arm flips the spec under the live tree — while `_paused_spec` and `_paused_spec_root` still anchored on the recorded `state.project`. Opened from a moved project, the modal showed the old tree's copy: unreadable once that tree was gone, which refused the very re-arm the live mapping exists for; and with both trees present, the operator reviewed one spec and re-armed a different, unreviewed one. Both anchors now go through the mapping the re-arm uses. `_live_spec_path` and `_live_spec_root` are promoted to public `live_spec_path` / `live_spec_root` rather than re-derived in the TUI, because the read and write sides of one gesture must produce the identical path. The no-task arm of the root moves with them — it is the same mapping applied to the recorded project, which lands on `self.project`, so the two arms keep making one claim. --- CHANGELOG.md | 7 +++++ src/bmad_loop/runs.py | 21 ++++++++------- src/bmad_loop/tui/app.py | 36 ++++++++++++++++--------- tests/test_runs.py | 8 +++--- tests/test_tui_app.py | 58 +++++++++++++++++++++++++++++++++------- 5 files changed, 95 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b897e95..ae1a15ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,13 @@ breaking changes may land in a minor release. ### Fixed +- **The TUI escalation modal reads the spec the re-arm will write.** `_paused_spec` and + `_paused_spec_root` anchored on the recorded `state.project` while `_do_rearm` flips the + copy under the live project, so a run opened from a moved project showed the old tree's + spec — unreadable once that tree was gone, refusing the re-arm — or let the operator + review one copy and re-arm another. Both now use the same live mapping as the re-arm + (`runs.live_spec_path` / `live_spec_root`, promoted from private). + - **`worktree list -z` is gated on git 2.36; the 2.34 support floor keeps the newline parse.** The NUL-delimited listing was issued unconditionally, and Ubuntu 22.04's stock 2.34.1 rejects the switch (exit 129), so every isolated-task resume escalated instead of diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index ec5e0684..d77e5882 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3194,11 +3194,14 @@ def task_stories_root(task: StoryTask | None, state: RunState) -> Path: return mount -def _live_spec_path(task: StoryTask, state: RunState, project_root: Path) -> Path: +def live_spec_path(task: StoryTask, state: RunState, project_root: Path) -> Path: """`task_spec_path` carried onto the tree the caller is acting in. The pair below is the WRITE side of `rearm_escalation`: the file it flips and - re-stamps, and the root every writer confines that edit to. They move together + re-stamps, and the root every writer confines that edit to. Public because the + TUI's escalation modal is the READ side of that same gesture: it shows and + validates the spec `_do_rearm` then flips, so it must anchor on the identical + live path or the operator reviews one copy and re-arms another. They move together because `task_spec_root` is the confinement claim about the very path `task_spec_path` produces — rebasing one alone would hand the writers a path outside their own root, and all four of them answer that by silently dropping to @@ -3207,9 +3210,9 @@ def _live_spec_path(task: StoryTask, state: RunState, project_root: Path) -> Pat return rebase_recorded_project_path(task_spec_path(task, state), state, project_root) -def _live_spec_root(task: StoryTask, state: RunState, project_root: Path) -> Path: +def live_spec_root(task: StoryTask, state: RunState, project_root: Path) -> Path: """`task_spec_root` carried onto the tree the caller is acting in — the confine - root for the path `_live_spec_path` names. See there.""" + root for the path `live_spec_path` names. See there.""" return rebase_recorded_project_path(task_spec_root(task, state), state, project_root) @@ -4174,7 +4177,7 @@ def rearm_escalation( # proof the tree is untouched. try: if task.spec_file: - spec_path = _live_spec_path(task, state, live_project) + spec_path = live_spec_path(task, state, live_project) # 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 @@ -4384,7 +4387,7 @@ def rearm_escalation( flipped = verify.set_frontmatter_status( spec_path, target_status, - confine_root=_live_spec_root(task, state, live_project), + confine_root=live_spec_root(task, state, live_project), ) # `set_frontmatter_status` answers "nothing to change" with `False` # for FOUR causes, not three — its own docstring lists them: no file, @@ -4491,7 +4494,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=_live_spec_root(task, state, live_project) + spec_path, confine_root=live_spec_root(task, state, live_project) ) except verify.FrontmatterWriteError as e: # The spec reads fine but carries `status:` in a shape no line @@ -4659,7 +4662,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 = _live_spec_path(task, state, live_project) + spec_path = live_spec_path(task, state, live_project) 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 @@ -4694,7 +4697,7 @@ def rearm_escalation( spec_path, "baseline_revision", task.baseline_commit, - confine_root=_live_spec_root(task, state, live_project), + confine_root=live_spec_root(task, state, live_project), ) 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 f1d50021..587ceca2 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1081,7 +1081,14 @@ def _paused_spec(self, state: RunState) -> tuple[Path | None, str, bool]: task = self._paused_task(state) if task is None or not task.spec_file: return None, "", True - path = runs.task_spec_path(task, state) + # `live_spec_path`, not `task_spec_path`: the anchor is carried onto the + # project this dashboard was launched against, the same mapping `_do_rearm` + # hands `rearm_escalation`. Anchored on the recorded `state.project` alone, a + # run opened from a moved project showed (and validated) the copy under the + # old tree — unreadable once that tree is gone, which refused the very re-arm + # the live mapping exists for; and when it still exists, the operator reviewed + # one spec and re-armed a different, unreviewed one. + path = runs.live_spec_path(task, state, self.project) try: # `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, @@ -1108,18 +1115,23 @@ def _paused_spec_root(self, state: RunState) -> Path: backing both halves: an anchor and a `confine_root` that name different trees do not refuse, they silently degrade the write (#593). - The no-task arm is `Path(state.project)`, NOT `self.project`, so both arms make - one claim: the delegate answers from the state the run persisted at launch, - while `self.project` is the constructor's `resolve_or_lexical` of the operator's - argument, and the two can differ. That arm is currently unreachable from the - write path — `_review_plan_checkpoint`'s `done()` refuses a `None` `spec_path` - before calling `_do_replan`, and `_paused_spec` returns `None` on BOTH of its - arms (no task, and a task carrying no `spec_file`) — so this is about not - leaving a second claim lying around for a future caller, not a live bug. The - no-task arm is the only one reachable here: a task with an empty `spec_file` - still answers from `task_spec_root`, which needs no spec to name a tree.""" + Both arms are carried onto the live project through the one mapping + `_paused_spec` uses for the path (`runs.rebase_recorded_project_path`), so the + two halves make one claim: the tree the operator opened the dashboard against, + spelled by moving the recorded anchor lexically — which for the recorded + project itself IS `self.project`. Spelling the no-task arm as `self.project` + directly would be the same answer by a second route, and the point of routing + both through the mapping is that they cannot drift. That arm is currently + unreachable from the write path — `_review_plan_checkpoint`'s `done()` + refuses a `None` `spec_path` before calling `_do_replan`, and `_paused_spec` + returns `None` on BOTH of its arms (no task, and a task carrying no + `spec_file`) — so this is about not leaving a second claim lying around for a + future caller, not a live bug. 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) + if task: + return runs.live_spec_root(task, state, self.project) + return runs.rebase_recorded_project_path(Path(state.project), state, self.project) def _story_subtitle(self, state: RunState) -> Text: key = state.paused_story_key or "?" diff --git a/tests/test_runs.py b/tests/test_runs.py index 640be2d6..f8ddfff1 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2904,8 +2904,8 @@ def test_rearm_flips_the_spec_in_the_live_project_after_a_rename(tmp_path): Both halves are asserted, because a fix that wrote BOTH files would satisfy the first alone. - Ablation: revert `_live_spec_path` to a bare `task_spec_path` and this reddens on - the live spec's unchanged status. `_live_spec_root` is graded by the row below + Ablation: revert `live_spec_path` to a bare `task_spec_path` and this reddens on + the live spec's unchanged status. `live_spec_root` is graded by the row below instead, and deliberately: ablating the ROOT alone is invisible here, because every writer answers an out-of-root path by silently dropping to the unconfined arm — the write still lands, it just loses #593's O_NOFOLLOW walk. That silent degrade is @@ -2950,8 +2950,8 @@ def test_live_spec_root_still_confines_the_live_spec_path(tmp_path): ) task = run.state.tasks["1-1-a"] - spec_path = runs._live_spec_path(task, run.state, live_project) - spec_root = runs._live_spec_root(task, run.state, live_project) + spec_path = runs.live_spec_path(task, run.state, live_project) + spec_root = runs.live_spec_root(task, run.state, live_project) assert spec_path == live_project / "spec.md" assert spec_root == live_project diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index d6ec979d..602f04a9 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4036,22 +4036,23 @@ async def test_sentinel_indicator_reads_the_worktree_under_isolation(project, mo assert "pre-planning-halt sentinel" in shown -def test_paused_spec_root_without_a_task_answers_the_states_project(tmp_path): +def test_paused_spec_root_without_a_task_answers_the_live_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. + The delegate (`runs.live_spec_root`) carries the recorded anchor onto the tree + the dashboard was opened against — `self.project`, the same mapping `_do_rearm` + hands `rearm_escalation` — while `state.project` is the string the run persisted + at launch. The two differ after a project move, so a no-task arm answering + `state.project` raw was a second claim for a future caller to trip on: a confine + root naming the OLD tree for a path `_paused_spec` now anchors on the new one. 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. + Ablation: return `Path(state.project)` from the no-task arm and this reddens — + the two directories are deliberately different here. """ app = BmadLoopApp(tmp_path / "opened-here") state = RunState( @@ -4060,8 +4061,45 @@ def test_paused_spec_root_without_a_task_answers_the_states_project(tmp_path): 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 + assert app._paused_spec_root(state) == app.project + assert app._paused_spec_root(state) != tmp_path / "persisted-at-launch" + + +def test_paused_spec_follows_a_moved_project_to_the_tree_the_rearm_writes(tmp_path): + """The escalation modal's READ anchor and `_do_rearm`'s WRITE anchor name one + file. `_do_rearm` hands `rearm_escalation` `project_root=self.project`, so after a + project move the re-arm flips the copy under the live tree; anchored on the + recorded `state.project` alone, the modal showed the OLD tree's copy — unreadable + once that tree is gone, which disabled the very re-arm the live mapping exists + for, and when both exist the operator reviewed one spec and re-armed another. + + The old tree is absent here on purpose: an anchor that did not move reads as + "could not be read", so the row cannot pass by finding a stale twin. + + Ablations: revert `_paused_spec` to `runs.task_spec_path` and this reddens on + `readable`; revert `_paused_spec_root` to `runs.task_spec_root` and it reddens on + the confine root, which must be the live tree the path sits under.""" + recorded = tmp_path / "project-before-rename" + live = tmp_path / "project-after-rename" + rel = Path("_bmad-output") / "implementation-artifacts" / "spec-1-1-a.md" + (live / rel).parent.mkdir(parents=True) + (live / rel).write_text("---\nstatus: escalated\n---\n\n# Story\n", encoding="utf-8") + assert not recorded.exists() # the tree the run recorded is gone + app = BmadLoopApp(live) + state = RunState( + run_id="20260611-100000-aaaa", + project=str(recorded), + started_at="2026-06-11T10:00:00", + paused_story_key="1-1-a", + ) + state.tasks["1-1-a"] = StoryTask(story_key="1-1-a", epic=1, spec_file=str(recorded / rel)) + + spec_path, spec_text, readable = app._paused_spec(state) + + assert readable is True + assert spec_path == live / rel + assert spec_text.startswith("---\nstatus: escalated") + assert app._paused_spec_root(state) == live async def test_paused_spec_missing_at_the_anchor_reads_as_not_found(project, monkeypatch): From f53c21e374a452bcf0ce40f28cc994ed482dfc34 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 20:15:37 -0700 Subject: [PATCH 23/35] fix(tui,runs): locate the modal's stories folder on the tree the re-arm writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_story_context` and `_sentinel_kind` anchored on `runs.task_stories_root`, whose no-mount arm is the recorded `state.project`, while `_paused_spec` already reads through the live mapping the re-arm writes. After a project move the modal showed the live spec beside a title, description and sentinel indicator from the old tree — omitted once it was gone, stale while it lingered. Add `runs.live_stories_root` beside `live_spec_root` and use it in both readers. A mount sits outside the recorded project, so the rebase passes it through and the isolated arm keeps its answer; only the project fallback moves. --- CHANGELOG.md | 15 +++++++----- src/bmad_loop/runs.py | 15 ++++++++++++ src/bmad_loop/tui/app.py | 23 ++++++++++------- tests/test_tui_app.py | 53 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae1a15ad..eb70ddcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,12 +248,15 @@ breaking changes may land in a minor release. ### Fixed -- **The TUI escalation modal reads the spec the re-arm will write.** `_paused_spec` and - `_paused_spec_root` anchored on the recorded `state.project` while `_do_rearm` flips the - copy under the live project, so a run opened from a moved project showed the old tree's - spec — unreadable once that tree was gone, refusing the re-arm — or let the operator - review one copy and re-arm another. Both now use the same live mapping as the re-arm - (`runs.live_spec_path` / `live_spec_root`, promoted from private). +- **The TUI escalation modal reads the spec, story context and sentinel the re-arm will + write.** `_paused_spec` / `_paused_spec_root` anchored on the recorded `state.project` + while `_do_rearm` flips the copy under the live project, so a run opened from a moved + project showed the old tree's spec — unreadable once that tree was gone, refusing the + re-arm — or let the operator review one copy and re-arm another; `_story_context` and + `_sentinel_kind` located the stories folder the same way, so the modal omitted the title, + description and sentinel indicator (or showed stale ones) beside the live spec. All four + now use the same live mapping as the re-arm (`runs.live_spec_path` / `live_spec_root`, + promoted from private, and the new `runs.live_stories_root`). - **`worktree list -z` is gated on git 2.36; the 2.34 support floor keeps the newline parse.** The NUL-delimited listing was issued unconditionally, and Ubuntu 22.04's stock diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index d77e5882..31e61d28 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3216,6 +3216,21 @@ def live_spec_root(task: StoryTask, state: RunState, project_root: Path) -> Path return rebase_recorded_project_path(task_spec_root(task, state), state, project_root) +def live_stories_root(task: StoryTask | None, state: RunState, project_root: Path) -> Path: + """`task_stories_root` carried onto the tree the caller is acting in — the root + the stories folder is located from by the READ side of the re-arm gesture. + + The escalation modal's title, description and sentinel indicator are read from + the stories folder, and `_do_rearm` clears that sentinel at `live_spec_path`. A + locator answering the recorded `state.project` after a project move reads the + manifest from a tree the re-arm no longer writes: absent once the old tree is + gone, stale while it lingers. A mount is a path under the run dir, outside the + recorded project, so `rebase_recorded_project_path` passes it through unchanged + and the isolated arm keeps its answer; only the project fallback moves. + """ + return rebase_recorded_project_path(task_stories_root(task, state), state, project_root) + + def _spec_is_shared_with_the_redrive(state: RunState, task: StoryTask) -> bool: """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. diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 587ceca2..01de4156 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1145,15 +1145,16 @@ 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` + # `live_stories_root`, not `self.project` bare, 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) + # defect the anchor exists to close. The no-task fallback is still the recorded + # `state.project`; what `live_stories_root` adds is the mapping `_do_rearm` + # writes through (`project_root=self.project`), so after a project move the + # manifest is read from the tree the re-arm clears the sentinel in, not from + # the launch-time spelling — absent once that tree is gone, stale while it stays. + root = runs.live_stories_root(state.tasks.get(key), state, self.project) try: folder = stories.resolve_spec_folder(root, state.spec_folder) entry = stories.load_stories(folder).get(key) @@ -1173,12 +1174,16 @@ def _sentinel_kind(self, state: RunState, key: str) -> str: # different trees let a single modal disagree with itself and rendered a # pre-planning sentinel wedge as an ordinary escalation. # - # `task_stories_root`, not `task_spec_root`: the folder is located from the + # `live_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) + # no-task fallback is not re-spelled here. And "live", for the same reason + # `_paused_spec` goes through `live_spec_path`: the re-arm clears the sentinel + # under `self.project`, so a scan anchored on the recorded `state.project` + # misses it after a project move (or keeps showing a cleared twin). A mount + # lies outside the recorded project and passes through the mapping unchanged. + root = runs.live_stories_root(state.tasks.get(key), state, self.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. diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 602f04a9..11d0d934 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4102,6 +4102,59 @@ def test_paused_spec_follows_a_moved_project_to_the_tree_the_rearm_writes(tmp_pa assert app._paused_spec_root(state) == live +def test_story_context_and_sentinel_follow_a_moved_project_to_the_tree_the_rearm_writes( + tmp_path, +): + """The modal's OTHER two readers move with `_paused_spec`. `_story_context` and + `_sentinel_kind` located the stories folder from `runs.task_stories_root`, whose + no-mount arm is the recorded `state.project`, while `_do_rearm` clears the sentinel + under the live tree (`rearm_escalation(..., project_root=self.project)`). After a + project move the modal showed the spec from the live tree beside a title, + description and sentinel indicator from the old one — omitted once that tree was + gone, stale while it lingered. + + The old tree is absent here on purpose: an anchor that did not move finds no + manifest and no sentinel, so the row cannot pass on a stale twin. + + Ablations: revert `_story_context` to `runs.task_stories_root(...)` and this + reddens on the title (`('', '') == ('Story 1', 'does a thing')`); revert + `_sentinel_kind` the same way and it reddens on the kind (`'' == 'unresolved'`).""" + import yaml + + from bmad_loop import stories + + recorded = tmp_path / "project-before-rename" + live = tmp_path / "project-after-rename" + folder = live / "epic-1" + (folder / "stories").mkdir(parents=True) + (folder / "stories.yaml").write_text( + yaml.safe_dump( + [{"id": "1", "title": "Story 1", "description": "does a thing"}], sort_keys=False + ), + encoding="utf-8", + newline="\n", + ) + spec = folder / "stories" / "1-unresolved.md" + spec.write_text("---\nstatus: escalated\n---\n", encoding="utf-8", newline="\n") + assert stories.resolve_story_spec(folder, "1").kind == stories.KIND_SENTINEL + assert not recorded.exists() # the tree the run recorded is gone + app = BmadLoopApp(live) + state = RunState( + run_id="20260611-100000-aaaa", + project=str(recorded), + started_at="2026-06-11T10:00:00", + source="stories", + spec_folder="epic-1", + paused_story_key="1", + ) + state.tasks["1"] = StoryTask( + story_key="1", epic=0, spec_file=str(recorded / "epic-1" / "stories" / "1-unresolved.md") + ) + + assert app._story_context(state, "1") == ("Story 1", "does a thing") + assert app._sentinel_kind(state, "1") == "unresolved" + + 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 285821b96888d8dfeed5a01bea264894c0883dc2 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 20:15:42 -0700 Subject: [PATCH 24/35] test(tui): write the moved-project spec with LF so the bytes read matches on Windows --- tests/test_tui_app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 11d0d934..37a4d41a 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -4083,7 +4083,9 @@ def test_paused_spec_follows_a_moved_project_to_the_tree_the_rearm_writes(tmp_pa live = tmp_path / "project-after-rename" rel = Path("_bmad-output") / "implementation-artifacts" / "spec-1-1-a.md" (live / rel).parent.mkdir(parents=True) - (live / rel).write_text("---\nstatus: escalated\n---\n\n# Story\n", encoding="utf-8") + (live / rel).write_text( + "---\nstatus: escalated\n---\n\n# Story\n", encoding="utf-8", newline="\n" + ) assert not recorded.exists() # the tree the run recorded is gone app = BmadLoopApp(live) state = RunState( From 5351da9f7466ab600bcb6f017baf90ce2e83b7e3 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 20:25:43 -0700 Subject: [PATCH 25/35] fix(workspace): park an orphaned mount's uncommitted work before the remount reclaims its path --- CHANGELOG.md | 10 ++ src/bmad_loop/workspace.py | 123 ++++++++++++++++++++-- src/bmad_loop/worktree_flow.py | 10 ++ tests/test_engine_worktree.py | 185 ++++++++++++++++++++++++++++++++- 4 files changed, 319 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb70ddcf..29716ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,16 @@ breaking changes may land in a minor release. ### Fixed +- **A remount parks an orphaned worktree's uncommitted work before reclaiming its path.** + `open_unit_workspace` force-removed whatever stood at the deterministic mount path, so the + directory an isolation flip had "retained for recovery" lost its tracked edits and + run-created files irreversibly (even under `keep_failed = true`) — only commits unique to a + story-branch tip were preserved. A registered orphan's dirty state is now snapshotted under + `refs/attempt-preserve-dirty/--orphan` (the family `scm.preserve_keep` already + bounds) and journaled as `isolation-flip-orphan-preserved`; a clean orphan parks nothing, a + plain directory is never read as a worktree (git run there would address the project + checkout), and a failed capture refuses the remount and leaves the orphan standing. + - **The TUI escalation modal reads the spec, story context and sentinel the re-arm will write.** `_paused_spec` / `_paused_spec_root` anchored on the recorded `state.project` while `_do_rearm` flips the copy under the live project, so a run opened from a moved diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index d5af41bf..2999cfe4 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -24,7 +24,7 @@ from . import verify from .bmadconfig import ProjectPaths from .platform_util import safe_ref_segment, safe_segment -from .recovery_flow import attempt_preserve_ref_name +from .recovery_flow import PRESERVE_REF_PROBE_LIMIT, attempt_preserve_ref_name # Per-unit worktrees live under the run dir (.bmad-loop/runs//worktrees/), # which `bmad-loop init` already gitignores — so unit checkouts never show up as @@ -101,6 +101,89 @@ def unit_branch_name(run_id: str, unit_key: str, branch_per: str) -> str: return f"bmad-loop/{safe_ref_segment(run_id)}/{safe_ref_segment(unit_key)}" +def orphan_preserve_ref_name(run_id: str, head: str) -> str: + """Canonical snapshot ref for the uncommitted state of an orphaned mount. + + Lives under ``refs/attempt-preserve-dirty/`` — the SAME family + :func:`verify.prune_preserve_dirty_refs` bounds with ``scm.preserve_keep`` at + every run start — so an orphan snapshot is retained and expired on exactly + the terms a rollback snapshot is, and no new unbounded ref family exists. The + ``-orphan`` suffix keeps it from ever colliding with a rollback's + ``{slug}-{baseline}-{attempt}`` shape, whose last segment is an integer. + """ + return f"refs/attempt-preserve-dirty/{safe_ref_segment(run_id)}-{head[:8]}-orphan" + + +def _preserve_orphan_state( + repo_root: Path, + wt: Path, + run_id: str, + unit_key: str, + on_orphan_preserved: Callable[[str, str], None] | None, +) -> None: + """Park the uncommitted work an orphaned mount at ``wt`` still holds before the + reclaim force-removes it. See the reclaim comment in :func:`open_unit_workspace` + for why an orphan can stand at this path at all. + + Three-way guard before any git runs *in* ``wt``: the path exists, it is one of + ``repo_root``'s registered linked worktrees, AND git invoked there reports that + exact toplevel (:func:`verify.worktree_is_registered`). The run dir lives INSIDE + the project checkout, so a leftover plain directory (rmtree fallback residue, an + operator's copy) would otherwise make ``git status``/``add`` address the + PROJECT's own working tree and park — or worse, report as the orphan's — the + user's uncommitted edits. Anything that fails the guard is left to the reclaim + exactly as before this gate existed. + + ``baseline_untracked=[]``, not ``None``: a mount is a fresh checkout, so every + non-ignored untracked file in it was run-created (seeded skill/config files are + shielded as ignored, see ``provision_worktree``) and there is no pre-existing + user file to protect — ``None`` would park the tracked edits and silently drop + every untracked file, which for a run that writes new modules is most of the + work. The snapshot is taken against the orphan's OWN ``HEAD`` (before any story + branch reset below moves that ref) so the parked commit is parented at the tree + the orphan actually diverged from and holds only what was uncommitted. + + A clean tree is a no-op (no ref, no callback). A capture failure raises + :class:`verify.GitError` and so refuses the remount (#340: a capture failure + over a tree with something to lose is a gate, not a footnote) — the orphan is + left standing for manual recovery, and the caller's ``worktree-open-failed`` + path defers the unit. ``OSError`` from the snapshot's temp index is folded into + that same refusal rather than escaping untyped. + """ + if not wt.exists() or not verify.worktree_is_registered(repo_root, wt): + return + try: + head = verify.rev_parse_head(wt) + base_ref = orphan_preserve_ref_name(run_id, head) + ref = base_ref + serial = 2 + # Same bounded serial probe as RecoveryFlow.preserve_attempt_worktree: a + # second orphaning of the same HEAD (flip, flip back, flip again with + # nothing committed in between) must not overwrite the first snapshot. + while verify.ref_exists(repo_root, ref): + if serial > PRESERVE_REF_PROBE_LIMIT: + raise verify.PreserveRefExhaustedError( + f"no free snapshot refname for {base_ref}: " + f"{PRESERVE_REF_PROBE_LIMIT} candidates through -r{serial - 1} " + f"are all taken (prune refs/attempt-preserve-dirty/*, or set " + f"scm.preserve_keep to a positive value below that limit)" + ) + ref = f"{base_ref}-r{serial}" + serial += 1 + parked = verify.snapshot_worktree(wt, ref, baseline_untracked=[]) + except OSError as e: + raise verify.GitError( + f"cannot snapshot orphaned worktree {wt} for {unit_key} before reclaim: {e}" + ) from e + except verify.GitError as e: + raise verify.GitError( + f"cannot snapshot orphaned worktree {wt} for {unit_key} before reclaim " + f"(left standing; recover by hand): {e}" + ) from e + if parked is not None and on_orphan_preserved is not None: + on_orphan_preserved(str(wt), parked) + + def open_unit_workspace( repo_root: Path, paths: ProjectPaths, @@ -109,6 +192,8 @@ def open_unit_workspace( base: str, branch_per: str, run_dir: Path, + *, + on_orphan_preserved: Callable[[str, str], None] | None = None, ) -> UnitWorkspace: """Mount a fresh worktree for `unit_key` and return its rebased workspace. @@ -118,6 +203,17 @@ def open_unit_workspace( remain reachable. Existing story-scoped branches are abandoned-attempt state: commits unique to their named tip are parked under ``attempt-preserve/*``, then the story branch is compare-and-swap reset to the pinned base before remount. + + Whatever already occupies the deterministic mount path is reclaimed first. If + it is a registered worktree of ``repo_root`` (an orphan left standing by an + isolation flip, see the reclaim comment) its *uncommitted* state — tracked edits + and run-created untracked files — is parked under + ``refs/attempt-preserve-dirty/--orphan`` before the force-remove; a + clean orphan parks nothing. ``on_orphan_preserved`` (worktree path, ref) fires + once per parked snapshot so a caller with a journal can record it — + ``open_unit_workspace`` has none, in the style of ``close_unit_workspace``'s + ``on_teardown_degraded``. A snapshot that cannot be written refuses the remount + (raises ``GitError``) and leaves the orphan standing. """ branch = unit_branch_name(run_id, unit_key, branch_per) unresolved_wt = unit_worktrees_dir(run_dir) / safe_segment(unit_key) @@ -135,6 +231,10 @@ def open_unit_workspace( branch_tip: str | None = None if verify.branch_exists(repo_root, branch): branch_tip = verify.rev_parse_revision(repo_root, f"refs/heads/{branch}") + # Park an orphan's uncommitted state FIRST — before the story reset below moves + # the ref the orphan's HEAD points at, so the snapshot is parented at the tree + # the orphan actually holds and captures only what was never committed. + _preserve_orphan_state(repo_root, wt, run_id, unit_key, on_orphan_preserved) if branch_tip is not None and branch_per == "story": commits = verify.commits_above(repo_root, pinned_base, branch_tip) if commits: @@ -153,13 +253,20 @@ def open_unit_workspace( # 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. + # `engine._release_orphaned_mount` 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, "retained for + # recovery"), 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. + # + # "Retained for recovery" is only honest if the reclaim does not itself destroy + # what was retained: the force-remove below discards the orphan's uncommitted + # files irreversibly (even under `keep_failed = true`, which governs teardown + # after a session, not this pre-mount reclaim), while the story-branch block + # above preserves committed work alone. `_preserve_orphan_state` closes that + # gap — a snapshot ref for anything uncommitted, taken before this line. # # The BRANCH is deliberately not passed: `discard_worktree` would force-delete it. # A run-scoped name carries commits earlier units landed; a story-scoped name has diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 14dc2a48..c63cd24e 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1542,6 +1542,16 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N self.state.target_branch, self.policy.scm.branch_per, self.run_dir, + # An orphan an isolation flip left standing at this unit's mount + # path is reclaimed by the open; its uncommitted state is parked + # first and named here so the recovery ref is discoverable from the + # journal (`isolation-flip-orphaned-worktree` recorded the orphan). + on_orphan_preserved=lambda worktree, ref: self.journal.append( + "isolation-flip-orphan-preserved", + story_key=task.story_key, + worktree=worktree, + ref=ref, + ), ) except verify.GitSpawnError as e: # a spawn fault is machine-wide, not this unit's: deferring would diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 2640546d..93b948fc 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -2986,6 +2986,10 @@ def test_story_remount_does_not_reread_head_after_tip_validation(project, monkey Ablation: restore ``baseline = rev_parse_head(wt)`` and the injected rival move lands between validation and that read, returning the rival as baseline. + + Only reads of the MOUNTED checkout count: the orphan reclaim legitimately reads + the first mount's HEAD (at the same path) before ``worktree_add`` to park its + uncommitted state, so the spy arms itself on the mount call. """ from bmad_loop.workspace import open_unit_workspace @@ -2996,16 +3000,24 @@ def test_story_remount_does_not_reread_head_after_tip_validation(project, monkey old_tip = rev_parse_head(first.path) pinned = rev_parse_head(project.project) real_head = verify.rev_parse_head + real_add = verify.worktree_add reads = 0 + mounted = False + + def arm_on_mount(*a, **k): + nonlocal mounted + real_add(*a, **k) + mounted = True def move_on_redundant_head_read(repo): nonlocal reads - if Path(repo).resolve() == first.path.resolve(): + if mounted and Path(repo).resolve() == first.path.resolve(): reads += 1 if reads == 2: git(project.project, "update-ref", f"refs/heads/{first.branch}", old_tip, pinned) return real_head(repo) + monkeypatch.setattr(verify, "worktree_add", arm_on_mount) monkeypatch.setattr(verify, "rev_parse_head", move_on_redundant_head_read) second = open_unit_workspace(*args) @@ -6379,3 +6391,174 @@ def host_loss(*_a, **_k): entry = _ledger_entry(project, "DW-1") assert entry.status.startswith("done") and not entry.open assert "resolution: resolved by story 1-1-a" in entry.body + + +# ------------------------------------------- remount reclaim: orphan preservation + + +def _open_args(project, key="1-1-a", branch_per="story"): + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + return (project.project, project, "test-run", key, "main", branch_per, run_dir) + + +def _dirty_refs(project) -> list[str]: + out = git( + project.project, "for-each-ref", "--format=%(refname)", "refs/attempt-preserve-dirty/" + ) + return out.splitlines() + + +def _commit_project(project, message: str) -> str: + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", message) + return rev_parse_head(project.project) + + +def test_remount_parks_dirty_orphan_before_reclaim(project): + """An orphan the isolation flip left standing is force-removed by the remount's + reclaim; its UNCOMMITTED state — a tracked edit and a run-created untracked file + — is parked under ``refs/attempt-preserve-dirty/--orphan`` first and + the callback names the ref. A second orphaning of the same HEAD probes to + ``-r2`` rather than overwriting the first snapshot. + + Ablation: drop the ``_preserve_orphan_state`` call and the remount still + succeeds but ``preserved == []`` and no ref holds either file. + """ + from bmad_loop.workspace import open_unit_workspace + + (project.project / "tracked.txt").write_text("v1\n") + first, _ = _open_unit(project) # commits tracked.txt with the sprint board + (first.path / "tracked.txt").write_text("edited in the orphan\n") + (first.path / "created.txt").write_text("run-created\n") + orphan_head = rev_parse_head(first.path) + + preserved: list[tuple[str, str]] = [] + second = open_unit_workspace( + *_open_args(project), on_orphan_preserved=lambda p, r: preserved.append((p, r)) + ) + + assert second.path == first.path and second.path.is_dir() + assert (second.path / "tracked.txt").read_text() == "v1\n" # a fresh checkout + assert not (second.path / "created.txt").exists() + ref = f"refs/attempt-preserve-dirty/test-run-{orphan_head[:8]}-orphan" + assert preserved == [(str(first.path), ref)] + assert verify.ref_exists(project.project, ref) + assert git(project.project, "show", f"{ref}:tracked.txt") == "edited in the orphan" + assert git(project.project, "show", f"{ref}:created.txt") == "run-created" + assert git(project.project, "rev-parse", f"{ref}^") == orphan_head # parented at HEAD + # the family scm.preserve_keep bounds — one ref, keep=1, nothing over budget + assert verify.prune_preserve_dirty_refs(project.project, 1) == [] + + (second.path / "created.txt").write_text("second orphaning\n") + preserved.clear() + open_unit_workspace( + *_open_args(project), on_orphan_preserved=lambda p, r: preserved.append((p, r)) + ) + assert preserved == [(str(first.path), f"{ref}-r2")] + assert git(project.project, "show", f"{ref}:created.txt") == "run-created" # untouched + assert git(project.project, "show", f"{ref}-r2:created.txt") == "second orphaning" + assert len(_dirty_refs(project)) == 2 # both in the family preserve_keep bounds + + +def test_remount_over_clean_orphan_parks_nothing(project): + """A clean orphan (tree == HEAD) is reclaimed silently: no ref, no callback.""" + from bmad_loop.workspace import open_unit_workspace + + first, _ = _open_unit(project) + preserved: list[tuple[str, str]] = [] + second = open_unit_workspace( + *_open_args(project), on_orphan_preserved=lambda p, r: preserved.append((p, r)) + ) + assert second.path == first.path and second.path.is_dir() + assert preserved == [] + assert _dirty_refs(project) == [] + + +def test_remount_over_plain_directory_never_snapshots_the_project_tree(project): + """The run dir lives INSIDE the project checkout, so a plain (non-worktree) + directory at the mount path must not have git run in it: `status`/`add` there + would address the PROJECT's own working tree and park the operator's edits as + the orphan's. The guard is ``verify.worktree_is_registered``; a failing guard + falls through to the reclaim exactly as before. + + Ablation: drop the ``worktree_is_registered`` half of the guard and the dirty + project checkout is snapshotted — ``_dirty_refs`` is non-empty and holds + ``operator.txt``. + """ + from bmad_loop.workspace import open_unit_workspace + + (project.project / "tracked.txt").write_text("v1\n") + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + plain = run_dir / "worktrees" / "1-1-a" + plain.mkdir(parents=True) + (plain / "leftover.txt").write_text("rmtree residue\n") + # the operator's own uncommitted work in the project checkout + (project.project / "tracked.txt").write_text("operator edit\n") + (project.project / "operator.txt").write_text("operator untracked\n") + + preserved: list[tuple[str, str]] = [] + unit = open_unit_workspace( + *_open_args(project), on_orphan_preserved=lambda p, r: preserved.append((p, r)) + ) + + assert unit.path == plain.resolve() and unit.path.is_dir() + assert not (unit.path / "leftover.txt").exists() # reclaimed as before + assert preserved == [] + assert _dirty_refs(project) == [] + # the project tree was neither read as the orphan nor touched + assert (project.project / "tracked.txt").read_text() == "operator edit\n" + assert (project.project / "operator.txt").read_text() == "operator untracked\n" + + +def test_remount_refuses_when_orphan_snapshot_fails(project, monkeypatch): + """A capture failure over a dirty orphan is a gate (#340): the remount raises + ``GitError`` and the orphan is left standing, dirty files intact, for manual + recovery — never force-removed past work that could not be parked. + + Ablation: swallow the ``snapshot_worktree`` failure in ``_preserve_orphan_state`` + (``except GitError: return``) and the remount succeeds over the orphan — no + ``GitError``, ``created.txt`` gone. + """ + from bmad_loop.workspace import open_unit_workspace + + first, _ = _open_unit(project) + (first.path / "created.txt").write_text("run-created\n") + + def boom(*a, **k): + raise verify.GitError("commit-tree: disk says no") + + monkeypatch.setattr(verify, "snapshot_worktree", boom) + + with pytest.raises(verify.GitError, match="left standing.*disk says no"): + open_unit_workspace(*_open_args(project)) + + assert first.path.is_dir() + assert (first.path / "created.txt").read_text() == "run-created\n" + assert verify.worktree_is_registered(project.project, first.path) + assert _dirty_refs(project) == [] + + +def test_engine_remount_journals_orphan_preservation(project): + """Engine wiring: the orphan snapshot the open parks is journaled as + ``isolation-flip-orphan-preserved`` with the worktree and ref, so an operator + reading the run's journal can find the recovery ref next to the + ``isolation-flip-orphaned-worktree`` record that named the orphan.""" + orphan, _ = _open_unit(project) + (orphan.path / "created.txt").write_text("run-created\n") + + engine, _ = make_engine( + project, + [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)], + ) + summary = engine.run() + + assert summary.done == 1 + entries = [ + e for e in engine.journal.entries() if e["kind"] == "isolation-flip-orphan-preserved" + ] + assert len(entries) == 1 + assert entries[0]["story_key"] == "1-1-a" + assert entries[0]["worktree"] == str(orphan.path) + assert entries[0]["ref"].startswith("refs/attempt-preserve-dirty/test-run-") + assert git(project.project, "show", f"{entries[0]['ref']}:created.txt") == "run-created" From b1b27d002c583d0e84c214eccfd768ce508b5632 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 20:26:11 -0700 Subject: [PATCH 26/35] fix(workspace): catch a remounted run branch up to the pinned base --- CHANGELOG.md | 10 +++ src/bmad_loop/workspace.py | 61 +++++++++++++-- tests/test_engine_worktree.py | 135 ++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29716ae2..0d81286a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -258,6 +258,16 @@ breaking changes may land in a minor release. plain directory is never read as a worktree (git run there would address the project checkout), and a failed capture refuses the remount and leaves the orphan standing. +- **A `branch_per = "run"` remount catches the run branch up to the pinned base.** An + existing run branch reattached at its own tip and ignored `pinned_base`, so when the target + advanced while the run branch was unmounted (a story landed in place, then isolation flipped + back) the next unit developed without that story — `merge_strategy = "ff"` then refused the + integration and the other strategies merged stale work. The run branch is now + compare-and-swap fast-forwarded before the mount when its tip is an ancestor of the base, + and a diverged base (the normal serial shape under `squash`) is merged into the fresh mount; + a conflicting catch-up aborts, drops the mount, leaves the run tip unchanged, and raises for + an operator to reconcile. The attempt baseline is read after the catch-up. + - **The TUI escalation modal reads the spec, story context and sentinel the re-arm will write.** `_paused_spec` / `_paused_spec_root` anchored on the recorded `state.project` while `_do_rearm` flips the copy under the live project, so a run opened from a moved diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index 2999cfe4..452f6c2c 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -199,10 +199,27 @@ def open_unit_workspace( The worktree is mounted under the run dir (see unit_worktrees_dir), not under .git/. A new unit branch is cut from a pinned resolution of ``base``. Existing - run-scoped branches reattach at their own pinned tip so earlier landed units - remain reachable. Existing story-scoped branches are abandoned-attempt state: - commits unique to their named tip are parked under ``attempt-preserve/*``, then - the story branch is compare-and-swap reset to the pinned base before remount. + story-scoped branches are abandoned-attempt state: commits unique to their + named tip are parked under ``attempt-preserve/*``, then the story branch is + compare-and-swap reset to the pinned base before remount. + + Existing run-scoped branches are cumulative and reattach carrying every unit + landed so far — but at a tip *caught up to the pinned base*, not blindly at + their own tip. The target can advance while the run branch is unmounted (live + policy flips isolation off, a story lands in place on the target, policy flips + back), and a remount at the stale tip would develop the next unit without that + story: ``merge_strategy = "ff"`` then refuses integration, the other strategies + merge stale work. Three shapes, decided on pinned shas: the run tip already + contains the base — mount as-is; the run tip is an ancestor of the base — the + run branch is compare-and-swap fast-forwarded to the base BEFORE the mount, so + the mount comes up at the base; the two diverged — mount at the tip and merge + the base into the run branch inside the fresh mount. Divergence is the NORMAL + serial-unit shape under ``merge_strategy = "squash"`` (the target receives a + squash commit that does not contain the run tip), and identical content on both + sides merges clean. A conflicting merge is aborted, the just-created mount is + dropped, and :class:`verify.GitError` is raised: the run branch tip is unchanged + and an operator must reconcile. The returned ``baseline`` is read AFTER that + catch-up, so the attempt baseline is the tree the session actually starts from. Whatever already occupies the deterministic mount path is reclaimed first. If it is a registered worktree of ``repo_root`` (an orphan left standing by an @@ -226,7 +243,8 @@ def open_unit_workspace( # Pin every moving input before the first mutation. A story branch is an # attempt-local name: reclaim preserves any commits unique to its old tip, # then resets it to the requested base with compare-and-swap semantics. A run - # branch is cumulative and deliberately keeps its own tip across remounts. + # branch is cumulative and keeps its own history across remounts, catching up + # to the pinned base below rather than being reset to it. pinned_base = verify.rev_parse_revision(repo_root, base) branch_tip: str | None = None if verify.branch_exists(repo_root, branch): @@ -273,7 +291,17 @@ def open_unit_workspace( # already been safely reset above. Dropping only the worktree frees the checkout # that blocks `worktree_add` without introducing a second ref mutation here. discard_worktree(repo_root, str(wt), "", run_dir=run_dir) + catch_up_base: str | None = None if branch_tip is not None: + if branch_per == "run" and not verify.is_ancestor(repo_root, pinned_base, branch_tip): + if verify.is_ancestor(repo_root, branch_tip, pinned_base): + # The base strictly advanced past the run tip: fast-forward the run + # branch (compare-and-swap on the pinned tip) so the mount comes up + # at the base. No mount holds the branch here — the reclaim above + # released it — so the ref move cannot desync a checkout. + verify.reset_branch_if_tip(repo_root, branch, pinned_base, branch_tip) + else: + catch_up_base = pinned_base # diverged: merge inside the fresh mount verify.worktree_add(repo_root, wt, branch, create=False) if branch_per == "story": try: @@ -290,12 +318,33 @@ def open_unit_workspace( # must not be reset or deleted by this failure cleanup. discard_worktree(repo_root, str(wt), "", run_dir=run_dir) raise + if catch_up_base is not None: + try: + verify.merge_branch( + wt, + catch_up_base, + strategy="merge", + message=f"Merge {base} ({catch_up_base[:12]}) into {branch}", + ) + except verify.GitError as e: + # `merge_branch` has already aborted a merge that started. Drop only + # the mount we just created (the run branch keeps its pinned tip) + # and refuse: the run branch and the target have diverged in a way + # only an operator can reconcile. + discard_worktree(repo_root, str(wt), "", run_dir=run_dir) + raise verify.GitError( + f"run branch {branch} at {branch_tip[:12]} diverged from {base} at " + f"{catch_up_base[:12]} and the catch-up merge failed; reconcile the " + f"run branch by hand before remounting: {e}" + ) from e else: verify.worktree_add(repo_root, wt, branch, base=pinned_base, create=True) # A story checkout was already verified against the pinned base above. Do # not re-read its symbolic HEAD after that boundary: a rival ref move in this # final window would record unverified history as the attempt baseline even - # though the mounted index and files still represent ``pinned_base``. + # though the mounted index and files still represent ``pinned_base``. A run + # checkout is read here, AFTER the fast-forward/merge catch-up above, so the + # baseline is the tree the session actually starts from. baseline = pinned_base if branch_per == "story" else verify.rev_parse_head(wt) return UnitWorkspace( workspace=Workspace(root=wt, paths=paths.rebased(wt)), diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 93b948fc..5cde54a4 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -6562,3 +6562,138 @@ def test_engine_remount_journals_orphan_preservation(project): assert entries[0]["worktree"] == str(orphan.path) assert entries[0]["ref"].startswith("refs/attempt-preserve-dirty/test-run-") assert git(project.project, "show", f"{entries[0]['ref']}:created.txt") == "run-created" + + +# ------------------------------------------- run-branch remount catches up to base + + +def test_run_branch_remount_fast_forwards_to_an_advanced_base(project): + """branch_per=run: a run branch whose tip is an ancestor of the (advanced) + pinned base is fast-forwarded to the base before the mount — the isolation-flip + shape where a story landed in place on the target while the run branch was + unmounted. The mount and the baseline come up at the base. + + Ablation: drop the ``reset_branch_if_tip`` fast-forward arm and the mount comes + up at the stale run tip (``HEAD == old_tip``, not the advanced base). + """ + from bmad_loop.workspace import discard_worktree, open_unit_workspace + + first, run_dir = _open_unit(project, branch_per="run") + old_tip = rev_parse_head(first.path) + discard_worktree(project.project, str(first.path), "", run_dir=run_dir) # flip away + (project.project / "landed-in-place.txt").write_text("story committed on main\n") + advanced = _commit_project(project, "story landed in place") + assert advanced != old_tip + + second = open_unit_workspace(*_open_args(project, branch_per="run")) + + assert rev_parse_head(second.path) == advanced + assert git(project.project, "rev-parse", f"refs/heads/{second.branch}") == advanced + assert second.baseline == advanced + assert (second.path / "landed-in-place.txt").exists() + + +def test_run_branch_remount_merges_a_diverged_base(project): + """branch_per=run, diverged: the run branch carries a unit the target lacks + AND the target advanced without the run tip. The remount mounts at the tip and + merges the base into the run branch inside the fresh mount; HEAD is a merge + commit whose parents are exactly the run tip and the base, and the returned + baseline is that merge commit (the tree the session starts from). + + Ablation: drop the ``catch_up_base`` merge and HEAD stays at the run tip with + a single parent and no ``target.txt``. + """ + from bmad_loop.workspace import discard_worktree, open_unit_workspace + + first, run_dir = _open_unit(project, branch_per="run") + (first.path / "unit.txt").write_text("landed on the run branch\n") + git(first.path, "add", "-A") + git(first.path, "commit", "-q", "-m", "unit on run branch") + run_tip = rev_parse_head(first.path) + discard_worktree(project.project, str(first.path), "", run_dir=run_dir) + (project.project / "target.txt").write_text("advanced without the run tip\n") + base = _commit_project(project, "target advances") + + second = open_unit_workspace(*_open_args(project, branch_per="run")) + + head = rev_parse_head(second.path) + parents = git(second.path, "rev-list", "--parents", "-n", "1", "HEAD").split()[1:] + assert set(parents) == {run_tip, base} + assert second.baseline == head + assert git(project.project, "rev-parse", f"refs/heads/{second.branch}") == head + assert (second.path / "unit.txt").exists() and (second.path / "target.txt").exists() + assert worktree_clean(second.path) + + +def test_run_branch_remount_refuses_a_conflicting_base(project): + """branch_per=run, diverged with a content conflict: the catch-up merge is + aborted, the just-created mount is dropped, the run branch tip is unchanged, + and ``GitError`` names the refusal — an operator must reconcile. + + Ablation: swallow the merge failure (``except GitError: pass`` around the + catch-up) and the open returns a mounted worktree — no ``GitError``. + """ + from bmad_loop.workspace import discard_worktree, open_unit_workspace + + (project.project / "conflict.txt").write_text("base\n") + first, run_dir = _open_unit(project, branch_per="run") + (first.path / "conflict.txt").write_text("run branch\n") + git(first.path, "commit", "-q", "-am", "run side") + run_tip = rev_parse_head(first.path) + discard_worktree(project.project, str(first.path), "", run_dir=run_dir) + (project.project / "conflict.txt").write_text("target\n") + _commit_project(project, "target side") + + with pytest.raises(verify.GitError, match="diverged from main"): + open_unit_workspace(*_open_args(project, branch_per="run")) + + assert not first.path.exists() + assert first.path not in [p.resolve() for p in worktree_list(project.project)] + assert git(project.project, "rev-parse", f"refs/heads/{first.branch}") == run_tip + + +def test_run_branch_remount_after_squash_integration_merges_clean(project): + """The diverged arm is the NORMAL serial-unit shape under + ``merge_strategy = "squash"``: the target receives a squash commit that does not + contain the run tip. Identical content on both sides merges clean, so the next + unit mounts on a merge commit holding both histories with no conflict.""" + from bmad_loop.workspace import discard_worktree, open_unit_workspace + + first, run_dir = _open_unit(project, branch_per="run") + (first.path / "unit.txt").write_text("landed on the run branch\n") + git(first.path, "add", "-A") + git(first.path, "commit", "-q", "-m", "unit on run branch") + run_tip = rev_parse_head(first.path) + discard_worktree(project.project, str(first.path), "", run_dir=run_dir) + git(project.project, "merge", "--squash", "-q", run_tip) + squashed = _commit_project(project, "squash of the unit") + + second = open_unit_workspace(*_open_args(project, branch_per="run")) + + parents = git(second.path, "rev-list", "--parents", "-n", "1", "HEAD").split()[1:] + assert set(parents) == {run_tip, squashed} + assert worktree_clean(second.path) + assert git(second.path, "diff", "--stat", squashed, "HEAD") == "" # same tree + + +@pytest.mark.parametrize("strategy", ["ff", "merge", "squash"]) +def test_run_branch_serial_units_stay_green_under_every_strategy(project, strategy): + """Regression fence for the catch-up: two serial units under ``branch_per=run`` + integrate under all three strategies and main ends with both changes.""" + commit_sprint(project, {"1-1-a": "ready-for-dev", "1-2-b": "ready-for-dev"}) + engine, _ = make_engine( + project, + [ + wt_dev_effect(project, "1-1-a"), + wt_review_effect(project, "1-1-a", clean=True), + wt_dev_effect(project, "1-2-b"), + wt_review_effect(project, "1-2-b", clean=True), + ], + policy=wt_policy(branch_per="run", merge_strategy=strategy), + ) + summary = engine.run() + + assert summary.done == 2 and not summary.paused and not summary.crashed + src = (project.project / "src.txt").read_text() + assert "change for 1-1-a" in src and "change for 1-2-b" in src + assert "worktree-open-failed" not in journal_kinds(engine) From 63f2bbfc6c017f928ff92f602b6bd56b48a76cf3 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 21:02:56 -0700 Subject: [PATCH 27/35] fix(workspace): refuse a remount whose branch is checked out at a foreign path --- CHANGELOG.md | 6 ++- src/bmad_loop/verify.py | 20 +++++++++ src/bmad_loop/workspace.py | 57 +++++++++++++++++++++++++- tests/test_engine_worktree.py | 77 +++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d81286a..d2824af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -266,7 +266,11 @@ breaking changes may land in a minor release. compare-and-swap fast-forwarded before the mount when its tip is an ancestor of the base, and a diverged base (the normal serial shape under `squash`) is merged into the fresh mount; a conflicting catch-up aborts, drops the mount, leaves the run tip unchanged, and raises for - an operator to reconcile. The attempt baseline is read after the catch-up. + an operator to reconcile. The attempt baseline is read after the catch-up. Both ref moves — + this fast-forward and the story-branch reset — are refused up front (`GitError` naming the + branch and the path) when the branch is checked out anywhere other than the unit's own mount + path, e.g. a `git worktree move`d recovery mount: the compare-and-swap would move the ref + under that live checkout, leaving its files and index at the old tip. - **The TUI escalation modal reads the spec, story context and sentinel the re-arm will write.** `_paused_spec` / `_paused_spec_root` anchored on the recorded `state.project` diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index f0a1f99f..636ee145 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -2153,6 +2153,26 @@ def branch_exists(repo: Path, name: str) -> bool: return rc == 0 +def branch_checkout_path(repo: Path, branch: str) -> Path | None: + """The worktree that has ``refs/heads/`` checked out, or ``None``. + + ``git for-each-ref --format=%(worktreepath)`` (git 2.23; the support floor is + 2.34) prints the registered path of the worktree whose HEAD is attached to the + ref — the MAIN checkout's path when the main checkout holds it — and an empty + line when no worktree has it attached (a detached HEAD at the same commit does + not count). A ref that does not exist also prints nothing; callers that need + the distinction check `branch_exists` first. The path is git's registered + spelling, un-canonicalized: compare it the way the caller compares its own. + Reads stdout alone (`_git_out`): the value is the answer (#442). + """ + rc, out, detail = _git_out( + repo, "for-each-ref", "--format=%(worktreepath)", f"refs/heads/{branch}" + ) + if rc != 0: + raise GitError(f"git for-each-ref refs/heads/{branch} failed in {repo}: {detail}") + return Path(out) if out else None + + def create_branch(repo: Path, name: str, base: str) -> None: """Create branch `name` at `base` without checking it out.""" rc, out = _git(repo, "branch", name, base) diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index 452f6c2c..07cffd03 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -184,6 +184,42 @@ def _preserve_orphan_state( on_orphan_preserved(str(wt), parked) +def _refuse_foreign_checkout(repo_root: Path, branch: str, wt: Path) -> None: + """Raise ``GitError`` when ``branch`` is checked out anywhere but ``wt``. + + `verify.reset_branch_if_tip` is ``git update-ref`` — a compare-and-swap on the + ref that does not know or care which worktree has the branch checked out. Moving + the ref under a live checkout leaves that checkout's files and index at the old + tip while its HEAD now reads the new one: the operator's tree suddenly looks + modified. The checkout at ``wt`` — this unit's own deterministic mount path — is + exempt: the reclaim force-removes it right after, so nothing observes the skew. + + ``wt`` is already resolved by the caller; git's registered path is resolved the + same way so the two compare lexically on canonical spellings. A registered path + that cannot be resolved (gone, a permission fault, a symlink loop) is treated as + foreign: it is not provably ours, and the failure mode of a wrong "ours" is a + silently desynced checkout, so the doubt refuses. + """ + holder = verify.branch_checkout_path(repo_root, branch) + if holder is None: + return + try: + resolved = holder.resolve() + except (OSError, RuntimeError) as e: + raise verify.GitError( + f"unit branch {branch} is checked out at {holder}, which cannot be " + f"resolved ({e}); refusing to move the branch under a checkout that is " + f"not this unit's mount path {wt}" + ) from e + if resolved != wt: + raise verify.GitError( + f"unit branch {branch} is checked out at {holder}, not at this unit's " + f"mount path {wt}; the remount would move the branch under that checkout. " + f"Detach it (git -C {holder} checkout --detach) or remove it " + f"(git worktree remove {holder}) before remounting" + ) + + def open_unit_workspace( repo_root: Path, paths: ProjectPaths, @@ -231,6 +267,12 @@ def open_unit_workspace( ``open_unit_workspace`` has none, in the style of ``close_unit_workspace``'s ``on_teardown_degraded``. A snapshot that cannot be written refuses the remount (raises ``GitError``) and leaves the orphan standing. + + Both ref moves — the story reset and the run fast-forward — are refused up front + when the branch is checked out anywhere other than that mount path (an operator + moved a retained recovery worktree, or holds the branch in the main checkout): + the compare-and-swap would move the ref under a live checkout and the mount + would then fail on the held branch anyway. See `_refuse_foreign_checkout`. """ branch = unit_branch_name(run_id, unit_key, branch_per) unresolved_wt = unit_worktrees_dir(run_dir) / safe_segment(unit_key) @@ -249,6 +291,15 @@ def open_unit_workspace( branch_tip: str | None = None if verify.branch_exists(repo_root, branch): branch_tip = verify.rev_parse_revision(repo_root, f"refs/heads/{branch}") + # Both ref moves below (the story reset, the run fast-forward) are + # `update-ref` compare-and-swaps that do not care which checkout holds the + # branch. The orphan AT `wt` is fine — the reclaim removes it right after — + # but a checkout anywhere ELSE (an operator `git worktree move`d a retained + # recovery mount, or checked the branch out in the main tree) would be left + # with its files and index at the old tip under a ref that moved, and the + # `worktree_add` that follows fails anyway on the held branch. Refuse + # before any mutation instead. + _refuse_foreign_checkout(repo_root, branch, wt) # Park an orphan's uncommitted state FIRST — before the story reset below moves # the ref the orphan's HEAD points at, so the snapshot is parented at the tree # the orphan actually holds and captures only what was never committed. @@ -297,8 +348,10 @@ def open_unit_workspace( if verify.is_ancestor(repo_root, branch_tip, pinned_base): # The base strictly advanced past the run tip: fast-forward the run # branch (compare-and-swap on the pinned tip) so the mount comes up - # at the base. No mount holds the branch here — the reclaim above - # released it — so the ref move cannot desync a checkout. + # at the base. No mount holds the branch here — the occupancy + # check above refused any checkout other than the one at ``wt``, + # and the reclaim released that — so the ref move cannot desync a + # checkout. verify.reset_branch_if_tip(repo_root, branch, pinned_base, branch_tip) else: catch_up_base = pinned_base # diverged: merge inside the fresh mount diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 5cde54a4..5bd010c0 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -6697,3 +6697,80 @@ def test_run_branch_serial_units_stay_green_under_every_strategy(project, strate src = (project.project / "src.txt").read_text() assert "change for 1-1-a" in src and "change for 1-2-b" in src assert "worktree-open-failed" not in journal_kinds(engine) + + +# --------------------------------------- remount refuses a branch held elsewhere + + +def test_story_remount_refuses_a_branch_checked_out_at_a_foreign_path(project, tmp_path): + """`reset_branch_if_tip` is ``update-ref`` — a ref compare-and-swap that does not + care which worktree has the branch checked out. When the story branch is held + by a worktree OTHER than this unit's deterministic mount path (here: the + operator ``git worktree move``d the retained recovery mount), the reset would + move the ref under that checkout — its files and index still at the old tip — + and the following ``worktree_add`` would fail on the held branch anyway. The + remount refuses BEFORE any mutation: ``GitError`` names the branch and the + foreign path, the branch tip is unchanged, the foreign checkout's HEAD still + equals it, no preserve ref was written, and nothing was mounted at ``wt``. + + The branch held only by the orphan AT the mount path keeps remounting fine — + `test_open_unit_workspace_reclaims_the_orphan_holding_its_mount_path` and + `test_story_remount_preserves_named_tip_and_restarts_from_pinned_base` grade + that arm. + + Ablation: drop the `_refuse_foreign_checkout` call in `open_unit_workspace` and + this reddens — `GitError` still arrives (from ``worktree add``), but the branch + ref has already been reset to the pinned base and the preserve ref written. + """ + from bmad_loop.workspace import open_unit_workspace + + first, _run_dir = _open_unit(project, branch_per="story") + (first.path / "attempt.txt").write_text("committed on the attempt\n") + git(first.path, "add", "-A") + git(first.path, "commit", "-q", "-m", "story attempt") + tip = rev_parse_head(first.path) + foreign = tmp_path / "moved-recovery-mount" + git(project.project, "worktree", "move", str(first.path), str(foreign)) + assert not first.path.exists() + (project.project / "advanced.txt").write_text("new base\n") + pinned = _commit_project(project, "base advances") + + with pytest.raises(verify.GitError, match=rf"{first.branch}.*checked out at .*moved-recovery"): + open_unit_workspace(*_open_args(project, branch_per="story")) + + assert git(project.project, "rev-parse", f"refs/heads/{first.branch}") == tip + assert rev_parse_head(foreign) == tip + assert tip != pinned + assert git(project.project, "for-each-ref", "refs/attempt-preserve/") == "" + assert not first.path.exists() + assert first.path not in [p.resolve() for p in worktree_list(project.project)] + + +def test_run_branch_remount_refuses_a_fast_forward_under_a_foreign_checkout(project, tmp_path): + """Same hazard on the `branch_per=run` arm: the fast-forward would fire (run tip + is an ancestor of the advanced base) but the run branch is checked out at a + path other than this unit's mount. The single occupancy check refuses before + the ref move: the run branch stays at its tip, the foreign checkout's HEAD + still equals it, and nothing was mounted. + + Ablation: drop the `_refuse_foreign_checkout` call and the run branch is + fast-forwarded to the advanced base before ``worktree add`` refuses — the + ``rev-parse`` assertion reddens. + """ + from bmad_loop.workspace import open_unit_workspace + + first, _run_dir = _open_unit(project, branch_per="run") + old_tip = rev_parse_head(first.path) + foreign = tmp_path / "moved-recovery-mount" + git(project.project, "worktree", "move", str(first.path), str(foreign)) + (project.project / "landed-in-place.txt").write_text("story committed on main\n") + advanced = _commit_project(project, "story landed in place") + assert advanced != old_tip + + with pytest.raises(verify.GitError, match=rf"{first.branch}.*checked out at .*moved-recovery"): + open_unit_workspace(*_open_args(project, branch_per="run")) + + assert git(project.project, "rev-parse", f"refs/heads/{first.branch}") == old_tip + assert rev_parse_head(foreign) == old_tip + assert not first.path.exists() + assert first.path not in [p.resolve() for p in worktree_list(project.project)] From fe2783cfe2c2e537fa9927fe003e2c84ac93736a Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 21:04:53 -0700 Subject: [PATCH 28/35] fix(runs): leave a recorded path that traverses out of the project unrebased --- CHANGELOG.md | 5 ++++- src/bmad_loop/runs.py | 10 +++++++++ tests/test_runs.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2824af5..b72194c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,10 @@ breaking changes may land in a minor release. keeping the session project-rooted and directing code fixes and commits to the code root. The re-arm writes the same tree the context published: `runs.rearm_escalation` takes the live project root from `resolve` and the TUI, so a moved project no longer - has the agent edit one copy of the spec while the re-arm flips another. + has the agent edit one copy of the spec while the re-arm flips another. A recorded + spelling that traverses out of the project through `..` (an external artifact root + named relative to it) is classified external and left unchanged rather than rebased + onto a different file under the moved project's new parent. - **A failed re-arm commits probe now journals `rearm-commits-probe-failed`** (DW-81). The warn-only probe that lists the commits an abandoned attempt left below the re-drive's diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 31e61d28..d0272dda 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3019,6 +3019,14 @@ def rebase_recorded_project_path(path: Path, state: RunState, project_root: Path edits the live copy while the re-arm writes a path under a directory that no longer exists, so the flip silently no-ops and the re-drive wedges on the escalated attempt's status. + + `relative_to` is a prefix match, and ``/../shared/spec.md`` carries the + prefix while naming a tree OUTSIDE it: a ``..`` after the recorded project + climbs out, so the remainder is not project-owned. Rebasing it would redirect + the spelling to ``/../shared/spec.md`` — a different file once the project + moved to another parent. Normalizing the ``..`` away lexically would be wrong + across symlinks (and is the canonicalization this function forbids), so a + traversing spelling is classified external and left alone like every other. """ recorded_project = Path(state.project) if project_root == recorded_project: @@ -3027,6 +3035,8 @@ def rebase_recorded_project_path(path: Path, state: RunState, project_root: Path relative = path.relative_to(recorded_project) except ValueError: return path + if ".." in relative.parts: + return path return project_root / relative diff --git a/tests/test_runs.py b/tests/test_runs.py index f8ddfff1..b221bc0d 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -3005,6 +3005,56 @@ def test_rearm_leaves_a_spec_outside_the_recorded_project_alone(tmp_path): assert verify.status_of(verify.read_frontmatter(spec)) == "ready-for-dev" +def test_rearm_leaves_a_spec_that_traverses_out_of_the_recorded_project_alone(tmp_path): + """`Path.relative_to` is a PREFIX match: ``/../shared/spec.md`` passes it + with remainder ``../shared/spec.md``, so a persisted spelling that climbs OUT of + the recorded project (into an external artifact root) used to be classified + project-owned and, once the project moved to a different parent, rebased to + ``/../shared/spec.md`` — a different file. A ``..`` after the recorded + prefix names a tree outside it, so `rebase_recorded_project_path` now returns + the spelling unchanged and the re-arm flips the file the run actually recorded. + (An in-project spelling still rebases: + `test_rearm_flips_the_spec_in_the_live_project_after_a_rename`.) + + Both parents hold a `shared/spec.md` deliberately: the decoy under the NEW + parent is the file the redirected spelling names, so "unflipped" on the recorded + spec cannot be read as a no-op. `resolve.build_context` publishes the same + rebased path with no confinement at all, so the agent would be handed the decoy. + + Ablation: drop the ``".." in relative.parts`` arm and the direct assertion + reddens on ``/../shared/spec.md``; with that assertion removed too, the + re-arm dies in `UnconfinedWriteError` (the redirected spelling climbs out of the + rebased confine root) rather than flipping the recorded spec.""" + old_parent = tmp_path / "old-parent" + new_parent = tmp_path / "new-parent" + recorded_project = old_parent / "project" + live_project = new_parent / "project" + for root in (recorded_project, live_project): + root.mkdir(parents=True) + recorded_spec = old_parent / "shared" / "spec.md" + decoy = new_parent / "shared" / "spec.md" + for spec in (recorded_spec, decoy): + spec.parent.mkdir() + spec.write_text(_SPEC_WITH_ARR, encoding="utf-8") + traversing = recorded_project / ".." / "shared" / "spec.md" # the persisted spelling + run = escalated_run( + recorded_project, "r1", story_key="1-1-a", attempt=2, spec_file=str(traversing) + ) + state = load_state(run.run_dir) + assert runs.rebase_recorded_project_path(traversing, state, live_project) == traversing + + runs.rearm_escalation( + run.run_dir, + "1-1-a", + isolated_redrive=False, + resolution_recorded=False, + project_root=live_project, + ) + + assert verify.status_of(verify.read_frontmatter(recorded_spec)) == "ready-for-dev" + assert verify.status_of(verify.read_frontmatter(decoy)) != "ready-for-dev" + + def test_rearm_resets_followup_reviews_spent(tmp_path): """A human-resolved re-drive gets a fresh damping budget: rearm_escalation zeroes followup_reviews_spent alongside review_cycle, so the clean rebuild From 95d30e978cd67ed46cdc43be4f3c5e3381eafe98 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 22:39:05 -0700 Subject: [PATCH 29/35] fix(runs,resolve): probe the rebased mount before the stories root falls back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `live_stories_root` called `task_stories_root` first, which decides between the mount and the project on an existence probe against the RECORDED spelling. `RUNS_DIR` is `.bmad-loop/runs`, so a mount lives inside the project and rebases with a rename; its recorded spelling is then gone, the probe fails, the mount is discarded, and rebasing the project fallback answers the main checkout — while `live_spec_path` (no probe in `task_spec_root`) follows the rename onto the moved mount. The escalation modal read title, description and sentinel from the stale twin while `blocking` and the re-arm targeted the moved worktree. Adopt `_context_stories_root`'s ordering, and make that function delegate here so one definition serves `context.json` and the TUI. Correct the mirrored claim in both docstring and `tui.app` that a mount lies outside the recorded project. --- CHANGELOG.md | 8 +++++ src/bmad_loop/resolve.py | 31 ++++++++-------- src/bmad_loop/runs.py | 31 ++++++++++++++-- src/bmad_loop/tui/app.py | 7 ++-- tests/test_resolve.py | 51 ++++++++++++++++++++++++-- tests/test_runs.py | 77 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 182 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b72194c7..091b2455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,14 @@ breaking changes may land in a minor release. ### Fixed +- **The escalation modal's story manifest follows a moved project onto the mount.** + `runs.live_stories_root` probed the mount at its RECORDED spelling before rebasing, so + after a project rename the probe failed, the mount was discarded, and the modal read + title, description and sentinel from the main checkout's stale twin while `blocking` and + the re-arm targeted the moved worktree. It now probes the rebased mount first, and + `resolve._context_stories_root` delegates to it so `context.json` and the TUI cannot + disagree about which tree the run owns. + - **A remount parks an orphaned worktree's uncommitted work before reclaiming its path.** `open_unit_workspace` force-removed whatever stood at the deterministic mount path, so the directory an isolation flip had "retained for recovery" lost its tracked edits and diff --git a/src/bmad_loop/resolve.py b/src/bmad_loop/resolve.py index f2706361..ce50c4c4 100644 --- a/src/bmad_loop/resolve.py +++ b/src/bmad_loop/resolve.py @@ -28,11 +28,11 @@ from .model import RunState, StoryTask from .platform_util import safe_segment from .runs import ( + live_stories_root, rebase_recorded_project_path, redrive_base_ref, spec_reaches_the_redrive, task_spec_path, - task_stories_root, validate_restore_latch, ) @@ -48,17 +48,16 @@ def context_path(run_dir: Path, story_key: str) -> Path: def _context_stories_root(task: StoryTask | None, state: RunState, project_root: Path) -> Path: - """Resolve the stories tree against the live project after a project move.""" - if task is not None and task.worktree_path: - recorded_mount = Path(task.worktree_path) - live_mount = rebase_recorded_project_path(recorded_mount, state, project_root) - if live_mount != recorded_mount: - try: - if live_mount.is_dir(): - return live_mount - except OSError: - pass - return rebase_recorded_project_path(task_stories_root(task, state), state, project_root) + """Resolve the stories tree against the live project after a project move. + + A delegation, not a second implementation. `context.json` and the TUI's escalation + modal are the two READ sides of one gesture whose WRITE side is `rearm_escalation`, + so they have to agree on the tree by construction: this module answered the moved + mount while `tui.app` answered the main checkout, which is the same + one-surface-two-trees split the anchor exists to close, merely relocated to a + different pair of surfaces. Kept as a named wrapper because the call site is + stories-mode-only (sprint mode must not probe for a stories root at all).""" + return live_stories_root(task, state, project_root) def resolution_path(run_dir: Path, story_key: str) -> Path: @@ -323,8 +322,8 @@ def build_context( stories_ctx: dict[str, Any] | None = None if state.source == "stories": # Which tree holds this run's STORY MANIFEST — the workspace root, answered by - # `task_stories_root` rather than by `task_spec_root`. Sprint mode consumes no - # stories data, so it must not probe for a stories root at all. + # `runs.live_stories_root` rather than by `task_spec_root`. Sprint mode consumes + # no stories data, so it must not probe for a stories root at all. stories_root = _context_stories_root(task, state, current_project_root) stories_ctx = _stories_context( state, story_key, stories_root, task, context_spec_path=context_spec_path @@ -441,8 +440,8 @@ def _stories_context( from . import stories # `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 + # `_context_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. diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index d0272dda..aebd1460 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3234,10 +3234,35 @@ def live_stories_root(task: StoryTask | None, state: RunState, project_root: Pat the stories folder, and `_do_rearm` clears that sentinel at `live_spec_path`. A locator answering the recorded `state.project` after a project move reads the manifest from a tree the re-arm no longer writes: absent once the old tree is - gone, stale while it lingers. A mount is a path under the run dir, outside the - recorded project, so `rebase_recorded_project_path` passes it through unchanged - and the isolated arm keeps its answer; only the project fallback moves. + gone, stale while it lingers. + + The mount is probed on its REBASED spelling FIRST, and that ordering is the whole + content of this function. `RUNS_DIR` is ``.bmad-loop/runs``, so a mount is spelled + ``/.bmad-loop/runs//worktrees/`` — INSIDE the recorded project, + and it rebases like every other project-owned path. (Outside-the-project is only + the symlinked-`.bmad-loop` layout `_spec_is_shared_with_the_redrive` names.) But + `task_stories_root` decides on an existence probe against the RECORDED spelling, + which after a project move names a directory that is gone: the probe fails, the + mount is discarded for the project fallback, and rebasing that fallback hands back + the MAIN CHECKOUT — while `live_spec_path` (whose `task_spec_root` runs no + existence probe) follows the rename onto the moved mount. One surface, two trees: + exactly the defect the spec anchor exists to close. Asking the live mount first + keeps the isolated arm on the moved worktree, and a mount that is genuinely gone + still degrades through `task_stories_root` to the project, rebased. + + `is_dir` degrades on OSError rather than raising, for `task_stories_root`'s own + reason: this is a READ locator, and a probe that cannot answer falls back to the + tree that always exists. """ + if task is not None and task.worktree_path: + recorded_mount = Path(task.worktree_path) + live_mount = rebase_recorded_project_path(recorded_mount, state, project_root) + if live_mount != recorded_mount: + try: + if live_mount.is_dir(): + return live_mount + except OSError: + pass return rebase_recorded_project_path(task_stories_root(task, state), state, project_root) diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 01de4156..893726e1 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1181,8 +1181,11 @@ def _sentinel_kind(self, state: RunState, key: str) -> str: # no-task fallback is not re-spelled here. And "live", for the same reason # `_paused_spec` goes through `live_spec_path`: the re-arm clears the sentinel # under `self.project`, so a scan anchored on the recorded `state.project` - # misses it after a project move (or keeps showing a cleared twin). A mount - # lies outside the recorded project and passes through the mapping unchanged. + # misses it after a project move (or keeps showing a cleared twin). A mount is + # spelled under the run dir INSIDE the project (`RUNS_DIR` is `.bmad-loop/runs`) + # and therefore rebases too — which is why `live_stories_root` probes the moved + # mount before falling back; the recorded spelling's own existence probe fails + # after the move and would drop this read onto the main checkout's stale twin. root = runs.live_stories_root(state.tasks.get(key), state, self.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. diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 7fe14608..1706b476 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -648,6 +648,49 @@ def test_build_context_rebases_project_owned_artifacts_after_project_rename(tmp_ assert ctx["stories"]["story"]["title"] == "Live title" +def test_build_context_reads_the_manifest_from_the_moved_mount_not_the_checkouts_twin(tmp_path): + """The isolated arm of the same rebase. `context.json` and the TUI's escalation + modal are the two READ sides of the gesture `rearm_escalation` writes, so + `_context_stories_root` delegates to `runs.live_stories_root` — one definition, + and this row is what holds the delegation honest from this side. + + The mount is spelled inside the project (`RUNS_DIR` is `.bmad-loop/runs`), so it + rebases with the rename; its RECORDED spelling is gone, which is exactly what made + `task_stories_root`'s existence probe discard the mount and hand the resolver the + main checkout's stale twin. Both manifests exist with different titles and the + assertion names the title, so it cannot pass because a path happened to resolve. + + Ablation: revert `runs.live_stories_root` to its one-liner and this reddens on the + title (`'main checkout twin' == 'mounted unit'`).""" + key = "6-4-cli-list-command" + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + mount_rel = Path(".bmad-loop") / "runs" / "20260613-111429-6a14" / "worktrees" / key + recorded_mount = recorded_project / mount_rel + live_mount = live_project / mount_rel + _stories_manifest( + live_project / "epic-1", [{"id": key, "title": "main checkout twin", "description": "d"}] + ) + _stories_manifest( + live_mount / "epic-1", [{"id": key, "title": "mounted unit", "description": "d"}] + ) + run_dir, state, _ = _escalated_run( + recorded_project, + source="stories", + spec_folder="epic-1", + worktree_path=str(recorded_mount), + ) + assert not recorded_mount.exists() # the mount the run recorded moved with the project + + path, _withheld, _unreadable = resolve.build_context( + state, run_dir, key, isolation="", project_root=live_project, code_root=live_project + ) + + assert resolve._context_stories_root(state.tasks[key], state, live_project) == live_mount + ctx = json.loads(path.read_text(encoding="utf-8")) + assert ctx["stories"]["story"]["title"] == "mounted unit" + + 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. @@ -3688,13 +3731,17 @@ def test_build_context_sprint_mode_does_not_resolve_a_stories_root(tmp_path, mon """A sprint context has no consumer for stories-root data, so it performs no stories-only filesystem lookup. - Ablation: move `task_stories_root` back above the source gate and this fails at + The seam is planted on `live_stories_root`, the module global + `_context_stories_root` delegates to, so the row grades the delegation as well as + the gate. + + Ablation: move `_context_stories_root` back above the source gate and this fails at the planted seam rather than passing from an absent `stories` payload alone. """ run_dir, state, _ = _escalated_run(tmp_path, spec_file="/abs/spec.md") monkeypatch.setattr( resolve, - "task_stories_root", + "live_stories_root", lambda *_a, **_k: pytest.fail("stories root resolved for sprint context"), ) diff --git a/tests/test_runs.py b/tests/test_runs.py index b221bc0d..7750fc14 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2958,6 +2958,83 @@ def test_live_spec_root_still_confines_the_live_spec_path(tmp_path): assert spec_path.is_relative_to(spec_root) # the confined arm stays reachable +def test_live_stories_root_follows_the_mount_through_a_project_rename(tmp_path): + """The READ side's OTHER anchor moves with `live_spec_path`, and the ORDERING is + what makes it move. `live_stories_root` called `task_stories_root` FIRST, which + decides between the mount and the project on an existence probe against the + RECORDED spelling. `RUNS_DIR` is `.bmad-loop/runs`, so a mount lives INSIDE the + project; after a rename its recorded spelling is gone, the probe failed, the mount + was discarded, and rebasing the project fallback handed back the MAIN CHECKOUT — + while `live_spec_path` (no existence probe in `task_spec_root`) followed the rename + onto the moved mount. One escalation modal, two trees. + + Two manifests, different titles, and the assertion is on the TITLE: a row that only + checked the returned path exists would pass for either tree. The recorded mount is + absent on purpose — that absence IS the pre-fix trigger, so the row cannot pass on + a stale twin. + + Ablation: revert `live_stories_root` to the bare + `rebase_recorded_project_path(task_stories_root(...), ...)` one-liner and this + reddens on the title (`'main checkout twin' == 'mounted unit'`).""" + import yaml + + from bmad_loop import stories + + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + live_project.mkdir() + mount_rel = Path(".bmad-loop") / "runs" / "r1" / "worktrees" / "1-1-a" + recorded_mount = recorded_project / mount_rel + live_mount = live_project / mount_rel + for root, title in ((live_project, "main checkout twin"), (live_mount, "mounted unit")): + (root / "epic-1").mkdir(parents=True) + (root / "epic-1" / "stories.yaml").write_text( + yaml.safe_dump([{"id": "1-1-a", "title": title, "description": "d"}], sort_keys=False), + encoding="utf-8", + ) + run = escalated_run( + recorded_project, + "r1", + story_key="1-1-a", + source="stories", + worktree_path=str(recorded_mount), + ) + assert not recorded_mount.exists() # the mount the run recorded moved with the project + + root = runs.live_stories_root(run.state.tasks["1-1-a"], run.state, live_project) + + assert root == live_mount + entry = stories.load_stories(stories.resolve_spec_folder(root, "epic-1")).get("1-1-a") + assert entry is not None and entry.title == "mounted unit" + + +def test_live_stories_root_degrades_to_the_live_project_when_the_mount_is_gone(tmp_path): + """The probe-first arm must not become a new way to name a directory that does not + exist. A mount removed by teardown (the `done_checkpoint` window, where the engine + leaves `worktree_path` set on a task whose worktree its integration already + deleted) is absent at BOTH spellings, so the answer falls through + `task_stories_root` to the project — rebased, so it is the live tree the re-arm + writes and not the launch-time one. + + Ablation: drop the `live_mount.is_dir()` guard (return `live_mount` outright) and + this reddens on the root.""" + recorded_project = tmp_path / "project-before-rename" + live_project = tmp_path / "project-after-rename" + live_project.mkdir() + recorded_mount = recorded_project / ".bmad-loop" / "runs" / "r1" / "worktrees" / "1-1-a" + run = escalated_run( + recorded_project, + "r1", + story_key="1-1-a", + source="stories", + worktree_path=str(recorded_mount), + ) + + root = runs.live_stories_root(run.state.tasks["1-1-a"], run.state, live_project) + + assert root == live_project + + def test_rearm_without_a_live_project_writes_the_tree_the_run_recorded(tmp_path): """The default is byte-for-byte today's behavior, and that is the whole argument for it being optional: `None` does not stand in for a fact only the caller holds From f1baf7f0c968d549ee7d0e97ed80582e634db2d5 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 2 Sep 2026 22:40:27 -0700 Subject: [PATCH 30/35] fix(engine): pick the rescue and salvage arms on the same mounted-task pair as the defer --- CHANGELOG.md | 8 +++ src/bmad_loop/engine.py | 52 ++++++++++------ src/bmad_loop/runs.py | 14 +++-- tests/test_engine_worktree.py | 111 +++++++++++++++++++++++++++++++++- 4 files changed, 160 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 091b2455..f671446c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,14 @@ breaking changes may land in a minor release. ### Fixed +- **The review-budget rescue and the timeout salvage route on the tree in hand, not on + live policy.** Both selected on `scm.isolation` alone, so an accepted continuation that + reopened a recorded mount after a `"worktree" -> "none"` flip took the in-place arm: the + commit landed in the mount and `_integrate_unit` merged it out, turning a story that never + converged (or a timed-out review) into DONE-and-merged where the same work under unchanged + policy defers with the unit's worktree and patch preserved. Salvage additionally performed + `in-review` repair writes the mounted path never performs. Both now use the + `self._isolated or task.worktree_path` pair `_defer` and `_run_story` already share. - **The escalation modal's story manifest follows a moved project onto the mount.** `runs.live_stories_root` probed the mount at its RECORDED spelling before rebasing, so after a project rename the probe failed, the mount was discarded, and the modal read diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 5ffe7324..47d55a6e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2934,11 +2934,21 @@ def _review_and_commit( # completed pass's own signal) AND _verify_review — the same authoritative # gate the converged path uses (frontmatter status==done AND sprint==done # AND verify commands pass) — so it can never ship uncompleted work, nor - # record a follow-up the last pass did not actually recommend. Only for - # the non-isolated path: in worktree isolation a defer already keeps the - # unit's worktree + patch (no work is lost), so there is nothing to - # rescue and committing into the main repo would be wrong. - if refileable_followup and not self._isolated: + # record a follow-up the last pass did not actually recommend. Only when + # the work does NOT live in a mount — the same + # `self._isolated or task.worktree_path` pair `_defer` and `_run_story` + # route on, and for the same reason: a recorded mount owns this attempt's + # work whether or not live policy still says worktree. `_finish_inflight` + # reopens that mount and swaps `self.workspace` onto it, while + # `self._isolated` is a read-only property over LIVE policy — so after an + # `isolation` flip an accepted continuation arrives here mounted with + # `self._isolated` False. Gated on policy alone the rescue commits into + # the mount and `_integrate_unit` merges it out to the target: a story + # that never converged lands DONE-and-merged, where the identical mounted + # work under unchanged policy would be DEFERRED with the unit's worktree + # and patch preserved for review. A defer under a mount already keeps + # both, so there is nothing to rescue there. + if refileable_followup and not (self._isolated or task.worktree_path): rescue = self._verify_review(task) if rescue.ok: self._journal_review_budget_spent(task) @@ -2995,23 +3005,31 @@ def _salvage_review_timeout(self, task: StoryTask, result: SessionResult) -> boo The cycle the timed-out session charged is deliberately not refunded — salvage changes what the *next* cycle costs, not what this one did. - Applicability, all deterministic: not worktree-isolated (a defer there - already keeps the unit's worktree + diff, and committing into the main - repo would be wrong — same scoping as the budget-exhaustion rescue); a - spec is recorded and its frontmatter reads ``done`` (the review never got - far enough to touch it — rare once the adapter's missing-marker fallback - (#224) completes those sessions, but a review that never wrote the spec - at all still lands here) or ``in-review`` (the mid-review interrupt: the - dying pass flipped the transient marker and died — reset it forward, - stripping any partial terminal section so the next launch's mtime-floor - scan can't misread it). Anything else — ``blocked``, ``in-progress``, a - custom token — was set deliberately or means unfinished dev work: never + Applicability, all deterministic: the work does not live in a mount — + neither live isolation NOR a recorded ``worktree_path``, the same pair + ``_defer`` and the budget-exhaustion rescue route on. A mount owns the + attempt's work even when live policy no longer says worktree, because + `_finish_inflight` reopens it and swaps ``self.workspace`` onto it while + ``self._isolated`` still reads live policy; salvaging there would commit + into that mount for `_integrate_unit` to merge out, turning a timed-out + review into a DONE-and-merged story where the defer would have kept the + unit's worktree + diff. The gate also has to precede the ``in-review`` + repair writes below (`reset_spec_status`, `strip_auto_run_result`), which + the mounted path never performs at all. Second: a spec is recorded and its + frontmatter reads ``done`` (the review never got far enough to touch it — + rare once the adapter's missing-marker fallback (#224) completes those + sessions, but a review that never wrote the spec at all still lands here) + or ``in-review`` (the mid-review interrupt: the dying pass flipped the + transient marker and died — reset it forward, stripping any partial + terminal section so the next launch's mtime-floor scan can't misread it). + Anything else — ``blocked``, ``in-progress``, a custom token — was set + deliberately or means unfinished dev work: never salvage over it. The commit is gated on the same authoritative ``_verify_review`` as every other converge path, so salvage can never ship unverified work; a timeout that produced no review result neither re-arms ``followup_review_recommended`` nor spends a damping grant — the outstanding recommendation is refiled to deferred work instead.""" - if self._isolated or not task.spec_file: + if self._isolated or task.worktree_path or not task.spec_file: return False spec_path = Path(task.spec_file) fm = self._observed_frontmatter(spec_path, task.story_key, "review-timeout-salvage") diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index aebd1460..15cb51e8 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3394,11 +3394,15 @@ def redrive_base_ref(state: RunState, *, isolated_redrive: bool) -> str: `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 + this one. `engine._run_story` selects on `self._isolated` OR a recorded mount, but a + re-drive never reaches it still carrying one: the restart arm releases the mount + first — `_discard_unit_for_restart` while policy is still isolated, + `_release_orphaned_mount` once it is not — so the mode a re-drive runs in is live + policy's. 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. diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 5bd010c0..e146b1f5 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1153,15 +1153,16 @@ def test_carry_harvest_dedupe_stays_status_agnostic(project): assert task.harvest_carry_commit_pending is False # nothing novel, so no latch -def _in_place_policy(): +def _in_place_policy(*, limits: LimitsPolicy | None = None): """`wt_policy`'s mirror: the live mode a mid-pause `isolation = "none"` edit leaves behind, with everything else identical so the two rows differ in one - field only.""" + field only. ``limits`` mirrors `wt_policy`'s own knob, for the row that has to + reach `max_review_cycles` exhaustion instead of the damped force-converge.""" return Policy( gates=GatesPolicy(mode="none"), notify=QUIET, scm=ScmPolicy(isolation="none"), - limits=LimitsPolicy(), + limits=limits if limits is not None else LimitsPolicy(), ) @@ -1297,6 +1298,110 @@ def test_defer_under_live_isolation_with_no_mount_yet_keeps_the_isolated_arm(pro assert [entry.title for entry in _main_harvest_entries(project)] == [_HARVEST_CARRY["summary"]] +def test_review_timeout_salvage_refused_under_a_recorded_mount_after_an_isolation_flip( + project, +): + """`_salvage_review_timeout` routes on the tree in hand, not on live policy alone. + + The third member of the pair `_defer` and `_run_story` already select on. An + accepted continuation reopens the recorded mount REGARDLESS of live policy — + `_finish_inflight` swaps `self.workspace` onto it, and `self._isolated` is a + read-only property over LIVE policy with no setter anywhere — so a run whose + `scm.isolation` was edited `"worktree" -> "none"` while it was paused reaches this + decision mounted with `self._isolated` False. Gated on policy alone, salvage then + committed the mounted work for `_integrate_unit` to merge out: a timed-out review + landing DONE-and-merged, where the identical work under unchanged policy defers + with the unit's worktree and diff kept for review. + + The bytes assertion is the discriminator, not the return value. The `in-review` + arm performs REPAIR WRITES the mounted path never performs — `reset_spec_status` + to `done` and `strip_auto_run_result` — and they fire before any later verify gate + could turn the answer back to False on its own; a return-value-only row would pass + for that unrelated reason. + + Ablation: restore `if self._isolated or not task.spec_file:` and this reddens on a + spec rewritten to `done` with its terminal marker stripped.""" + write_sprint(project, {"1-1-a": "done"}) + sp = project.implementation_artifacts / "spec-1-1-a.md" + sp.parent.mkdir(parents=True, exist_ok=True) + # `in-review` is the mid-review interrupt the salvage arm repairs forward; the + # terminal marker is the second thing it strips. + write_spec(sp, "in-review", rev_parse_head(project.project), prose_status="done") + before = sp.read_text() + engine, _ = make_engine(project, [], policy=_in_place_policy()) + assert engine._isolated is False # MEASURED: live policy really says in place + task = StoryTask( + story_key="1-1-a", + epic=1, + phase=Phase.REVIEW_VERIFY, # the phase the review loop calls salvage from + spec_file=str(sp), + worktree_path=str(project.project / ".bmad-loop" / "runs" / "test-run" / "wt" / "1-1-a"), + ) + + assert engine._salvage_review_timeout(task, SessionResult(status="timeout")) is False + assert sp.read_text() == before # no repair write into a story the mount owns + + +def test_budget_exhausted_rescue_defers_under_a_recorded_mount_after_an_isolation_flip( + project, +): + """The budget-exhaustion rescue picks the same arm as the defer it replaces. + + `test_budget_exhausted_finalized_work_commits`'s harness, with one field added: the + task carries the attempt's mount while live policy answers "in place" — exactly + what `_finish_inflight` leaves after an `isolation` flip across a resume, since it + sets only `self.workspace` and `self._isolated` keeps reading live policy. Selected + on policy alone, the rescue committed a story that never converged; `_commit` lands + in the mount and `_integrate_unit` merges that unit branch out to the target, so + the outcome inverts — DONE-and-merged instead of DEFERRED with the unit's worktree + and patch preserved. + + The gate is inline in `_review_and_commit`, so a full sandbox run is the lowest + layer that reaches it: the loop has to actually exhaust `max_review_cycles` with a + finalized, verify-green tree still recommending a follow-up (`max_followup_reviews` + pinned high, or the damping converges the story before exhaustion and this row + never reaches the branch under test). + + `review_cycle == 3` is the premise control — without it a story that deferred for + any earlier reason would satisfy the outcome assertions. + + Ablation: restore `if refileable_followup and not self._isolated:` and this reddens + on a DONE task with a fresh commit at HEAD.""" + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + head_before = rev_parse_head(project.project) + mount = project.project / ".bmad-loop" / "runs" / "test-run" / "wt" / "1-1-a" + box: list[Engine] = [] + dev = wt_dev_effect(project, "1-1-a") + + def dev_then_record_the_mount(spec): + # The reopened mount, recorded on the task, is all `_finish_inflight` leaves + # behind for the review loop to read; recording it after dispatch keeps + # `_run_story` out of the row so the gate under test is the only selector. + result = dev(spec) + box[0].state.tasks["1-1-a"].worktree_path = str(mount) + return result + + engine, _ = make_engine( + project, + [dev_then_record_the_mount] + + [wt_review_effect(project, "1-1-a", clean=False) for _ in range(3)], + policy=_in_place_policy(limits=LimitsPolicy(max_followup_reviews=99)), + ) + box.append(engine) + assert engine._isolated is False # MEASURED: live policy really says in place + + summary = engine.run() + + task = engine.state.tasks["1-1-a"] + assert task.review_cycle == 3 # MEASURED: the budget really was exhausted + assert task.worktree_path == str(mount) # and the mount really was in hand + assert summary.deferred == 1 and summary.done == 0 and not summary.paused + assert task.phase == Phase.DEFERRED + assert not task.commit_sha + assert rev_parse_head(project.project) == head_before # nothing was committed + assert "review-budget-committed" not in journal_kinds(engine) + + def test_tracked_harvest_carry_commit_failure_propagates(project, monkeypatch): """A tracked ledger persistence fault cannot be reported as a completed carry.""" project.deferred_work.parent.mkdir(parents=True, exist_ok=True) From 64bca0d297db31f3f9b7773301401b397795394d Mon Sep 17 00:00:00 2001 From: t Date: Thu, 3 Sep 2026 09:50:55 -0700 Subject: [PATCH 31/35] fix(runs): confine the re-arm rollback to the live spec root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_restore_rearmed_spec` derived its `confine_root` from `task_spec_root`, the recorded project spelling that nothing re-stamps, while the three forward writers it undoes — the status flip, the `## Auto Run Result` strip and the baseline re-stamp — all confine against `live_spec_root`, and the path it writes is itself `live_spec_path`. The arm predicate is a lexical `is_relative_to`, so after an isolated project rename the live path is not under the stale root, the predicate goes False, and the rollback silently takes the unconfined `atomic_write_bytes` arm on exactly the specs its siblings had just written through the confined one. Without an attacker the outcome is indistinguishable — right file, right bytes, `rollback='restored'` — which is why nothing downstream caught it; what is lost is #593's O_NOFOLLOW walk of the PARENT components, since `follow_symlinks=False` guards only the final one. Thread the live project root through `rearm_escalation` -> `_rollback_rearm` -> `_restore_rearmed_spec` as a REQUIRED parameter: an optional one falling back to `task_spec_root` would reintroduce the same silent degradation. The two-arm structure and `require_writable_target=True` on both arms are unchanged — only the root the predicate compares against is corrected. The dimension was wholly uncovered: every existing row calls `rearm_escalation` without `project_root`, so the two roots were the same string and each stayed green either way. The new row supplies a differing `project_root` and spies both writers on the `runs` namespace the call site reads them from. --- CHANGELOG.md | 9 ++++++ src/bmad_loop/runs.py | 39 ++++++++++++++++++++---- tests/test_runs.py | 70 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f671446c..8c0b8155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,15 @@ breaking changes may land in a minor release. ### Fixed +- **An aborted re-arm's spec rollback confines against the live project root, not the + recorded one.** `_restore_rearmed_spec` picked between the confined and plain writers on + a lexical `is_relative_to` against `task_spec_root` — the launch-time spelling nothing + re-stamps — while the flip, the strip and the baseline re-stamp it undoes all confine + against `live_spec_root`. After a project rename the live spec path was not under the + recorded root, so the undo silently dropped to the unconfined arm and lost #593's + O_NOFOLLOW walk of the parent components (`follow_symlinks=False` guards only the final + one) on exactly the specs its siblings had just written through the confined one. The + live project root is now threaded through `_rollback_rearm` as a required parameter. - **The review-budget rescue and the timeout salvage route on the tree in hand, not on live policy.** Both selected on `scm.isolation` alone, so an accepted continuation that reopened a recorded mount after a `"worktree" -> "none"` flip took the in-place arm: the diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 15cb51e8..9d513e3e 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3609,7 +3609,11 @@ def _redrive_reads_the_upstream_artifacts(state: RunState) -> bool: def _restore_rearmed_spec( - spec_path: Path, original: bytes | None, task: StoryTask, state: RunState + spec_path: Path, + original: bytes | None, + task: StoryTask, + state: RunState, + live_project: Path, ) -> Literal["restored", "unchanged", "unknown"]: """Put back the bytes a re-arm FOUND on the spec, and say what is now on disk. @@ -3655,7 +3659,25 @@ def _restore_rearmed_spec( (`frontmatter.set_frontmatter_status` states the rule; `verify.set_frontmatter_field` and `devcontract._atomic_write_spec` restate it): under `confine_root`, through the component-walking confined helper (#593); outside it, the plain `follow_symlinks=False` - write. Calling the confined helper unconditionally looked stricter and was strictly + write. + + `confine_root` is the LIVE root — `live_spec_root(task, state, live_project)`, which + is `task_spec_root` REBASED onto the tree this gesture is acting in — and `live_project` + is a required parameter for that reason rather than an optional one falling back to + `task_spec_root`. The three forward writers this undoes all confine against the live + root (`rearm_escalation` passes `live_spec_root(task, state, live_project)` to the flip, + the strip and the baseline re-stamp), and `spec_path` is itself `live_spec_path`, so + reading the RECORDED root here compared the live path against a root it need not sit + under. After a project rename the two spellings diverge, the lexical + `is_relative_to` goes False, and this undo silently dropped to the plain arm on + exactly the specs its own siblings had just written through the CONFINED one — losing + #593's O_NOFOLLOW walk of the parent components with no signal, since + `follow_symlinks=False` guards only the FINAL component. The observable outcome is + otherwise identical (right file, right bytes, `rollback="restored"`), which is why + nothing downstream could catch it and why the parity is asserted at this seam. The + arm-selection RULE above is unchanged; only the root it compares against is corrected. + + Calling the confined helper unconditionally looked stricter and was strictly worse — an artifacts folder configured OUTSIDE both the mount and the project is supported configuration (`bmadconfig` resolves one, `verify.spec_within_roots` trusts it, `_spec_is_shared_with_the_redrive` treats it as first-class), and there the flip, @@ -3692,7 +3714,7 @@ def _restore_rearmed_spec( # raises `RearmError` if it cannot land, which is the loud outcome the docstring # above promises. The cost of being wrong here is one redundant identical write. pass - confine_root = task_spec_root(task, state) + confine_root = live_spec_root(task, state, live_project) try: if spec_path.is_relative_to(confine_root): atomic_write_bytes_confined( @@ -3720,6 +3742,7 @@ def _rollback_rearm( spec_before: bytes | None, task: StoryTask, state: RunState, + live_project: Path, error: BaseException, ) -> None: """Undo an aborted re-arm's spec writes and RECORD that the re-arm aborted. @@ -3729,6 +3752,12 @@ def _rollback_rearm( one: this function's whole job is to leave the spec's bytes as the re-arm found them and put the fact on the run's audit trail. + `live_project` is threaded straight through to `_restore_rearmed_spec`, which confines + against the LIVE (rebased) spec root so the undo validates its write with the same + root the forward writers used. The guard's caller already holds it, so this parameter + carries the fact rather than re-deriving it — see `_restore_rearmed_spec` for what + re-deriving it from `task_spec_root` silently cost after a project rename. + `rollback` goes ON the record, not left to be re-derived, because every reader is OUT of process: `rearm_event_notice` renders from a journal line alone, with neither the task nor the tree to consult, and the outcomes need different sentences. @@ -3767,7 +3796,7 @@ def _rollback_rearm( rollback = "unknown" try: if spec_path is not None: - rollback = _restore_rearmed_spec(spec_path, spec_before, task, state) + rollback = _restore_rearmed_spec(spec_path, spec_before, task, state, live_project) except BaseException: rollback = "failed" raise @@ -4821,7 +4850,7 @@ def rearm_escalation( # undoing the spec then would build the mirror image of the defect this guard # closes, persisted state re-armed against a spec that is not. if not _rearm_commit_landed(run_dir, key, task): - _rollback_rearm(journal, key, spec_path, spec_before, task, state, e) + _rollback_rearm(journal, key, spec_path, spec_before, task, state, live_project, e) raise journal.append( "story-escalation-resolved", diff --git a/tests/test_runs.py b/tests/test_runs.py index 7750fc14..949fbe61 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2958,6 +2958,76 @@ def test_live_spec_root_still_confines_the_live_spec_path(tmp_path): assert spec_path.is_relative_to(spec_root) # the confined arm stays reachable +def test_rearm_rollback_confines_the_undo_to_the_live_root_after_a_rename(tmp_path, monkeypatch): + """The undo has to confine against the root its own forward writers confined against. + + `_restore_rearmed_spec` picks between the component-walking confined writer and the + plain no-follow one on a LEXICAL `is_relative_to(confine_root)`, and it derived that + root from `task_spec_root` — the RECORDED project spelling, which nothing re-stamps. + The three writes it undoes (the status flip, the `## Auto Run Result` strip and the + baseline re-stamp) all pass `live_spec_root(task, state, live_project)`, and the path + itself is `live_spec_path`. Un-renamed those two roots are the same string, which is + why every pre-existing `_restore_rearmed_spec` row stayed green either way: they all + call `rearm_escalation` WITHOUT `project_root`, so the whole dimension was uncovered. + Rename the project and they diverge — the live path is not under the recorded root, + the predicate goes False, and the undo takes the UNCONFINED arm on exactly the spec + its siblings just wrote through the confined one. + + Nothing downstream can see that: the right file gets the right bytes and the record + still says `restored`. What is lost is #593's O_NOFOLLOW walk of the PARENT + components — `follow_symlinks=False` covers only the final one — so the invariant is + asserted directly, at the seam, the same way + `test_live_spec_root_still_confines_the_live_spec_path` grades the forward half. + + The spies go on the `runs` namespace because that is where the call site reads them: + `runs` binds both writers with `from .platform_util import ...`, so patching here + reaches `_restore_rearmed_spec` and CANNOT reach the forward writers, which hold + their own bindings in `verify`, `frontmatter` and `devcontract`. The recorded call + list is therefore the undo's alone. + + The byte comparison is the second claim rather than a redundant one: it proves the + spy observed a write that actually LANDED, so the writer-identity assertion is not + reading a no-op. + + Ablation: revert `confine_root` to `task_spec_root(task, state)` and this reddens on + the call list — `[("plain", None)]` instead of the confined arm.""" + run_dir, _recorded_spec, live_spec, live_project = _renamed_project_pair(tmp_path) + before = live_spec.read_bytes() + calls: list[tuple[str, Path | None]] = [] + real_confined = runs.atomic_write_bytes_confined + real_plain = runs.atomic_write_bytes + + def spy_confined(path, data, *, confine_root, **kwargs): + calls.append(("confined", confine_root)) + return real_confined(path, data, confine_root=confine_root, **kwargs) + + def spy_plain(path, data, **kwargs): + calls.append(("plain", None)) + return real_plain(path, data, **kwargs) + + monkeypatch.setattr(runs, "atomic_write_bytes_confined", spy_confined) + monkeypatch.setattr(runs, "atomic_write_bytes", spy_plain) + + def boom(run_dir_, state_): + # raised after the flip and the strip have PUBLISHED, so the undo has a real + # write to put back and reaches its arm selection + raise MemoryError("nothing to do with the spec") + + monkeypatch.setattr(runs, "save_state", boom) + + with pytest.raises(MemoryError, match="nothing to do with the spec"): + runs.rearm_escalation( + run_dir, + "1-1-a", + isolated_redrive=False, + resolution_recorded=False, + project_root=live_project, + ) + + assert calls == [("confined", live_project)] + assert live_spec.read_bytes() == before # the confined write actually landed + + def test_live_stories_root_follows_the_mount_through_a_project_rename(tmp_path): """The READ side's OTHER anchor moves with `live_spec_path`, and the ORDERING is what makes it move. `live_stories_root` called `task_stories_root` FIRST, which From 0bdc4d671e59d3481827649c1c45b348ef56c3ef Mon Sep 17 00:00:00 2001 From: t Date: Thu, 3 Sep 2026 09:58:24 -0700 Subject: [PATCH 32/35] fix(verify): keep a registered checkout path's trailing whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `branch_checkout_path` read `for-each-ref --format=%(worktreepath)` through `_git_out`, which returns `stdout.strip()`. A foreign checkout registered at a unit's own deterministic mount path plus a trailing space therefore came back as the bare mount path, compared EQUAL to it in `_refuse_foreign_checkout`, and was exempted as if it were this unit's own orphan. The ref then moved under a live foreign checkout, its tree went spuriously dirty, and `worktree add` failed on the held branch anyway — exactly the harm the occupancy guard was added to prevent, with the guard present. The function's own docstring already promised the opposite ("git's registered spelling, un-canonicalized"). Scoped to the call site. `_git_out` is untouched: it has 16 other callers and at least two would change behavior — `untracked_files` and the `status --porcelain` read in `commit_paths`, whose records legitimately begin with a space. `_git_raw` drops the diagnostic, so a failure message would lose stderr. A new `_git_raw_out` beside it returns `(rc, stdout VERBATIM, merged detail)`, and only the single framing `\n` is removed — an empty answer still means "no worktree has it attached" and returns None. The error could only go the unsafe way: `safe_segment` rstrips ". " from every segment we compose, so our own mount path can never end in whitespace and a spurious REFUSE is unreachable. The negative control pins that half and is ablated twice, since "does not raise" passes for every reason. Accepted bound, documented at the reader: `_run_git` uses `text=True` (universal newlines), so a path ending in `\r` stays indistinguishable even after this fix. --- CHANGELOG.md | 11 ++++ src/bmad_loop/verify.py | 48 +++++++++++++++-- tests/test_engine_worktree.py | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0b8155..aca7ea5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,17 @@ breaking changes may land in a minor release. ### Fixed +- **`branch_checkout_path` keeps a registered worktree path's trailing whitespace.** The + reader went through `_git_out`, which returns `stdout.strip()`, so a foreign checkout + registered at a unit's own mount path plus a trailing space came back as the bare mount + path, compared equal to it, and was exempted by the remount's occupancy guard — the ref + then moved under a live foreign checkout, its tree went spuriously dirty, and `worktree +add` failed on the held branch anyway, which is the harm the guard exists to prevent. A + new `_git_raw_out` hands back stdout verbatim alongside the merged diagnostic, and only + the single framing newline is removed; `_git_out` and its other callers are unchanged. + POSIX-only, and a spurious refusal is unreachable because `safe_segment` rstrips `". "` + from every segment we compose. A path ending in `\r` stays indistinguishable under + `text=True` universal newlines — an accepted bound, documented at the reader. - **An aborted re-arm's spec rollback confines against the live project root, not the recorded one.** `_restore_rearmed_spec` picked between the confined and plain writers on a lexical `is_relative_to` against `task_spec_root` — the launch-time spelling nothing diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 636ee145..debb5d1f 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -416,6 +416,24 @@ def _git_raw(repo: Path, *args: str) -> tuple[int, str]: return proc.returncode, proc.stdout +def _git_raw_out(repo: Path, *args: str) -> tuple[int, str, str]: + """`_git_raw`'s value with `_git_out`'s diagnostic — + `(returncode, stdout VERBATIM, (stdout + stderr).strip())`. + + The fourth variant, and it exists for the one shape the other three cannot serve + together: a caller whose ANSWER is a path whose own trailing whitespace is + significant, and which still has to raise with stderr when git fails. `_git_out` + strips the value (silently eating that whitespace) and `_git_raw` drops the + diagnostic (so the failure message loses stderr). + + stdout is handed back with its line terminator still on. Trimming that is the + caller's job precisely because only the caller knows how much of the tail is + framing and how much is data — `.strip()` here would rebuild the very hazard this + helper exists to avoid.""" + proc = _run_git(["git", "-C", str(repo), *args], repo) + return proc.returncode, proc.stdout, (proc.stdout + proc.stderr).strip() + + def _git_out(repo: Path, *args: str, env: dict[str, str] | None = None) -> tuple[int, str, str]: """Like `_git`, but hands the VALUE and the DIAGNOSTIC back separately — `(returncode, stdout.strip(), (stdout + stderr).strip())`. @@ -436,7 +454,9 @@ def _git_out(repo: Path, *args: str, env: dict[str, str] | None = None) -> tuple this whenever the text is the answer; leave `_git` to the rc-only callers. `worktree_clean` and `path_tracked` (#441) predate this helper and spell the same split inline against `_run_git`; `_git_raw` is the third variant, for `-z` output - whose records can begin with a space and which `.strip()` would corrupt. + whose records can begin with a space and which `.strip()` would corrupt, and + `_git_raw_out` the fourth, for a value whose trailing whitespace is significant but + whose failure message still needs stderr (`branch_checkout_path`). `env` mirrors `_git_env`, for the snapshot path's throwaway `GIT_INDEX_FILE` and synthetic-identity calls that also read a sha back.""" @@ -2163,14 +2183,34 @@ def branch_checkout_path(repo: Path, branch: str) -> Path | None: not count). A ref that does not exist also prints nothing; callers that need the distinction check `branch_exists` first. The path is git's registered spelling, un-canonicalized: compare it the way the caller compares its own. - Reads stdout alone (`_git_out`): the value is the answer (#442). + Reads stdout alone (`_git_raw_out`): the value is the answer (#442). + + That "un-canonicalized" promise is why this reader does NOT go through `_git_out`, + which returns `stdout.strip()`. A worktree registered at a path with TRAILING + WHITESPACE — ` ` — came back stripped to ``, which compares EQUAL to + a unit's own mount path, so the occupancy guard exempted a foreign checkout as if + it were the unit's own. The ref then moved under a live foreign worktree, its tree + went spuriously dirty, and `worktree add` failed anyway: exactly the harm the guard + exists to prevent, WITH the guard present. The error can only go that unsafe way — + `safe_segment` rstrips `". "` from every segment we compose, so our own mount path + can never end in whitespace and a spurious REFUSE is unreachable. + + Only the single trailing `\n` that `for-each-ref` frames each record with is + removed, never arbitrary whitespace; an empty answer (`""` or a bare `"\n"`) still + means "no worktree has it attached" and returns `None`. + + Accepted bound: `_run_git` runs with `text=True` (universal newlines), so a + registered path ending in `\r` arrives already translated and stays + indistinguishable from one that does not. Closing that needs a bytes read, which is + out of scope here. """ - rc, out, detail = _git_out( + rc, out, detail = _git_raw_out( repo, "for-each-ref", "--format=%(worktreepath)", f"refs/heads/{branch}" ) if rc != 0: raise GitError(f"git for-each-ref refs/heads/{branch} failed in {repo}: {detail}") - return Path(out) if out else None + path = out.removesuffix("\n") + return Path(path) if path else None def create_branch(repo: Path, name: str, base: str) -> None: diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index e146b1f5..2789c389 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -6879,3 +6879,102 @@ def test_run_branch_remount_refuses_a_fast_forward_under_a_foreign_checkout(proj assert rev_parse_head(foreign) == old_tip assert not first.path.exists() assert first.path not in [p.resolve() for p in worktree_list(project.project)] + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="win32 strips trailing spaces at the API layer, so the shape cannot be registered", +) +def test_branch_checkout_path_keeps_a_foreign_checkouts_trailing_space(project): + """The occupancy guard exempts the unit's OWN mount, so the reader must not hand it + a foreign path that has been trimmed INTO that spelling. + + `branch_checkout_path` read `for-each-ref --format=%(worktreepath)` through + `_git_out`, which returns `stdout.strip()`. A worktree registered at the unit's + deterministic mount path PLUS a trailing space came back as the bare mount path, + compared EQUAL to `wt` in `_refuse_foreign_checkout`, and was exempted as if it were + this unit's own orphan. The ref then moved under a live foreign checkout — files and + index left at the old tip — its tree went spuriously dirty, and `worktree add` failed + on the held branch anyway: precisely the harm the guard was added to prevent, WITH the + guard present. The function's own docstring already promised the opposite ("git's + registered spelling, un-canonicalized"), so this is the promise being kept. + + The error could only ever go that unsafe way. `safe_segment` rstrips `". "` from every + segment we compose, so our own mount path can never end in whitespace and a spurious + REFUSE is unreachable; the negative control below pins that half. + + The shape is produced the same way the two sibling rows above produce a foreign + checkout — a deliberate operator `git worktree move` — with a destination one space + longer than the mount. git registers and reports that spelling verbatim. + + Three claims, because the first two alone cannot show the harm: the returned spelling + keeps its space, it is therefore NOT equal to the mount path, and the remount refuses + BEFORE any mutation — the branch tip is unchanged and the foreign checkout still + holds it. Pre-fix a `GitError` still arrived (from `worktree add`), which is why the + `match=` names the guard's own sentence and the tip assertion stands behind it. + + Ablation: revert the read to `_git_out` and this reddens on the returned spelling, + with the unchanged-tip assertion reddening behind it (the exempted branch is reset to + the pinned base before `worktree add` refuses). + """ + from bmad_loop.workspace import open_unit_workspace + + first, _run_dir = _open_unit(project, branch_per="story") + (first.path / "attempt.txt").write_text("committed on the attempt\n") + git(first.path, "add", "-A") + git(first.path, "commit", "-q", "-m", "story attempt") + tip = rev_parse_head(first.path) + # the unit's own deterministic mount path plus ONE trailing space: the whole point is + # that `.strip()` collapses this spelling onto the path the guard exempts + foreign = Path(f"{first.path} ") + git(project.project, "worktree", "move", str(first.path), str(foreign)) + assert foreign.is_dir() and not first.path.exists() + (project.project / "advanced.txt").write_text("new base\n") + pinned = _commit_project(project, "base advances") + assert tip != pinned + + holder = verify.branch_checkout_path(project.project, first.branch) + + assert holder is not None + assert str(holder) == f"{first.path} " # git's registered spelling, verbatim + assert holder != first.path # ...so it is not mistaken for this unit's own mount + + with pytest.raises(verify.GitError, match="would move the branch under that checkout"): + open_unit_workspace(*_open_args(project, branch_per="story")) + + assert git(project.project, "rev-parse", f"refs/heads/{first.branch}") == tip + assert rev_parse_head(foreign) == tip + + +def test_branch_checkout_path_answers_an_ordinary_mount_path_exactly(project): + """Negative control for the row above: the un-stripped read must not OVER-refuse. + + Only the single `\\n` that `for-each-ref` frames each record with is removed, never + arbitrary whitespace — so an ordinary registered path (the overwhelming majority, and + the only shape `safe_segment` can compose) still round-trips to exactly the mount path + the guard compares against, and the unit's own checkout stays EXEMPT. A reader that + trimmed too little would leave the framing newline on, make every path unequal to its + own mount, and turn the guard into a refusal on every ordinary remount. + + `_refuse_foreign_checkout` is called directly rather than through a remount because + the exemption is the claim: it returns None on the unit's own mount and raises + otherwise, so the call itself is the assertion. + + Ablated TWICE, because "does not raise" passes for every reason: + + * Return `Path(out)` un-trimmed (keep the framing `\\n`) and the equality assertion + reddens — the spelling gains a newline. + * With that equality assertion ALSO removed, the same ablation still reddens, now on + `_refuse_foreign_checkout` raising `GitError` over the unit's own mount. So the + negative half is not vacuous: it fails when the function over-refuses. + """ + from bmad_loop.workspace import _refuse_foreign_checkout + + first, _run_dir = _open_unit(project, branch_per="story") + + holder = verify.branch_checkout_path(project.project, first.branch) + + assert holder == first.path # exact round-trip: no framing left on, nothing eaten + + # the exemption still holds — this raises if the guard over-refuses its own mount + _refuse_foreign_checkout(project.project, first.branch, first.path) From b746d5a52f70d8b3bc47af8f2bf6631a046e43e1 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 3 Sep 2026 12:35:06 -0700 Subject: [PATCH 33/35] fix(worktree): require the mounted accepted-spec probe to stay in the unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-dispatch guard in `run_isolated` asked only `_is_file(unit.path / task.spec_file)`. That helper folds OSError to False around `Path.is_file()`, which FOLLOWS symlinks and answers only "are there readable bytes here" — it carries no containment check at all. `_accepted_spec_seed` already refuses a destination that resolves outside the mount, but it refuses SILENTLY: the rel never reaches `seed_files`, so neither `worktree-seed-skipped` nor `worktree-seed-dropped` names it, leaving this probe as the only remaining guard. When the accepted spec's parent is a real directory in the main checkout but a committed OUTWARD symlink in the commit the mount is cut from, the probe follows that link to an unrelated external artifact, answers true, and the unit dispatches reading someone else's bytes under the accepted spec's name. The regular-file half was already correct; only containment was missing. Resolve the probe once and require it to stay under the mount, folding OSError / RuntimeError / ValueError to "not delivered" so an unresolvable probe escalates rather than binds. A legitimate INWARD link whose target is inside the mount still passes, because resolve() lands it back under the unit root. Regression test builds the real shape — committed outward symlink materialized in the fresh worktree over an external file — and is POSIX-only, matching the neighbouring symlink tests. Ablated twice: removing the containment clause and removing the whole condition each turn it red. --- CHANGELOG.md | 7 +++++ src/bmad_loop/worktree_flow.py | 34 ++++++++++++++++++----- tests/test_engine_worktree.py | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aca7ea5c..3d843fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,13 @@ breaking changes may land in a minor release. ### Fixed +- **An accepted spec reached through a link out of the unit worktree no longer counts as + delivered.** The pre-dispatch check asked only whether a file existed at the mounted path, + and that probe follows symlinks, so a spec directory the checkout carries as a link + pointing outside the mount let an unrelated external file stand in for the accepted spec + and the session ran against the wrong bytes. The check now also requires the resolved path + to stay inside the worktree; a link whose target is inside it still passes, and a probe + that cannot be resolved escalates rather than binding. - **`branch_checkout_path` keeps a registered worktree path's trailing whitespace.** The reader went through `_git_out`, which returns `stdout.strip()`, so a foreign checkout registered at a unit's own mount path plus a trailing space came back as the bare mount diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index c63cd24e..91d10952 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1678,12 +1678,34 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N "worktree-seed-dropped", story_key=task.story_key, entries=undelivered_seeds ) - if accepted_spec_relocated and not _is_file(unit.path / str(task.spec_file)): - self.escalate_unit( - task, - f"accepted spec for {task.story_key} disappeared before it could be " - "delivered to the replacement worktree", - ) + # The last guard standing over a relocated accepted spec, and the only one + # that can see this particular loss at all: `_accepted_spec_seed` refuses on + # its own containment arm SILENTLY — the rel reaches neither `seed_files` nor + # `skipped_seeds` nor `undelivered_seeds`, so neither journal above names it. + # File-ness alone is therefore not enough to call the spec delivered. + # `_is_file` follows symlinks and asks only "are there bytes here", so a + # parent that is a real directory in the main checkout but a committed + # OUTWARD symlink in the commit this mount was cut from lands the probe on an + # unrelated external artifact, which answers true and dispatches the unit + # reading someone else's bytes under the accepted spec's name. Require + # containment as well: resolve the probe and keep it inside the mount. A + # legitimate INWARD link whose target is under the mount still passes, and an + # unresolvable probe cannot prove delivery, so every filesystem fault + # escalates rather than binding. + if accepted_spec_relocated: + accepted_probe = unit.path / str(task.spec_file) + try: + accepted_delivered = _is_file(accepted_probe) and accepted_probe.resolve( + strict=False + ).is_relative_to(unit.path.resolve()) + except (OSError, RuntimeError, ValueError): + accepted_delivered = False + if not accepted_delivered: + self.escalate_unit( + task, + f"accepted spec for {task.story_key} disappeared before it could be " + "delivered to the replacement worktree", + ) trees = [p.skill_tree for p in profiles] # The wheel's own bundled skills, journal-only like worktree-seed-dropped but diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 2789c389..a13d2f57 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -350,6 +350,55 @@ def open_then_remove_source(*args, **kwargs): assert task.phase == Phase.ESCALATED +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_relocated_accepted_spec_escaping_the_mount_does_not_bind_an_outside_file( + project, tmp_path +): + """A probe that leaves the unit is not delivery, however file-shaped it reads. + + The accepted spec's parent is a real directory in the main checkout but a + committed OUTWARD symlink in the commit the fresh worktree is cut from, so + `_accepted_spec_seed` refuses on its own containment arm and — uniquely among + the seed refusals — journals nothing: the rel reaches neither `seed_files` nor + `worktree-seed-skipped` nor `worktree-seed-dropped`. The mounted probe is the + only remaining guard, and file-ness alone follows the link to an unrelated + external artifact and reads as delivered. + + Ablation: drop the containment clause at that probe (or the whole condition) + and the unit dispatches, bound to the outside file instead of escalating. + """ + from bmad_loop.engine import RunPaused + + rel_dir = "_bmad-output/accepted-elsewhere" + rel = f"{rel_dir}/escape.md" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "escape.md").write_bytes(b"unrelated external bytes\n") + link = project.project / rel_dir + link.symlink_to(outside, target_is_directory=True) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + # The commit keeps the outward symlink, so the fresh worktree materializes the + # escape; only the main checkout gets the real directory holding the accepted + # artifact, which is what lets the absolute spelling normalize in the first place. + link.unlink() + link.mkdir() + accepted = project.project / rel + accepted.write_bytes(b"accepted operator bytes\n") + + engine, _ = make_engine(project, [], policy=wt_policy(keep_failed=False)) + engine.state.target_branch = "main" + task = StoryTask("1-1-a", 1, spec_file=str(accepted)) + engine.state.tasks[task.story_key] = task + drove: list[bool] = [] + + with pytest.raises(RunPaused, match="accepted spec.*disappeared"): + engine._run_isolated(task, lambda _task: drove.append(True)) + + assert drove == [] + assert task.phase == Phase.ESCALATED + assert (outside / "escape.md").read_bytes() == b"unrelated external bytes\n" + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") def test_missing_upstream_skill_seed_escalates_before_dispatch_and_records_mount(project, tmp_path): """A shared install outside the repo passes main's through-link resolution but From 44bfcfd1d3139a33393e0dcb878a7ac8f48f211f Mon Sep 17 00:00:00 2001 From: t Date: Thu, 3 Sep 2026 17:00:33 -0700 Subject: [PATCH 34/35] fix(deferredwork,engine): decline the ledger anchor over a rival that beat the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both locked mutators hand back the WHOLE post-edit file, so an entry a rival appended between this task's pre-harvest snapshot and the mutator's locked read was re-published under the run's name and landed in post_engine_ledger_digest. A later rejected attempt then read _restore_ledger's compare-and-set as owned and whole-file-overwrote with the old snapshot, deleting the rival's entry in silence — the default ledger is gitignored, so the digest is the sole write authorization and git never republishes it. mark_seen_again_many and append_entries_published now also return the preimage they read under the lock. Both engine anchor sites re-anchor only when that preimage is still the bytes the run last claimed; otherwise the anchor stays on the snapshot, the restore sees ours as False, and it journals the existing ledger-restore-skipped-diverged kind. The comparison is against the anchor rather than task.pre_harvest_ledger because the harvest's mark leg legitimately moves the anchor before its append leg runs, so the append's preimage is the mark's published text. --- CHANGELOG.md | 21 ++-- src/bmad_loop/deferredwork.py | 65 ++++++++---- src/bmad_loop/engine.py | 47 ++++++++- tests/test_deferredwork.py | 22 +++- tests/test_engine.py | 183 +++++++++++++++++++++++++++++++++- 5 files changed, 298 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d843fcc..eca2d588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,12 @@ breaking changes may land in a minor release. ### Added - **Interactive resolve context names both the BMAD project root and the run's code - root.** `bmad-loop resolve` warns before a divergent-root session launches, while - keeping the session project-rooted and directing code fixes and commits to the code - root. The re-arm writes the same tree the context published: `runs.rearm_escalation` - takes the live project root from `resolve` and the TUI, so a moved project no longer - has the agent edit one copy of the spec while the re-arm flips another. A recorded - spelling that traverses out of the project through `..` (an external artifact root - named relative to it) is classified external and left unchanged rather than rebased - onto a different file under the moved project's new parent. + root.** `bmad-loop resolve` warns before a divergent-root session launches, keeps the + session project-rooted, and directs code fixes and commits to the code root. The + re-arm, from both `resolve` and the TUI, writes the tree the context published, so a + moved project no longer has the agent edit one copy of the spec while the re-arm flips + another. A recorded spelling that traverses out through `..` stays external and + unchanged. - **A failed re-arm commits probe now journals `rearm-commits-probe-failed`** (DW-81). The warn-only probe that lists the commits an abandoned attempt left below the re-drive's @@ -251,6 +249,13 @@ breaking changes may land in a minor release. ### Fixed +- **A harvest's ledger anchor no longer adopts a rival entry that beat it to the lock.** + Both `deferredwork` mutators publish the whole post-edit file, so an entry appended + between the pre-harvest snapshot and their locked read was claimed by + `post_engine_ledger_digest`, and a later rejected attempt restored over it — + unrecoverably, on a gitignored ledger. The mark and append legs now re-anchor only when + the preimage they wrote over is still the bytes the run last claimed; the restore skips + and journals `ledger-restore-skipped-diverged`. - **An accepted spec reached through a link out of the unit worktree no longer counts as delivered.** The pre-dispatch check asked only whether a file existed at the mounted path, and that probe follows symlinks, so a spec directory the checkout carries as a link diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 427054a7..92f38cd7 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -1020,11 +1020,12 @@ def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool: def mark_seen_again_many( path: Path, dw_ids: Sequence[str], date: str, note: str -) -> tuple[list[bool], str | None, list[str]]: +) -> tuple[list[bool], str | None, list[str], str | None]: """Stamp `seen-again: ()` under each entry's status line, in ONE read and ONE atomic write. Returns one applied flag per id, the text it - published — None when it wrote nothing — and the ids whose match went STALE - inside the hold. + published — None when it wrote nothing — the ids whose match went STALE + inside the hold, and the PREIMAGE this call read under that hold (None when + no locked read happened at all). The writer half of the format doc's dedupe rule (deferred-work-format.md): a finding that matches an existing entry is recorded as a sighting on that @@ -1056,17 +1057,26 @@ def mark_seen_again_many( hold for the same ``post_engine_ledger_digest`` reasons as :func:`append_entries_published`. `date` is orchestrator-owned and raises when malformed; `note` is sanitized to one line (#305). + + The PREIMAGE comes back for the other half of that anchor question. The + published text says WHAT WAS WRITTEN; only the preimage says WHAT IT WAS + WRITTEN OVER, and an anchor may claim the published bytes as the caller's own + solely when the preimage is still the bytes the caller last knew the file to + hold. A rival that lands between the caller's snapshot and this locked read + is folded into the preimage — and therefore re-published — so without that + comparison the caller's anchor would authorize retracting the rival's work. """ _require_iso_date(date) line = f"seen-again: {date} ({_one_line(note)})" if not dw_ids: - return [], None, [] + return [], None, [], None if not path.is_file(): - return [False for _ in dw_ids], None, list(dw_ids) + return [False for _ in dw_ids], None, list(dw_ids), None with ledger_lock(path): if not path.is_file(): - return [False for _ in dw_ids], None, list(dw_ids) - text = path.read_text(encoding="utf-8") + return [False for _ in dw_ids], None, list(dw_ids), None + preimage = path.read_text(encoding="utf-8") + text = preimage applied: list[bool] = [] stale: list[str] = [] for dw_id in dw_ids: @@ -1081,11 +1091,12 @@ def mark_seen_again_many( text = _insert_after_status(text, entry, line) applied.append(True) if not any(applied): - return applied, None, stale + return applied, None, stale, preimage atomic_write_text(path, text) - # Returned from INSIDE the hold: this is the published text by - # construction, not a read-back that a rival could have moved. - return applied, text, stale + # Both returned from INSIDE the hold: the published text by + # construction, and the preimage it replaced — neither a read-back that a + # rival could have moved. + return applied, text, stale, preimage _MARK_DONE_TAIL_RE = re.compile( @@ -1527,10 +1538,11 @@ def append_entries(path: Path, specs: Sequence[EntrySpec]) -> list[str | None]: def append_entries_published( path: Path, specs: Sequence[EntrySpec] -) -> tuple[list[str | None], str | None]: +) -> tuple[list[str | None], str | None, str | None]: """:func:`append_entries`, additionally handing back the text it published — or None when it wrote nothing, because every spec deduped or `specs` was - empty. + empty — and the PREIMAGE it read under the lock, or None when no locked read + happened (nothing to write, or the ledger did not exist). For a caller that has to record WHAT IT WROTE rather than what the file holds afterwards. Reading the ledger back after this returns is a different @@ -1542,6 +1554,14 @@ def append_entries_published( retract it, which is the loss this module exists to prevent (#286). Taking the text from inside the hold removes the window rather than narrowing it. + That closes the window AFTER the locked read; the preimage closes the one + BEFORE it. A rival that appended between the caller's snapshot and this + locked read is already in the text this call re-publishes, so the published + bytes carry it and an anchor set from them would still authorize retracting + it. Handing the preimage back lets the caller answer the only question that + makes the anchor safe — "is what I wrote over still what I last knew this + file to be?" — and decline to move the anchor when it is not. + The returned text is what was handed to :func:`~bmad_loop.platform_util.atomic_write_text`, so a digest of it equals a digest of a later ``read_text`` of the file: the writer's text mode @@ -1591,7 +1611,7 @@ def append_entries_published( ) if not specs: # Nothing to serialize against, so nothing to take a lock for. - return [], None + return [], None, None try: # ADVISORY pre-lock probe (#736): one read — shaped exactly like the # locked one, absence included — and the same pure decision the locked @@ -1602,19 +1622,22 @@ def append_entries_published( probe = path.read_text(encoding="utf-8") if path.is_file() else "" minted = _apply_appends(probe, specs)[1] if all(dw_id is None for dw_id in minted): - return minted, None + # No lock was taken, so there is no locked read to report a preimage + # from — and nothing was written for an anchor to claim either. + return minted, None, None except Exception: # nosec B110 - ADVISORY probe: a fault here must decide nothing pass with ledger_lock(path): - text = path.read_text(encoding="utf-8") if path.is_file() else "" - text, minted = _apply_appends(text, specs) + preimage = path.read_text(encoding="utf-8") if path.is_file() else None + text, minted = _apply_appends(preimage or "", specs) if all(dw_id is None for dw_id in minted): - return minted, None + return minted, None, preimage path.parent.mkdir(parents=True, exist_ok=True) atomic_write_text(path, text) - # Returned from INSIDE the hold: this is the published text by - # construction, not a read-back that a rival could have moved. - return minted, text + # Both returned from INSIDE the hold: the published text by + # construction, and the preimage it replaced — neither a read-back that a + # rival could have moved. + return minted, text, preimage def append_entry( diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 47d55a6e..ac5898e4 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -3984,6 +3984,33 @@ def _harvest_spec_path(self, task: StoryTask, result_json: dict | None) -> Path return None return verify.resolve_spec_path(str(spec_file), self.workspace.paths) + def _may_move_ledger_anchor(self, task: StoryTask, preimage: str | None) -> bool: + """May a ledger write that replaced `preimage` become the restore anchor? + + Only when `preimage` is still the bytes this engine last claimed. The + published text a `deferredwork` mutator hands back is the WHOLE post-edit + file, so a rival entry that landed between this task's pre-harvest + snapshot and the mutator's locked read is inside it — re-published under + our name. Anchoring on that would have :meth:`_restore_ledger` read + ``ours`` as True on a rejected attempt and whole-file-overwrite the + rival's entry away, which is the very loss the anchor exists to prevent. + Declining to move the anchor leaves it naming the snapshot instead, so the + restore sees ``ours`` as False, skips, and journals + ``ledger-restore-skipped-diverged``. + + The anchor is what is compared, not ``task.pre_harvest_ledger`` itself: + the harvest's mark leg legitimately moves the anchor before its append + leg runs, so the append's preimage is the mark's published text and + matching it against the raw snapshot would decline every contended-free + mark+append pair. An UNARMED anchor (``None``) claims nothing yet, so the + first publish establishes it — a state the engine's own harvest path never + reaches, since the snapshot at ``_run_story`` arms both together. + """ + return ( + task.post_engine_ledger_digest is None + or _digest_of(preimage) == task.post_engine_ledger_digest + ) + def _absorb_harvest_records( self, task: StoryTask, @@ -4259,16 +4286,21 @@ def _harvest_spec_deferrals( if not task.harvest_wrote_ledger: task.harvest_wrote_ledger = True self._save() - _, marked_published, stale = deferredwork.mark_seen_again_many( + _, marked_published, stale, marked_preimage = deferredwork.mark_seen_again_many( ledger, seen_again_ids, self._today(), f"spec-deferral harvest of {spec_name}", ) - if marked_published is not None: + if marked_published is not None and self._may_move_ledger_anchor(task, marked_preimage): # Re-anchor the pre-harvest restore's CAS on what the mark # published; a following append overwrites this with its own # published text (its locked read includes these marks). + # + # Conditional on the preimage: the published text is the whole + # post-edit file, so a rival that landed before this mark's + # locked read is re-published inside it. Anchoring then would let + # a rejected attempt's restore retract the rival's entry as ours. task.post_engine_ledger_digest = _digest_of(marked_published) self._save() if stale: @@ -4333,9 +4365,9 @@ def _harvest_spec_deferrals( # because each spec is applied to the text the previous one produced. # The scan above already ran, and the latch above already fired, so the # durability ordering the comment there describes is unchanged. - minted, published = deferredwork.append_entries_published(ledger, specs) + minted, published, append_preimage = deferredwork.append_entries_published(ledger, specs) filed = [dw_id for dw_id in minted if dw_id is not None] - if filed: + if filed and self._may_move_ledger_anchor(task, append_preimage): # Re-anchor the pre-harvest restore's compare-and-set on what this # append actually published. `append_entries_published` writes only # when some spec minted an id (it hands back None when every one @@ -4350,6 +4382,13 @@ def _harvest_spec_deferrals( # That is the loss this change exists to prevent, so the anchor comes # from inside the hold instead. # + # The preimage guards the other side of the same window. A rival that + # landed BEFORE the writer's locked read is inside the text it + # re-published, so the published bytes alone cannot say whose they + # are; `_may_move_ledger_anchor` declines the move when what we wrote + # over is no longer what we last claimed, leaving the anchor on the + # snapshot so the restore skips and journals instead of retracting. + # # Durable before the decision that consumes it: a crash replay # re-runs the harvest, which either writes again (refreshing this) # or dedupes to no write at all, leaving the dead attempt's bytes diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index c7ad825b..d2f745e0 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -150,12 +150,14 @@ def test_mark_done_missing_entry(tmp_path): def test_mark_seen_again_many_inserts_after_status(tmp_path): path = write_ledger(tmp_path) - applied, published, stale = mark_seen_again_many( + applied, published, stale, preimage = mark_seen_again_many( path, ["DW-1"], "2026-08-31", "spec-deferral harvest of spec-2-2-b.md" ) assert applied == [True] and stale == [] text = path.read_text(encoding="utf-8") assert published == text + # the bytes the write landed on, read under the same hold as the publish + assert preimage == LEDGER assert "status: open\nseen-again: 2026-08-31 (spec-deferral harvest of spec-2-2-b.md)" in text entries = {e.id: e for e in parse_ledger(text)} assert entries["DW-1"].open # the line does not disturb status parsing @@ -166,9 +168,13 @@ def test_mark_seen_again_many_is_idempotent_on_replay(tmp_path): path = write_ledger(tmp_path) assert mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x")[0] == [True] snapshot = path.read_text(encoding="utf-8") - applied, published, stale = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest of x") + applied, published, stale, preimage = mark_seen_again_many( + path, ["DW-1"], "2026-08-31", "harvest of x" + ) # the line is already there: a live sighting on a live entry, NOT a stale match assert applied == [False] and published is None and stale == [] + # a locked read still happened, so the preimage is reported even with no write + assert preimage == snapshot assert path.read_text(encoding="utf-8") == snapshot # a different sighting (new date) is a new line, not a dupe to skip assert mark_seen_again_many(path, ["DW-1"], "2026-09-01", "harvest of x")[0] == [True] @@ -177,19 +183,22 @@ def test_mark_seen_again_many_is_idempotent_on_replay(tmp_path): def test_mark_seen_again_many_missing_id_is_false_and_stale(tmp_path): path = write_ledger(tmp_path) - applied, published, stale = mark_seen_again_many( + applied, published, stale, preimage = mark_seen_again_many( path, ["DW-99", "DW-1"], "2026-08-31", "harvest of x" ) + assert preimage == LEDGER # an id that is simply gone had nowhere to take the sighting either — the # caller matched it against a snapshot and must file its finding after all assert applied == [False, True] and published is not None and stale == ["DW-99"] assert "seen-again: 2026-08-31 (harvest of x)" in path.read_text(encoding="utf-8") # a missing ledger applies nothing, creates nothing, and reports every id stale missing = tmp_path / "absent" / "deferred-work.md" + # no locked read happened on the absent-file arm, so there is no preimage assert mark_seen_again_many(missing, ["DW-1"], "2026-08-31", "x") == ( [False], None, ["DW-1"], + None, ) assert not missing.exists() @@ -202,11 +211,12 @@ def test_mark_seen_again_many_reports_a_closed_match_as_stale(tmp_path): path = write_ledger(tmp_path) # DW-2 is done in the fixture, standing in for an entry closed since the # caller's snapshot; DW-3 is open and must still take its line in the same call. - applied, published, stale = mark_seen_again_many( + applied, published, stale, preimage = mark_seen_again_many( path, ["DW-2", "DW-3"], "2026-08-31", "harvest of x" ) assert applied == [False, True] and stale == ["DW-2"] + assert preimage == LEDGER text = path.read_text(encoding="utf-8") assert published == text entries = {e.id: e for e in parse_ledger(text)} @@ -222,6 +232,7 @@ def test_mark_seen_again_many_writes_nothing_when_every_match_is_stale(tmp_path) [False, False], None, ["DW-2", "DW-99"], + snapshot, ) assert path.read_text(encoding="utf-8") == snapshot @@ -239,13 +250,14 @@ def test_mark_seen_again_many_treats_an_unparseable_status_as_stale(tmp_path): [False], None, ["DW-1"], + snapshot, ) assert path.read_text(encoding="utf-8") == snapshot def test_mark_seen_again_many_sanitizes_the_note_to_one_line(tmp_path): path = write_ledger(tmp_path) - applied, _, _ = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest\nof spec-x.md") + applied, _, _, _ = mark_seen_again_many(path, ["DW-1"], "2026-08-31", "harvest\nof spec-x.md") assert applied == [True] text = path.read_text(encoding="utf-8") assert "seen-again: 2026-08-31 (harvest of spec-x.md)" in text diff --git a/tests/test_engine.py b/tests/test_engine.py index 7a136ab6..4ff66c60 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -13923,7 +13923,7 @@ def test_harvest_anchor_names_what_was_published_not_a_later_rival(project, monk rival_filed: list[bool] = [] def append_then_a_rival_writes(*args, **kwargs): - minted, text = real_append(*args, **kwargs) + minted, text, preimage = real_append(*args, **kwargs) if not rival_filed: # Latched BEFORE the nested call, not after: the rival goes through # the ordinary public appender, which delegates down to this very @@ -13939,7 +13939,7 @@ def append_then_a_rival_writes(*args, **kwargs): source_spec="other.md", reason="a rival writer got here first.", ) - return minted, text + return minted, text, preimage monkeypatch.setattr(deferredwork, "append_entries_published", append_then_a_rival_writes) @@ -13951,6 +13951,185 @@ def append_then_a_rival_writes(*args, **kwargs): assert task.post_engine_ledger_digest != _digest_of(on_disk) +def _arm_ledger_snapshot(task, ledger): + """Arm the pre-harvest restore exactly as ``_run_story`` does, and hand back + the snapshot text so a test can assert the anchor never left it.""" + snapshot = ledger.read_text(encoding="utf-8") if ledger.is_file() else None + task.pre_harvest_ledger = snapshot + task.pre_harvest_ledger_captured = True + task.post_engine_ledger_digest = _digest_of(snapshot) + return snapshot + + +def test_harvest_append_declines_the_anchor_over_a_rival_that_beat_it_to_the_lock( + project, monkeypatch +): + """A rival that lands BEFORE the writer's locked read must not be adopted. + + The published text handed back from inside the hold is the WHOLE post-edit + file, so a rival entry appended between this task's pre-harvest snapshot and + the append's locked read is in the preimage, is re-published, and would land + in ``post_engine_ledger_digest``. A later rejected attempt then reads ``ours`` + as True and whole-file-overwrites with the old snapshot, deleting the rival's + entry in silence — the default ledger is gitignored, so the digest is the sole + write authorization and git never republishes it. + + The anchor therefore moves only when the bytes written over are still the + bytes this engine last claimed; here they are not, so it stays on the + snapshot, the restore declines and journals instead. + + Ablation: drop the `_may_move_ledger_anchor` guard from the append site and + the anchor row reds, then the rival's entry vanishes from disk.""" + engine, _ = make_engine(project, [], policy=_harvest_policy()) + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_VERIFY) + engine.state.tasks[task.story_key] = task + sp = spec_path(project, task.story_key) + sp.parent.mkdir(parents=True, exist_ok=True) + write_spec(sp, "done", "abc123", deferred=[HARVEST_A]) + result_json = {"spec_file": str(sp)} + + ledger = project.deferred_work + ledger.parent.mkdir(parents=True, exist_ok=True) + ledger.write_text("# Deferred Work\n", encoding="utf-8") + snapshot = _arm_ledger_snapshot(task, ledger) + + real_append = deferredwork.append_entries_published + rival_filed: list[bool] = [] + + def a_rival_writes_then_append(*args, **kwargs): + if not rival_filed: + # Latched BEFORE the nested call: the rival goes through the ordinary + # public appender, which delegates down to this very symbol. + rival_filed.append(True) + deferredwork.append_entry( + ledger, + title="filed by another process", + origin="sweep, 2026-06-11", + source_spec="other.md", + reason="a rival writer got here first.", + ) + return real_append(*args, **kwargs) + + monkeypatch.setattr(deferredwork, "append_entries_published", a_rival_writes_then_append) + engine._harvest_spec_deferrals(task, result_json) + monkeypatch.setattr(deferredwork, "append_entries_published", real_append) + + published = ledger.read_text(encoding="utf-8") + assert rival_filed and "filed by another process" in published # the rival really did land + assert [e.title for e in _harvest_entries(project)][-1] == HARVEST_A["summary"] # ours too + # The anchor never left the snapshot: what this append wrote over was not what + # the task last claimed, so the published bytes are not provably ours. + assert task.post_engine_ledger_digest == _digest_of(snapshot) + assert task.post_engine_ledger_digest != _digest_of(published) + + engine._restore_persisted_ledger(task, replayed=False) + + assert ledger.read_text(encoding="utf-8") == published # nothing retracted + assert "filed by another process" in ledger.read_text(encoding="utf-8") + (event,) = [ + e for e in engine.journal.entries() if e["kind"] == "ledger-restore-skipped-diverged" + ] + assert event["story_key"] == "1-1-a" and event["ledger"] == str(ledger) + + +def test_harvest_mark_declines_the_anchor_over_a_rival_that_beat_it_to_the_lock( + project, monkeypatch +): + """The identical loss through the `seen-again:` leg, which files no entry. + + `mark_seen_again_many` hands back the whole post-edit file too, so fixing only + the append site leaves this path re-publishing — and then retracting — a + rival's entry. Shaped so the harvest's append leg is inert: the one finding + matches an already-open entry from another spec, so nothing is filed and the + mark is the only ledger write this harvest makes. + + Ablation: drop the `_may_move_ledger_anchor` guard from the mark site and the + anchor row reds, then the rival's entry vanishes from disk.""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + + engine, _ = make_engine(project, [], policy=_harvest_policy()) + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_VERIFY) + engine.state.tasks[task.story_key] = task + sp = spec_path(project, task.story_key) + sp.parent.mkdir(parents=True, exist_ok=True) + write_spec(sp, "done", "abc123", deferred=[HARVEST_A]) + result_json = {"spec_file": str(sp)} + + ledger = project.deferred_work + snapshot = _arm_ledger_snapshot(task, ledger) + + real_mark = deferredwork.mark_seen_again_many + + def a_rival_writes_then_mark(path, dw_ids, date, note): + deferredwork.append_entry( + path, + title="filed by another process", + origin="sweep, 2026-06-11", + source_spec="other.md", + reason="a rival writer got here first.", + ) + return real_mark(path, dw_ids, date, note) + + monkeypatch.setattr(deferredwork, "mark_seen_again_many", a_rival_writes_then_mark) + engine._harvest_spec_deferrals(task, result_json) + monkeypatch.setattr(deferredwork, "mark_seen_again_many", real_mark) + + published = ledger.read_text(encoding="utf-8") + assert "filed by another process" in published # the rival really did land + assert "seen-again: " in published # and the mark really did write + # The append leg is inert here, so the mark's own decision is the only one + # that could have moved the anchor. + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["dw_ids"] == [] and event["seen_again"] == ["DW-1"] + assert task.post_engine_ledger_digest == _digest_of(snapshot) + assert task.post_engine_ledger_digest != _digest_of(published) + + engine._restore_persisted_ledger(task, replayed=False) + + assert ledger.read_text(encoding="utf-8") == published # nothing retracted + assert "filed by another process" in ledger.read_text(encoding="utf-8") + assert "ledger-restore-skipped-diverged" in [e["kind"] for e in engine.journal.entries()] + + +def test_harvest_anchor_still_moves_for_an_uncontended_mark_then_append(project): + """The control the conditional must not cost: with no rival, both legs anchor. + + The append's locked read sees the text the mark published, not the raw + pre-harvest snapshot, so the guard compares against the anchor rather than the + snapshot. One finding matches an open entry (the mark) and a second does not + (the append), which is the only ordering where both legs write in one harvest.""" + from bmad_loop import devcontract + + fp = devcontract.harvest_fingerprint(HARVEST_A["summary"], HARVEST_A["location"]) + _seeded_ledger(project, origin=f"spec-deferred {fp}", source_spec="spec-9-9-z.md") + + engine, _ = make_engine(project, [], policy=_harvest_policy()) + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_VERIFY) + engine.state.tasks[task.story_key] = task + sp = spec_path(project, task.story_key) + sp.parent.mkdir(parents=True, exist_ok=True) + write_spec(sp, "done", "abc123", deferred=[HARVEST_A, HARVEST_B]) + ledger = project.deferred_work + snapshot = _arm_ledger_snapshot(task, ledger) + + engine._harvest_spec_deferrals(task, {"spec_file": str(sp)}) + + published = ledger.read_text(encoding="utf-8") + assert "seen-again: " in published # the mark leg wrote + (event,) = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"] + assert event["seen_again"] == ["DW-1"] and event["dw_ids"] == ["DW-2"] # and the append leg + assert task.post_engine_ledger_digest == _digest_of(published) + assert task.post_engine_ledger_digest != _digest_of(snapshot) + + # And the restore still owns those bytes: an accepted-then-rejected attempt + # retracts this engine's own harvest exactly as it did before the guard. + engine._restore_persisted_ledger(task, replayed=False) + assert ledger.read_text(encoding="utf-8") == snapshot + + def test_spec_deferrals_dedup_sees_already_done_entries(project): write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) engine, _ = make_engine( From 8ecc8ad580a5742ecb61c9149ea05b03ea4a3100 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 3 Sep 2026 19:42:37 -0700 Subject: [PATCH 35/35] fix(workspace): park the orphan's orchestrator-owned artifacts before the reclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An orphan-mount reclaim drew its snapshot candidates from `untracked_files`, i.e. `git ls-files --others --exclude-standard`, which excludes ignored paths by contract. The deferred-work ledger, the sprint board and the bound spec are ignored inside every mount by construction: WorktreeFlow seeds them into a tracked-only checkout and folds every seeded rel into the worktree-local info/exclude. The mount therefore held the only copy, and the reclaim's `worktree_remove(force=True)` deleted it — silently over a clean tracked tree, where the snapshot returned None so there was no ref, no on_orphan_preserved callback and no journal line. An orphan never merges, so no carry and no replay handle survived either. `_orphan_owned_rels` derives those three rels from the `paths` argument the open already receives, rebased onto the mount, and includes each only when the mount holds it as a regular file; they ride `snapshot_worktree`'s existing `force_include`. `ProjectPaths` carries no spec member, so the accepted spec's spelling is threaded in as a new keyword-only `spec_file` and resolved against the rebased paths. Candidates are judged one at a time. An artifacts dir configured outside the project tree is supported, and `rebased` leaves it unmoved there, so the ledger and board resolve outside the mount and correctly drop — they are shared, not per-checkout. The spec must not drop with them: `_accepted_spec_seed` lays it inside the mount regardless, where it is still the only copy. A single `try` around the loop voided all three the moment the first raised, which would have made this inert in exactly that configuration. `is_file()` re-raising EACCES through 3.13 has the same shape and is contained the same way. Naming still degrades to `()` on a setup fault, in `_ledger_seed`'s style; the #340 gate stays at the capture, where a named rel git cannot stage refuses the remount. Deliberately narrow. Preserving every ignored path would park the seeded `_bmad/` tree, the adapters' MCP configs and venv residue into a ref `scm.preserve_keep` retains 20 deep; refusing the reclaim would break the flip-back path outright, since every mount has shielded ignored files. --- CHANGELOG.md | 7 ++ src/bmad_loop/workspace.py | 130 +++++++++++++++++++--- src/bmad_loop/worktree_flow.py | 7 ++ tests/test_engine_worktree.py | 197 +++++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca2d588..223a7c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -249,6 +249,13 @@ breaking changes may land in a minor release. ### Fixed +- **An orphaned mount's reclaim no longer destroys the orchestrator's own artifacts.** The + pre-reclaim snapshot drew its untracked candidates from `untracked_files`, which excludes + ignored paths by contract, and the deferred-work ledger, sprint board and bound spec are + ignored inside every mount by construction. Over a clean tracked tree it therefore parked + nothing, so the force-remove took them with no ref, no callback and no journal line. Those + three are now force-included when the mount holds them as regular files, judged one by + one; no other ignored path is parked. - **A harvest's ledger anchor no longer adopts a rival entry that beat it to the lock.** Both `deferredwork` mutators publish the whole post-edit file, so an entry appended between the pre-harvest snapshot and their locked read was claimed by diff --git a/src/bmad_loop/workspace.py b/src/bmad_loop/workspace.py index 07cffd03..832c3868 100644 --- a/src/bmad_loop/workspace.py +++ b/src/bmad_loop/workspace.py @@ -114,11 +114,98 @@ def orphan_preserve_ref_name(run_id: str, head: str) -> str: return f"refs/attempt-preserve-dirty/{safe_ref_segment(run_id)}-{head[:8]}-orphan" +def _orphan_owned_rels(wt: Path, paths: ProjectPaths, spec_file: str | None) -> tuple[str, ...]: + """The mount-relative paths of the orchestrator's OWN artifacts inside an + orphaned mount at ``wt`` — the deferred-work ledger, the sprint board and the + accepted spec — for :func:`verify.snapshot_worktree`'s ``force_include``. + + Without this the orphan snapshot cannot see them at all. Its untracked + candidates come from ``verify.untracked_files``, i.e. ``git ls-files --others + --exclude-standard``, which excludes IGNORED files by contract — and these three + are ignored inside every mount by construction: ``WorktreeFlow`` seeds them into + a checkout that carries tracked files only, and folds every seeded rel into the + worktree-local ``info/exclude`` so the unit's ``git add -A`` cannot ride them + onto the merge. So the mount holds the only copy, the reclaim's + ``worktree_remove(force=True)`` deletes it, and an orphan never merges — no + carry runs and no replay handle survives (`_replay_unlatched_ledger_carries` + gates on ``task.worktree_path``, cleared when the flip releases the mount). + Worse, over a CLEAN tracked tree ``snapshot_worktree`` returns ``None``, so the + loss came with no ref, no ``on_orphan_preserved`` callback and no journal line. + + Deliberately NARROW: naming every ignored path instead would park the seeded + ``_bmad/`` tree, the adapters' MCP configs and venv residue into a + ``refs/attempt-preserve-dirty/*`` object that ``scm.preserve_keep`` retains 20 + deep. Refusing the reclaim instead is no remedy either — every mount has + shielded ignored files by construction, so the flip-back path would never work + again. + + Derived from ``paths`` rebased onto the mount, and each candidate is included + only when it is present there as a REGULAR FILE inside the mount root: the + ``git add -f`` in ``snapshot_worktree`` is a repair write that raises (and so + refuses the remount) on a path it cannot stage. ``resolve()`` decides + containment, so a candidate that lands outside the mount drops out. + + Each candidate is judged ALONE, which is the whole point of the per-candidate + ``try``. An artifacts dir configured outside the project tree is a supported + shape, and ``ProjectPaths.rebased`` deliberately leaves it unmoved there + ("configured outside the project tree; doesn't move"): the ledger and the board + then resolve outside the mount and SHOULD drop, because they are shared rather + than per-checkout and the reclaim cannot destroy them. The spec must not drop + with them — ``WorktreeFlow._accepted_spec_seed`` lays it INSIDE the mount + whatever the artifacts dir is doing, so there it is still the only copy. A + single ``try`` around the loop returned ``()`` for all three the moment the + first candidate raised, making this fix silently inert in exactly that + configuration — the same silence it exists to remove. + + Naming DEGRADES to ``()`` only on a setup fault (the mount path or the rebase + itself), in ``WorktreeFlow._ledger_seed``'s style: this function decides which + rels are orchestrator-owned, and an unanswerable question about that is not + evidence of work to lose. The #340 gate stays where the capture is — a rel this + DOES name that git then cannot stage raises and leaves the orphan standing. + """ + # Setup only: a fault here has no per-candidate meaning, so it voids the answer. + try: + root = wt.resolve() + mounted = paths.rebased(wt) + except (OSError, RuntimeError): + return () + spec_candidate: Path | None = None + if spec_file and not Path(spec_file).is_absolute(): + try: + spec_candidate = verify.resolve_spec_path(spec_file, mounted) + except OSError: + # That probe decides between its two locations with `is_file()`, which + # re-raises EACCES through 3.13 (3.14 returns False instead — neither is + # relied on). An unreadable probe drops the SPEC leg alone. + spec_candidate = None + candidates = [mounted.deferred_work, mounted.sprint_status] + if spec_candidate is not None: + candidates.append(spec_candidate) + rels: list[str] = [] + for candidate in candidates: + # Per candidate, never shared: one path that is out-of-mount, unreadable or a + # broken link drops ITSELF. A single try around the loop would have the + # out-of-tree artifacts dir — a supported configuration whose ledger and board + # correctly drop — take the spec down with them, and the spec IS in the mount. + try: + target = candidate.resolve() + if not target.is_file(): + continue + rel = target.relative_to(root).as_posix() + except (OSError, RuntimeError, ValueError): + continue + if rel and rel != "." and rel not in rels: + rels.append(rel) + return tuple(rels) + + def _preserve_orphan_state( repo_root: Path, wt: Path, run_id: str, unit_key: str, + paths: ProjectPaths, + spec_file: str | None, on_orphan_preserved: Callable[[str, str], None] | None, ) -> None: """Park the uncommitted work an orphaned mount at ``wt`` still holds before the @@ -143,12 +230,21 @@ def _preserve_orphan_state( branch reset below moves that ref) so the parked commit is parented at the tree the orphan actually diverged from and holds only what was uncommitted. - A clean tree is a no-op (no ref, no callback). A capture failure raises - :class:`verify.GitError` and so refuses the remount (#340: a capture failure - over a tree with something to lose is a gate, not a footnote) — the orphan is - left standing for manual recovery, and the caller's ``worktree-open-failed`` - path defers the unit. ``OSError`` from the snapshot's temp index is folded into - that same refusal rather than escaping untyped. + ``[]`` is nonetheless the MAXIMALLY preserving value and still not enough: the + parked set is the derived difference ``untracked_files(repo) - + baseline_untracked``, and ``untracked_files`` excludes ignored paths by + contract. The orchestrator's own artifacts are ignored inside every mount by + construction, so they need the narrow ``force_include`` + :func:`_orphan_owned_rels` derives — which is also what makes a CLEAN tracked + tree stop being a silent total loss, since the forced ``add`` is what lifts the + snapshot tree above HEAD. + + An orphan holding nothing in either set is a no-op (no ref, no callback). A + capture failure raises :class:`verify.GitError` and so refuses the remount + (#340: a capture failure over a tree with something to lose is a gate, not a + footnote) — the orphan is left standing for manual recovery, and the caller's + ``worktree-open-failed`` path defers the unit. ``OSError`` from the snapshot's + temp index is folded into that same refusal rather than escaping untyped. """ if not wt.exists() or not verify.worktree_is_registered(repo_root, wt): return @@ -170,7 +266,12 @@ def _preserve_orphan_state( ) ref = f"{base_ref}-r{serial}" serial += 1 - parked = verify.snapshot_worktree(wt, ref, baseline_untracked=[]) + parked = verify.snapshot_worktree( + wt, + ref, + baseline_untracked=[], + force_include=_orphan_owned_rels(wt, paths, spec_file), + ) except OSError as e: raise verify.GitError( f"cannot snapshot orphaned worktree {wt} for {unit_key} before reclaim: {e}" @@ -229,6 +330,7 @@ def open_unit_workspace( branch_per: str, run_dir: Path, *, + spec_file: str | None = None, on_orphan_preserved: Callable[[str, str], None] | None = None, ) -> UnitWorkspace: """Mount a fresh worktree for `unit_key` and return its rebased workspace. @@ -259,11 +361,13 @@ def open_unit_workspace( Whatever already occupies the deterministic mount path is reclaimed first. If it is a registered worktree of ``repo_root`` (an orphan left standing by an - isolation flip, see the reclaim comment) its *uncommitted* state — tracked edits - and run-created untracked files — is parked under - ``refs/attempt-preserve-dirty/--orphan`` before the force-remove; a - clean orphan parks nothing. ``on_orphan_preserved`` (worktree path, ref) fires - once per parked snapshot so a caller with a journal can record it — + isolation flip, see the reclaim comment) its *uncommitted* state — tracked edits, + run-created untracked files, and the orchestrator-owned artifacts the mount + shields as ignored (ledger, board, and the ``spec_file`` binding when one is + passed; see :func:`_orphan_owned_rels`) — is parked under + ``refs/attempt-preserve-dirty/--orphan`` before the force-remove; an + orphan holding none of them parks nothing. ``on_orphan_preserved`` (worktree + path, ref) fires once per parked snapshot so a caller with a journal can record it — ``open_unit_workspace`` has none, in the style of ``close_unit_workspace``'s ``on_teardown_degraded``. A snapshot that cannot be written refuses the remount (raises ``GitError``) and leaves the orphan standing. @@ -303,7 +407,7 @@ def open_unit_workspace( # Park an orphan's uncommitted state FIRST — before the story reset below moves # the ref the orphan's HEAD points at, so the snapshot is parented at the tree # the orphan actually holds and captures only what was never committed. - _preserve_orphan_state(repo_root, wt, run_id, unit_key, on_orphan_preserved) + _preserve_orphan_state(repo_root, wt, run_id, unit_key, paths, spec_file, on_orphan_preserved) if branch_tip is not None and branch_per == "story": commits = verify.commits_above(repo_root, pinned_base, branch_tip) if commits: diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 91d10952..684c8dcb 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1542,6 +1542,13 @@ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> N self.state.target_branch, self.policy.scm.branch_per, self.run_dir, + # The accepted spec the reclaim must not destroy. `_accepted_spec_seed` + # lays a gitignored spec into the mount and the shield then ignores it + # there, so the orphan holds the only copy; the isolation flip kept + # `spec_file` at its mount-relative spelling for exactly this reason + # (`release_spec_paths_from_mount`), which is the spelling the reclaim's + # snapshot resolves against the mount. + spec_file=task.spec_file, # An orphan an isolation flip left standing at this unit's mount # path is reclaimed by the open; its uncommitted state is parked # first and named here so the recovery ref is discoverable from the diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index a13d2f57..a5347af4 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -6628,6 +6628,203 @@ def test_remount_over_clean_orphan_parks_nothing(project): assert _dirty_refs(project) == [] +SPEC_REL = "_bmad-output/implementation-artifacts/story-1-1-a.md" + + +def _ref_tree(project, ref: str) -> list[str]: + return git(project.project, "ls-tree", "-r", "--name-only", ref).splitlines() + + +def _mount_ignored_artifacts(project, unit, *, ledger: str, board: str, spec: str) -> None: + """Lay the three orchestrator-owned artifacts into a mount as IGNORED files — + the state `WorktreeFlow` leaves behind when its ledger/board/accepted-spec seeds + copy them in and the shield folds every seeded rel into the worktree-local + `info/exclude`. Here the project's own committed `.gitignore` — checked out into + the mount like any tracked file — is the shield; the predicate git answers is the + same one, and `open_unit_workspace` is exercised directly, without provisioning.""" + mounted = project.rebased(unit.path) + mounted.implementation_artifacts.mkdir(parents=True, exist_ok=True) + mounted.deferred_work.write_text(ledger, encoding="utf-8") + mounted.sprint_status.write_text(board, encoding="utf-8") + (unit.path / SPEC_REL).write_text(spec, encoding="utf-8") + + +def test_remount_parks_the_orphans_owned_artifacts_over_a_clean_tree(project): + """The orchestrator's OWN artifacts — deferred-work ledger, sprint board, bound + spec — are ignored inside every mount by construction (seeded into a tracked-only + checkout, then folded into the shield), so `untracked_files` never offers them as + snapshot candidates and the reclaim's force-remove destroys the mount's only copy. + + The severity is the SILENCE: with a clean tracked tree the old snapshot returned + ``None``, so there was no ref, no ``on_orphan_preserved`` callback and no journal + line while the files went. This pins the clean-tree case specifically — nothing + tracked is edited and there is not one non-ignored untracked file in the mount. + + Ablation: drop ``force_include`` from ``_preserve_orphan_state``'s + ``snapshot_worktree`` call and the remount parks nothing — ``preserved == []``. + """ + from bmad_loop.workspace import open_unit_workspace + + ignore_before_commit(project, "**/deferred-work.md", "**/sprint-status.yaml", "**/story-*.md") + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + unit = open_unit_workspace(*_open_args(project), spec_file=SPEC_REL) + _mount_ignored_artifacts( + project, + unit, + ledger="# Deferred Work\n\n### DW-1: closed in the orphan\n\nstatus: done\n", + board="development_status:\n 1-1-a: in-progress\n", + spec="# story 1-1-a\n\nthe orphan's bound spec\n", + ) + orphan_head = rev_parse_head(unit.path) + # the tracked tree is CLEAN and nothing non-ignored is untracked: the whole + # uncommitted delta is the three ignored artifacts + assert git(unit.path, "status", "--porcelain") == "" + assert verify.untracked_files(unit.path) == set() + + preserved: list[tuple[str, str]] = [] + second = open_unit_workspace( + *_open_args(project), + spec_file=SPEC_REL, + on_orphan_preserved=lambda p, r: preserved.append((p, r)), + ) + + assert second.path == unit.path and second.path.is_dir() + ref = f"refs/attempt-preserve-dirty/test-run-{orphan_head[:8]}-orphan" + assert preserved == [(str(unit.path), ref)] + assert "closed in the orphan" in git( + project.project, + "show", + f"{ref}:{project.deferred_work.relative_to(project.project).as_posix()}", + ) + assert "in-progress" in git( + project.project, + "show", + f"{ref}:{project.sprint_status.relative_to(project.project).as_posix()}", + ) + assert "bound spec" in git(project.project, "show", f"{ref}:{SPEC_REL}") + # the reclaim did what it always did — the mount's copies are gone + assert not project.rebased(second.path).deferred_work.exists() + assert not (second.path / SPEC_REL).exists() + + +def test_remount_never_parks_an_orphans_unowned_ignored_files(project): + """The forced include is NARROW on purpose. Parking every ignored path instead + would push the seeded `_bmad/` tree, the adapters' MCP configs and venv residue + into a ``refs/attempt-preserve-dirty/*`` object ``scm.preserve_keep`` retains 20 + deep — which is why that remedy was rejected. Only the three orchestrator-owned + rels ride the snapshot; every other ignored file is reclaimed as before. + + Ablation A: widen ``_orphan_owned_rels`` to name every file under the mount. + Ablation B: widen the staging instead — make ``snapshot_worktree``'s forced add + ``git add -f -A``. Both park ``.venv/residue.txt`` and fail this test. + """ + from bmad_loop.workspace import open_unit_workspace + + ignore_before_commit( + project, "**/deferred-work.md", "**/sprint-status.yaml", "**/story-*.md", ".venv/", "*.log" + ) + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + unit = open_unit_workspace(*_open_args(project), spec_file=SPEC_REL) + _mount_ignored_artifacts( + project, + unit, + ledger="# Deferred Work\n\n### DW-1: item\n\nstatus: open\n", + board="development_status:\n 1-1-a: in-progress\n", + spec="# story 1-1-a\n", + ) + (unit.path / ".venv").mkdir() + (unit.path / ".venv" / "residue.txt").write_text("venv residue\n", encoding="utf-8") + (unit.path / "session.log").write_text("stray ignored artifact\n", encoding="utf-8") + orphan_head = rev_parse_head(unit.path) + + preserved: list[tuple[str, str]] = [] + open_unit_workspace( + *_open_args(project), + spec_file=SPEC_REL, + on_orphan_preserved=lambda p, r: preserved.append((p, r)), + ) + + ref = f"refs/attempt-preserve-dirty/test-run-{orphan_head[:8]}-orphan" + assert preserved == [(str(unit.path), ref)] + assert ".venv/residue.txt" not in _ref_tree(project, ref) + assert "session.log" not in _ref_tree(project, ref) + # exhaustive, not just those two: HEAD's tracked tree plus exactly the three + assert sorted(_ref_tree(project, ref)) == sorted( + [ + *git(project.project, "ls-tree", "-r", "--name-only", "HEAD").splitlines(), + project.deferred_work.relative_to(project.project).as_posix(), + project.sprint_status.relative_to(project.project).as_posix(), + SPEC_REL, + ] + ) + + +def test_remount_parks_the_mounted_spec_when_the_artifacts_dir_is_out_of_tree(project): + """An artifacts dir configured OUTSIDE the project tree is a supported shape, and + `ProjectPaths.rebased` deliberately leaves it unmoved there — so the ledger and the + board resolve outside the mount and must drop from the forced include: they are + shared, not per-checkout, and the reclaim cannot destroy them. + + The spec must NOT drop with them. `_accepted_spec_seed` lays it inside the mount + whatever the artifacts dir is doing, so there it is still the mount's only copy and + the force-remove still takes it. Judging the candidates as a group is what made + that leg inert: the ledger raises `ValueError` on `relative_to` first. + + Ablation: put the whole candidate loop back under one `try ... except (OSError, + RuntimeError, ValueError): return ()` and the first candidate voids all three — + nothing is forced in, the clean tracked tree parks nothing, `preserved == []`. + """ + from bmad_loop.workspace import open_unit_workspace + + shared = project.project.parent / "shared-artifacts" + shared.mkdir() + paths = ProjectPaths( + project=project.project, + implementation_artifacts=shared, + planning_artifacts=project.planning_artifacts, + ) + # the two shared artifacts really exist, out of the repo entirely + (shared / "deferred-work.md").write_text("# Deferred Work\n", encoding="utf-8") + (shared / "sprint-status.yaml").write_text( + "development_status:\n 1-1-a: ready-for-dev\n", encoding="utf-8" + ) + ignore_before_commit(project, "**/story-*.md") + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "ignore specs") + + run_dir = project.project / ".bmad-loop" / "runs" / "test-run" + open_args = (project.project, paths, "test-run", "1-1-a", "main", "story", run_dir) + spec_rel = "specs/story-1-1-a.md" + unit = open_unit_workspace(*open_args, spec_file=spec_rel) + (unit.path / "specs").mkdir() + (unit.path / spec_rel).write_text("# story 1-1-a\n\nthe mount's only copy\n", encoding="utf-8") + orphan_head = rev_parse_head(unit.path) + # the ledger and board rebase OUTSIDE the mount; the spec is inside it + mounted = paths.rebased(unit.path) + assert mounted.deferred_work == shared / "deferred-work.md" + assert not mounted.deferred_work.is_relative_to(unit.path) + # and the tracked tree is clean, so only the forced include can park anything + assert git(unit.path, "status", "--porcelain") == "" + assert verify.untracked_files(unit.path) == set() + + preserved: list[tuple[str, str]] = [] + open_unit_workspace( + *open_args, + spec_file=spec_rel, + on_orphan_preserved=lambda p, r: preserved.append((p, r)), + ) + + ref = f"refs/attempt-preserve-dirty/test-run-{orphan_head[:8]}-orphan" + assert preserved == [(str(unit.path), ref)] + assert "the mount's only copy" in git(project.project, "show", f"{ref}:{spec_rel}") + # the shared pair is not in the ref — and was never the reclaim's to destroy + assert sorted(_ref_tree(project, ref)) == sorted( + [*git(project.project, "ls-tree", "-r", "--name-only", "HEAD").splitlines(), spec_rel] + ) + assert (shared / "deferred-work.md").read_text(encoding="utf-8") == "# Deferred Work\n" + assert (shared / "sprint-status.yaml").is_file() + + def test_remount_over_plain_directory_never_snapshots_the_project_tree(project): """The run dir lives INSIDE the project checkout, so a plain (non-worktree) directory at the mount path must not have git run in it: `status`/`add` there