diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 5f89893..d1c794d 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -511,8 +511,11 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: Flips the escalated task out of its terminal ESCALATED phase back to PENDING — which makes `_finish_inflight` reset the tree to the story's baseline and re-run it (clean rebuild) against the now-corrected frozen - spec. Deterministically sets that spec's status to `ready-for-dev` so the - dev session routes straight to implement, and strips the escalated + spec. The baseline itself is advanced to the project's current HEAD (and + the untracked snapshot refreshed) so commits and files the resolve session + produced count as the rebuild's starting point, not as attempt debris to + roll back. Deterministically sets that spec's status to `ready-for-dev` so + the dev session routes straight to implement, and strips the escalated attempt's stale `## Auto Run Result` section so the re-drive cannot read as terminal from its first save. Does NOT clear the pause; the caller resumes the run separately. @@ -544,6 +547,28 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: task.rearmed = True # resume-time recovery notice describes a clean rebuild, # not a failed attempt (engine._finish_inflight clears it once the rebuild runs) + # Advance the attempt baseline to the project's current HEAD and refresh the + # untracked snapshot: whatever the human-driven resolve session left on the + # branch (a committed fixture, a corrected ledger, ...) is authorized input + # for the re-drive, not failed-attempt debris. Without this, the re-drive's + # reset-to-baseline in engine._rollback_or_pause parks the resolution + # commits on an attempt-preserve ref and rebuilds against a tree that + # contradicts the corrected spec — the re-driven dev session then hits the + # very gap the human just resolved. Best-effort: on a git failure the old + # baseline stands (the redrive rollback path tolerates a stale baseline; it + # just loses this protection). + # The two locals are computed before either task field is assigned, so a + # failure on either git call can't advance baseline_commit while + # baseline_untracked stays stale, or vice versa. + try: + repo = Path(state.project) + head = verify.rev_parse_head(repo) + untracked = sorted(verify.untracked_files(repo)) + task.baseline_commit = head + task.baseline_untracked = untracked + except Exception: # noqa: BLE001 # nosec B110 - best-effort git read, must not fail re-arm + pass + if task.spec_file: # route /bmad-dev-auto to re-implement (decision table: ready-for-dev # -> step-03); independent of the resolve agent having set it. @@ -555,5 +580,7 @@ def rearm_escalation(run_dir: Path, story_key: str | None = None) -> str: devcontract.strip_auto_run_result(Path(task.spec_file)) save_state(run_dir, state) - Journal(run_dir).append("story-escalation-resolved", story_key=key) + Journal(run_dir).append( + "story-escalation-resolved", story_key=key, baseline=task.baseline_commit or "" + ) return key diff --git a/tests/test_resolve.py b/tests/test_resolve.py index cbcbb1a..e4fb8e6 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -3,6 +3,7 @@ import json import pytest +from conftest import git from bmad_loop import devcontract, resolve, runs, verify from bmad_loop.journal import load_state, save_state @@ -174,6 +175,54 @@ def test_rearm_journals_event(tmp_path): assert "story-escalation-resolved" in journal +def test_rearm_advances_baseline_to_resolved_head(project): + # The resolve session committed work on the project branch (e.g. a fixture + # the human authorized). Re-arm must adopt that state as the new attempt + # baseline, or the redrive's reset-to-baseline parks the resolution commit + # on an attempt-preserve ref and re-drives against the unresolved tree. + root = project.project + old_head = git(root, "rev-parse", "HEAD") + run_dir, _, _ = _escalated_run(root) + (root / "fixture.txt").write_text("captured baseline\n", encoding="utf-8") + git(root, "add", "fixture.txt") + git(root, "commit", "-q", "-m", "resolution: capture fixture") + # a file the resolve session (or the user) left untracked must enter the + # snapshot, so the redrive reset treats it as pre-existing, not run-created + (root / "leftover.txt").write_text("keep me\n", encoding="utf-8") + runs.rearm_escalation(run_dir) + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.baseline_commit == git(root, "rev-parse", "HEAD") + assert task.baseline_commit != old_head + assert "leftover.txt" in task.baseline_untracked + + +def test_rearm_baseline_all_or_nothing_on_partial_git_failure(monkeypatch, project): + """rev_parse_head succeeding but untracked_files failing must not advance + baseline_commit while leaving baseline_untracked stale: both locals are + computed before either field is assigned, so a failure on the second call + leaves the pair exactly as it was, same as a failure on the first.""" + root = project.project + run_dir, _, _ = _escalated_run(root) + + def boom(repo): + raise verify.GitError("simulated failure") + + monkeypatch.setattr(runs.verify, "untracked_files", boom) + runs.rearm_escalation(run_dir) + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.baseline_commit == "abc123" + assert task.baseline_untracked is None + + +def test_rearm_keeps_stale_baseline_outside_a_repo(tmp_path): + # best-effort contract: a project dir that is not a git repo (or a broken + # one) must not make re-arm fail — the old baseline simply stands + run_dir, _, _ = _escalated_run(tmp_path) + runs.rearm_escalation(run_dir) + task = load_state(run_dir).tasks["6-4-cli-list-command"] + assert task.baseline_commit == "abc123" + + def test_rearm_rejects_non_escalation_stage(tmp_path): run_dir = tmp_path / ".bmad-loop" / "runs" / "r1" save_state(