#!/usr/bin/env python3 """ Red Queen — A3: Deterministic Safety Layer (V1). Zwischen Mission/WP-State (A2 `missions.db`) und dem spaeteren Orchestrator / Action/Retry/Delegation sitzt dieser deterministische Safety-Layer. Er entscheidet deterministisch (Zaehler, Limits, Tabellen) und NICHT ueber 'LLM-Lust': CONTINUE / RETRY / DEBUG / SECOND_OPINION / BLOCK / ESCALATE / CIRCUIT_BREAK Ziel: Red Queen erhaelt die Safety-Logik, BEVOR spaetere autonome Mission-Loops existieren. Nach A3 gibt es KEINE autonome Orchestrierung — kein dispatcher, kein loop, kein heartbeat, kein cron, kein self-improvement. Dieses Modul ist eine reine Library / Safety-Capability. Grundsaetze (A1 SAFETY_CONTRACT + A3-Spezifikation): * DETERMINISTISCH : harte Entscheidung aus Zaehlern/Limits/Tabellen in `safety.db`. * IDEMPOTENT : derselbe Attempt/Event/Trigger wird nie doppelt gezaehlt. * RESTART-STABIL : Zustand aus DB wiederherstellbar; nichts wird beim Restart vergessen. * FAIL-CLOSED : unbekannter/inkonsistenter Safety-State -> SAFETY_STATE_ERROR, keine Mutation, Evidence wird gesichert. * APPEND-ONLY : Attempt-Ledger ist unverstaendlich, kein Loeschen/Ueberschreiben. * KEINE SECRETS : im Ledger/Events/Evidence/Telegram keine Tokens/Passwoerter. DB-Entscheidung (Maker-Entscheidung, siehe README): EIGENE `safety.db`. - A2 `missions.db` bleibt voellig unangetastet -> maximale Rollback-Faehigkeit fuer A2. - Safety-State (Circuit, Attempts, Events, Evidence) ist unabhängig vom Mission-State. - Kein ALTER auf bestehenden A2-Tabellen; nur neue Tabellen in eigener DB. - Beide DBs werden immer ueber waehlbare Pfade getestet (temp), nie produktiv beruehrt. """ from __future__ import annotations import datetime import hashlib import json import re import sqlite3 from typing import Any, Callable, Dict, List, Optional, Tuple # --------------------------------------------------------------------------- # # Fehler-Codes (maschinenlesbar, stabil) — spiegelt A2 `RqError`-Kontrakt # --------------------------------------------------------------------------- # class SafetyError(Exception): """Geworfener, maschinenlesbarer Safety-Fehler mit stabilem `code`.""" 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 to_dict(self) -> Dict[str, Any]: return {"code": self.code, "message": self.message, "detail": self.detail} def _err(code: str, msg: str, **detail) -> SafetyError: return SafetyError(code, msg, detail) # --------------------------------------------------------------------------- # # Konstanten (Verbindliche, konservative Limits aus A1 §2) # --------------------------------------------------------------------------- # MAX_MAKER_CHECKER_REPAIRS = 3 # Maker->Checker Repair MAX 3 MAX_SAME_ERROR_SIGNATURE = 2 # Gleiche Error-Signatur MAX 2 MAX_ITERATIONS = 50 # AEUSSERSTE Runtime-Notbremse (nicht operativ) OSCILLATION_SIGNATURE_REPEATS = 3 # gleiche Signatur >=3 -> Oscillation-Verdacht (A1 §4.1) OSCILLATION_TARGET_CHANGES = 3 # gleiche Datei/Change-Ziel >=3 ohne Progress ABAB_PATTERN_LEN = 4 # A-B-A-B NO_PROGRESS_FAILS_LIMIT = 2 # >=2 FAIL ohne messbaren Fortschritt -> Debug # Zustaende & Entscheidungen CIRCUIT_CLOSED = "CLOSED" CIRCUIT_OPEN = "OPEN" CIRCUIT_STATES = frozenset({CIRCUIT_CLOSED, CIRCUIT_OPEN}) SEVERITIES = frozenset({"INFO", "WARNING", "HIGH", "CRITICAL"}) SCOPE_GLOBAL = "GLOBAL" SCOPE_MISSION = "MISSION" SCOPE_WORK_PACKAGE = "WORK_PACKAGE" SCOPE_COMPONENT = "COMPONENT" SCOPE_TYPES = frozenset({SCOPE_GLOBAL, SCOPE_MISSION, SCOPE_WORK_PACKAGE, SCOPE_COMPONENT}) DECISIONS = frozenset( { "CONTINUE", "RETRY", "DEBUG", "SECOND_OPINION", "BLOCK", "ESCALATE", "CIRCUIT_BREAK", } ) ALLOWED_CONTINUE = "CONTINUE" ALLOWED_RETRY = "RETRY" ALLOWED_STRATEGY_CHANGE = "STRATEGY_CHANGE" ALLOWED_DEBUG = "DEBUG" ALLOWED_SECOND_OPINION = "SECOND_OPINION" ALLOWED_READ_ONLY_DIAGNOSIS = "READ_ONLY_DIAGNOSIS" ALLOWED_HUMAN_GATE = "HUMAN_GATE" ALLOWED_NONE = "NONE" # Reason Codes (§22) — stabile, maschinenlesbare Codes REASON_RETRY_AVAILABLE = "RETRY_AVAILABLE" REASON_RETRY_LIMIT = "RETRY_LIMIT" REASON_SAME_ERROR_LIMIT = "SAME_ERROR_LIMIT" REASON_FAILED_STRATEGY_REPEAT = "FAILED_STRATEGY_REPEAT" REASON_NO_MEASURABLE_PROGRESS = "NO_MEASURABLE_PROGRESS" REASON_OSCILLATION_ABAB = "OSCILLATION_ABAB" REASON_CIRCUIT_ALREADY_OPEN = "CIRCUIT_ALREADY_OPEN" REASON_STATE_INCONSISTENT = "STATE_INCONSISTENT" REASON_CRITICAL_TRIGGER = "CRITICAL_TRIGGER" REASON_HUMAN_GATE_REQUIRED = "HUMAN_GATE_REQUIRED" REASON_ITERATION_LIMIT = "ITERATION_LIMIT" # Event-Typen (§17) EVENT_RETRY_ALLOWED = "RETRY_ALLOWED" EVENT_RETRY_DENIED = "RETRY_DENIED" EVENT_DEBUG_REQUIRED = "DEBUG_REQUIRED" EVENT_SECOND_OPINION_REQUIRED = "SECOND_OPINION_REQUIRED" EVENT_RETRY_LIMIT_REACHED = "RETRY_LIMIT_REACHED" EVENT_ERROR_SIGNATURE_REPEAT = "ERROR_SIGNATURE_REPEAT" EVENT_STRATEGY_REPEAT = "STRATEGY_REPEAT" EVENT_NO_PROGRESS = "NO_PROGRESS" EVENT_OSCILLATION_DETECTED = "OSCILLATION_DETECTED" EVENT_CIRCUIT_OPENED = "CIRCUIT_OPENED" EVENT_CIRCUIT_RESET_REQUESTED = "CIRCUIT_RESET_REQUESTED" EVENT_CIRCUIT_CLOSED = "CIRCUIT_CLOSED" EVENT_SAFETY_STATE_ERROR = "SAFETY_STATE_ERROR" EVENT_ESCALATION_REQUIRED = "ESCALATION_REQUIRED" EVENT_HUMAN_DECISION_REQUIRED = "HUMAN_DECISION_REQUIRED" # Critical-Trigger (§12 / A1 §5.1) die einen GLOBAL-Scope-Circuit oeffnen GLOBAL_SCOPE_TRIGGERS = frozenset( { "SSH_RECOVERY_JEOPARDIZED", "SECRET_EXPOSURE_UNKNOWN_SCOPE", "PERSISTENCE_CORRUPTED", "IDENTITY_AUTH_MISMATCH_CRITICAL", "RUNTIME_INTEGRITY_JEOPARDIZED", } ) # Ergebnis-/Fortschritt-Werte RESULT_PASS = "PASS" RESULT_FAIL = "FAIL" RESULT_UNKNOWN = "UNKNOWN" RESULT_VALUES = frozenset({RESULT_PASS, RESULT_FAIL, RESULT_UNKNOWN}) PROGRESS_YES = "YES" PROGRESS_NO = "NO" PROGRESS_UNKNOWN = "UNKNOWN" PROGRESS_VALUES = frozenset({PROGRESS_YES, PROGRESS_NO, PROGRESS_UNKNOWN}) # --------------------------------------------------------------------------- # # DB-Helfer (A2-Konvention) # --------------------------------------------------------------------------- # 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") # --------------------------------------------------------------------------- # # Secret-Redaction (§28) — bewusst einfach & konservativ # --------------------------------------------------------------------------- # _SECRET_PATTERNS = [ re.compile(r"(?i)(api[_-]?key|secret|token|password|passwd|pwd|pw|authorization|bearer|access[_-]?key)\s*[=:]\s*[^\s,;]+"), re.compile(r"(?i)https?://[^\s/@]+:[^\s/@]+@"), re.compile(r"(?i)(-----BEGIN[ A-Z]*PRIVATE KEY-----.*?-----END[ A-Z]*PRIVATE KEY-----)", re.DOTALL), re.compile(r"(?i)\b(?:ghp|gho|ghu|ghs|sk-|xox[baprs]-)[a-z0-9_-]{10,}\b"), re.compile(r"(?i)((?:password|secret|token|key)\s*[=:]\s*)(?:['\"]?)[^\s'\",;]+"), ] def redact_secret(text: Optional[str]) -> Optional[str]: """Maskiert credential-artige Werte. Leere/None-Eingabe bleibt unveraendert.""" if not text: return text out = str(text) for pat in _SECRET_PATTERNS: out = pat.sub("REDACTED", out) return out # --------------------------------------------------------------------------- # # Error-Signature Detection (§7) # --------------------------------------------------------------------------- # _VOLATILE_RE = [ (re.compile(r"\b0x[0-9a-fA-F]{4,}\b"), ""), (re.compile(r"\b(pid|ppid)\s*[=:]\s*\d+\b"), r"\1="), (re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}"), ""), (re.compile(r"\b[A-Fa-f0-9]{32}\b"), ""), (re.compile(r"\b[A-Fa-f0-9]{64}\b"), ""), (re.compile(r"\b[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\b"), ""), (re.compile(r"(?i)(port\s*[=:]\s*)\d{2,5}"), r"\1"), (re.compile(r"(?i)(session|request|trace|run)[_-]?id[^:,;]*\s*[=:]\s*[^\s,;]+"), r"\1="), ] def normalize_error(raw: Optional[str]) -> str: """Normalisiert volatile Bestandteile zu stabilen Platzhaltern. NICHT so aggressiv, dass unterschiedliche Fehler faelschlich gleich werden. Es werden nur klar volatile, generische Kategorien ersetzt; der Rest bleibt. """ if not raw: return "" s = str(raw) for pat, repl in _VOLATILE_RE: s = pat.sub(repl, s) return s.strip() def signature_hash(normalized: str) -> str: """Deterministischer SHA-256-Hash (gekuerzt auf 16 hex) einer normalisierten Signatur.""" return hashlib.sha256((normalized or "").encode("utf-8")).hexdigest()[:16] def error_signature(raw: Optional[str]) -> Tuple[str, str]: """Liefert (NORMALIZED_SIGNATURE, SIGNATURE_HASH) fuer einen rohen Fehler.""" norm = normalize_error(raw) return norm, signature_hash(norm) # --------------------------------------------------------------------------- # # Strategy Fingerprinting (§8) — deterministische Merkmale # --------------------------------------------------------------------------- # def strategy_fingerprint( operation_type: str, target_component: str, target_files: Optional[List[str]] = None, actions_class: Optional[str] = None, normalized_strategy: str = "", intended_change: Optional[str] = None, ) -> Tuple[str, str]: """Deterministisches Merkmals-Tupel (LABEL, HASH) einer Strategie. Erkennt, ob eine bereits gescheiterte Strategie im Wesentlichen erneut angewendet wird. Files werden sortiert, damit die Reihenfolge egal ist. """ files = sorted(set(target_files or [])) parts = [ "op=" + (operation_type or ""), "tgt=" + (target_component or ""), "files=" + ",".join(files), "act=" + (actions_class or ""), "strat=" + (normalized_strategy or ""), "change=" + (intended_change or ""), ] label = "|".join(parts) h = hashlib.sha256(label.encode("utf-8")).hexdigest()[:16] return label, h # --------------------------------------------------------------------------- # # SafetyStore # --------------------------------------------------------------------------- # class SafetyStore: """Persistenter, deterministischer Safety-Layer auf einer eigenen `safety.db`.""" def __init__(self, db_path: str, now_fn: Callable[[], str] = _utcnow): self.db_path = db_path self._now = now_fn self._ensure_schema() # -- Schema --------------------------------------------------------------- def _ensure_schema(self) -> None: conn = _connect(self.db_path) try: conn.executescript( """ CREATE TABLE IF NOT EXISTS attempts ( id TEXT PRIMARY KEY, idempotency_key TEXT, timestamp TEXT NOT NULL, mission_id TEXT, wp_id TEXT, actor TEXT, task TEXT, hypothesis TEXT, strategy TEXT, strategy_fingerprint TEXT, strategy_fingerprint_hash TEXT, change TEXT, result TEXT, error_raw TEXT, error_signature TEXT, error_signature_hash TEXT, progress_metric TEXT, progress_before TEXT, progress_after TEXT, progress_delta TEXT, progress TEXT ); CREATE INDEX IF NOT EXISTS idx_att_miss ON attempts(mission_id); CREATE INDEX IF NOT EXISTS idx_att_wp ON attempts(wp_id); CREATE INDEX IF NOT EXISTS idx_att_sig ON attempts(error_signature_hash); CREATE INDEX IF NOT EXISTS idx_att_fp ON attempts(strategy_fingerprint_hash); CREATE TABLE IF NOT EXISTS circuit_state ( scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, state TEXT NOT NULL, severity TEXT NOT NULL, trigger TEXT, reason TEXT, evidence_ref TEXT, opened_at TEXT, closed_at TEXT, updated_at TEXT NOT NULL, PRIMARY KEY (scope_type, scope_id) ); CREATE TABLE IF NOT EXISTS safety_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, timestamp TEXT NOT NULL, type TEXT NOT NULL, severity TEXT NOT NULL, scope_type TEXT, scope_id TEXT, mission_id TEXT, wp_id TEXT, reason TEXT, reason_code TEXT, evidence_ref TEXT ); CREATE INDEX IF NOT EXISTS idx_sev_type ON safety_events(type); CREATE INDEX IF NOT EXISTS idx_sev_miss ON safety_events(mission_id); CREATE TABLE IF NOT EXISTS safety_evidence ( id INTEGER PRIMARY KEY AUTOINCREMENT, ref TEXT NOT NULL UNIQUE, kind TEXT, data TEXT, created_at TEXT NOT NULL ); """ ) conn.commit() finally: conn.close() # -- Restart-stabile ID-Helpers (Max+1) ------------------------------------ def _next_event_id(self, conn) -> str: rows = conn.execute("SELECT event_id FROM safety_events").fetchall() n = 0 for r in rows: s = r["event_id"] if s.startswith("SE-") and s[3:].isdigit(): n = max(n, int(s[3:])) return f"SE-{n + 1:04d}" def _next_evidence_ref(self, conn) -> str: row = conn.execute("SELECT COUNT(*) AS c FROM safety_evidence").fetchone() return f"E-{int(row['c']) + 1:04d}" def _next_attempt_id(self, conn, mission_id: str, wp_id: Optional[str]) -> str: prefix = f"att-{mission_id}" + (f"-{wp_id}" if wp_id else "") rows = conn.execute( "SELECT id FROM attempts WHERE id LIKE ?", (prefix + "-%",) ).fetchall() n = 0 for r in rows: suffix = r["id"][len(prefix) + 1:] if suffix.isdigit(): n = max(n, int(suffix)) return f"{prefix}-{n + 1:04d}" # -- Attempt Ledger -------------------------------------------------------- def record_attempt( self, mission_id: str, wp_id: Optional[str] = None, *, actor: str = "red-queen", task: Optional[str] = None, hypothesis: Optional[str] = None, strategy: Optional[str] = None, target_component: Optional[str] = None, target_files: Optional[List[str]] = None, actions_class: Optional[str] = None, intended_change: Optional[str] = None, change: Optional[str] = None, result: str = RESULT_UNKNOWN, error: Optional[str] = None, progress_metric: Optional[str] = None, progress_before: Optional[str] = None, progress_after: Optional[str] = None, progress_delta: Optional[str] = None, progress: str = PROGRESS_UNKNOWN, idempotency_key: Optional[str] = None, timestamp: Optional[str] = None, ) -> Dict[str, Any]: """Append-only Attempt-Eintrag. Idempotent via `idempotency_key`. Unbekannte Werte -> fail-closed (INVALID_ARGS), KEINE Mutation. Secrets werden grundsaetzlich redacted, bevor sie die DB erreichen. """ if progress not in PROGRESS_VALUES: raise _err("INVALID_ARGS", f"invalid progress value {progress!r}", progress=progress) if result not in RESULT_VALUES: raise _err("INVALID_ARGS", f"invalid result value {result!r}", result=result) # Secret-Safety (§28): Credential-artige Werte werden VOR jeder # Ableitung (Fingerprint/Signatur) und vor der Persistenz redacted, damit # weder das Ledger noch abgeleitete Hashes/Fingerprints Secrets enthalten. error_red = redact_secret(error) strategy_red = redact_secret(strategy) change_red = redact_secret(change) intended_change_red = redact_secret(intended_change) fp_label, fp_hash = strategy_fingerprint( operation_type=actions_class or "", target_component=target_component or "", target_files=target_files, actions_class=actions_class, normalized_strategy=strategy_red or "", intended_change=intended_change_red, ) sig_norm, sig_hash = error_signature(error_red) conn = _connect(self.db_path) try: if idempotency_key: existing = conn.execute( "SELECT id FROM attempts WHERE idempotency_key = ?", (idempotency_key,) ).fetchone() if existing is not None: return {"attempt_id": existing["id"], "idempotent": True, "deduplicated": True} now = timestamp or self._now() attempt_id = self._next_attempt_id(conn, mission_id, wp_id) conn.execute( "INSERT INTO attempts " "(id,idempotency_key,timestamp,mission_id,wp_id,actor,task," " hypothesis,strategy,strategy_fingerprint,strategy_fingerprint_hash," " change,result,error_raw,error_signature,error_signature_hash," " progress_metric,progress_before,progress_after,progress_delta,progress) " "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( attempt_id, idempotency_key, now, mission_id, wp_id, actor, task, redact_secret(hypothesis), strategy_red, fp_label, fp_hash, change_red, result, error_red, sig_norm, sig_hash, progress_metric, progress_before, progress_after, progress_delta, progress, ), ) conn.commit() return {"attempt_id": attempt_id, "idempotent": False, "deduplicated": False} finally: conn.close() def attempts( self, mission_id: Optional[str] = None, wp_id: Optional[str] = None ) -> List[Dict[str, Any]]: conn = _connect(self.db_path) try: q = "SELECT * FROM attempts" params: List[Any] = [] conds = [] if mission_id: conds.append("mission_id = ?") params.append(mission_id) if wp_id: conds.append("wp_id = ?") params.append(wp_id) if conds: q += " WHERE " + " AND ".join(conds) q += " ORDER BY id ASC" rows = conn.execute(q, params).fetchall() return [dict(r) for r in rows] finally: conn.close() def attempts_count(self, mission_id: str, wp_id: Optional[str] = None) -> int: return len(self.attempts(mission_id, wp_id)) # -- Safety Events ---------------------------------------------------------- def _record_event( self, conn, *, event_type: str, severity: str, mission_id: Optional[str], wp_id: Optional[str], scope_type: Optional[str], scope_id: Optional[str], reason: Optional[str], reason_code: Optional[str], evidence_ref: Optional[str], timestamp: Optional[str] = None, ) -> str: if severity not in SEVERITIES: raise _err("SAFETY_STATE_ERROR", f"invalid severity {severity!r}", severity=severity) now = timestamp or self._now() event_id = self._next_event_id(conn) conn.execute( "INSERT INTO safety_events " "(event_id,timestamp,type,severity,scope_type,scope_id,mission_id,wp_id,reason,reason_code,evidence_ref) " "VALUES (?,?,?,?,?,?,?,?,?,?,?)", (event_id, now, event_type, severity, scope_type, scope_id, mission_id, wp_id, redact_secret(reason), reason_code, evidence_ref), ) return event_id def safety_event( self, event_type: str, severity: str = "WARNING", *, mission_id: Optional[str] = None, wp_id: Optional[str] = None, scope_type: Optional[str] = None, scope_id: Optional[str] = None, reason: Optional[str] = None, reason_code: Optional[str] = None, evidence_ref: Optional[str] = None, timestamp: Optional[str] = None, ) -> Dict[str, Any]: conn = _connect(self.db_path) try: event_id = self._record_event( conn, event_type=event_type, severity=severity, mission_id=mission_id, wp_id=wp_id, scope_type=scope_type, scope_id=scope_id, reason=reason, reason_code=reason_code, evidence_ref=evidence_ref, timestamp=timestamp, ) conn.commit() return {"event_id": event_id, "type": event_type, "severity": severity} finally: conn.close() def safety_events( self, mission_id: Optional[str] = None, event_type: Optional[str] = None ) -> List[Dict[str, Any]]: conn = _connect(self.db_path) try: q = "SELECT * FROM safety_events" params: List[Any] = [] conds = [] if mission_id: conds.append("mission_id = ?") params.append(mission_id) if event_type: conds.append("type = ?") params.append(event_type) if conds: q += " WHERE " + " AND ".join(conds) q += " ORDER BY id ASC" rows = conn.execute(q, params).fetchall() return [dict(r) for r in rows] finally: conn.close() # -- Safety Evidence --------------------------------------------------------- def safety_evidence(self, kind: str, data: Dict[str, Any], timestamp: Optional[str] = None) -> Dict[str, Any]: conn = _connect(self.db_path) try: ref = self._next_evidence_ref(conn) now = timestamp or self._now() serialized = redact_secret(json.dumps(data, ensure_ascii=False, default=str)) conn.execute( "INSERT INTO safety_evidence (ref,data,created_at) VALUES (?,?,?)", (ref, serialized, now), ) conn.commit() return {"ref": ref, "kind": kind} finally: conn.close() def evidence(self, ref: Optional[str] = None) -> List[Dict[str, Any]]: conn = _connect(self.db_path) try: if ref: rows = conn.execute( "SELECT * FROM safety_evidence WHERE ref = ?", (ref,) ).fetchall() else: rows = conn.execute( "SELECT * FROM safety_evidence ORDER BY id ASC" ).fetchall() out = [] for r in rows: d = dict(r) try: d["data"] = json.loads(d["data"]) except Exception: pass out.append(d) return out finally: conn.close() # -- Circuit Breaker (§12-15) ------------------------------------------------- def circuit_state(self, scope_type: str, scope_id: str) -> Dict[str, Any]: if scope_type not in SCOPE_TYPES: raise _err("SAFETY_STATE_ERROR", f"invalid scope_type {scope_type!r}", scope_type=scope_type) conn = _connect(self.db_path) try: row = conn.execute( "SELECT * FROM circuit_state WHERE scope_type=? AND scope_id=?", (scope_type, scope_id), ).fetchone() if row is None: return {"scope_type": scope_type, "scope_id": scope_id, "state": CIRCUIT_CLOSED} return dict(row) finally: conn.close() def _circuit_is_open(self, conn, scope_type: str, scope_id: str) -> bool: row = conn.execute( "SELECT state FROM circuit_state WHERE scope_type=? AND scope_id=?", (scope_type, scope_id), ).fetchone() return row is not None and row["state"] == CIRCUIT_OPEN def circuit_open_for_scope(self, scope_type: str, scope_id: str) -> bool: conn = _connect(self.db_path) try: return self._circuit_is_open(conn, scope_type, scope_id) finally: conn.close() def open_circuit( self, scope_type: str, scope_id: str, *, trigger: str, severity: str = "HIGH", reason: Optional[str] = None, mission_id: Optional[str] = None, wp_id: Optional[str] = None, evidence_ref: Optional[str] = None, timestamp: Optional[str] = None, ) -> Dict[str, Any]: """Oeffnet einen Circuit. Idempotent: bereits offen + gleicher Trigger -> KEIN Event-Sturm. FAIL-CLOSED bei unbekannter severity -> keine Mutation. GLOBAL-Scope nur mit kritischem GLOBAL-Trigger (§15 Scope-Policy). """ if scope_type not in SCOPE_TYPES: raise _err("SAFETY_STATE_ERROR", f"invalid scope_type {scope_type!r}", scope_type=scope_type) if severity not in SEVERITIES: raise _err("SAFETY_STATE_ERROR", f"invalid severity {severity!r}", severity=severity) if scope_type == SCOPE_GLOBAL and trigger not in GLOBAL_SCOPE_TRIGGERS: raise _err( "INVALID_GLOBAL_TRIGGER", f"GLOBAL scope requires a critical global trigger, got {trigger!r}", trigger=trigger, scope_id=scope_id, ) now = timestamp or self._now() conn = _connect(self.db_path) try: existing = self.circuit_state(scope_type, scope_id) if existing["state"] == CIRCUIT_OPEN: if existing.get("trigger") == trigger: return { "scope_type": scope_type, "scope_id": scope_id, "state": CIRCUIT_OPEN, "idempotent": True, "event_emitted": False, } conn.execute( "UPDATE circuit_state SET trigger=?, reason=?, updated_at=? " "WHERE scope_type=? AND scope_id=?", (trigger, reason, now, scope_type, scope_id), ) ev = self._record_event( conn, event_type=EVENT_CIRCUIT_OPENED, severity=severity, mission_id=mission_id, wp_id=wp_id, scope_type=scope_type, scope_id=scope_id, reason=f"re-trigger {trigger}: {reason}", reason_code=REASON_CRITICAL_TRIGGER, evidence_ref=evidence_ref, timestamp=now, ) conn.commit() return { "scope_type": scope_type, "scope_id": scope_id, "state": CIRCUIT_OPEN, "idempotent": False, "event_emitted": True, "event_id": ev, } conn.execute( "INSERT INTO circuit_state (scope_type,scope_id,state,severity,trigger,reason," "evidence_ref,opened_at,closed_at,updated_at) VALUES (?,?,?,?,?,?,?,?,NULL,?)", (scope_type, scope_id, CIRCUIT_OPEN, severity, trigger, reason, evidence_ref, now, now), ) ev = self._record_event( conn, event_type=EVENT_CIRCUIT_OPENED, severity=severity, mission_id=mission_id, wp_id=wp_id, scope_type=scope_type, scope_id=scope_id, reason=reason or trigger, reason_code=REASON_CRITICAL_TRIGGER, evidence_ref=evidence_ref, timestamp=now, ) conn.commit() return { "scope_type": scope_type, "scope_id": scope_id, "state": CIRCUIT_OPEN, "idempotent": False, "event_emitted": True, "event_id": ev, } finally: conn.close() def request_circuit_reset( self, scope_type: str, scope_id: str, *, requested_by: str = "red-queen", cause: str, recovery_evidence: str, mission_id: Optional[str] = None, wp_id: Optional[str] = None, timestamp: Optional[str] = None, ) -> Dict[str, Any]: """Registriert einen Reset-Wunsch. Schliesst den Circuit NICHT automatisch.""" cur = self.circuit_state(scope_type, scope_id) if cur["state"] != CIRCUIT_OPEN: raise _err("CIRCUIT_NOT_OPEN", f"circuit not open for {scope_type}/{scope_id}") conn = _connect(self.db_path) try: ev = self._record_event( conn, event_type=EVENT_CIRCUIT_RESET_REQUESTED, severity="WARNING", mission_id=mission_id, wp_id=wp_id, scope_type=scope_type, scope_id=scope_id, reason=f"reset requested by {requested_by}; cause={cause}; evidence={recovery_evidence}", reason_code=REASON_HUMAN_GATE_REQUIRED, evidence_ref=None, timestamp=timestamp, ) conn.commit() return {"event_id": ev, "status": "requested", "scope_type": scope_type, "scope_id": scope_id} finally: conn.close() def close_circuit( self, scope_type: str, scope_id: str, *, approved_by: str, gate: str = "documented_recovery", cause: str, recovery_evidence: str, mission_id: Optional[str] = None, wp_id: Optional[str] = None, timestamp: Optional[str] = None, ) -> Dict[str, Any]: """Schliesst einen offenen Circuit mit Gate-Policy (§15). LOW/MEDIUM -> dokumentierte Ursache + Recovery-Evidence. HIGH/CRITICAL/GLOBAL -> Human Gate (gate='human_gate'/'external_review'). Ohne ausreichendes Gate -> FAIL-CLOSED (HUMAN_GATE_REQUIRED), keine Mutation. """ cur = self.circuit_state(scope_type, scope_id) if cur["state"] != CIRCUIT_OPEN: return { "scope_type": scope_type, "scope_id": scope_id, "state": CIRCUIT_CLOSED, "idempotent": True, } severity = cur.get("severity", "HIGH") needs_human = severity in ("HIGH", "CRITICAL") or scope_type == SCOPE_GLOBAL if needs_human: if gate not in ("human_gate", "external_review"): raise _err( "HUMAN_GATE_REQUIRED", f"closing {severity}/{scope_type} circuit requires human gate or external review", scope_type=scope_type, scope_id=scope_id, severity=severity, gate=gate, ) elif not cause or not recovery_evidence: raise _err( "INVALID_ARGS", "LOW/MEDIUM circuit close requires documented cause + recovery evidence", scope_type=scope_type, scope_id=scope_id, ) now = timestamp or self._now() conn = _connect(self.db_path) try: conn.execute( "UPDATE circuit_state SET state=?, closed_at=?, updated_at=? " "WHERE scope_type=? AND scope_id=?", (CIRCUIT_CLOSED, now, now, scope_type, scope_id), ) ev = self._record_event( conn, event_type=EVENT_CIRCUIT_CLOSED, severity="INFO", mission_id=mission_id, wp_id=wp_id, scope_type=scope_type, scope_id=scope_id, reason=f"closed by {approved_by} via gate={gate}; cause={cause}", reason_code=None, evidence_ref=None, timestamp=now, ) conn.commit() return {"scope_type": scope_type, "scope_id": scope_id, "state": CIRCUIT_CLOSED, "idempotent": False, "event_id": ev} finally: conn.close() # -- Fail-Closed Safety State --------------------------------------------- def check_safety_state( self, mission_id: Optional[str] = None, wp_id: Optional[str] = None ) -> Dict[str, Any]: """Konsistenzpruefung. Bei unbekanntem/inkonsistentem Circuit-State -> FAIL-CLOSED: SafetyError(STATE_INCONSISTENT), Evidence + Safety-Event, KEINE weitere Mutation.""" conn = _connect(self.db_path) try: rows = conn.execute("SELECT scope_type, scope_id, state FROM circuit_state").fetchall() for r in rows: if r["state"] not in CIRCUIT_STATES: ref = self._next_evidence_ref(conn) conn.execute( "INSERT INTO safety_evidence (ref,data,created_at) VALUES (?,?,?)", (ref, redact_secret(json.dumps({ "scope_type": r["scope_type"], "scope_id": r["scope_id"], "corrupt_state": r["state"], }, default=str)), self._now()), ) self._record_event( conn, event_type=EVENT_SAFETY_STATE_ERROR, severity="CRITICAL", mission_id=mission_id, wp_id=wp_id, scope_type=r["scope_type"], scope_id=r["scope_id"], reason=f"corrupt circuit state {r['state']!r}", reason_code=REASON_STATE_INCONSISTENT, evidence_ref=ref, ) conn.commit() raise _err( "STATE_INCONSISTENT", f"corrupt circuit state {r['state']!r} for {r['scope_type']}/{r['scope_id']}", scope_type=r["scope_type"], scope_id=r["scope_id"], state=r["state"], ) return {"ok": True} finally: conn.close() # -- Oscillation Detection (deterministisch) ------------------------------- def _strategy_seq(self, attempts: List[Dict[str, Any]]) -> List[str]: return [a.get("strategy_fingerprint_hash") or a.get("strategy") or "" for a in attempts] def _detect_abab(self, seq: List[str]) -> bool: """A-B-A-B im letzten 4er-Fenster. Leere/identische Eintraege sind kein ABAB.""" if len(seq) < ABAB_PATTERN_LEN: return False win = seq[-ABAB_PATTERN_LEN:] a, b, a2, b2 = win return a and b and a != b and a == a2 and b == b2 def _detect_oscillation( self, attempts: List[Dict[str, Any]], new_sig_hash: str ) -> Dict[str, Any]: """Konservativ V1: A-B-A-B der Strategie-Fingerprints (Oscillation-Kandidat). Wiederholte Fehler-Signatur wird NICHT hier gezaehlt — sie wird vorher durch den SAME_ERROR_LIMIT-Retry-Controller behandelt (§11: gleiche Fehler-Signatur MAX 2; A-B-A-B = Circuit-Breaker-Kandidat). Die Funktion meldet also nur echte oszillierende Strategie-Muster (mehrere DISTINCT Strategien im Wechsel). """ reasons = [] if self._detect_abab(self._strategy_seq(attempts)): reasons.append("abab_strategy_pattern") if reasons: return {"oscillation": True, "reasons": reasons} return {"oscillation": False, "reasons": []} # -- Retry Controller / Safety Decision API (§21) ----------------------------- def evaluate_next_action( self, mission_id: str, wp_id: Optional[str] = None, *, error: Optional[str] = None, strategy_label: Optional[str] = None, target_component: Optional[str] = None, target_files: Optional[List[str]] = None, is_mutating: bool = True, persist: bool = True, ) -> Dict[str, Any]: """Deterministische Antwort: 'Darf ich weitermachen und welche Aktionsklasse?' Reihenfolge (hart, deterministisch): 1) Fail-closed Safety-State-Pruefung 2) Circuit fuer relevante Scopes -> CIRCUIT_ALREADY_OPEN 3) Max-Iteration-Notbremse 4) Oscillation (A-B-A-B / repeated signature) -> OSCILLATION_ABAB 5) Retry-Limits (Signature / Strategy / Maker-Checker-Repair) 6) kein messbarer Fortschritt bei wiederholtem FAIL 7) sonst RETRY/CONTINUE """ out = { "DECISION": None, "REASON_CODE": None, "SEVERITY": "INFO", "CIRCUIT_STATE": CIRCUIT_CLOSED, "ALLOWED_ACTION": ALLOWED_NONE, "ACTION_TEXT": "", "EVIDENCE": [], } # 1) Fail-closed Safety State try: self.check_safety_state(mission_id, wp_id) except SafetyError as e: out.update( DECISION="BLOCK", REASON_CODE=e.code, SEVERITY="CRITICAL", CIRCUIT_STATE=CIRCUIT_OPEN, ALLOWED_ACTION=ALLOWED_NONE, ACTION_TEXT=f"fail-closed: {e.message}", ) return self._persist(out, persist, EVENT_SAFETY_STATE_ERROR, mission_id, wp_id) # 2) Circuit-Check scoped circuit = self._find_open_circuit(mission_id, wp_id, target_component) if circuit is not None: st, sid, cstate = circuit if is_mutating: out.update( DECISION="BLOCK", REASON_CODE=REASON_CIRCUIT_ALREADY_OPEN, SEVERITY="CRITICAL", CIRCUIT_STATE=cstate, ALLOWED_ACTION=ALLOWED_READ_ONLY_DIAGNOSIS, ACTION_TEXT=f"circuit OPEN for {st}/{sid}; mutating ops blocked", ) else: out.update( DECISION="CONTINUE", REASON_CODE=REASON_RETRY_AVAILABLE, SEVERITY="INFO", CIRCUIT_STATE=cstate, ALLOWED_ACTION=ALLOWED_READ_ONLY_DIAGNOSIS, ACTION_TEXT="read-only diagnosis allowed while circuit open", ) return self._persist(out, persist, None, mission_id, wp_id) attempts = self.attempts(mission_id, wp_id) count = len(attempts) # 3) Max-Iteration-Notbremse if count >= MAX_ITERATIONS: out.update( DECISION="CIRCUIT_BREAK", REASON_CODE=REASON_ITERATION_LIMIT, SEVERITY="CRITICAL", ALLOWED_ACTION=ALLOWED_HUMAN_GATE, ACTION_TEXT=f"global iteration limit {MAX_ITERATIONS} reached; forced stop", ) return self._persist(out, persist, EVENT_OSCILLATION_DETECTED, mission_id, wp_id) sig_norm, sig_hash = error_signature(error) # 4) Oscillation osc = self._detect_oscillation(attempts, sig_hash) if osc["oscillation"]: out.update( DECISION="CIRCUIT_BREAK", REASON_CODE=REASON_OSCILLATION_ABAB, SEVERITY="CRITICAL", ALLOWED_ACTION=ALLOWED_HUMAN_GATE, ACTION_TEXT="oscillation detected: " + "; ".join(osc["reasons"]), ) return self._persist(out, persist, EVENT_OSCILLATION_DETECTED, mission_id, wp_id) # 5) Retry-Limits fail_attempts = [a for a in attempts if a.get("result") == RESULT_FAIL] # SAFETY_CONTRACT §2.4: Zaehler werden bei tatsaechlichem Fortschritt # zurueckgesetzt. Messbarer Progress (YES) hebt die Error-Signatur-Sperre auf, # damit eine Fehler+Messbare-Fortschritt-Situation NICHT vorschnell blockt (§26 TEST D). has_measurable_progress = any( a.get("progress") == PROGRESS_YES for a in attempts ) sig_count_total = sum(1 for a in fail_attempts if a.get("error_signature_hash") == sig_hash) if sig_hash and not has_measurable_progress and sig_count_total >= MAX_SAME_ERROR_SIGNATURE: out.update( DECISION="DEBUG", REASON_CODE=REASON_SAME_ERROR_LIMIT, SEVERITY="WARNING", ALLOWED_ACTION=ALLOWED_STRATEGY_CHANGE, ACTION_TEXT=f"same error signature seen {sig_count_total} times " f"(limit {MAX_SAME_ERROR_SIGNATURE}); strategy change required", ) return self._persist(out, persist, EVENT_ERROR_SIGNATURE_REPEAT, mission_id, wp_id) if strategy_label or target_component: fp_label, fp_hash = strategy_fingerprint( operation_type="", target_component=target_component or "", target_files=target_files, actions_class=None, normalized_strategy=strategy_label or "", intended_change=None, ) failed_strategy_repeat = any( a.get("strategy_fingerprint_hash") == fp_hash and a.get("result") == RESULT_FAIL for a in attempts ) if failed_strategy_repeat: out.update( DECISION="DEBUG", REASON_CODE=REASON_FAILED_STRATEGY_REPEAT, SEVERITY="WARNING", ALLOWED_ACTION=ALLOWED_STRATEGY_CHANGE, ACTION_TEXT="same already-failed strategy proposed; blind repeat forbidden", ) return self._persist(out, persist, EVENT_STRATEGY_REPEAT, mission_id, wp_id) repair_count = sum( 1 for a in attempts if (a.get("actor") or "").lower() in ("maker", "repair") and a.get("result") == RESULT_FAIL ) if repair_count >= MAX_MAKER_CHECKER_REPAIRS: out.update( DECISION="SECOND_OPINION", REASON_CODE=REASON_RETRY_LIMIT, SEVERITY="HIGH", ALLOWED_ACTION=ALLOWED_SECOND_OPINION, ACTION_TEXT=f"maker/checker repair limit {MAX_MAKER_CHECKER_REPAIRS} reached; " f"need fresh second opinion", ) return self._persist(out, persist, EVENT_RETRY_LIMIT_REACHED, mission_id, wp_id) # 6) kein messbarer Fortschritt bei wiederholtem FAIL (UNKNOWN != Progress) no_progress_fails = sum( 1 for a in fail_attempts if a.get("progress") in (PROGRESS_NO, PROGRESS_UNKNOWN) ) if fail_attempts and no_progress_fails >= NO_PROGRESS_FAILS_LIMIT: out.update( DECISION="DEBUG", REASON_CODE=REASON_NO_MEASURABLE_PROGRESS, SEVERITY="WARNING", ALLOWED_ACTION=ALLOWED_DEBUG, ACTION_TEXT="repeated failures without measurable progress; " "UNKNOWN is not progress", ) return self._persist(out, persist, EVENT_NO_PROGRESS, mission_id, wp_id) # 7) Retry erlaubt out.update( DECISION="RETRY" if is_mutating else "CONTINUE", REASON_CODE=REASON_RETRY_AVAILABLE, SEVERITY="INFO", ALLOWED_ACTION=ALLOWED_RETRY if is_mutating else ALLOWED_CONTINUE, ACTION_TEXT="retry / next action allowed", ) return self._persist(out, persist, EVENT_RETRY_ALLOWED, mission_id, wp_id) def _find_open_circuit( self, mission_id: str, wp_id: Optional[str], target_component: Optional[str] ) -> Optional[Tuple[str, str, str]]: """Offene Circuits in Scope-Prioritaet: GLOBAL > MISSION > WP > COMPONENT.""" scopes: List[Tuple[str, str]] = [(SCOPE_GLOBAL, "global")] if mission_id: scopes.append((SCOPE_MISSION, mission_id)) if wp_id: scopes.append((SCOPE_WORK_PACKAGE, wp_id)) if target_component: scopes.append((SCOPE_COMPONENT, target_component)) conn = _connect(self.db_path) try: for st, sid in scopes: if self._circuit_is_open(conn, st, sid): return (st, sid, CIRCUIT_OPEN) finally: conn.close() return None def _persist( self, outcome: Dict[str, Any], persist: bool, event_type: Optional[str], mission_id: Optional[str], wp_id: Optional[str], ) -> Dict[str, Any]: """Sichert das Outcome als Safety-Event, falls gewuenscht. Events sind Beobachtungen; ein Fehler beim Event-Log darf die Entscheidung nicht kippen.""" if persist and event_type: try: self.safety_event( event_type=event_type, severity=outcome["SEVERITY"], mission_id=mission_id, wp_id=wp_id, reason=outcome["ACTION_TEXT"], reason_code=outcome["REASON_CODE"], ) except Exception: # noqa pass return outcome