"""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"