#!/usr/bin/env python3 """Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode (``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben. Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von ``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die Patches (re-)ein. Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen Zeilenverschiebungen durch Updates, anders als ``git apply``): * ``marker`` schon im Quelltext -> Patch bereits drin, ueberspringen. * ``old`` genau 1x vorhanden -> ersetzen, dann ``py_compile`` pruefen. * ``old`` 0x oder >1x -> Kontext hat sich durch ein Update geaendert -> LAUT scheitern (Exit 1), damit der Postcheck rot wird und niemand denkt, der Fix sei noch aktiv. Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler). """ import os import py_compile import shutil import sys from pathlib import Path HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent"))) HERE = Path(__file__).resolve().parent # --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) --- # (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir) COPIES = [ ("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"), ] # --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) --- PATCHES = [ { "name": "0001-needs-input-wait", "file": "hermes_cli/kanban_db.py", "marker": 'kind not in ("needs_input", "capability")', "old": " if recurrences >= BLOCK_RECURRENCE_LIMIT:\n", "new": ( " # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n" " # human-only — sie muessen in ``blocked`` auf den Commander warten\n" " # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n" " # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n" ' # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n' " # fuer transient/untyped Blocks aktiv.\n" ' if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n' ), }, { "name": "0002-decompose-at-readiness", "file": "hermes_cli/kanban_decompose.py", "marker": "Startreife-Gate", "old": ( " cfg = _load_config()\n" " orchestrator = _resolve_orchestrator_profile(cfg)\n" ), "new": ( " # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n" " # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n" " # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n" " # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n" " # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n" " with kb.connect_closing() as _pconn:\n" " _parents = [r[0] for r in _pconn.execute(\n" ' "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n' " if _parents:\n" ' _ph = ",".join("?" * len(_parents))\n' " _open = _pconn.execute(\n" ' "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n' " \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n" " ).fetchone()[0]\n" " if _open:\n" " return DecomposeOutcome(\n" " task_id, False,\n" ' f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n' " )\n" " cfg = _load_config()\n" " orchestrator = _resolve_orchestrator_profile(cfg)\n" ), }, { "name": "0003-orphan-guard", "file": "gateway/kanban_watchers.py", "marker": "mc2_kanban_reaper", "old": ( " pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n" " if pids:\n" " logger.info(\n" ' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n' " len(pids),\n" " pids,\n" " )\n" ), "new": ( " pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n" " if pids:\n" " logger.info(\n" ' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n' " len(pids),\n" " pids,\n" " )\n" " # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n" " # Worker, deren Task nicht mehr 'running' ist (extern\n" " # blockiert/reset) — sonst unterlaufen sie max_in_progress\n" " # und verbrennen Rechenzeit.\n" " try:\n" " from hermes_cli import mc2_kanban_reaper as _mc2reaper\n" " _orph = await asyncio.to_thread(\n" " _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n" " )\n" " if _orph:\n" " logger.info(\n" ' "kanban dispatcher: reaped %d orphaned worker(s): %s",\n' " len(_orph), _orph,\n" " )\n" " except Exception:\n" ' logger.exception("kanban dispatcher: orphan reaper failed")\n' ), }, { "name": "0004a-context-inherit-compute", "file": "hermes_cli/kanban_decompose.py", "marker": '_parent_ctx = ""', "old": ( " children: list[dict] = []\n" " for idx, entry in enumerate(raw_tasks):\n" ), "new": ( " children: list[dict] = []\n" " # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n" ' # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n' " # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n" " # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n" ' _parent_ctx = ""\n' ' if getattr(task, "body", None) and task.body.strip():\n' " _parent_ctx = (\n" ' "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n' " + task.body.strip()[:1500]\n" ' + "\\n\\n## DEIN TEILAUFTRAG\\n"\n' " )\n" " for idx, entry in enumerate(raw_tasks):\n" ), }, { "name": "0004b-context-inherit-apply", "file": "hermes_cli/kanban_decompose.py", "marker": "_parent_ctx + body", "old": ( ' body = entry.get("body")\n' " if not isinstance(body, str):\n" ' body = ""\n' ), "new": ( ' body = entry.get("body")\n' " if not isinstance(body, str):\n" ' body = ""\n' " body = _parent_ctx + body # MC2 (20.07.): Projekt-Kontext vererben\n" ), }, { "name": "0005-create-chain-guard", "file": "tools/kanban_tools.py", "marker": "_MC2_RECENT_ROOTS", "old": ( " new_task = kb.get_task(conn, new_tid)\n" " subscribed = _maybe_auto_subscribe(conn, new_tid)\n" ), "new": ( " # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n" " # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n" " # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n" " # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n" " # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n" " # Verkettung harmlos (laeuft eh seriell).\n" " try:\n" " if triage and not parents and session_id:\n" " import time as _mc2t\n" ' _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n' " _prev = _roots.get(session_id)\n" " _now = _mc2t.monotonic()\n" " if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n" " try:\n" " kb.link_tasks(conn, _prev[0], new_tid)\n" " except Exception:\n" " pass\n" " _roots[session_id] = (new_tid, _now)\n" " if len(_roots) > 64:\n" " _roots.clear()\n" " except Exception:\n" " pass\n" " new_task = kb.get_task(conn, new_tid)\n" " subscribed = _maybe_auto_subscribe(conn, new_tid)\n" ), }, ] def main() -> int: if not HA.is_dir(): print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.") return 0 applied, skipped, failed = [], [], [] # --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) --- for src_rel, dst_rel in COPIES: src, dst = HERE / src_rel, HA / dst_rel if not src.is_file(): failed.append((src_rel, f"Quelle fehlt: {src}")) continue try: same = dst.is_file() and dst.read_bytes() == src.read_bytes() if same: skipped.append(f"copy:{dst_rel}") continue dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) if dst.suffix == ".py": py_compile.compile(str(dst), doraise=True) applied.append(f"copy:{dst_rel}") except Exception as exc: failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}")) for p in PATCHES: f = HA / p["file"] if not f.is_file(): failed.append((p["name"], f"Datei fehlt: {f}")) continue src = f.read_text(encoding="utf-8") if p["marker"] in src: skipped.append(p["name"]) continue n = src.count(p["old"]) if n != 1: failed.append((p["name"], (f"erwartete 1 Vorkommen von old, fand {n} " "(Update hat den Kontext geaendert?)"))) continue f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8") try: py_compile.compile(str(f), doraise=True) except Exception as exc: failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}")) continue applied.append(p["name"]) print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}") for name, why in failed: print(f" FEHLER {name}: {why}", file=sys.stderr) if failed: return 1 # Fehler → Deploy/Postcheck rot if applied: return 10 # etwas geaendert → Aufrufer sollte hermes-gateway neu starten return 0 # nichts zu tun (alles schon drin) if __name__ == "__main__": sys.exit(main())