#!/usr/bin/env python3 """Red Queen — A5: deterministische Tests (CONTROLLED HEARTBEAT & RESUME v1). Lauf: python3 test_a5.py Exit-Code 0 = alle Tests gruen; 1 = mindestens ein Fehler. Nutzt ausschliesslich temporaere DBs. Kein Netz, kein echter A4-Dispatch (Stub) ausser einem echten bounded Orchestrator-Test (ohne dispatcher). """ from __future__ import annotations import os import sys import tempfile from pathlib import Path _HERE = Path(__file__).resolve().parent _REPO = _HERE.parent for _p in (str(_REPO), str(_REPO / "a2"), str(_REPO / "a3"), str(_REPO / "a4"), str(_HERE)): if _p not in sys.path: sys.path.insert(0, _p) from a2.rq_mission import MissionStore from a3.rq_safety import SafetyStore from a4.rq_orchestrator import ( # noqa: E402 ST_NEEDS_DISPATCH, ST_APPROVAL_REQUIRED, ST_NO_RUNNABLE_WORK, Orchestrator, ) from a5.rq_heartbeat import ( # noqa: E402 HB_APPROVAL_PENDING, HB_APPROVAL_REQUIRED, HB_BOUNDED_RUN_COMPLETE, HB_CANCELLED, HB_CIRCUIT_OPEN, HB_GIT_CONFLICT, HB_KILL_SWITCH_OFF, HB_LOCKED, HB_MISSION_COMPLETED, HB_NO_ACTIVE_MISSION, HB_NO_RUNNABLE_WORK, HB_PAUSED, HB_SAFETY_BLOCKED, HB_TERMINAL, Heartbeat, ) PASS = 0 FAIL = 0 FAILURES = [] def check(name: str, cond: bool, extra: str = ""): global PASS, FAIL if cond: PASS += 1 print(f" [PASS] {name}") else: FAIL += 1 FAILURES.append(name) print(f" [FAIL] {name} {extra}") def _mk_env(): d = tempfile.mkdtemp(prefix="a5_test_") return { "dir": d, "registry": os.path.join(d, "heartbeat.db"), "mission": os.path.join(d, "missions.db"), "safety": os.path.join(d, "safety.db"), } class StubOrchestrator: """Deterministischer A4-Stub: liefert genau den injizierten Status.""" def __init__(self, status=ST_NEEDS_DISPATCH, **extra): self._status = status self._extra = extra self.run_bounded_calls = 0 def run_bounded(self, mission_id, *, max_steps=None, actor="red-queen"): self.run_bounded_calls += 1 return {"status": self._status, "mission_id": mission_id, **self._extra} def mission_completion_gate(self, mission_id, *, final_review_pass=False): return {"mission_id": mission_id, "valid": False, "reasons": ["no wps done"]} def attempt_mission_complete(self, mission_id, *, final_review_pass=False, actor="red-queen"): return {"status": "MISSION_NOT_COMPLETED", "mission_id": mission_id, "valid": False} def _mission_running(ms: MissionStore, mid: str, wp_ids=None): ms.mission_create(title=f"M {mid}", goal="g", scope="s", mission_id=mid) for c, t in (("CREATED", "PLANNING"), ("PLANNING", "READY"), ("READY", "RUNNING")): ms.mission_transition(mid, t, evidence=f"to {t}") if wp_ids: for w in wp_ids: ms.wp_create(mid, f"WP {w}", wp_id=f"{mid}-{w}") ms.wp_transition(f"{mid}-{w}", "READY") # --------------------------------------------------------------------------- # # 1. Kill Switch # --------------------------------------------------------------------------- # def test_kill_switch_off_blocks_mutation(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-KS") stub = StubOrchestrator(ST_NEEDS_DISPATCH) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub) check("kill switch default OFF", hb.registry.kill_switch_on() is False) res = hb.run_tick(mission_id="M-KS") check("kill switch OFF -> HB_KILL_SWITCH_OFF", res["code"] == HB_KILL_SWITCH_OFF, res["code"]) check("kill switch OFF -> keine A4-Mutation", stub.run_bounded_calls == 0, str(stub.run_bounded_calls)) def test_kill_switch_on_allows_bounded(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-KSON") stub = StubOrchestrator(ST_NEEDS_DISPATCH) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-KSON") check("kill switch ON -> bounded run", res["code"] == HB_BOUNDED_RUN_COMPLETE, res["code"]) check("exactly one A4 call", stub.run_bounded_calls == 1, str(stub.run_bounded_calls)) # --------------------------------------------------------------------------- # # 2. Tick Lock # --------------------------------------------------------------------------- # def test_tick_lock_prevents_overlap(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-TL") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) assert hb.registry.acquire_lock("RUN-A") == "ok" res = hb.run_tick(mission_id="M-TL") check("overlapping tick -> HB_LOCKED", res["code"] == HB_LOCKED, res["code"]) def test_tick_lock_preserved_on_overlap(): """Regressionsschutz: Ein HB_LOCKED-Tick darf das FREMD-aktive Lock NICHT freigeben. Defekt, den der Fresh Checker fand: blindes release_lock im finally loeschte das Lock von Tick A, sodass Tick C sofort wieder mutieren konnte. Jetzt: nur der Owner-Tick gibt sein eigenes Lock frei.""" env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-TLP") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) # Tick A haelt das Lock aktiv assert hb.registry.acquire_lock("RUN-A") == "ok" # Tick B (ueberlappend) -> HB_LOCKED; darf A's Lock NICHT loeschen res = hb.run_tick(mission_id="M-TLP") check("overlapping tick -> HB_LOCKED", res["code"] == HB_LOCKED, res["code"]) # Das Lock von A MUSS weiterhin bestehen bleiben (Owner A) lock = hb.registry.acquire_lock("RUN-C") check("fremdes Lock nach HB_LOCKED NICHT freigegeben", lock == "locked", lock) def test_tick_lock_release_after_run(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-TL2") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-TL2") check("run completes", res["code"] == HB_BOUNDED_RUN_COMPLETE, res["code"]) lock = hb.registry.acquire_lock("RUN-NEXT") check("lock released after run", lock == "ok", lock) # --------------------------------------------------------------------------- # # 3. Mission Eligibility # --------------------------------------------------------------------------- # def test_eligibility_cancelled(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-TERM") ms.mission_transition("M-TERM", "CANCELLED") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-TERM") check("cancelled -> HB_CANCELLED", res["code"] == HB_CANCELLED, res["code"]) def test_eligibility_paused_not_resumable(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-PAUSE") ms.mission_transition("M-PAUSE", "PAUSED") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-PAUSE") check("paused (no resume) -> HB_PAUSED", res["code"] == HB_PAUSED, res["code"]) def test_eligibility_paused_resumable(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-PR") ms.mission_transition("M-PR", "PAUSED") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) hb.registry.set_resume_pending("M-PR", True) res = hb.run_tick(mission_id="M-PR") check("paused (resumable) -> bounded", res["code"] == HB_BOUNDED_RUN_COMPLETE, res["code"]) # --------------------------------------------------------------------------- # # 4. Priority (deterministisch) # --------------------------------------------------------------------------- # def test_priority_selection(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-B") _mission_running(ms, "M-A") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) hb.registry.set_priority("M-A", 10) res = hb.run_tick() check("priority selects M-A", res["mission_id"] == "M-A", str(res["mission_id"])) # --------------------------------------------------------------------------- # # 5. Bounded: EXACTLY ONE A4 run # --------------------------------------------------------------------------- # def test_bounded_single_a4_call(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-BD") stub = StubOrchestrator(ST_NEEDS_DISPATCH) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-BD") check("exactly one A4 run", stub.run_bounded_calls == 1, str(stub.run_bounded_calls)) check("bounded complete code", res["code"] == HB_BOUNDED_RUN_COMPLETE, res["code"]) # --------------------------------------------------------------------------- # # 6. NO RUNNABLE WORK # --------------------------------------------------------------------------- # def test_no_runnable_work(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-NR") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NO_RUNNABLE_WORK)) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-NR") check("no runnable -> HB_NO_RUNNABLE_WORK", res["code"] == HB_NO_RUNNABLE_WORK, res["code"]) # --------------------------------------------------------------------------- # # 7. Approval # --------------------------------------------------------------------------- # def test_approval_required_blocks_retry(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-APP") stub = StubOrchestrator(ST_APPROVAL_REQUIRED, wp_id="M-APP-WP1", risk="CRITICAL", approval={"WP": "M-APP-WP1", "REQUESTED_ACTION": "a"}) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-APP") check("approval required", res["code"] == HB_APPROVAL_REQUIRED, res["code"]) check("gate persisted", hb.registry.approval_pending("M-APP") is not None) # naechster Tick darf NICHT erneut dispatchen stub2 = StubOrchestrator(ST_NEEDS_DISPATCH) hb2 = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub2) hb2.registry.set_kill_switch(True) res2 = hb2.run_tick(mission_id="M-APP") check("approval pending blocks re-dispatch", res2["code"] == HB_APPROVAL_PENDING, res2["code"]) check("no re-dispatch A4 call", stub2.run_bounded_calls == 0, str(stub2.run_bounded_calls)) def test_approval_grant_resumes(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-AGR") stub = StubOrchestrator(ST_APPROVAL_REQUIRED, wp_id="M-AGR-WP1", risk="CRITICAL", approval={"WP": "M-AGR-WP1", "REQUESTED_ACTION": "a"}) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub) hb.registry.set_kill_switch(True) hb.run_tick(mission_id="M-AGR") gate = hb.registry.approval_pending("M-AGR") check("gate exists", gate is not None) hb.registry.approval_grant(gate["gate_id"], "christian", "approved") check("gate cleared after grant", hb.registry.approval_pending("M-AGR") is None) stub2 = StubOrchestrator(ST_NEEDS_DISPATCH) hb2 = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub2) hb2.registry.set_kill_switch(True) res = hb2.run_tick(mission_id="M-AGR") check("resume after approval", res["code"] == HB_BOUNDED_RUN_COMPLETE, res["code"]) # --------------------------------------------------------------------------- # # 8. Circuit # --------------------------------------------------------------------------- # def test_circuit_open_blocks(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-CIR") ss = SafetyStore(env["safety"]) ss.open_circuit("MISSION", "M-CIR", trigger="test circuit", reason="test") stub = StubOrchestrator(ST_NEEDS_DISPATCH) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=stub) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-CIR") check("circuit open -> HB_CIRCUIT_OPEN", res["code"] == HB_CIRCUIT_OPEN, res["code"]) check("circuit open -> kein A4", stub.run_bounded_calls == 0, str(stub.run_bounded_calls)) # --------------------------------------------------------------------------- # # 9. Git Conflict # --------------------------------------------------------------------------- # def test_git_conflict_blocks_resume(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-GIT") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH), git_preflight=lambda mid: {"ok": False, "reason": "uncommitted"}) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-GIT") check("git conflict -> HB_GIT_CONFLICT", res["code"] == HB_GIT_CONFLICT, res["code"]) # --------------------------------------------------------------------------- # # 10. Unknown mission / state conflict # --------------------------------------------------------------------------- # def test_unknown_mission(): env = _mk_env() hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-UNKNOWN") check("unknown mission -> HB_NO_ACTIVE_MISSION", res["code"] == HB_NO_ACTIVE_MISSION, res["code"]) # --------------------------------------------------------------------------- # # 11. Real bounded orchestrator (kein dispatcher) # --------------------------------------------------------------------------- # def test_real_orchestrator_bounded_dispatch_contract(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-REAL") a4 = Orchestrator(mission_db=env["mission"], safety_db=env["safety"]) hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=a4) hb.registry.set_kill_switch(True) res = hb.run_tick(mission_id="M-REAL", execute=True) check("real orchestrator bounded -> complete|no_runnable", res["code"] in (HB_BOUNDED_RUN_COMPLETE, HB_NO_RUNNABLE_WORK), res["code"]) # --------------------------------------------------------------------------- # # 12. Health (read-only, Kill-Switch OFF erlaubt) # --------------------------------------------------------------------------- # def test_health_readonly(): env = _mk_env() ms = MissionStore(env["mission"]) _mission_running(ms, "M-HEALTH") hb = Heartbeat(env["registry"], env["mission"], env["safety"], orchestrator=StubOrchestrator(ST_NEEDS_DISPATCH)) h = hb.health() check("health kill switch OFF", h["kill_switch"] == "OFF") check("health lists mission", any(m["id"] == "M-HEALTH" for m in h["missions"])) # --------------------------------------------------------------------------- # # Test Runner # --------------------------------------------------------------------------- # ALL_TESTS = [ test_kill_switch_off_blocks_mutation, test_kill_switch_on_allows_bounded, test_tick_lock_prevents_overlap, test_tick_lock_preserved_on_overlap, test_tick_lock_release_after_run, test_eligibility_cancelled, test_eligibility_paused_not_resumable, test_eligibility_paused_resumable, test_priority_selection, test_bounded_single_a4_call, test_no_runnable_work, test_approval_required_blocks_retry, test_approval_grant_resumes, test_circuit_open_blocks, test_git_conflict_blocks_resume, test_unknown_mission, test_real_orchestrator_bounded_dispatch_contract, test_health_readonly, ] def main(): for fn in ALL_TESTS: try: fn() except Exception as e: # noqa global FAIL FAIL += 1 FAILURES.append(fn.__name__) print(f" [ERROR] {fn.__name__}: {type(e).__name__}: {e}") print("\n" + "=" * 60) print(f"PASS={PASS} FAIL={FAIL}") if FAIL: print("FAILURES:", FAILURES) return 1 print("ALL TESTS PASSED") return 0 if __name__ == "__main__": sys.exit(main())