Hermes-Runtime-Patch-Traeger + Fan-out-/Kontext-Fixes
Neuer update-fester Patch-Traeger (deploy/hermes-patches/apply.py) haelt
MC2-eigene Fixes im Hermes-Quellcode, idempotent, in deploy.sh + postcheck
verdrahtet (Exit 10 = Gateway-Neustart, Exit 1 = rot):
0001 needs_input/capability warten auf Commander (keine triage-Eskalation
-> keine Muenz-Schleife)
0002 Decompose erst bei Startreife (Parents done)
0003 Orphan-Guard: Reaper killt Worker, deren Task nicht mehr running
(mc2_kanban_reaper.py) -> max_in_progress leckt nicht
0004 Projekt-/Repo-Kontext vererbt sich auf Kind-Karten
0005 Etappen-Auto-Kette: Bulk-Root-triage-Karten einer Session verketten
Steckbrief: 'Repo fehlt -> selbst anlegen' + verschaerfte Kontext-Regel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user