trading-system-docs/a5/test_a5.py
Red Queen 968094498a feat(a5): Controlled Heartbeat & Resume v1
- a5/rq_heartbeat.py: HeartbeatStore + Heartbeat.run_tick (thin scheduler/resume layer)
- a5/rq_heartbeat_cli.py: status/tick/enable/disable/resume/approve/deny/priority
- a5/test_a5.py: 31 deterministic tests (tick-lock incl. ownership, eligibility, kill-switch, approval, circuit, git-conflict, bounded single A4)
- a5/DESIGN.md, a5/README.md, a5/scripts/a5_heartbeat_tick.sh
- a2/rq_mission.py: add read-only mission_list() for A5 enumeration (additive)
- Fresh checker: PASS after tick-lock ownership repair (Regressionschutz lock_owned)
2026-08-24 23:04:16 +00:00

426 lines
17 KiB
Python

#!/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())