trading-system-docs/a5/rq_heartbeat.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

707 lines
29 KiB
Python

#!/usr/bin/env python3
"""
Red Queen — A5: CONTROLLED HEARTBEAT & RESUME v1 (dünne Scheduling-/Resume-Schicht).
VERBINDLICHER RAHMEN
====================
A5 ist KEINE neue Mission-Engine und KEIN Orchestrator. A5 ist eine DUENNE,
deterministische Scheduler-/Resume-Schicht, die Red Queen (Hermes) aufweckt,
einen GENAU BEGRENZTEN A4-Arbeitszyklus anstösst, den Zustand persistiert und
dann stoppt. Es gibt KEINEN while-true, keinen unbounded Agent-Loop, kein
Selbst-Reschedule, kein Auto-Rain, kein Self-Improvement.
Authority:
* A2 `MissionStore` = Mission-/WP-State Authority (Transitions, Dependencies).
* A3 `SafetyStore` = Safety Authority (Circuit, Retry, Attempt-Ledger).
* A4 `Orchestrator` = Orchestrierungs-Logik (bounded run, Approval, Completion).
A5 erfindet NICHTS davon — es delegiert ausschliesslich und bewertet den
Rueckgabecode. A5 implementiert NUR: Kill-Switch, Tick-Lock, Run-ID, Mission-
Eligibility & -Priorität, exakt-ein-bounded-A4-Aufruf pro Tick, Notification-
Dedup, Approval-Gate-Warten, Recovery-Reconciliation (fail-closed) und das
Heartbeat-Registry.
Kill-Switch (§25/§26): persistierter globaler Gate AUTONOMOUS_EXECUTION_ENABLED.
Default OFF. Wenn OFF -> KEINE A4-Mutation; read-only Health erlaubt. Das Gate
wird nie von einem LLM-Satz überschrieben (Gated Config, >= SI-3).
V1: MAX 1 Mission pro Heartbeat. Pro Tick genau EIN A4 bounded run (max_steps=1).
Keine parallelen Missionen.
RESTART-FEST: Alle Zustände (Registry, Tick-Log, Dedup, Approval-Gate,
Kill-Switch) liegen in einer eigenen, isolierten SQLite-DB (heartbeat.db).
Nach Container-/Hermes-Restart werden die DBs neu geladen und deterministisch
fortgesetzt.
"""
from __future__ import annotations
import datetime
import hashlib
import json
import sqlite3
import sys
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
_REPO_ROOT = Path(__file__).resolve().parent.parent
for _p in (str(_REPO_ROOT), str(_REPO_ROOT / "a2"), str(_REPO_ROOT / "a3"), str(_REPO_ROOT / "a4")):
if _p not in sys.path:
sys.path.insert(0, _p)
from a2.rq_mission import MissionStore, RqError # noqa: E402
from a3.rq_safety import ( # noqa: E402
CIRCUIT_OPEN,
SafetyError,
SafetyStore,
redact_secret,
)
from a4.rq_orchestrator import ( # noqa: E402
CIRCUIT_CLOSED,
ST_APPROVAL_REQUIRED,
ST_BUDGET_REACHED,
ST_CHECKER_PASS,
ST_DEBUG_REQUIRED,
ST_MISSION_COMPLETED,
ST_MISSION_NOT_COMPLETED,
ST_MUTATION_BLOCKED,
ST_NEEDS_CHECKER_DISPATCH,
ST_NEEDS_DISPATCH,
ST_NO_RUNNABLE_WORK,
ST_SECOND_OPINION_REQUIRED,
ST_STATE_CONFLICT,
ST_TERMINAL,
ST_WAITING_PENDING,
ST_WP_DONE,
Orchestrator,
OrchestratorError,
)
# --------------------------------------------------------------------------- #
# A5-Fehler
# --------------------------------------------------------------------------- #
class HeartbeatError(Exception):
def __init__(self, code: str, message: str, detail: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.code = code
self.message = message
self.detail = detail or {}
def _err(code: str, msg: str, **detail) -> HeartbeatError:
return HeartbeatError(code, msg, detail)
# --------------------------------------------------------------------------- #
# A5-Konstanten (deterministisch)
# --------------------------------------------------------------------------- #
HB_NO_ACTIVE_MISSION = "HB_NO_ACTIVE_MISSION"
HB_MISSION_NOT_ELIGIBLE = "HB_MISSION_NOT_ELIGIBLE"
HB_SAFETY_BLOCKED = "HB_SAFETY_BLOCKED"
HB_STATE_CONFLICT = "HB_STATE_CONFLICT"
HB_NO_RUNNABLE_WORK = "HB_NO_RUNNABLE_WORK"
HB_BOUNDED_RUN_COMPLETE = "HB_BOUNDED_RUN_COMPLETE"
HB_MISSION_COMPLETED = "HB_MISSION_COMPLETED"
HB_APPROVAL_REQUIRED = "HB_APPROVAL_REQUIRED"
HB_ESCALATED = "HB_ESCALATED"
HB_CIRCUIT_OPEN = "HB_CIRCUIT_OPEN"
HB_TIMEOUT = "HB_TIMEOUT"
HB_INTERNAL_ERROR = "HB_INTERNAL_ERROR"
HB_LOCKED = "HB_LOCKED"
HB_KILL_SWITCH_OFF = "HB_KILL_SWITCH_OFF"
HB_PAUSED = "HB_PAUSED"
HB_CANCELLED = "HB_CANCELLED"
HB_TERMINAL = "HB_TERMINAL"
HB_GIT_CONFLICT = "HB_GIT_CONFLICT"
HB_RECOVERY_REQUIRED = "HB_RECOVERY_REQUIRED"
HB_APPROVAL_PENDING = "HB_APPROVAL_PENDING"
HB_MISSION_NOT_COMPLETED = "HB_MISSION_NOT_COMPLETED"
ELIGIBLE_STATES = frozenset({"READY", "RUNNING", "REVIEW"})
RESUME_CAPABLE = frozenset({"PAUSED"})
TICK_LOCK_STALE_SECONDS = 600 # 10 min ohne Heartbeat -> stale (recovery)
# --------------------------------------------------------------------------- #
# DB-Hilfsfunktionen
# --------------------------------------------------------------------------- #
def _connect(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _utcnow() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _now_epoch() -> float:
return datetime.datetime.now(datetime.timezone.utc).timestamp()
def _new_run_id() -> str:
return f"HB-{datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
# --------------------------------------------------------------------------- #
# HeartbeatStore: persistente A5-Registry
# --------------------------------------------------------------------------- #
class HeartbeatStore:
"""Isolierte, durable A5-Registry (heartbeat.db)."""
def __init__(self, db_path: str):
self.db_path = db_path
self._ensure_schema()
def _ensure_schema(self) -> None:
conn = _connect(self.db_path)
try:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tick_log (
run_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
finished_at TEXT,
result_code TEXT,
mission_id TEXT,
exit_reason TEXT,
detail TEXT
);
CREATE TABLE IF NOT EXISTS notification_dedup (
dedup_key TEXT PRIMARY KEY,
sent_at TEXT NOT NULL,
mission_id TEXT,
reason_code TEXT,
message_id TEXT
);
CREATE TABLE IF NOT EXISTS lock (
id INTEGER PRIMARY KEY CHECK (id = 1),
owner TEXT,
acquired_at TEXT,
heartbeat_at TEXT
);
CREATE TABLE IF NOT EXISTS approval_gates (
gate_id TEXT PRIMARY KEY,
mission_id TEXT NOT NULL,
wp_id TEXT NOT NULL,
action TEXT NOT NULL,
requested_action TEXT,
risk TEXT,
state TEXT NOT NULL,
requested_at TEXT NOT NULL,
approved_at TEXT,
approved_by TEXT,
evidence TEXT
);
"""
)
conn.commit()
finally:
conn.close()
# -- config ---------------------------------------------------------------
def get_config(self, key: str, default: Optional[str] = None) -> Optional[str]:
conn = _connect(self.db_path)
try:
row = conn.execute("SELECT value FROM config WHERE key = ?", (key,)).fetchone()
return row["value"] if row else default
finally:
conn.close()
def set_config(self, key: str, value: str) -> None:
conn = _connect(self.db_path)
try:
conn.execute(
"INSERT INTO config (key,value) VALUES (?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
conn.commit()
finally:
conn.close()
# -- Kill Switch ----------------------------------------------------------
def kill_switch_on(self) -> bool:
v = self.get_config("AUTONOMOUS_EXECUTION_ENABLED", "OFF")
return str(v).strip().upper() == "ON"
def set_kill_switch(self, on: bool) -> None:
self.set_config("AUTONOMOUS_EXECUTION_ENABLED", "ON" if on else "OFF")
# -- Resume-Flag (PAUSED -> resume-faehig) --------------------------------
def set_resume_pending(self, mission_id: str, pending: bool) -> None:
self.set_config(f"resume_pending:{mission_id}", "1" if pending else "0")
def resume_pending(self, mission_id: str) -> bool:
return self.get_config(f"resume_pending:{mission_id}", "0") == "1"
def set_priority(self, mission_id: str, priority: int) -> None:
self.set_config(f"priority:{mission_id}", str(int(priority)))
# -- Tick Lock ------------------------------------------------------------
def acquire_lock(self, run_id: str) -> str:
"""EIN-Tick-Lock. Rueckgabe 'ok'|'locked'|'recover'."""
now = _now_epoch()
conn = _connect(self.db_path)
try:
row = conn.execute("SELECT * FROM lock WHERE id=1").fetchone()
if row is None:
conn.execute(
"INSERT INTO lock(id,owner,acquired_at,heartbeat_at) VALUES (1,?,?,?)",
(run_id, _utcnow(), now),
)
conn.commit()
return "ok"
heartbeat_at = float(row["heartbeat_at"]) if row["heartbeat_at"] else 0
if now - heartbeat_at <= TICK_LOCK_STALE_SECONDS:
return "locked"
return "recover"
finally:
conn.close()
def heartbeat_mark(self, run_id: str) -> None:
conn = _connect(self.db_path)
try:
conn.execute(
"INSERT INTO lock(id,owner,acquired_at,heartbeat_at) VALUES (1,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, heartbeat_at=excluded.heartbeat_at",
(run_id, _utcnow(), _now_epoch()),
)
conn.commit()
finally:
conn.close()
def release_lock(self) -> None:
conn = _connect(self.db_path)
try:
conn.execute("DELETE FROM lock WHERE id=1")
conn.commit()
finally:
conn.close()
# -- tick_log -------------------------------------------------------------
def log_tick(self, run_id, started_at, finished_at, result_code, mission_id,
exit_reason, detail=None) -> None:
conn = _connect(self.db_path)
try:
conn.execute(
"INSERT OR REPLACE INTO tick_log "
"(run_id,started_at,finished_at,result_code,mission_id,exit_reason,detail) "
"VALUES (?,?,?,?,?,?,?)",
(run_id, started_at, finished_at, result_code, mission_id, exit_reason,
json.dumps(detail or {}, default=str)),
)
conn.commit()
finally:
conn.close()
def last_tick(self) -> Optional[Dict[str, Any]]:
conn = _connect(self.db_path)
try:
row = conn.execute("SELECT * FROM tick_log ORDER BY started_at DESC LIMIT 1").fetchone()
if row is None:
return None
d = dict(row)
try:
d["detail"] = json.loads(d["detail"]) if d["detail"] else {}
except (ValueError, TypeError):
d["detail"] = {}
return d
finally:
conn.close()
def run_id_known(self, run_id: str) -> bool:
conn = _connect(self.db_path)
try:
return conn.execute("SELECT 1 FROM tick_log WHERE run_id=?", (run_id,)).fetchone() is not None
finally:
conn.close()
# -- Notification-Dedup ---------------------------------------------------
def notification_key(self, mission_id: str, reason_code: str, evidence: str) -> str:
raw = f"{mission_id}|{reason_code}|{evidence}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def notification_seen(self, dedup_key: str) -> bool:
conn = _connect(self.db_path)
try:
return conn.execute("SELECT 1 FROM notification_dedup WHERE dedup_key=?", (dedup_key,)).fetchone() is not None
finally:
conn.close()
def notification_record(self, dedup_key: str, mission_id: str, reason_code: str, message_id: str) -> None:
conn = _connect(self.db_path)
try:
conn.execute(
"INSERT INTO notification_dedup (dedup_key,sent_at,mission_id,reason_code,message_id) "
"VALUES (?,?,?,?,?)",
(dedup_key, _utcnow(), mission_id, reason_code, message_id),
)
conn.commit()
finally:
conn.close()
# -- Approval Gate --------------------------------------------------------
def approval_pending(self, mission_id: str) -> Optional[Dict[str, Any]]:
conn = _connect(self.db_path)
try:
row = conn.execute(
"SELECT * FROM approval_gates WHERE mission_id=? AND state='PENDING' "
"ORDER BY requested_at LIMIT 1",
(mission_id,),
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def approval_issue(self, mission_id: str, wp_id: str, action: str, risk: str) -> str:
"""Persistiert einen wartenden Approval-Gate. Idempotent (kein Duplikat
fuer denselben WP im PENDING-Zustand)."""
conn = _connect(self.db_path)
try:
existing = conn.execute(
"SELECT * FROM approval_gates WHERE mission_id=? AND wp_id=? AND state='PENDING'",
(mission_id, wp_id),
).fetchone()
if existing:
return existing["gate_id"]
gate_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO approval_gates "
"(gate_id,mission_id,wp_id,action,requested_action,risk,state,requested_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(gate_id, mission_id, wp_id, "APPROVE", action, risk, "PENDING", _utcnow()),
)
conn.commit()
return gate_id
finally:
conn.close()
def approval_grant(self, gate_id: str, approved_by: str, evidence: str) -> Dict[str, Any]:
conn = _connect(self.db_path)
try:
row = conn.execute("SELECT * FROM approval_gates WHERE gate_id=?", (gate_id,)).fetchone()
if row is None:
raise _err("UNKNOWN_GATE", f"unknown approval gate {gate_id!r}", gate_id=gate_id)
if row["state"] == "APPROVED":
return {"gate_id": gate_id, "state": "APPROVED", "idempotent": True}
conn.execute(
"UPDATE approval_gates SET state='APPROVED', approved_at=?, approved_by=?, evidence=? "
"WHERE gate_id=?",
(_utcnow(), approved_by, evidence, gate_id),
)
conn.commit()
return {"gate_id": gate_id, "state": "APPROVED", "idempotent": False}
finally:
conn.close()
def approval_deny(self, gate_id: str, denied_by: str, evidence: str) -> Dict[str, Any]:
conn = _connect(self.db_path)
try:
row = conn.execute("SELECT * FROM approval_gates WHERE gate_id=?", (gate_id,)).fetchone()
if row is None:
raise _err("UNKNOWN_GATE", f"unknown approval gate {gate_id!r}", gate_id=gate_id)
conn.execute(
"UPDATE approval_gates SET state='DENIED', approved_at=?, approved_by=?, evidence=? "
"WHERE gate_id=?",
(_utcnow(), denied_by, evidence, gate_id),
)
conn.commit()
return {"gate_id": gate_id, "state": "DENIED"}
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Heartbeat
# --------------------------------------------------------------------------- #
class Heartbeat:
"""A5: Kontrollierter Heartbeat & Resume. `run_tick()` = genau ein bounded cycle."""
def __init__(
self,
registry_db: str,
mission_db: str,
safety_db: str,
orchestrator: Optional[Orchestrator] = None,
*,
max_bounded_steps: int = 1,
git_preflight: Optional[Callable[[str], Dict[str, Any]]] = None,
):
self.registry = HeartbeatStore(registry_db)
self.missions = MissionStore(mission_db)
self.safety = SafetyStore(safety_db)
self.orchestrator = orchestrator
self.max_bounded_steps = max_bounded_steps
self.git_preflight = git_preflight
# -- Helper: Mission-Enumeration ------------------------------------------
def _mission(self) -> List[Dict[str, Any]]:
try:
return self.missions.mission_list()
except RqError:
return []
# -- Eligibility & Priority (deterministisch) ------------------------------
def _eligible_states(self, state: str, resume_pending: bool) -> bool:
if state in ELIGIBLE_STATES:
return True
if state == "PAUSED" and resume_pending:
return True
return False
def _mission_circuit(self, mission_id: str) -> Optional[str]:
try:
g = self.safety.circuit_state("GLOBAL", "global")
if g["state"] == CIRCUIT_OPEN:
return "global"
except SafetyError:
pass
try:
m = self.safety.circuit_state("MISSION", mission_id)
if m["state"] == CIRCUIT_OPEN:
return f"mission:{mission_id}"
except SafetyError:
pass
return None
def _not_eligible_code(self, state: str) -> str:
if state in ("COMPLETED", "FAILED", "ESCALATED"):
return HB_TERMINAL
if state == "CANCELLED":
return HB_CANCELLED
if state == "PAUSED":
return HB_PAUSED
return HB_MISSION_NOT_ELIGIBLE
def select_eligible(self, mission_id: Optional[str] = None) -> Dict[str, Any]:
"""Deterministische Auswahl EINER heartbeat-eligible Mission.
Rueckgabe {'eligible', 'mission', 'reason', 'code'}."""
if mission_id is not None:
try:
m = self.missions.mission_read(mission_id)
except RqError as e:
return {"eligible": False, "mission": None, "reason": e.message, "code": HB_NO_ACTIVE_MISSION}
st = m["state"]
resume_pending = self.registry.resume_pending(mission_id)
if not self._eligible_states(m["state"], resume_pending):
return {"eligible": False, "mission": {"id": m["id"], "state": st},
"reason": f"mission {st}; not eligible", "code": self._not_eligible_code(st)}
circ = self._mission_circuit(mission_id)
if circ is not None:
return {"eligible": False, "mission": {"id": m["id"], "state": st},
"reason": f"circuit open: {circ}", "code": HB_CIRCUIT_OPEN}
return {"eligible": True, "mission": {"id": m["id"], "state": st, "created_at": m["created_at"]},
"reason": "", "code": "OK"}
cands = []
for m in self._mission():
resume_pending = self.registry.resume_pending(m["id"])
if not self._eligible_states(m["state"], resume_pending):
continue
if self._mission_circuit(m["id"]) is not None:
continue
cands.append({"id": m["id"], "state": m["state"], "created_at": m["created_at"],
"priority": int(self.registry.get_config(f"priority:{m['id']}", "0") or 0)})
if not cands:
return {"eligible": False, "mission": None, "reason": "no active eligible mission",
"code": HB_NO_ACTIVE_MISSION}
cands.sort(key=lambda x: (-x["priority"], x["created_at"], x["id"]))
return {"eligible": True, "mission": cands[0], "reason": "", "code": "OK"}
# -- run_tick --------------------------------------------------------------
def run_tick(self, mission_id: Optional[str] = None, *, execute: bool = True,
actor: str = "red-queen") -> Dict[str, Any]:
"""EIN Heartbeat-Tick. Endet garantiert."""
started = _utcnow()
run_id = _new_run_id()
result_code = HB_INTERNAL_ERROR
exit_reason = ""
mission_processed: Optional[str] = None
detail: Dict[str, Any] = {}
lock_owned = False # nur TRUE, wenn DIESER Tick das Tick-Lock erworben hat
try:
# Idempotenz: gleiche Run-ID nie doppelt
if self.registry.run_id_known(run_id):
return self._finalize(run_id, started, HB_NO_RUNNABLE_WORK, None,
"run id already processed", {"idempotent": True})
# Kill-Switch-Gate
if not self.registry.kill_switch_on():
return self._finalize(run_id, started, HB_KILL_SWITCH_OFF, None,
"autonomous execution disabled (kill switch OFF)",
{"mutating": False})
# Tick-Lock
lock = self.registry.acquire_lock(run_id)
if lock == "locked":
return self._finalize(run_id, started, HB_LOCKED, None,
"tick lock held: overlapping heartbeat prevented",
{"lock": "locked"})
if lock == "recover":
return self._finalize(run_id, started, HB_RECOVERY_REQUIRED, None,
"stale tick lock: recovery required, no mutation",
{"lock": "recover"})
lock_owned = True
self.registry.heartbeat_mark(run_id)
try:
# Eligibility
sel = self.select_eligible(mission_id)
if not sel["eligible"]:
mission_processed = sel.get("mission", {}).get("id") if sel.get("mission") else None
return self._finalize(run_id, started, sel["code"], mission_processed,
sel["reason"], {"mission": sel.get("mission")})
mid = sel["mission"]["id"]
mission_processed = mid
# A2-Load
try:
m = self.missions.mission_read(mid)
except RqError as e:
return self._finalize(run_id, started, HB_STATE_CONFLICT, mid,
f"mission read failed: {e.message}", {})
if m["state"] in ("COMPLETED", "FAILED", "CANCELLED", "ESCALATED"):
return self._finalize(run_id, started, HB_TERMINAL, mid,
f"mission {m['state']}; terminal", {})
# A3 Safety
try:
self.safety.check_safety_state(mid)
except SafetyError as e:
return self._finalize(run_id, started, HB_SAFETY_BLOCKED, mid,
f"safety state: {e.message}", {"reason_code": e.code})
# Git-Preflight
if self.git_preflight is not None:
git = self.git_preflight(mid)
if not git.get("ok", False):
return self._finalize(run_id, started, HB_GIT_CONFLICT, mid,
f"git state conflict: {git.get('reason')}", {"git": git})
# Approval-Pending (persistierter Gate)
pending = self.registry.approval_pending(mid)
if pending is not None:
return self._finalize(run_id, started, HB_APPROVAL_PENDING, mid,
"approval gate pending; cannot dispatch",
{"gate_id": pending["gate_id"], "wp_id": pending["wp_id"]})
# Execute-Gate (read-only)
if not execute:
return self._finalize(run_id, started, HB_NO_RUNNABLE_WORK, mid,
"execute=False (read-only); no A4 mutation", {})
# EXACTLY ONE bounded A4 run
a4 = self._orchestrator()
a4_result = a4.run_bounded(mid, max_steps=self.max_bounded_steps, actor=actor)
st = a4_result.get("status")
detail["a4"] = a4_result
# Map A4 -> A5
if st in (ST_NEEDS_DISPATCH, ST_NEEDS_CHECKER_DISPATCH, ST_WAITING_PENDING,
ST_CHECKER_PASS, ST_WP_DONE, ST_BUDGET_REACHED):
result_code = HB_BOUNDED_RUN_COMPLETE
exit_reason = f"bounded a4 cycle complete: {st}"
elif st == ST_NO_RUNNABLE_WORK:
# Completion-Gate pruefen
cc = a4.mission_completion_gate(mid, final_review_pass=False)
if cc["valid"]:
cmp = a4.attempt_mission_complete(mid, final_review_pass=False)
if cmp["status"] == ST_MISSION_COMPLETED:
result_code = HB_MISSION_COMPLETED
exit_reason = "mission completed (all wps done)"
detail["completed"] = True
else:
result_code = HB_MISSION_NOT_COMPLETED
exit_reason = f"completion gate passed but complete failed: {cmp.get('reasons')}"
else:
result_code = HB_NO_RUNNABLE_WORK
exit_reason = "no runnable work"
elif st == ST_APPROVAL_REQUIRED:
result_code = HB_APPROVAL_REQUIRED
wp_id = a4_result.get("wp_id") or a4_result.get("approval", {}).get("WP")
act = a4_result.get("approval", {}).get("REQUESTED_ACTION") or ""
risk = a4_result.get("risk") or "CRITICAL"
if wp_id:
self.registry.approval_issue(mid, wp_id, act, risk)
exit_reason = "approval required; gate persisted"
elif st == ST_MUTATION_BLOCKED:
result_code = HB_CIRCUIT_OPEN
exit_reason = f"mutation blocked: {a4_result.get('reason')}"
elif st == ST_STATE_CONFLICT:
result_code = HB_STATE_CONFLICT
exit_reason = a4_result.get("reason", "orchestrator state conflict")
elif st in (ST_DEBUG_REQUIRED, ST_SECOND_OPINION_REQUIRED):
result_code = HB_ESCALATED
exit_reason = f"escalated: {st}"
elif st == ST_MISSION_COMPLETED:
result_code = HB_MISSION_COMPLETED
exit_reason = "mission completed"
elif st == ST_TERMINAL:
result_code = HB_TERMINAL
exit_reason = "mission terminal"
else:
result_code = HB_INTERNAL_ERROR
exit_reason = f"unhandled orchestrator status {st!r}"
return self._finalize(run_id, started, result_code, mid, exit_reason, detail)
except HeartbeatError as e:
return self._finalize(run_id, started, HB_INTERNAL_ERROR, mission_processed,
e.message, {"code": e.code})
finally:
if lock_owned:
self.registry.release_lock()
except HeartbeatError as e:
return self._finalize(run_id, started, HB_INTERNAL_ERROR, mission_processed,
e.message, {"code": e.code})
except Exception as e: # noqa
return self._finalize(run_id, started, HB_INTERNAL_ERROR, mission_processed,
f"internal error: {redact_secret(str(e))}", {})
finally:
if lock_owned:
try:
self.registry.release_lock()
except Exception:
pass
def _orchestrator(self) -> Orchestrator:
if self.orchestrator is not None:
return self.orchestrator
return Orchestrator(mission_db=self.missions.db_path, safety_db=self.safety.db_path)
def _finalize(self, run_id, started, result_code, mission_id, exit_reason, detail) -> Dict[str, Any]:
finished = _utcnow()
self.registry.log_tick(run_id, started, finished, result_code, mission_id, exit_reason, detail)
return {
"run_id": run_id,
"code": result_code,
"result": result_code,
"mission_id": mission_id,
"exit_reason": exit_reason,
"detail": detail,
"started_at": started,
"finished_at": finished,
}
# -- Health (read-only) ----------------------------------------------------
def health(self) -> Dict[str, Any]:
return {
"kill_switch": "ON" if self.registry.kill_switch_on() else "OFF",
"last_tick": self.registry.last_tick(),
"missions": [{"id": m["id"], "state": m["state"]} for m in self._mission()],
}
# Referenz: Wir brauchen einen Code fuer "Completion-Gate ok aber complete failed".
HB_MISSION_NOT_FOUND_CODE = "HB_MISSION_NOT_COMPLETED"