Files
automaton/tests/test_status_brakes.py

502 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"