diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f04df..9f848a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ breaking changes may land in a minor release. patch that fails to apply escalates instead of running on a half-restored tree (a resolve session that committed over the patched lines triggers exactly this — re-resolve without a restore). Restore is rejected up front for worktree-isolation runs and for stories-mode pre-planning - sentinels, and the latched patch file itself never counts as proof-of-work. + sentinels, and the latched patch file itself never counts as proof-of-work. Deferred-work + `sweep` bundles get the same recovery — an escalated bundle re-arms to `in-review` and the + re-driven bundle session resumes review on the re-applied patch (#75). - **Preflight covers the inline review layers.** `bmad-loop validate` (and run-start) now require the three upstream review-hunter skills `bmad-dev-auto`'s step-04 invokes — `bmad-review-adversarial-general`, `bmad-review-edge-case-hunter`, and `bmad-review-verification-gap` (new in BMAD-METHOD#2550) — plus a diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4b7484d..de94b21 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -61,7 +61,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues. - Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. - CRITICAL resolution: `bmad-loop resolve ` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself. -- Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, `bmad-dev-auto` saves the attempted change as a patch file (in the implementation-artifacts folder, referenced from the halt output) before reverting the tree. If the attempted reading turns out to be correct, the resolve agent adds `"restore_patch": ""` to its `resolution.json`; the orchestrator then re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch onto the baseline after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing from scratch. A hand-driven `bmad-loop resolve --no-interactive --restore-patch ` does the same. A patch that fails to apply escalates rather than dispatching a session onto a half-restored tree. +- Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, `bmad-dev-auto` saves the attempted change as a patch file (in the implementation-artifacts folder, referenced from the halt output) before reverting the tree. If the attempted reading turns out to be correct, the resolve agent adds `"restore_patch": ""` to its `resolution.json`; the orchestrator then re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch onto the baseline after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing from scratch. A hand-driven `bmad-loop resolve --no-interactive --restore-patch ` does the same. A patch that fails to apply escalates rather than dispatching a session onto a half-restored tree. Deferred-work sweep bundles (below) get the same recovery — an escalated bundle re-arms to `in-review` and the re-driven bundle session resumes review on the re-applied patch. ### Git worktree isolation (opt-in) @@ -106,7 +106,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Answer skipped/missed decisions out of band with `bmad-loop decisions` (or `d` in the TUI): reconstructed from past triage output, saved to `.bmad-loop/decisions.json`, and consumed by the next sweep with no re-prompt (build → bundle, close → closed, keep-open → recorded). - Auto-sweep at epic boundaries or run-end (`[sweep] auto`); a failed/paused child sweep never interrupts the parent run. - Repeat mode (`--repeat` / `[sweep] repeat`): re-triages after each cycle to absorb newly generated deferred work, stopping when a cycle does nothing addressable or hits `max_cycles`. -- Sweeps are their own resumable runs (`bmad-loop resume `). +- Sweeps are their own resumable runs (`bmad-loop resume `). An escalated bundle resolves like a story escalation, including intent-gap patch-restore: `bmad-loop resolve --restore-patch ` re-arms the bundle spec to `in-review` and the re-driven bundle session resumes review on the re-applied patch instead of re-implementing. ### Stories mode (folder+id dispatch) diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 119384e..c3f4767 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -515,7 +515,10 @@ def _run_bundle(self, bundle: Bundle, cycle: int) -> None: self.state.tasks[key] = task self.journal.append("bundle-start", story_key=key, dw_ids=list(bundle.dw_ids)) else: - # interrupted mid-bundle: same recovery as Engine._finish_inflight + # interrupted mid-bundle (or a re-armed escalation resolved via + # `bmad-loop resolve`): same recovery as Engine._finish_inflight, + # including the re-drive latch so a human-resolved escalation is + # protected through every reset (mirrors engine.py:1151-1157). self.journal.append("resume-restart", story_key=key, phase=str(task.phase)) isolated = self._isolated and task.worktree_path if task.phase == Phase.DEV_VERIFY and task.spec_file: @@ -538,7 +541,13 @@ def _run_bundle(self, bundle: Bundle, cycle: int) -> None: task.worktree_path = "" task.branch = "" elif task.baseline_commit: - self._rollback_or_pause(task) + # 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 + # pause-free regardless of scm.rollback_on_failure + task.resolved_redrive = task.resolved_redrive or task.rearmed + self._rollback_or_pause(task, cause="resolved" if task.rearmed else "stopped") + task.rearmed = False # past rollback (only reached when not paused) task.phase = Phase.PENDING # deliberate reset, not a normal transition dirname = bundle.name if cycle == 1 else f"c{cycle}-{bundle.name}" task.bundle_file = str(self._write_intent(bundle, dirname)) @@ -979,9 +988,25 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str: intent.md (intent + verbatim ledger entries) is handed over as freeform intent. The orchestrator owns the deferred-work ledger — the skill is told not to edit it — and records resolution itself in `_post_dev_state_sync`. - On a repair the bundle spec is re-opened first (B6) so step-01 resumes.""" + On a repair the bundle spec is re-opened first (B6) so step-01 resumes. + + A patch-restore re-drive (#2564, #75) must point at the bundle spec + explicitly: only step-01's spec-pointer intent check EARLY EXITs on the + `in-review` status the re-arm set — before step-01's version-control + sanity check, which would otherwise HALT `blocked` on the diff + `_restore_patch` just laid onto the tree. The freeform intent.md pointer + takes the path where that dirty-tree check runs first.""" bundle_ref = task.bundle_file or task.story_key if feedback is None: + if task.restore_patch and task.spec_file: + return ( + f"/bmad-dev-auto Resume review of the in-review spec at " + f"`{task.spec_file}` for the deferred-work bundle `{bundle_ref}`. " + f"The attempted change was restored onto the working tree after " + f"an intent-gap resolution; review it against the amended spec. " + f"Do NOT edit the deferred-work ledger; the orchestrator records " + f"resolution." + ) return ( f"/bmad-dev-auto Implement the deferred-work bundle described in " f"`{bundle_ref}` — it carries the intent and the verbatim ledger " diff --git a/tests/conftest.py b/tests/conftest.py index 3704391..ac20eb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -430,3 +430,31 @@ def effect(spec: SessionSpec) -> SessionResult: ) return effect + + +def bundle_dev_escalates(paths: ProjectPaths, name: str, dw_ids, detail: str = "intent gap"): + """Simulate a bmad-dev-auto bundle session that hits an intent gap during its + inline review: it reverts its attempt, saves a patch, writes the bundle spec + ``blocked``, and surfaces a CRITICAL escalation naming the spec — so the run + pauses for `bmad-loop resolve --restore-patch`. ``spec_file`` in the result lets + ``_record_dev_spec`` latch ``task.spec_file`` (the restore re-arm's in-review + target), and the ``blocked`` status keeps the dw ids open (not synced done).""" + + def effect(spec: SessionSpec) -> SessionResult: + sp = bundle_spec_path(paths, name) + baseline = rev_parse_head(paths.project) + write_spec(sp, "blocked", baseline) + return SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "story_key": f"dw-{name}", + "spec_file": str(sp), + "dw_ids": list(dw_ids), + "escalations": [ + {"type": "bundle-item-blocked", "severity": "CRITICAL", "detail": detail} + ], + }, + ) + + return effect diff --git a/tests/test_stories_e2e.py b/tests/test_stories_e2e.py index d170ac0..e7bcbef 100644 --- a/tests/test_stories_e2e.py +++ b/tests/test_stories_e2e.py @@ -18,13 +18,16 @@ two-leg plan-halt + resume, (4) blocked → resolve → re-dispatch, (6) sprint-mode regression (the new folder+id-capable dev skill installed, yet a plain sprint run drives dev → verify → commit → sprint-status advance untouched -by the stories wiring), and (7) sprint-mode intent-gap patch-restore (halt saves +by the stories wiring), (7) sprint-mode intent-gap patch-restore (halt saves the attempt as a patch → `resolve --restore-patch` re-arms to in-review + re-stamps the spec baseline → resume re-applies the patch and dispatches an -explicit spec pointer, resuming review instead of re-implementing). Scenarios -(3) `done_checkpoint` and (5) worktree isolation are covered deterministically -at the engine level in test_stories_engine.py; here we prove the end-to-end CLI -stack. +explicit spec pointer, resuming review instead of re-implementing), and (8) the +same intent-gap patch-restore for a `sweep` deferred-work bundle (#75) — +triage → bundle halt → `resolve --restore-patch` → resume, driving the +sweep-specific CLI resolve→resume path (SweepEngine rebuilt from sweep.json). +Scenarios (3) `done_checkpoint` and (5) worktree isolation are covered +deterministically at the engine level in test_stories_engine.py; here we prove +the end-to-end CLI stack. """ from __future__ import annotations @@ -57,6 +60,23 @@ "$ts" "$tid" > "$rd/events/$ts-$tid-SessionStart.json" baseline=$(git rev-parse HEAD) +# SWEEP triage (`/bmad-loop-sweep`): the triage adapter is a plain GenericAdapter +# that reads a real result.json (not the spec-synthesizing dev adapter), so write +# the partition ourselves — one bundle "fix" owning the single open ledger id — and +# Stop. Bundle dev sessions carry no BMAD_LOOP_SPEC_FOLDER and fall through to the +# SPRINT branch below, which already drives spec-.md and the intent-gap / +# patch-restore contracts (here = dw-fix, the bundle task key). +if printf '%s' "$prompt" | grep -q "bmad-loop-sweep"; then + tdir="$rd/tasks/$tid"; mkdir -p "$tdir" + printf '%s' '{"workflow": "deferred-sweep-triage", "open_ids": ["DW-1"], "already_resolved": [], "bundles": [{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}], "blocked": [], "skip": [], "decisions": [], "escalations": []}' \ + > "$tdir/result.json" + ts2=$(( ts + 1 )) + printf '{"ts": %s, "event": "Stop", "task_id": "%s", "session_id": "fake-1"}' \ + "$ts2" "$tid" > "$rd/events/$ts2-$tid-Stop.json" + sleep 30 + exit 0 +fi + # SPRINT mode (no BMAD_LOOP_SPEC_FOLDER in env): the folder+id-capable skill is # installed, but a plain sprint run must still work. Write the result artifact # the orchestrator scans by mtime under implementation-artifacts, make a real @@ -313,6 +333,93 @@ def _scaffold_sprint(root: Path, story_key: str) -> None: _git(root, "commit", "-q", "-m", "sandbox") +def _scaffold_sweep(root: Path) -> None: + """A committed, clean SWEEP-mode sandbox: same folder+id-capable dev-skill + stubs as `_scaffold_sprint`, but no sprint-status — instead a canonical + deferred-work.md ledger with one open entry (DW-1). The policy has no + [stories] section; the run is a plain `bmad-loop sweep`.""" + root.mkdir(parents=True, exist_ok=True) + (root / "src.txt").write_text("original\n", encoding="utf-8") + (root / ".gitignore").write_text(".bmad-loop/runs/\n", encoding="utf-8") + + cfg = root / "_bmad" / "bmm" + cfg.mkdir(parents=True) + (cfg / "config.yaml").write_text( + "implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n" + "planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n", + encoding="utf-8", + ) + impl = root / "_bmad-output" / "implementation-artifacts" + for sub in ("implementation-artifacts", "planning-artifacts"): + (root / "_bmad-output" / sub).mkdir(parents=True, exist_ok=True) + (root / "_bmad-output" / sub / ".keep").write_text("", encoding="utf-8") + + # the SAME folder+id-capable skill stubs the other scaffolds install + skills = root / ".claude" / "skills" + dev = skills / "bmad-dev-auto" + dev.mkdir(parents=True) + (dev / "SKILL.md").write_text("# bmad-dev-auto\n", encoding="utf-8") + (dev / "step-04-review.md").write_text("x\n", encoding="utf-8") + (dev / "customize.toml").write_text("# review layers\n", encoding="utf-8") + (dev / "step-01-clarify-and-route.md").write_text( + "This is a **folder+id dispatch** router.\n", encoding="utf-8" + ) + for hunter in ( + "bmad-review-adversarial-general", + "bmad-review-edge-case-hunter", + "bmad-review-verification-gap", + ): + (skills / hunter).mkdir(parents=True) + (skills / hunter / "SKILL.md").write_text(f"# {hunter}\n", encoding="utf-8") + + # canonical DW-format ledger (no legacy content → migration is skipped) + (impl / "deferred-work.md").write_text( + "# Deferred Work\n\n" + "### DW-1: item DW-1\n\n" + "origin: test, 2026-06-01\nlocation: src.txt:1\nreason: test entry.\nstatus: open\n", + encoding="utf-8", + ) + + fake = root / ".bmad-loop" / "fake-cli.sh" + fake.parent.mkdir(parents=True, exist_ok=True) + fake.write_text(FAKE_CLI, encoding="utf-8") + os.chmod(fake, 0o755) + profiles = root / ".bmad-loop" / "profiles" + profiles.mkdir(parents=True) + (profiles / "fakestories.toml").write_text( + PROFILE_TOML.format(binary=str(fake)), encoding="utf-8" + ) + (root / ".bmad-loop" / "policy.toml").write_text( + '[adapter]\nname = "fakestories"\n\n' + "[review]\nenabled = false\n\n" + '[gates]\nmode = "none"\n', + encoding="utf-8", + ) + + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "e2e@test") + _git(root, "config", "user.name", "e2e") + _git(root, "config", "core.fsync", "none") + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "sandbox") + + +def _dw_status(root: Path, dw_id: str) -> str: + """The status field of one deferred-work ledger entry ('' if absent).""" + text = (root / "_bmad-output" / "implementation-artifacts" / "deferred-work.md").read_text( + encoding="utf-8" + ) + in_entry = False + for line in text.splitlines(): + if line.startswith(f"### {dw_id}:"): + in_entry = True + elif line.startswith("### "): + in_entry = False + elif in_entry and line.startswith("status:"): + return line.split(":", 1)[1].strip() + return "" + + def _sprint_status(root: Path, story_key: str) -> str: doc = yaml.safe_load( (root / "_bmad-output" / "implementation-artifacts" / "sprint-status.yaml").read_text( @@ -478,6 +585,59 @@ def test_e2e_sprint_intent_gap_patch_restore(tmp_path): assert any(str(spec) in p for p in prompts) +def test_e2e_sweep_intent_gap_patch_restore(tmp_path): + # Scenario 8 (#75): a SWEEP deferred-work bundle hits an intent gap during its + # dev session — the patch is saved, the tree reverted, and the run escalates. + # `resolve --restore-patch` re-arms the bundle spec to in-review + re-stamps its + # baseline; resume re-applies the patch and dispatches an EXPLICIT spec pointer + # (Change A) so the bundle resumes review on the restored diff instead of + # re-implementing. This is the only scenario that drives the sweep-specific CLI + # resolve→resume path (SweepEngine rebuilt from sweep.json in _resume_paused_run). + root = tmp_path / "sbx" + _scaffold_sweep(root) + story = "dw-fix" # triage names the bundle "fix" → task key dw-fix + (root / f".intent-gap-{story}").write_text("", encoding="utf-8") + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "poison: intent gap") + impl = root / "_bmad-output" / "implementation-artifacts" + spec = impl / f"spec-{story}.md" + patch = impl / f"attempt-{story}.patch" + + # triage → bundle dev halts on the intent gap: patch saved, tree reverted + proc = _run(root, "sweep", "--no-prompt") + assert proc.returncode == 0, proc.stderr or proc.stdout + assert "status: blocked" in spec.read_text(encoding="utf-8") + assert patch.is_file() # the attempted change survives the revert + assert "attempted reading" not in (root / "src.txt").read_text(encoding="utf-8") + assert _dw_status(root, "DW-1").startswith("open") # a blocked pass does not close it + run_id = _run_id(root) + + # resolve latches the restore: bundle spec → in-review + baseline re-stamped + resolve = _run( + root, "resolve", run_id, "--no-interactive", "--no-resume", "--restore-patch", str(patch) + ) + assert resolve.returncode == 0, resolve.stderr or resolve.stdout + text = spec.read_text(encoding="utf-8") + assert "status: in-review" in text # restore routing: step-01 → step-04 + head = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True + ).stdout.strip() + assert f"baseline_revision: {head}" in text # F2: spec baseline re-stamped + + # resume: patch re-applied, review resumed, bundle lands done + ledger closed + resume = _run(root, "resume", run_id) + assert resume.returncode == 0, resume.stderr or resume.stdout + final = spec.read_text(encoding="utf-8") + assert "status: done" in final, final # fake blocks loudly on a broken contract + assert _dw_status(root, "DW-1").startswith("done") # the bundle closed the ledger id + src = (root / "src.txt").read_text(encoding="utf-8") + assert src.count("attempted reading") == 1 # restored from the patch, not re-implemented + # Change A: the re-drive dispatch pointed at the bundle spec, never the intent.md + run_dir = root / ".bmad-loop" / "runs" / run_id + prompts = [p.read_text(encoding="utf-8") for p in (run_dir / "tasks").glob("*/prompt.txt")] + assert any(str(spec) in p for p in prompts) + + def test_e2e_sprint_mode_regression(tmp_path): # Scenario 6 (audit MAJOR-2): the new folder+id-capable bmad-dev-auto skill is # installed, but this is a plain SPRINT-mode run. It must drive dev → verify → diff --git a/tests/test_sweep.py b/tests/test_sweep.py index c8e5146..2d37eaf 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -5,6 +5,7 @@ from conftest import ( bundle_dev_effect, + bundle_dev_escalates, bundle_review_effect, git, migrate_effect, @@ -14,7 +15,7 @@ write_spec, ) -from bmad_loop import deferredwork +from bmad_loop import deferredwork, runs, verify from bmad_loop.adapters.base import SessionResult from bmad_loop.adapters.mock import MockAdapter from bmad_loop.journal import Journal, load_state @@ -1025,6 +1026,132 @@ def escalating_dev(spec): assert ledger_entries(project)["DW-1"].open # escalated bundle untouched +# ------------------------------ intent-gap patch-restore re-drive (#75) + + +def _run_to_dev_escalation(project, policy=None): + """Drive a one-bundle sweep until its dev session escalates on an intent gap. + Returns the paused engine; the bundle task is ESCALATED with spec_file set and + DW-1 still open (blocked spec is not synced done).""" + write_ledger(project, {"DW-1": "open"}) + plan = triage_result( + ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}] + ) + engine, _ = make_sweep( + project, + [triage_effect(plan), bundle_dev_escalates(project, "fix", ["DW-1"])], + policy=policy, + ) + summary = engine.run() + assert summary.paused + task = engine.state.tasks["dw-fix"] + assert task.phase == Phase.ESCALATED + assert task.spec_file # latched by _record_dev_spec on the dev escalation + assert ledger_entries(project)["DW-1"].open # blocked spec not synced done + return engine + + +def test_generic_bundle_prompt_restore_branch_points_at_spec(project): + # T-A: the restore-aware prompt (Change A) — no run needed. + engine, _ = make_sweep(project, []) + spec = str(project.implementation_artifacts / "spec-dw-fix.md") + task = StoryTask( + story_key="dw-fix", + epic=0, + dw_ids=["DW-1"], + bundle_file="/run/bundles/fix/intent.md", + spec_file=spec, + restore_patch="/run/artifacts/attempt-dw-fix.patch", + ) + restore_prompt = engine._generic_bundle_prompt(task, None) + assert "Resume review of the in-review spec" in restore_prompt + assert spec in restore_prompt + assert "Do NOT edit the deferred-work ledger" in restore_prompt + # without a latched patch the fresh-implement prompt is unchanged + task.restore_patch = None + fresh_prompt = engine._generic_bundle_prompt(task, None) + assert "Implement the deferred-work bundle" in fresh_prompt + assert "Resume review of the in-review spec" not in fresh_prompt + + +def test_sweep_bundle_restore_redrive_reaches_done_and_clears_latch(project, monkeypatch): + # T-C + T-D: rearm with a restore patch, resume, land done; assert the dispatched + # prompt pointed at the in-review spec, the patch apply seam fired, the dw id + # closed, and both latches cleared on commit (inherited Engine._commit). + monkeypatch.setattr(verify, "apply_patch", lambda repo, patch: None) + engine = _run_to_dev_escalation(project) + patch = project.implementation_artifacts / "attempt-dw-fix.patch" + patch.parent.mkdir(parents=True, exist_ok=True) + patch.write_text("dummy\n") + + runs.rearm_escalation(engine.run_dir, "dw-fix", restore_patch=str(patch)) + + resumed, adapter = resume_sweep( + project, + engine, + [bundle_dev_effect(project, "fix", ["DW-1"]), bundle_review_effect(project, "fix")], + ) + summary = resumed.run() + + assert not summary.paused + task = resumed.state.tasks["dw-fix"] + assert task.phase == Phase.DONE + # Change A: the re-drive dev session was pointed at the in-review spec + spec = str(project.implementation_artifacts / "spec-dw-fix.md") + assert "Resume review of the in-review spec" in adapter.sessions[0].prompt + assert spec in adapter.sessions[0].prompt + # the restore apply seam fired + assert "attempt-restored" in journal_text(resumed) + # latches cleared on commit + assert task.restore_patch is None + assert task.resolved_redrive is False + # the deferred-work id was closed + assert ledger_entries(project)["DW-1"].status.startswith("done") + + +def test_sweep_restore_redrive_exhaustion_pauses_not_defers(project, monkeypatch): + # T-B (restore): a non-critical exhaustion mid-restore-re-drive must PAUSE + # (re-escalate for the human), not silently DEFER the resolved escalation. + monkeypatch.setattr(verify, "apply_patch", lambda repo, patch: None) + policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(rollback_on_failure=True), + limits=LimitsPolicy(max_dev_attempts=1), + ) + engine = _run_to_dev_escalation(project, policy=policy) + patch = project.implementation_artifacts / "attempt-dw-fix.patch" + patch.parent.mkdir(parents=True, exist_ok=True) + patch.write_text("dummy\n") + runs.rearm_escalation(engine.run_dir, "dw-fix", restore_patch=str(patch)) + + resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) + summary = resumed.run() + + assert summary.paused + assert resumed.state.tasks["dw-fix"].phase == Phase.ESCALATED + + +def test_sweep_from_scratch_redrive_exhaustion_pauses_not_defers(project): + # T-B (from-scratch twin): the resolved_redrive latch also protects a plain + # `resolve` re-drive (no --restore-patch) — the pre-existing sweep defer bug + # Change B closes. Without the fix this exhaustion would DEFER, not PAUSE. + policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + scm=ScmPolicy(rollback_on_failure=True), + limits=LimitsPolicy(max_dev_attempts=1), + ) + engine = _run_to_dev_escalation(project, policy=policy) + runs.rearm_escalation(engine.run_dir, "dw-fix") # from-scratch, no restore + + resumed, _ = resume_sweep(project, engine, [lambda spec: SessionResult(status="died")]) + summary = resumed.run() + + assert summary.paused + assert resumed.state.tasks["dw-fix"].phase == Phase.ESCALATED + + # ----------------------------------------------------------- repeat cycles