#!/usr/bin/env python3 """ Red Queen — A4: BOUNDED ORCHESTRATOR v1 (deterministische, inaktive Library). VERBINDLICHER RAHMEN ==================== Red Queen (ein Hermes-Agent) IST der Orchestrator. Der echte Sub-Agenten-Versand (Planner/Maker/Checker) erfolgt ueber die Hermes-native `delegate_task`-Engine, die ein Python-Prozess NICHT aufrufen kann. Deshalb implementiert A4 die DETERMINISTISCHE ORCHESTRIERUNGS-LOGIK als reine Library (`Orchestrator`), die Red Queen importiert und ansteuert. Die Entscheidungen (was ist runnable, Safety-Gate, Risk>Size, Approval, Completion-Gate, Bounded-Run-Limits, idempotente Wiederaufnahme, Restart-Persistenz) sind VOLLSTAENDIG deterministisch und unabhaengig vom LLM. Diese Library ist INAKTIV: Sie startet nichts, macht nichts zyklisch und delegiert NICHTS von selbst. Jede Aktion wird EXPLIZIT durch eine Methode/CLI-Befehl angestossen und ist garantiert begrenzt (bounded). Es gibt KEINEN Loop, kein Heartbeat, kein Cron, kein Daemon, kein self-reschedule, kein self-improvement, keinen automatischen Rain. INTEGRATION: * A2 `MissionStore` (missions.db) ist die AUTHORITATIVE Quelle fuer Mission/WP-State und Transitionen. A4 erzeugt WPs + Dependencies ausschliesslich ueber die A2-API, nie per direkter SQL-Manipulation. * A3 `SafetyStore` (safety.db) ist die verbindliche Safety-Decision (Retry-Limits, Circuit Breaker, Attempt-Ledger). A4 fragt vor JEDER mutierenden Aktion A3. * Der eigentliche Child-Versand bleibt bei Red Queen. A4 liefert reine Dispatch- Contracts (Builder) und die `ChildDispatcher`-Abstraktion (Callable), die ein Plugin/Test injizieren kann. Die Library selbst ruft nie delegate_task. SECRET-SAFETY: Sub-Agent-Evidence und Logs werden durch `redact_secret` (A3) bereinigt. Es werden NIE Credential-artige Werte persistiert oder ausgegeben. Import-Loesung: Dieses Modul liegt in `a4/`; die A2-/A3-Module liegen in `a2/`/`a3/` im selben Repo-Root. Es wird `sys.path` um Repo-Root ergaenzt, damit `from a2.rq_mission import MissionStore` und `from a3.rq_safety import SafetyStore` zuverlaessig funktionieren. """ from __future__ import annotations import datetime import json import os import sys from pathlib import Path from typing import Any, Callable, Dict, List, Optional # --------------------------------------------------------------------------- # # Pfad-Loesung fuer A2/A3-Importe (Repo-Root = eine Ebene ueber `a4/`) # --------------------------------------------------------------------------- # _REPO_ROOT = Path(__file__).resolve().parent.parent for _p in (str(_REPO_ROOT), str(_REPO_ROOT / "a2"), str(_REPO_ROOT / "a3")): 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_CLOSED, CIRCUIT_OPEN, MAX_MAKER_CHECKER_REPAIRS, MAX_SAME_ERROR_SIGNATURE, PROGRESS_NO, PROGRESS_UNKNOWN, PROGRESS_YES, RESULT_FAIL, RESULT_PASS, SafetyError, SafetyStore, redact_secret, ) # --------------------------------------------------------------------------- # # A4-Fehler (maschinenlesbar, stabil) # --------------------------------------------------------------------------- # class OrchestratorError(Exception): """Geworfener, maschinenlesbarer Orchestrator-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) -> OrchestratorError: return OrchestratorError(code, msg, detail) # --------------------------------------------------------------------------- # # Verbindliche, deterministische Konstanten (Bounded-Run-Limits) # --------------------------------------------------------------------------- # MAX_WORK_PACKAGES_PER_RUN = 12 # max WP, die ein bounded run materialisiert MAX_REPAIR_CYCLES = 3 # Maker->Checker Repair MAX 3 (spiegelt A3) MAX_CHILDREN_ACTIVE = 3 # max gleichzeitig aktive Kinder MAX_BOUNDED_RUNTIME = 1000 # aeusserste Schritt-Budget eines bounded run MAX_PLAN_WPS = 32 # max WP pro Plan (Validierung) # Risk-Klassen (ARCHITECTURE.md §5) RISK_SMALL = "SMALL" # Red Queen direkt RISK_MEDIUM = "MEDIUM" # MAKER RISK_LARGE = "LARGE" # MAKER RISK_CRITICAL = "CRITICAL" # MAKER + CHECKER (+ ggf. Approval) RISK_DIFFICULT = "DIFFICULT" # DEBUGGER RISK_SELF_MOD = "SELF-MOD" # Human/External Gate RISK_CLASSES = frozenset( {RISK_SMALL, RISK_MEDIUM, RISK_LARGE, RISK_CRITICAL, RISK_DIFFICULT, RISK_SELF_MOD} ) # Kritische Bereiche (ROOT_SSH_GATE.md §4) -> deterministische Risk-Klassifikation. # RISK_CLASS UEBERSCHREIBT TASK_SIZE: auch ein SMALL-WP mit kritischem Target ist CRITICAL. CRITICAL_TARGETS = frozenset( { "ssh", "ssh-access", "remote-access", "firewall", "networking", "network", "docker", "container", "container-infrastructure", "reverse-proxy", "proxy", "auth", "authentication", "authorization", "secret", "secrets", "credential", "credentials", "credential-management", "recovery", "recovery-access", "backup", "forgejo-security", "forgejo", "ollama-core", "ollama", "red-queen-runtime", "hermes-runtime", "hermes", "persistence", "state-db", "kanban", "cron", "circuit-breaker", "approval-gates", "approval", } ) # Child-Rollen ROLE_MAKER = "MAKER" ROLE_CHECKER = "CHECKER" ROLE_RED_QUEEN = "RED_QUEEN" # --- Status-/Reason-Codes (maschinenlesbar, stabil) -------------------------- ST_PLAN_VALIDATED = "PLAN_VALIDATED" ST_PLAN_INVALID = "PLAN_INVALID" ST_TERMINAL = "MISSION_TERMINAL" ST_NO_RUNNABLE_WORK = "NO_RUNNABLE_WORK" ST_MUTATION_BLOCKED = "MUTATION_BLOCKED" ST_STATE_CONFLICT = "ORCHESTRATOR_STATE_CONFLICT" ST_APPROVAL_REQUIRED = "APPROVAL_REQUIRED" ST_BUDGET_REACHED = "RUN_BUDGET_REACHED" ST_NEEDS_DISPATCH = "NEEDS_DISPATCH" ST_NEEDS_CHECKER_DISPATCH = "NEEDS_CHECKER_DISPATCH" ST_WAITING_PENDING = "WAITING_PENDING_CHILD" ST_CHECKER_PASS = "CHECKER_PASS" ST_CHECKER_BLOCKED = "CHECKER_BLOCKED" ST_WP_DONE = "WP_DONE" ST_DEBUG_REQUIRED = "DEBUG_REQUIRED" ST_SECOND_OPINION_REQUIRED = "SECOND_OPINION_REQUIRED" ST_RETRY4_IMPOSSIBLE = "RETRY4_IMPOSSIBLE" ST_MISSION_COMPLETED = "MISSION_COMPLETED" ST_CHILD_TECHNICAL_FAILURE = "CHILD_TECHNICAL_FAILURE" ST_MISSION_NOT_COMPLETED = "MISSION_NOT_COMPLETED" # Alle deterministisch moeglichen Status-Codes (maschinenlesbar, stabil). RUN_CODES = frozenset( { ST_PLAN_VALIDATED, ST_PLAN_INVALID, ST_TERMINAL, ST_NO_RUNNABLE_WORK, ST_MUTATION_BLOCKED, ST_STATE_CONFLICT, ST_APPROVAL_REQUIRED, ST_BUDGET_REACHED, ST_NEEDS_DISPATCH, ST_NEEDS_CHECKER_DISPATCH, ST_WAITING_PENDING, ST_CHECKER_PASS, ST_CHECKER_BLOCKED, ST_WP_DONE, ST_DEBUG_REQUIRED, ST_SECOND_OPINION_REQUIRED, ST_RETRY4_IMPOSSIBLE, ST_MISSION_COMPLETED, ST_CHILD_TECHNICAL_FAILURE, ST_MISSION_NOT_COMPLETED, } ) # Status-Codes, die einen bounded run sicher beenden (kein self-reschedule). _TERMINAL_STEPS = frozenset( { ST_TERMINAL, ST_MUTATION_BLOCKED, ST_STATE_CONFLICT, ST_APPROVAL_REQUIRED, ST_SECOND_OPINION_REQUIRED, ST_DEBUG_REQUIRED, ST_WAITING_PENDING, ST_RETRY4_IMPOSSIBLE, ST_MISSION_COMPLETED, ST_CHILD_TECHNICAL_FAILURE, ST_NO_RUNNABLE_WORK, } ) # Codes, die auf einen Red-Queen-Dispatch warten (run endet an der Grenze). _DISPATCH_CODES = frozenset({ST_NEEDS_DISPATCH, ST_NEEDS_CHECKER_DISPATCH}) # Zulaessige WP-Zustaende, mit denen ein WP im Plan starten darf. WP_PLAN_STATES = frozenset({"TODO", "READY"}) # --------------------------------------------------------------------------- # # ChildDispatcher-Abstraktion # child_dispatcher(contract: Dict[str, Any]) -> Dict[str, Any] # Ein Plugin/Test injiziert diese Callable. Die Library ruft sie nur, wenn sie # explizit gesetzt ist (kein deterministisches auto-dispatch). ChildDispatcher = Callable[[Dict[str, Any]], Dict[str, Any]] def _utcnow() -> str: return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") # --------------------------------------------------------------------------- # # Risk-Klassifikation (deterministisch, RISK_CLASS ueberschreibt TASK_SIZE) # --------------------------------------------------------------------------- # def classify_risk(target_component: Optional[str], size_class: Optional[str] = None) -> str: """Deterministische Risk-Klasse aus Target-Bereich (ROOT_SSH_GATE kritische Bereiche). Reihenfolge (RISK OVERRIDES SIZE): 1) Kritischer Target -> CRITICAL (unabhaengig von size_class). 2) Explizite size_class -> normalisierte Klasse. 3) Sonst -> MEDIUM (Standard: MAKER). """ target = (target_component or "").strip().lower() if target and any(k in target for k in CRITICAL_TARGETS): return RISK_CRITICAL cls = (size_class or "").strip().upper() or RISK_MEDIUM if cls in RISK_CLASSES: return cls return RISK_MEDIUM def requires_approval(risk_class: str, wp: Dict[str, Any]) -> bool: """Bestimmt, ob ein WP laut Contract Human-Approval verlangt (ROOT_SSH_GATE).""" if wp.get("requires_approval"): return True if risk_class in (RISK_CRITICAL, RISK_SELF_MOD): return True return False # --------------------------------------------------------------------------- # # Plan-Validierung (§6) — deterministisch, KEIN LLM # --------------------------------------------------------------------------- # class PlanValidator: """Validiert einen Planner-Output gegen die A4-Regeln (beschreibend, keine Ausfuehrung).""" def __init__(self, mission_id: Optional[str] = None): self.mission_id = mission_id @staticmethod def validate_cycle(deps_map: Dict[str, List[str]], ids: List[str]) -> Optional[List[str]]: """Deterministische Cycle-Detektion (iterative DFS). Liefert Cycle-Pfad oder None.""" state = {} # 0=unbesucht,1=in-stack,2=fertig for start in ids: if state.get(start) == 2: continue state[start] = 1 stack = [start] visit = [(start, iter(deps_map.get(start, [])))] while visit: node, it = visit[-1] advanced = False for dep in it: if state.get(dep) == 1: idx = stack.index(dep) return stack[idx:] + [dep] if state.get(dep) == 2: continue state[dep] = 1 stack.append(dep) visit.append((dep, iter(deps_map.get(dep, [])))) advanced = True break if not advanced: state[node] = 2 if stack: stack.pop() visit.pop() return None def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]: """Liefert {'valid': bool, 'errors': [{'code','message','...'}]}.""" errors: List[Dict[str, Any]] = [] wps = plan.get("work_packages") if not isinstance(wps, list) or not wps: errors.append({"code": "EMPTY_PLAN", "message": "plan has no work packages"}) return {"valid": False, "errors": errors, "plan": plan} if len(wps) > MAX_PLAN_WPS: errors.append({"code": "PLAN_TOO_LARGE", "message": f"plan exceeds {MAX_PLAN_WPS} wps", "count": len(wps)}) ids: List[str] = [] for w in wps: wp_id = str(w.get("wp_id") or "").strip() if not wp_id: errors.append({"code": "MISSING_WP_ID", "message": "wp missing wp_id"}) continue if wp_id in ids: errors.append({"code": "DUPLICATE_WP_ID", "message": f"duplicate wp_id {wp_id!r}", "wp_id": wp_id}) continue ids.append(wp_id) if w.get("mission_id") and self.mission_id and str(w.get("mission_id")) != self.mission_id: errors.append({"code": "FOREIGN_WP", "message": f"wp {wp_id} belongs to another mission", "wp_id": wp_id}) if not w.get("goal") and not w.get("description"): errors.append({"code": "MISSING_GOAL", "message": f"wp {wp_id} missing goal/description", "wp_id": wp_id}) if not w.get("acceptance_criteria"): errors.append({"code": "MISSING_AC", "message": f"wp {wp_id} missing acceptance_criteria", "wp_id": wp_id}) if not w.get("scope") and not w.get("target_component"): errors.append({"code": "MISSING_SCOPE", "message": f"wp {wp_id} missing scope", "wp_id": wp_id}) st = w.get("state") if st is not None and st not in WP_PLAN_STATES: errors.append({"code": "INVALID_WP_STATE", "message": f"wp {wp_id} state {st!r} not allowed", "wp_id": wp_id}) rc = w.get("risk_classification") if rc is not None and str(rc).upper() not in RISK_CLASSES: errors.append({"code": "INVALID_RISK_CLASS", "message": f"wp {wp_id} invalid risk {rc!r}", "wp_id": wp_id}) id_set = set(ids) deps_map: Dict[str, List[str]] = {wid: [] for wid in ids} for w in wps: wid = str(w.get("wp_id") or "") for dep in (w.get("dependencies") or []): dep = str(dep) if dep == wid: errors.append({"code": "SELF_DEPENDENCY", "message": f"wp {wid} depends on itself", "wp_id": wid}) continue if dep not in id_set: errors.append({"code": "UNKNOWN_DEPENDENCY", "message": f"wp {wid} depends on unknown {dep!r}", "wp_id": wid}) continue deps_map[wid].append(dep) cycle = self.validate_cycle(deps_map, ids) if cycle: errors.append({"code": "DEPENDENCY_CYCLE", "message": f"dependency cycle: {' -> '.join(cycle)}", "cycle": cycle}) return {"valid": not errors, "errors": errors, "plan": plan} # --------------------------------------------------------------------------- # # Dispatch-Contract-Builder (§5) # --------------------------------------------------------------------------- # def _child_task_id(mission_id: str, wp_id: str, role: str, round_: int) -> str: return f"{mission_id}:{wp_id}:{role}:R{round_}" def build_maker_contract( wp: Dict[str, Any], mission: Dict[str, Any], risk_class: str, retry_round: int = 1, ) -> Dict[str, Any]: """Reine Datenstruktur fuer einen MAKER-Dispatch (Minimum-Necessary-Context). Enthaelt KEINE Chain-of-Thought; Werte sind secret-redacted. """ return { "role": ROLE_MAKER, "TASK_ID": _child_task_id(mission["id"], wp["id"], ROLE_MAKER, retry_round), "MISSION_ID": mission["id"], "WP_ID": wp["id"], "GOAL": redact_secret(wp.get("goal") or wp.get("description") or ""), "SCOPE": redact_secret(wp.get("scope") or wp.get("target_component") or ""), "ALLOWED_FILES": list(wp.get("allowed_files") or []), "FORBIDDEN_FILES": list(wp.get("forbidden_files") or []), "ACCEPTANCE_CRITERIA": redact_secret(wp.get("acceptance_criteria") or ""), "TEST_REQUIREMENTS": redact_secret(wp.get("test_requirements") or ""), "SAFETY_RULES": list(wp.get("safety_rules") or ["NO_ROOT", "NO_SECRETS", "BOUNDED"]), "RISK_CLASS": risk_class, "RETRY_ROUND": retry_round, } def build_checker_contract( wp: Dict[str, Any], mission: Dict[str, Any], maker_evidence: Dict[str, Any], risk_class: str, retry_round: int = 1, ) -> Dict[str, Any]: """Checker-Input: Requirement/AC/Diff/Tests/Regeln — OHNE Maker-Argumentation. Der Maker-Rechtfertigungstext (IMPLEMENTATION_SUMMARY) wird bewusst NICHT in den Checker-Contract uebernommen (AGENT_CONTRACTS §3). """ return { "role": ROLE_CHECKER, "task_id": _child_task_id(mission["id"], wp["id"], ROLE_CHECKER, retry_round), "mission_id": mission["id"], "wp_id": wp["id"], "requirement": redact_secret(wp.get("goal") or wp.get("description") or ""), "acceptance_criteria": redact_secret(wp.get("acceptance_criteria") or ""), "diff_files": list(wp.get("allowed_files") or []), "test_results": list(maker_evidence.get("test_results") or []), "architecture_rules": list(wp.get("architecture_rules") or []), "safety_rules": list(wp.get("safety_rules") or ["NO_ROOT", "NO_SECRETS", "BOUNDED"]), "risk_class": risk_class, } # --------------------------------------------------------------------------- # # Sub-Agent-Evidence-Record (§30) - maschinenlesbar, KEINE CoT, KEINE Secrets # --------------------------------------------------------------------------- # def make_child_evidence( mission_id: str, wp_id: str, child_role: str, child_task_id: str, start_ts: str, result: str, verdict: str, evidence_ref: Optional[str] = None, ) -> Dict[str, Any]: """Baut einen maschinenlesbaren Evidence-Record (Child Role, Child Task ID, Mission ID, WP ID, Start, Result, Verdict). KEINE Chain-of-Thought, redacted.""" return { "child_role": child_role, "child_task_id": child_task_id, "mission_id": mission_id, "wp_id": wp_id, "start_ts": start_ts, "result": redact_secret(result), "verdict": verdict, "evidence_ref": redact_secret(evidence_ref), } # --------------------------------------------------------------------------- # # Orchestrator # --------------------------------------------------------------------------- # class Orchestrator: """Deterministischer, bounded Orchestrator. Verbindet A2 (Mission/WP-State) + A3 (verbindliche Safety-Decision) in EINEM bounded run. EXPLICIT-only: kein Auto-Start. `run_one_step` fuehrt GENAU EINEN Schritt aus; `run_bounded` laeuft nur, solange explizit aufgerufen und ist durch Limits garantiert begrenzt. Es gibt keinen Daemon/Heartbeat/Cron. """ def __init__( self, mission_db: str, safety_db: str, child_dispatcher: Optional[ChildDispatcher] = None, *, run_small_inline: bool = True, max_wps_per_run: int = MAX_WORK_PACKAGES_PER_RUN, max_repair_cycles: int = MAX_REPAIR_CYCLES, max_children_active: int = MAX_CHILDREN_ACTIVE, max_bounded_runtime: int = MAX_BOUNDED_RUNTIME, ): self.mission_db = mission_db self.safety_db = safety_db self.missions = MissionStore(mission_db) self.safety = SafetyStore(safety_db) self.child_dispatcher = child_dispatcher self.run_small_inline = run_small_inline self.max_wps_per_run = max_wps_per_run self.max_repair_cycles = max_repair_cycles self.max_children_active = max_children_active self.max_bounded_runtime = max_bounded_runtime self.validator = PlanValidator() self._wp_meta_cache: Dict[str, Dict[str, Any]] = {} # -- Plan-Materialisierung ------------------------------------------------- def load_plan(self, plan: Dict[str, Any], *, actor: str = "red-queen") -> Dict[str, Any]: """Validiert + materialisiert einen Plan in A2 (Mission + WPs + Dependencies). KEINE direkte SQL-Manipulation; nutzt ausschliesslich die A2-API. Ungueltig -> OrchestratorError(PLAN_INVALID), keine Ausfuehrung. """ v = self.validator.validate(plan) if not v["valid"]: raise _err("PLAN_INVALID", "plan validation failed", errors=v["errors"]) wps = plan["work_packages"] if len(wps) > self.max_wps_per_run: raise _err("PLAN_INVALID", f"plan exceeds max_wps_per_run {self.max_wps_per_run}", count=len(wps)) mission = self.missions.mission_create( title=plan.get("title") or plan.get("mission_id") or "untitled", goal=plan.get("goal"), scope=plan.get("scope"), constraints=plan.get("constraints"), acceptance_criteria=plan.get("acceptance_criteria"), mission_id=plan.get("mission_id"), actor=actor, ) mid = mission["id"] # WP-Metadaten registrieren (fuer Dispatch-Contracts) for w in wps: wid = str(w["wp_id"]) self.missions.wp_create(mid, w.get("title") or wid, wp_id=wid, actor=actor) self._wp_meta_cache[wid] = dict(w) self._wp_meta_cache[wid]["id"] = wid self._wp_meta_cache[wid]["mission_id"] = mid # Dependencies (zweiter Durchlauf: alle WPs existieren) for w in wps: for dep in (w.get("dependencies") or []): self.missions.wp_dependency(mid, str(w["wp_id"]), str(dep)) # WPs in READY bringen (TODO->READY deterministisch via A2) for w in wps: try: self.missions.wp_transition(str(w["wp_id"]), "READY", actor=actor, evidence="plan validated") except RqError: pass # bereits READY # CREATED->PLANNING->READY (deterministische A2-Transitions) self.missions.mission_transition(mid, "PLANNING", actor=actor, evidence="plan accepted") self.missions.mission_transition(mid, "READY", actor=actor, evidence="plan validated") return {"status": ST_PLAN_VALIDATED, "mission": self.missions.mission_read(mid)} # -- State-Helfer ----------------------------------------------------------- def _mission_state(self, mission_id: str) -> str: try: return self.missions.mission_state(mission_id)["state"] except RqError: return "UNKNOWN" def _wp_state(self, wp_id: str) -> str: try: return self.missions.wp_state(wp_id)["state"] except RqError: return "UNKNOWN" def _wp_deps_done(self, mission_id: str, wp_id: str) -> bool: try: m = self.missions.mission_read(mission_id) except RqError: return False for d in m.get("dependencies", []): if d["wp_id"] == wp_id: if self._wp_state(d["depends_on"]) != "DONE": return False return True def _wp_meta(self, wp_id: str) -> Dict[str, Any]: """Liefert Plan-Metadaten des WP; Fallback auf eine deterministische Default-Struktur (Target aus WP-Titel abgeleitet).""" cached = self._wp_meta_cache.get(wp_id) if cached: return cached wp = self.missions.wp_read(wp_id) title = (wp.get("title") or "").lower() target = next((k for k in CRITICAL_TARGETS if k in title), "") return { "mission_id": wp["mission_id"], "id": wp_id, "title": wp.get("title"), "goal": wp.get("title"), "description": wp.get("title"), "target_component": target, "risk_classification": None, "allowed_files": [], "forbidden_files": [], "acceptance_criteria": "WP accepted via deterministic review gate", "test_requirements": "", "safety_rules": ["NO_ROOT", "NO_SECRETS", "BOUNDED"], "architecture_rules": [], "impact": "", "rollback": "", "risk": "", "requires_approval": False, } # -- Consistency-Gate (§9, KRITISCH) -------------------------------------- def _consistency_gate( self, mission_id: str, wp_id: Optional[str] = None, target_component: Optional[str] = None, ) -> Dict[str, Any]: """Vor JEDER mutierenden Aktion: A2-State + A3 check_safety_state + A3 evaluate. Rueckgabe {'ok': bool, 'status': str, 'mission_state': str, 'reason': str, ...} Circuit OPEN / Safety-State-Inkonsistenz -> ok=False, NO MUTATION. """ ms = self._mission_state(mission_id) if ms in ("COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "PAUSED", "ESCALATED"): return {"ok": False, "status": ST_TERMINAL, "mission_state": ms, "reason": f"mission {ms}; cannot run"} try: self.safety.check_safety_state(mission_id, wp_id) except SafetyError as e: return {"ok": False, "status": ST_STATE_CONFLICT, "mission_state": ms, "reason": f"safety state error: {e.message}", "reason_code": e.code} dec = self.safety.evaluate_next_action( mission_id, wp_id, target_component=target_component, is_mutating=True, persist=False, ) if dec["DECISION"] == "BLOCK" or dec["CIRCUIT_STATE"] == CIRCUIT_OPEN: return {"ok": False, "status": ST_MUTATION_BLOCKED, "mission_state": ms, "reason": dec["ACTION_TEXT"], "reason_code": dec["REASON_CODE"], "circuit": dec["CIRCUIT_STATE"]} return {"ok": True, "status": "OK", "mission_state": ms} # -- Runnable-Selection (§8) ------------------------------------------------ def select_runnable(self, mission_id: str) -> List[Dict[str, Any]]: """WPs, die RUNNABLE sind: DEPENDENCIES DONE? STATE READY? SAFETY CLOSED? SCOPE ALLOWED? -> nur dann RUNNABLE. Kein Polling/kein Wait.""" try: m = self.missions.mission_read(mission_id) except RqError: return [] if m["state"] not in ("READY", "RUNNING"): return [] runnable = [] for wp in m["work_packages"]: if wp["state"] != "READY": continue if not self._wp_deps_done(mission_id, wp["id"]): continue # Consistency: Safety CLOSED + Scope erlaubt cg = self._consistency_gate(mission_id, wp["id"], None) if not cg["ok"]: continue runnable.append(wp) runnable.sort(key=lambda x: x["id"]) return runnable # -- EIN Schritt (explicit, bounded) ---------------------------------------- def run_one_step(self, mission_id: str, actor: str = "red-queen") -> Dict[str, Any]: """Fuehrt GENAU EINEN deterministischen Schritt aus und ENDET GARANTIERT. Wenn `child_dispatcher` injiziert ist, wird ein kompletter Maker(+Checker)- Zyklus deterministisch abgearbeitet. Sonst liefert die Methode das Dispatch-Contract (ST_NEEDS_DISPATCH / ST_NEEDS_CHECKER_DISPATCH), das Red Queen selbst an `delegate_task` weitergibt. """ cg = self._consistency_gate(mission_id) if not cg["ok"]: return {"status": cg["status"], "mission_id": mission_id, **cg} runnable = self.select_runnable(mission_id) if not runnable: # Pending children? m = self.missions.mission_read(mission_id) pending = [w["id"] for w in m["work_packages"] if w["state"] in ("IN_PROGRESS", "CHECKING")] if pending: return {"status": ST_WAITING_PENDING, "mission_id": mission_id, "pending_wps": pending} return {"status": ST_NO_RUNNABLE_WORK, "mission_id": mission_id} wp = runnable[0] wp_meta = self._wp_meta(wp["id"]) risk = classify_risk(wp_meta.get("target_component"), wp_meta.get("risk_classification")) # Approval-Gate (§25) if requires_approval(risk, wp_meta): return self._approval_block(wp["id"], risk, wp_meta) # Consistency vor Dispatch (scoped) cg2 = self._consistency_gate(mission_id, wp["id"], wp_meta.get("target_component")) if not cg2["ok"]: return {"status": cg2["status"], "mission_id": mission_id, "wp_id": wp["id"], **cg2} # READY->IN_PROGRESS via A2 try: self.missions.wp_transition(wp["id"], "IN_PROGRESS", actor=actor, evidence="dispatch maker") except RqError: pass # Mission READY->RUNNING (nur beim ersten echten Schritt) if cg["mission_state"] == "READY": try: self.missions.mission_transition(mission_id, "RUNNING", actor=actor, evidence="orchestrator started") except RqError: pass mission = self.missions.mission_read(mission_id) contract = build_maker_contract(wp_meta, mission, risk, retry_round=1) self._make_record(wp_meta["mission_id"], wp["id"], ROLE_MAKER, contract["TASK_ID"], _utcnow(), "dispatched", "PENDING") if self.child_dispatcher is None: return {"status": ST_NEEDS_DISPATCH, "mission_id": mission_id, "wp_id": wp["id"], "role": ROLE_MAKER, "dispatch": contract, "risk_class": risk} try: child_result = self.child_dispatcher(contract) except Exception as e: # noqa return self._child_technical_failure(mission_id, wp["id"], f"maker dispatch: {e}", contract["TASK_ID"]) return self.apply_child_result(mission_id, wp["id"], child_result, contract=contract, risk_class=risk, actor=actor) # -- Evidence-Record (§30) --------------------------------------------------- def _make_record(self, mission_id, wp_id, role, task_id, start_ts, result, verdict) -> str: rec = make_child_evidence(mission_id, wp_id, role, task_id, start_ts, result, verdict) # persistiere als Safety-Evidence (redacted) ref = self.safety.safety_evidence("child_dispatch", rec)["ref"] return ref # -- Approval-Block ----------------------------------------------------------- def _approval_block(self, wp_id: str, risk: str, wp_meta: dict) -> Dict[str, Any]: try: self.missions.wp_transition(wp_id, "BLOCKED", evidence="approval required") except RqError: pass return { "status": ST_APPROVAL_REQUIRED, "mission_id": wp_meta.get("mission_id"), "wp_id": wp_id, "risk": risk, "approval": { "MISSION": wp_meta.get("mission_id"), "WP": wp_id, "REQUESTED_ACTION": redact_secret(wp_meta.get("goal") or wp_meta.get("description") or ""), "WHY": "WP-Class kritisch/SELF_MOD verlangt Human-Approval (ROOT_SSH_GATE)", "IMPACT": redact_secret(wp_meta.get("impact") or "change in critical target area"), "ROLLBACK": redact_secret(wp_meta.get("rollback") or "restore from documented backup"), "RISK": redact_secret(wp_meta.get("risk") or "critical"), }, } # -- Child-Result-Anwendung -------------------------------------------------- def apply_child_result( self, mission_id: str, wp_id: str, child_result: Dict[str, Any], *, contract: Optional[Dict[str, Any]] = None, risk_class: Optional[str] = None, actor: str = "red-queen", ) -> Dict[str, Any]: """Deterministische Anwendung eines Child-Results. - Idempotenz: gleiche Child-Task-ID wird nie doppelt gewertet. - technischer Fehlschlag -> CHILD_TECHNICAL_FAILURE, A3 gefragt, kein implizit PASS, kein endlos spawn. - Checker PASS -> WP DONE. Checker FAIL -> A3-Entscheidung + Repair-Flow. """ role = str(child_result.get("role") or child_result.get("ROLE") or "").upper() task_id = str(child_result.get("child_task_id") or child_result.get("TASK_ID") or "").strip() retry_round = 1 if contract: retry_round = int(contract.get("RETRY_ROUND") or 1) wstate = self._wp_state(wp_id) if wstate in ("DONE", "FAILED"): return {"status": ST_WP_DONE, "mission_id": mission_id, "wp_id": wp_id, "idempotent": True, "reason": "wp already terminal"} cg = self._consistency_gate(mission_id, wp_id, None) if not cg["ok"]: return {"status": cg["status"], "mission_id": mission_id, "wp_id": wp_id, **cg} idem_key = task_id or f"{mission_id}:{wp_id}:{role}:{retry_round}" wp_meta = self._wp_meta(wp_id) risk = risk_class or classify_risk(wp_meta.get("target_component"), None) if role == ROLE_CHECKER: return self._apply_checker(mission_id, wp_id, child_result, task_id, idem_key, retry_round, risk, actor) if role == ROLE_MAKER: return self._apply_maker(mission_id, wp_id, child_result, task_id, idem_key, retry_round, risk, actor) return self._child_technical_failure(mission_id, wp_id, f"unknown role {role!r}", idem_key) def _apply_maker(self, mission_id, wp_id, result, task_id, idem_key, retry_round, risk, actor): errors = result.get("errors") or result.get("FAIL") or result.get("fail") test_results = result.get("test_results") or [] has_fail = any( (isinstance(t, dict) and (t.get("verdict") == "FAIL" or t.get("PASS/FAIL") == "FAIL")) or t == "FAIL" for t in test_results ) if isinstance(test_results, list) else False maker_result = result.get("result") or (RESULT_FAIL if (errors or has_fail) else RESULT_PASS) progress = result.get("progress") or (PROGRESS_YES if maker_result == RESULT_PASS else PROGRESS_NO) self.safety.record_attempt( mission_id, wp_id, actor="maker", result=maker_result, error=result.get("error"), strategy=result.get("strategy"), change=result.get("change"), progress=progress, idempotency_key=idem_key, ) try: self.missions.wp_transition(wp_id, "CHECKING", actor=actor, evidence="maker result") except RqError: pass mission = self.missions.mission_read(mission_id) checker_contract = build_checker_contract( self._wp_meta(wp_id), mission, result, risk, retry_round) self._make_record(mission_id, wp_id, ROLE_CHECKER, checker_contract["task_id"], _utcnow(), "dispatched", "PENDING") if self.child_dispatcher is None: return {"STATUS": ST_NEEDS_CHECKER_DISPATCH, "mission_id": mission_id, "wp_id": wp_id, "dispatch": checker_contract, "risk": risk, "maker_result": maker_result} try: checker_result = self.child_dispatcher(checker_contract) except Exception as e: # noqa return self._child_technical_failure(mission_id, wp_id, f"checker error: {e}", checker_contract["task_id"]) return self.apply_child_result(mission_id, wp_id, checker_result, contract=checker_contract, risk_class=risk, actor=actor) def _apply_checker(self, mission_id, wp_id, result, task_id, idem_key, retry_round, risk, actor): verdict = str(result.get("verdict") or result.get("VERDICT") or "BLOCKED").upper() progress = result.get("progress") or (PROGRESS_YES if verdict == "PASS" else PROGRESS_UNKNOWN) fail_reason = None if verdict == "PASS" else (result.get("fail_reason") or result.get("FAIL_REASON")) self.safety.record_attempt( mission_id, wp_id, actor="checker", result=RESULT_PASS if verdict == "PASS" else RESULT_FAIL, error=fail_reason, strategy="checker-review", change=None, progress=progress, idempotency_key=idem_key, ) if verdict == "PASS": self.missions.wp_complete(wp_id, result="checker PASS", actor=actor) return {"status": ST_CHECKER_PASS, "mission_id": mission_id, "wp_id": wp_id, "verdict": "PASS", "wp_state": "DONE"} if verdict == "BLOCKED": try: self.missions.wp_transition(wp_id, "BLOCKED", actor=actor, evidence="checker blocked") except RqError: pass return {"status": ST_CHECKER_BLOCKED, "mission_id": mission_id, "wp_id": wp_id, "verdict": "BLOCKED"} return self._checker_fail(mission_id, wp_id, result, retry_round, risk, actor) def _checker_fail(self, mission_id, wp_id, result, retry_round, risk, actor): wp_meta = self._wp_meta(wp_id) dec = self.safety.evaluate_next_action( mission_id, wp_id, error=result.get("fail_reason") or result.get("error"), strategy_label=result.get("strategy"), target_component=wp_meta.get("target_component"), is_mutating=True, ) decision = dec["DECISION"] reason = dec["REASON_CODE"] if decision == "CIRCUIT_BREAK": self.safety.open_circuit("WORK_PACKAGE", wp_id, trigger="OSCILLATION_ABAB", severity="CRITICAL", mission_id=mission_id, wp_id=wp_id, reason="circuit break from safety decision") try: self.missions.wp_transition(wp_id, "BLOCKED", actor=actor, evidence="circuit break") except RqError: pass return {"status": ST_MUTATION_BLOCKED, "mission_id": mission_id, "wp_id": wp_id, "decision": decision, "reason_code": reason, "circuit": CIRCUIT_OPEN} if decision in ("SECOND_OPINION", "BLOCK"): # kein auto-Rain: nur BLOCK/ESCALATE an Red Queen -> Christian target = "ESCALATED" if decision == "SECOND_OPINION" else "BLOCKED" try: self.missions.wp_transition(wp_id, target, actor=actor, evidence="repair limit / second opinion") except RqError: pass return {"status": ST_SECOND_OPINION_REQUIRED, "mission_id": mission_id, "wp_id": wp_id, "decision": decision, "reason": reason, "no_auto_rain": True} if decision == "DEBUG": try: self.missions.wp_transition(wp_id, "IN_PROGRESS", actor=actor, evidence="debug required") except RqError: pass return {"status": ST_DEBUG_REQUIRED, "mission_id": mission_id, "wp_id": wp_id, "decision": "DEBUG", "reason": reason, "recommendation": "A5 debugger / strategy change required"} # RETRY -> Repair repairs = self._repair_count(mission_id, wp_id) if repairs >= self.max_repair_cycles: return {"status": ST_RETRY4_IMPOSSIBLE, "mission_id": mission_id, "wp_id": wp_id, "decision": "RETRY_DENIED", "reason": "RETRY_LIMIT", "repair_count": repairs, "max": self.max_repair_cycles} try: self.missions.wp_transition(wp_id, "IN_PROGRESS", actor=actor, evidence="repair retry") except RqError: pass next_round = repairs + 1 mission = self.missions.mission_read(mission_id) contract = build_maker_contract(wp_meta, mission, risk or classify_risk(wp_meta.get("target_component"), None), retry_round=next_round) # RETRY -> naechsten Maker bereitstellen (EIN Schritt, bounded). # WICHTIG: Es wird NICHT rekursiv ein weiterer Maker/Checker gestartet. # run_one_step = genau EIN Child-Zyklus. Der naechste Maker wird als # Dispatch-Contract zurueckgegeben, den Red Queen (bzw. der Aufrufer) # ausfuehrt. So endet jeder Schritt garantiert (kein Rekursions-Loop). return {"status": ST_NEEDS_DISPATCH, "mission_id": mission_id, "wp_id": wp_id, "dispatch": contract, "retry_round": next_round, "decision": "RETRY"} def _repair_count(self, mission_id, wp_id) -> int: attempts = self.safety.attempts(mission_id, wp_id) # Ein Repair-Round ist eine MAKER+CHECKER-Runde, die in FAIL endet. # Zaehle deshalb sowohl fehlgeschlagene Maker- als auch fehlgeschlagene # Checker-Attempts (Determinismus/SAFETY_CONTRACT §2.4). Nur so ist der # Retry-Kreislauf auch dann begrenzt, wenn der Maker "PASS", der Checker # aber wiederholt "FAIL" liefert (sonst waere die Schleife unbegrenzt). return sum( 1 for a in attempts if (a.get("actor") or "").lower() in ("maker", "repair", "checker") and a.get("result") == RESULT_FAIL ) def _child_technical_failure(self, mission_id, wp_id, reason, idem_key) -> Dict[str, Any]: try: self.safety.record_attempt(mission_id, wp_id, actor="orchestrator", result=RESULT_FAIL, error=reason, strategy="dispatch", progress=PROGRESS_NO, idempotency_key=idem_key) except SafetyError: pass try: dec = self.safety.evaluate_next_action(mission_id, wp_id, error=reason, is_mutating=True) except SafetyError: dec = {"DECISION": "BLOCK", "REASON_CODE": "STATE_INCONSISTENT"} return {"status": ST_CHILD_TECHNICAL_FAILURE, "mission_id": mission_id, "wp_id": wp_id, "reason": redact_secret(reason), "decision": dec["DECISION"], "reason_code": dec["REASON_CODE"], "no_implicit_pass": True} # -- Completion-Gate (§20) --------------------------------------------------- def mission_completion_gate(self, mission_id: str, *, final_review_pass: bool = False) -> Dict[str, Any]: """Mission nur REVIEW/COMPLETED, wenn alle Pflicht-WPs DONE, keine BLOCKED, keine relevanten Circuits OPEN, keine unresolved critical findings, AC erfuellt, Working-State konsistent. Vor COMPLETED: Final-Review-Gate (PASS).""" reasons: List[str] = [] try: m = self.missions.mission_read(mission_id) except RqError as e: return {"mission_id": mission_id, "valid": False, "reasons": [e.message]} wps = m["work_packages"] if not wps: reasons.append("no work packages") not_done = [w["id"] for w in wps if w["state"] != "DONE"] if not_done: reasons.append(f"mandatory wps not done: {not_done}") blocked = [w["id"] for w in wps if w["state"] == "BLOCKED"] if blocked: reasons.append(f"blocked wps: {blocked}") try: self.safety.check_safety_state(mission_id) except SafetyError as e: reasons.append(f"safety inconsistent: {e.message}") dec = self.safety.evaluate_next_action(mission_id, None, is_mutating=False, persist=False) if dec["CIRCUIT_STATE"] == CIRCUIT_OPEN: reasons.append(f"circuit open: {dec['REASON_CODE']}") if not final_review_pass: reasons.append("final review gate not passed") return {"mission_id": mission_id, "valid": not reasons, "reasons": reasons} def attempt_mission_complete(self, mission_id: str, *, final_review_pass: bool = False, actor: str = "red-queen") -> Dict[str, Any]: """Mission RUNNING->REVIEW->COMPLETED, nur wenn Completion-Gate ok.""" gate = self.mission_completion_gate(mission_id, final_review_pass=final_review_pass) if not gate["valid"]: return {"status": ST_MISSION_NOT_COMPLETED, "mission_id": mission_id, "valid": False, "reasons": gate["reasons"]} ms = self._mission_state(mission_id) if ms != "REVIEW": try: self.missions.mission_transition(mission_id, "REVIEW", actor=actor, evidence="all wps done; review") except RqError: pass try: self.missions.mission_complete(mission_id, actor=actor, evidence="final review passed") except RqError as e: return {"status": ST_MISSION_NOT_COMPLETED, "mission_id": mission_id, "valid": False, "reasons": [e.message]} return {"status": ST_MISSION_COMPLETED, "mission_id": mission_id, "valid": True} # -- Bounded Run ------------------------------------------------------------- def run_bounded(self, mission_id: str, *, max_steps: Optional[int] = None, actor: str = "red-queen") -> Dict[str, Any]: """EXPLICIT begrenzter Run. Nach max_steps/max_bounded_runtime -> STOP mit ST_BUDGET_REACHED. Kein self-reschedule; endet garantiert.""" budget = min(max_steps or self.max_bounded_runtime, self.max_bounded_runtime) last = {"status": ST_NO_RUNNABLE_WORK, "mission_id": mission_id} for i in range(1, budget + 1): last = self.run_one_step(mission_id, actor=actor) st = last.get("status") if st in _TERMINAL_STEPS or st in _DISPATCH_CODES: last["steps"] = i return last last["steps"] = budget last["status"] = ST_BUDGET_REACHED return last