Files
automaton/tests/test_status_brakes.py
T

502 lines
20 KiB
Python
Raw Normal View History

"""Tests for loop-management brakes layer (task add-status-brakes).
Covers R1–R10 from tasks/add-status-brakes/SPEC.md:
R1 .state.loop schema defaults
R2 --create-loop
R3 --version + --approve --loop (halt clear)
R4 --can-continue
R5 --check-gate (all six gates)
R6 --install-schedule (Darwin/Linux/Windows stub generation only — no live cron/plist)
R7 --can-edit --loop [--loop-worktree] --file scope
R8 --transition refuses when owning loop is HALTED
R9 --audit loops section + --loop-list
R10 .state.log tick trail
"""
import json
import re
import sys
import subprocess
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
STATUS = Path.home() / ".automaton" / "scripts" / "status.py"
def _run(args, project=None, expect_failure=False):
cmd = [sys.executable, str(STATUS)]
if project:
cmd.extend(["--project", str(project)])
cmd.extend(args)
res = subprocess.run(cmd, capture_output=True, text=True)
if not expect_failure:
assert res.returncode == 0, f"cmd {cmd!r} exited {res.returncode}:\n{res.stdout}\n{res.stderr}"
return res.stdout.strip(), res.stderr.strip(), res.returncode
@pytest.fixture
def tmp_project(tmp_path):
(tmp_path / ".automaton" / "tasks").mkdir(parents=True)
return tmp_path
def _create_loop(project, name="ci-loop", template="ci-triage"):
out, err, code = _run(["--create-loop", name, "--from-template", template], project)
assert code == 0, out + err
return project / ".automaton" / "loops" / name
def _state(loop_path):
return json.loads((loop_path / ".state.loop").read_text())
# ---------------------------------------------------------------------------
# R1 — .state.loop schema defaults
# ---------------------------------------------------------------------------
class TestStateLoopSchema:
def test_create_loop_writes_defaults(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp)
assert s["schema_version"] == 1
assert s["name"] == "ci-loop"
assert s["status"] == "running"
assert s["halt_reason"] is None
assert s["iteration_count"] == 0
assert s["resumed_count"] == 0
assert s["last_tick_at"] is None
assert s["last_verdict"] is None
assert s["score_history"] == []
assert s["current_task"] is None
assert s["worktree_branch"] is None
assert s["worktree_path"] is None
def test_loop_dir_has_tick_log(self, tmp_project):
lp = _create_loop(tmp_project)
assert (lp / ".state.log").exists()
# ---------------------------------------------------------------------------
# R2 — --create-loop
# ---------------------------------------------------------------------------
class TestCreateLoop:
def test_rejects_non_kebab(self, tmp_project):
out, err, code = _run(["--create-loop", "CI Loop"], tmp_project, expect_failure=True)
assert code == 2
assert "kebab-case" in out
def test_rejects_uppercase(self, tmp_project):
out, err, code = _run(["--create-loop", "CI-Loop"], tmp_project, expect_failure=True)
assert code == 2
def test_rejects_duplicate(self, tmp_project):
_create_loop(tmp_project)
out, err, code = _run(["--create-loop", "ci-loop"], tmp_project, expect_failure=True)
assert code == 2
assert "already exists" in out
def test_unknown_template_rejected(self, tmp_project):
out, err, code = _run(
["--create-loop", "x", "--from-template", "nope"], tmp_project, expect_failure=True)
assert code == 2
assert "template" in out.lower()
def test_name_patched_in_config(self, tmp_project):
lp = _create_loop(tmp_project)
cfg = json.loads((lp / "loop.json").read_text())
assert cfg["name"] == "ci-loop"
# ---------------------------------------------------------------------------
# R3 — --version and --approve --loop
# ---------------------------------------------------------------------------
class TestVersionAndApprove:
def test_version_prints_automaton(self, tmp_project):
out, _, code = _run(["--version"], tmp_project)
assert code == 0
assert re.match(r"automaton\s+\S+", out)
def test_approve_loop_unknown_rejected(self, tmp_project):
out, err, code = _run(["--approve", "--loop", "ghost"], tmp_project, expect_failure=True)
assert code == 2
assert "UNTRACKED" in out
def test_approve_loop_only_clears_halt(self, tmp_project):
lp = _create_loop(tmp_project)
# Loop is running, --approve should refuse.
out, err, code = _run(["--approve", "--loop", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "not 'halted'" in out
def test_approve_clears_halt(self, tmp_project):
lp = _create_loop(tmp_project)
(lp / ".state.loop").write_text(json.dumps({
"schema_version": 1, "name": "ci-loop", "status": "halted",
"halt_reason": "iterations_exhausted", "iteration_count": 25,
"resumed_count": 0, "last_tick_at": None, "last_verdict": None,
"score_history": [], "current_task": None,
"worktree_branch": None, "worktree_path": None}))
out, err, code = _run(["--approve", "--loop", "ci-loop"], tmp_project)
assert code == 0
s = _state(lp)
assert s["status"] == "running"
assert s["halt_reason"] is None
assert s["resumed_count"] == 1
# ---------------------------------------------------------------------------
# R4 — --can-continue
# ---------------------------------------------------------------------------
class TestCanContinue:
def test_running_loop_ok(self, tmp_project):
_create_loop(tmp_project)
out, _, code = _run(["--can-continue", "ci-loop"], tmp_project)
assert code == 0
assert "ok: True" in out
def test_halted_loop_denied(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["status"] = "halted"; s["halt_reason"] = "verifier_failed"
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--can-continue", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "halted" in out
assert "verifier_failed" in out
def test_unknown_loop_rejected(self, tmp_project):
out, _, code = _run(["--can-continue", "ghost"], tmp_project, expect_failure=True)
assert code == 2
# ---------------------------------------------------------------------------
# R5 — --check-gate (six gates)
# ---------------------------------------------------------------------------
class TestCheckGate:
def test_all_pass_for_fresh_loop(self, tmp_project):
_create_loop(tmp_project)
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project)
assert code == 0
assert "ok: True" in out
def test_status_gate_halted(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["status"] = "halted"; s["halt_reason"] = "drift_detected"
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "drift_detected" in out
def test_iterations_exhausted(self, tmp_project):
lp = _create_loop(tmp_project)
# template caps max_iterations=25
s = _state(lp); s["iteration_count"] = 25
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "iterations_exhausted" in out
def test_iterations_remaining(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["iteration_count"] = 10
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project)
assert code == 0
assert "remaining_iterations: 15" in out
def test_budget_exhausted(self, tmp_project):
lp = _create_loop(tmp_project)
cfg = json.loads((lp / "loop.json").read_text())
cfg["brakes"]["max_budget_usd"] = 5.0
(lp / "loop.json").write_text(json.dumps(cfg))
(lp / "cost.json").write_text(json.dumps({"spent_usd": 6.0}))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "budget_exhausted" in out
def test_budget_informational_when_unset(self, tmp_project):
lp = _create_loop(tmp_project)
# max_budget_usd is null in template — gate skipped, loop fine.
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project)
assert code == 0
def test_task_phase_halt_on_human_intervention(self, tmp_project):
lp = _create_loop(tmp_project)
# create a task the loop owns
out, _, _ = _run(["--create-task", "owned-task"], tmp_project)
td = tmp_project / ".automaton" / "tasks" / "owned-task"
(td / ".state").write_text("human_intervention\n")
s = _state(lp); s["current_task"] = "owned-task"
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "human_intervention" in out
def test_score_plateau(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["score_history"] = [0.5, 0.4, 0.3, 0.3, 0.3]
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "verifier_failed" in out
def test_score_plateau_short_history_ok(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["score_history"] = [0.5, 0.4]
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--check-gate", "ci-loop"], tmp_project)
assert code == 0
def test_json_output(self, tmp_project):
_create_loop(tmp_project)
out, _, code = _run(["--check-gate", "ci-loop", "--json"], tmp_project)
assert code == 0
payload = json.loads(out.splitlines()[-1])
assert payload["ok"] is True
assert payload["remaining_iterations"] == 25
# ---------------------------------------------------------------------------
# R6 -- --install-schedule (only stub files; OS units are platform-side effects
# and best-effort-disabled here to keep tests portable)
# ---------------------------------------------------------------------------
class TestInstallSchedule:
def test_generates_run_tick_stub(self, tmp_project, monkeypatch):
lp = _create_loop(tmp_project)
out, _, code = _run(["--install-schedule", "ci-loop", "--interval", "120"], tmp_project)
assert code == 0
# Stub is shell or bat depending on platform; both should be present.
stubs = list(lp.glob("automaton-loop-tick.*"))
assert stubs, f"no automaton-loop-tick stub under {lp}"
def test_interval_default_from_config(self, tmp_project):
lp = _create_loop(tmp_project)
out, _, code = _run(["--install-schedule", "ci-loop"], tmp_project)
assert code == 0
# We don't parse the cron/plist here; just assert it didn't error.
def test_unknown_loop_rejected(self, tmp_project):
out, _, code = _run(["--install-schedule", "ghost"], tmp_project, expect_failure=True)
assert code == 2
# ---------------------------------------------------------------------------
# R7 -- --can-edit --loop [--loop-worktree] --file
# ---------------------------------------------------------------------------
class TestCanEditLoop:
def test_in_scope_allowed(self, tmp_project):
lp = _create_loop(tmp_project)
cfg = json.loads((lp / "loop.json").read_text())
cfg["blast_radius"]["file_scope"] = [str(tmp_project / "src")]
(lp / "loop.json").write_text(json.dumps(cfg))
(tmp_project / "src").mkdir()
target = tmp_project / "src" / "a.py"
out, _, code = _run(
["--can-edit", "--loop", "ci-loop", "--file", str(target)], tmp_project)
assert code == 0
assert "ALLOWED" in out
def test_out_of_scope_denied(self, tmp_project):
lp = _create_loop(tmp_project)
cfg = json.loads((lp / "loop.json").read_text())
cfg["blast_radius"]["file_scope"] = [str(tmp_project / "src")]
(lp / "loop.json").write_text(json.dumps(cfg))
(tmp_project / "docs").mkdir()
target = tmp_project / "docs" / "x.md"
out, _, code = _run(
["--can-edit", "--loop", "ci-loop", "--file", str(target)],
tmp_project, expect_failure=True)
assert code == 1
assert "DENIED" in out
assert "blast radius" in out
def test_outside_root_denied(self, tmp_project):
lp = _create_loop(tmp_project)
cfg = json.loads((lp / "loop.json").read_text())
cfg["blast_radius"]["file_scope"] = []
(lp / "loop.json").write_text(json.dumps(cfg))
out, _, code = _run(
["--can-edit", "--loop", "ci-loop", "--file", "/etc/passwd"],
tmp_project, expect_failure=True)
assert code == 1
def test_no_file_rejected(self, tmp_project):
_create_loop(tmp_project)
out, _, code = _run(["--can-edit", "--loop", "ci-loop"], tmp_project, expect_failure=True)
assert code == 2
# ---------------------------------------------------------------------------
# R8 -- --transition refuses when owning loop HALTED
# ---------------------------------------------------------------------------
class TestTransitionHaltRefusal:
def _setup_halted_owner(self, tmp_project):
lp = _create_loop(tmp_project)
out, _, _ = _run(["--create-task", "looped-task"], tmp_project)
td = tmp_project / ".automaton" / "tasks" / "looped-task"
(td / ".state").write_text("implement\n")
(td / "IMPLEMENTATION.md").write_text("placeholder\n")
s = _state(lp)
s["current_task"] = "looped-task"
s["status"] = "halted"
s["halt_reason"] = "verifier_failed"
(lp / ".state.loop").write_text(json.dumps(s))
return lp, td
def test_transition_refused_when_loop_halted(self, tmp_project):
lp, td = self._setup_halted_owner(tmp_project)
out, _, code = _run(
["--task", "looped-task", "--transition", "code_review"], tmp_project,
expect_failure=True)
assert code == 1
assert "HALTED" in out
assert "--approve --loop" in out
def test_transition_allowed_when_loop_running(self, tmp_project):
lp = _create_loop(tmp_project)
out, _, _ = _run(["--create-task", "looped-task"], tmp_project)
td = tmp_project / ".automaton" / "tasks" / "looped-task"
(td / ".state").write_text("implement\n")
(td / "IMPLEMENTATION.md").write_text("placeholder\n")
s = _state(lp); s["current_task"] = "looped-task"; s["status"] = "running"
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(
["--task", "looped-task", "--transition", "code_review"], tmp_project)
assert code == 0
assert "Transitioned" in out
def test_transition_allowed_when_no_loop_owns(self, tmp_project):
out, _, _ = _run(["--create-task", "free-task"], tmp_project)
td = tmp_project / ".automaton" / "tasks" / "free-task"
(td / ".state").write_text("implement\n")
(td / "IMPLEMENTATION.md").write_text("placeholder\n")
out, _, code = _run(
["--task", "free-task", "--transition", "code_review"], tmp_project)
assert code == 0
# ---------------------------------------------------------------------------
# R9 -- --audit loops section + --loop-list
# ---------------------------------------------------------------------------
class TestAuditAndList:
def test_loop_list_no_loops(self, tmp_project):
out, _, code = _run(["--loop-list"], tmp_project)
assert code == 0
assert "No loops found" in out
def test_loop_list_shows_loop(self, tmp_project):
_create_loop(tmp_project)
out, _, code = _run(["--loop-list"], tmp_project)
assert code == 0
assert "ci-loop" in out
assert "running" in out
def test_audit_has_loops_section_no_loops(self, tmp_project):
out, _, code = _run(["--audit"], tmp_project)
assert code == 0
assert "Category 6: Loops" in out
def test_audit_flags_halted_loop(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["status"] = "halted"; s["halt_reason"] = "drift_detected"
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--audit"], tmp_project, expect_failure=True)
assert code == 1
assert "HALTED" in out
assert "drift_detected" in out
def test_audit_flags_untracked_loop_dir(self, tmp_project):
(tmp_project / ".automaton" / "loops" / "stray").mkdir(parents=True)
out, _, code = _run(["--audit"], tmp_project, expect_failure=True)
assert code == 1
assert "stray" in out
assert "UNTRACKED" in out
def test_audit_passes_running_loop(self, tmp_project):
_create_loop(tmp_project)
out, _, code = _run(["--audit"], tmp_project)
assert code == 0
assert "ci-loop" in out
# ---------------------------------------------------------------------------
# R10 -- .state.log tick trail
# ---------------------------------------------------------------------------
class TestTickLog:
def test_pause_resume_logged(self, tmp_project):
lp = _create_loop(tmp_project)
_run(["--pause-loop", "ci-loop"], tmp_project)
_run(["--resume-loop", "ci-loop"], tmp_project)
log = (lp / ".state.log").read_text()
assert "PAUSED" in log
assert "RESUMED" in log
def test_approve_logged(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["status"] = "halted"; s["halt_reason"] = "iterations_exhausted"
(lp / ".state.loop").write_text(json.dumps(s))
_run(["--approve", "--loop", "ci-loop"], tmp_project)
log = (lp / ".state.log").read_text()
assert "APPROVED" in log
def test_halt_via_gate_logged(self, tmp_project):
lp = _create_loop(tmp_project)
s = _state(lp); s["iteration_count"] = 25
(lp / ".state.loop").write_text(json.dumps(s))
_run(["--check-gate", "ci-loop"], tmp_project, expect_failure=True)
log = (lp / ".state.log").read_text()
assert "HALT" in log
# ---------------------------------------------------------------------------
# Pause / Resume shape checks
# ---------------------------------------------------------------------------
class TestPauseResume:
def test_pause_sets_paused(self, tmp_project):
lp = _create_loop(tmp_project)
out, _, code = _run(["--pause-loop", "ci-loop"], tmp_project)
assert code == 0
assert _state(lp)["status"] == "paused"
def test_resume_only_from_paused(self, tmp_project):
lp = _create_loop(tmp_project)
# running loop cannot be resumed
out, _, code = _run(["--resume-loop", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
# halted loop should tell user to --approve
s = _state(lp); s["status"] = "halted"; s["halt_reason"] = "verifier_failed"
(lp / ".state.loop").write_text(json.dumps(s))
out, _, code = _run(["--resume-loop", "ci-loop"], tmp_project, expect_failure=True)
assert code == 1
assert "--approve" in out
def test_resume_after_pause(self, tmp_project):
lp = _create_loop(tmp_project)
_run(["--pause-loop", "ci-loop"], tmp_project)
out, _, code = _run(["--resume-loop", "ci-loop"], tmp_project)
assert code == 0
assert _state(lp)["status"] == "running"