Files
automaton/tests/test_blast_radius.py
T

543 lines
20 KiB
Python

"""Tests for task add-blast-radius-scheduler.
Covers R1-R6 from tasks/add-blast-radius-scheduler/SPEC.md. Unit tests stub
subprocess.run; integration tests use a real git repo on tmp_path.
"""
import json
import subprocess
import sys
import importlib.util
from pathlib import Path
from typing import Optional
import pytest
_RUNNER_PATH = Path.home() / ".automaton" / "scripts" / "loop-runner.py"
_spec = importlib.util.spec_from_file_location("loop_runner_br", _RUNNER_PATH)
lr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(lr)
def _state(loop_path: Path) -> dict:
return json.loads((loop_path / ".state.loop").read_text())
def _write_state(loop_path: Path, state: dict) -> None:
(loop_path / ".state.loop").write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
def _make_loop(project: Path, name: str = "br-loop",
cfg_overrides: Optional[dict] = None,
state_overrides: Optional[dict] = None) -> Path:
lp = project / ".automaton" / "loops" / name
lp.mkdir(parents=True, exist_ok=True)
cfg = {
"name": name,
"description": "test loop",
"schedule": {"interval_seconds": 3600},
"brakes": {"max_iterations": 25, "max_budget_usd": None,
"score_plateau_window": 5},
"blast_radius": {"file_scope": [], "use_worktree": True},
"work_source": {"kind": "single"},
"roles": {
"implement": {"prompt": "test-impl.md"},
"verify": {"prompt": "test-verify.md"},
"orchestrate": {"prompt": "test-orch.md"},
},
}
if cfg_overrides:
cfg.update(cfg_overrides)
(lp / "loop.json").write_text(json.dumps(cfg, indent=2) + "\n")
state = {
"schema_version": 1, "name": name, "status": "running",
"halt_reason": None, "iteration_count": 0, "resumed_count": 0,
"last_tick_at": None, "last_verdict": None, "score_history": [],
"current_task": None, "worktree_branch": None, "worktree_path": None,
}
if state_overrides:
state.update(state_overrides)
(lp / ".state.loop").write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
(lp / ".state.log").write_text("")
roles = cfg.get("roles") or {}
if isinstance(roles, dict):
for role_cfg in roles.values():
if isinstance(role_cfg, dict) and role_cfg.get("prompt"):
prompt_ref = role_cfg["prompt"]
if not (Path.home() / ".automaton" / "prompts" / prompt_ref).exists():
try:
(lp / prompt_ref).write_text(f"prompt: {prompt_ref}\n")
except OSError:
pass
return lp
def _make_task(project: Path, name: str) -> Path:
tp = project / ".automaton" / "tasks" / name
tp.mkdir(parents=True, exist_ok=True)
(tp / ".state").write_text("implement\n")
return tp
class _FakeSubprocess:
def __init__(self):
self.rules: list = []
self.invocations: list = []
def add(self, needle, handler):
self.rules.append((needle, handler))
def add_simple(self, needle, stdout="", rc=0):
def handler(argv):
class R:
pass
r = R()
r.stdout = stdout
r.stderr = ""
r.returncode = rc
return r
self.rules.append((needle, handler))
def run(self, argv, *args, **kwargs):
self.invocations.append(list(argv))
for needle, handler in self.rules:
if any(needle in str(a) for a in argv):
return handler(argv)
class R:
pass
r = R()
r.stdout = ""
r.stderr = ""
r.returncode = 0
return r
@pytest.fixture
def tmp_project(tmp_path):
(tmp_path / ".automaton" / "tasks").mkdir(parents=True)
return tmp_path
@pytest.fixture
def fake_run(monkeypatch):
fake = _FakeSubprocess()
monkeypatch.setattr(subprocess, "run", fake.run)
return fake
def _gate_ok(fake):
fake.add_simple("--check-gate", json.dumps({"ok": True}))
def _ctx_ok(fake):
fake.add_simple("--loop-mode", json.dumps({"loop_mode_eligible": True}))
def _verdict_stdout(p=True, score=0.9, hint=""):
body = {"pass": p, "score": score}
if hint:
body["next_hint"] = hint
return json.dumps(body)
def _tick_args(loop_name, project):
class A:
pass
a = A()
a.mode = "tick"
a.loop = loop_name
a.project = str(project)
a.json_output = False
return a
def _run_tick(loop_name, project):
return lr.cmd_tick(_tick_args(loop_name, project))
# ---------------------------------------------------------------------------
# R1 -- _ensure_worktree basic behavior
# ---------------------------------------------------------------------------
class TestEnsureWorktree:
def test_ensure_worktree_creates_worktree(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-task")
lp = _make_loop(tmp_project, state_overrides={"current_task": "wt-task"})
fake_run.add_simple("rev-parse", "true")
fake_run.add_simple("worktree add", "", rc=0)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] is not None
assert st["worktree_path"].endswith("worktree")
assert st["worktree_branch"] == "loop/br-loop"
def test_ensure_worktree_reuses_existing(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-reuse")
existing_wt = tmp_project / ".automaton" / "loops" / "br-loop" / "worktree"
existing_wt.mkdir(parents=True)
lp = _make_loop(tmp_project, state_overrides={
"current_task": "wt-reuse",
"worktree_path": str(existing_wt),
"worktree_branch": "loop/br-loop",
})
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
git_calls = [inv for inv in fake_run.invocations if "git" in inv]
assert len(git_calls) == 0
def test_ensure_worktree_use_worktree_false_returns_project_root(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-off")
lp = _make_loop(tmp_project,
cfg_overrides={"blast_radius": {"file_scope": [], "use_worktree": False}},
state_overrides={"current_task": "wt-off"})
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] is None
git_calls = [inv for inv in fake_run.invocations if "git" in inv]
assert len(git_calls) == 0
def test_ensure_worktree_missing_field_defaults_true(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-default")
cfg = {
"name": "br-loop", "description": "test",
"schedule": {"interval_seconds": 3600},
"brakes": {"max_iterations": 25, "max_budget_usd": None, "score_plateau_window": 5},
"blast_radius": {"file_scope": []},
"work_source": {"kind": "single"},
"roles": {
"implement": {"prompt": "test-impl.md"},
"verify": {"prompt": "test-verify.md"},
"orchestrate": {"prompt": "test-orch.md"},
},
}
lp = tmp_project / ".automaton" / "loops" / "br-loop"
lp.mkdir(parents=True, exist_ok=True)
(lp / "loop.json").write_text(json.dumps(cfg, indent=2) + "\n")
(lp / ".state.loop").write_text(json.dumps({
"schema_version": 1, "name": "br-loop", "status": "running",
"halt_reason": None, "iteration_count": 0, "resumed_count": 0,
"last_tick_at": None, "last_verdict": None, "score_history": [],
"current_task": "wt-default", "worktree_branch": None, "worktree_path": None,
}, indent=2, sort_keys=True) + "\n")
(lp / ".state.log").write_text("")
fake_run.add_simple("rev-parse", "true")
fake_run.add_simple("worktree add", "", rc=0)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] is not None
# ---------------------------------------------------------------------------
# R2 -- graceful degradation
# ---------------------------------------------------------------------------
class TestGracefulDegradation:
def test_falls_back_when_not_git_repo(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "no-git")
lp = _make_loop(tmp_project, state_overrides={"current_task": "no-git"})
fake_run.add_simple("rev-parse", "", rc=128)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] is None
log = (lp / ".state.log").read_text()
assert "not a git repo" in log
def test_falls_back_when_git_missing(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "no-git-bin")
def git_not_found(argv, *a, **kw):
class R:
pass
r = R()
r.stdout = ""
r.stderr = ""
r.returncode = 0
if "git" in argv:
raise FileNotFoundError("git not found")
return r
monkeypatch_fn = fake_run.run
original_run = subprocess.run
class CombinedFake:
def run(self, argv, *a, **kw):
fake_run.invocations.append(list(argv))
if "git" in argv:
raise FileNotFoundError("git not found")
for needle, handler in fake_run.rules:
if any(needle in str(x) for x in argv):
return handler(argv)
class R:
pass
r = R()
r.stdout = ""
r.stderr = ""
r.returncode = 0
return r
subprocess.run = CombinedFake().run
lp = _make_loop(tmp_project, state_overrides={"current_task": "no-git-bin"})
try:
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
finally:
subprocess.run = original_run
st = _state(lp)
assert st["worktree_path"] is None
def test_falls_back_when_worktree_add_fails(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-fail")
lp = _make_loop(tmp_project, state_overrides={"current_task": "wt-fail"})
fake_run.add_simple("rev-parse", "true")
def fail_add(argv):
class R:
pass
r = R()
r.stdout = ""
r.stderr = "worktree add failed"
r.returncode = 1
return r
fake_run.add("worktree", fail_add)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] is None
log = (lp / ".state.log").read_text()
assert "worktree add failed" in log or "worktree" in log
# ---------------------------------------------------------------------------
# R4 -- branch already exists
# ---------------------------------------------------------------------------
class TestBranchExists:
def test_reuses_existing_branch(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-branch")
lp = _make_loop(tmp_project, state_overrides={"current_task": "wt-branch"})
fake_run.add_simple("rev-parse", "true")
call_count = {"n": 0}
def add_handler(argv):
call_count["n"] += 1
class R:
pass
r = R()
if "-b" in argv:
r.stdout = ""
r.stderr = "fatal: a branch named 'loop/br-loop' already exists"
r.returncode = 128
else:
r.stdout = ""
r.stderr = ""
r.returncode = 0
return r
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
fake_run.add("worktree", add_handler)
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] is not None
assert call_count["n"] == 2
# ---------------------------------------------------------------------------
# R5 -- state consistency
# ---------------------------------------------------------------------------
class TestStateConsistency:
def test_clears_stale_worktree_path(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-stale")
lp = _make_loop(tmp_project, state_overrides={
"current_task": "wt-stale",
"worktree_path": "/nonexistent/path/worktree",
"worktree_branch": "loop/br-loop",
})
fake_run.add_simple("rev-parse", "true")
fake_run.add_simple("worktree add", "", rc=0)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"] != "/nonexistent/path/worktree"
assert st["worktree_path"] is not None
def test_recreates_after_deletion(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-recreate")
lp = _make_loop(tmp_project, state_overrides={
"current_task": "wt-recreate",
"worktree_path": str(tmp_project / ".automaton" / "loops" / "br-loop" / "old-wt"),
"worktree_branch": "loop/br-loop",
})
fake_run.add_simple("rev-parse", "true")
fake_run.add_simple("worktree add", "", rc=0)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
st = _state(lp)
assert st["worktree_path"].endswith("worktree")
# ---------------------------------------------------------------------------
# R3 -- tick integration
# ---------------------------------------------------------------------------
class TestTickIntegration:
def test_tick_creates_worktree_on_first_tick(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-first")
lp = _make_loop(tmp_project, state_overrides={"current_task": "wt-first"})
fake_run.add_simple("rev-parse", "true")
fake_run.add_simple("worktree add", "", rc=0)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
summary = _run_tick("br-loop", tmp_project)
assert summary["skipped"] is False
assert summary["iter"] == 1
st = _state(lp)
assert st["worktree_path"] is not None
def test_tick_reuses_worktree_on_second_tick(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-second")
existing_wt = tmp_project / ".automaton" / "loops" / "br-loop" / "worktree"
existing_wt.mkdir(parents=True)
lp = _make_loop(tmp_project, state_overrides={
"current_task": "wt-second",
"worktree_path": str(existing_wt),
"worktree_branch": "loop/br-loop",
})
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
git_calls = [inv for inv in fake_run.invocations if "git" in inv]
assert len(git_calls) == 0
def test_tick_falls_back_to_project_root_when_no_git(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-nogit")
lp = _make_loop(tmp_project, state_overrides={"current_task": "wt-nogit"})
fake_run.add_simple("rev-parse", "", rc=128)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
summary = _run_tick("br-loop", tmp_project)
assert summary["skipped"] is False
st = _state(lp)
assert st["worktree_path"] is None
# ---------------------------------------------------------------------------
# R6 -- platform path handling
# ---------------------------------------------------------------------------
class TestPlatformPaths:
def test_worktree_path_uses_pathlib(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-path")
lp = _make_loop(tmp_project, state_overrides={"current_task": "wt-path"})
captured = {}
def capture_git(argv, *a, **kw):
if "worktree" in argv and "add" in argv:
captured["worktree_argv"] = list(argv)
class R:
pass
r = R()
if "rev-parse" in argv:
r.stdout = "true"
else:
r.stdout = ""
r.stderr = ""
r.returncode = 0
return r
fake_run.add("rev-parse", capture_git)
fake_run.add("worktree", capture_git)
fake_run.add_simple("test-impl", _verdict_stdout())
fake_run.add_simple("test-verify", _verdict_stdout())
fake_run.add_simple("test-orch", "")
_run_tick("br-loop", tmp_project)
assert "worktree_argv" in captured
wt_path_arg = next(a for a in captured["worktree_argv"] if "worktree" in a and a != "worktree")
assert "/" in wt_path_arg or "\\" in wt_path_arg
# ---------------------------------------------------------------------------
# Regression -- existing loop with worktree_path ticks unchanged
# ---------------------------------------------------------------------------
class TestRegression:
def test_existing_loop_with_worktree_path_ticks_unchanged(self, tmp_project, fake_run):
_gate_ok(fake_run)
_ctx_ok(fake_run)
_make_task(tmp_project, "wt-reg")
existing_wt = tmp_project / ".automaton" / "loops" / "br-loop" / "worktree"
existing_wt.mkdir(parents=True)
lp = _make_loop(tmp_project, state_overrides={
"current_task": "wt-reg",
"worktree_path": str(existing_wt),
"worktree_branch": "loop/br-loop",
})
fake_run.add_simple("test-impl", _verdict_stdout(True, 0.9, "n"))
fake_run.add_simple("test-verify", _verdict_stdout(True, 0.9, "n"))
fake_run.add_simple("test-orch", "")
summary = _run_tick("br-loop", tmp_project)
assert summary["skipped"] is False
assert summary["iter"] == 1
assert summary["verdict"]["pass"] is True
st = _state(lp)
assert st["worktree_path"] == str(existing_wt)