502 lines
20 KiB
Python
502 lines
20 KiB
Python
"""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" |