Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions src/bmad_loop/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand All @@ -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
49 changes: 49 additions & 0 deletions tests/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading