diff --git a/deploy/agent-hooks/box-steckbrief-inject.sh b/deploy/agent-hooks/box-steckbrief-inject.sh index 6823d7e..dc1b69c 100644 --- a/deploy/agent-hooks/box-steckbrief-inject.sh +++ b/deploy/agent-hooks/box-steckbrief-inject.sh @@ -33,7 +33,8 @@ TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu un PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren. REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/. FRONTEND-BAUEN (seit 15.07. abends): Node LTS liegt sudo-frei unter ~/.local (node/npm im Worker-PATH). Bei Frontend-Aenderungen baust DU das dist MIT: cd frontend && npm ci --no-audit --no-fund && npm run build — und committest frontend/dist im selben Branch (die Box zeigt sonst nach Annahme den alten Stand; Bau-Beweis 15.07.: kompletter Build in ~4 s). -KONTEXT-BUDGET & ETAPPEN-REGEL (15.07. — vier Worker starben an finish_reason=length): Dein Kontext ist ENDLICH. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte; grosse Dateien nur in Ausschnitten. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage. +REPO FEHLT? (20.07.): Soll dein Ergebnis in ein Gitea-Repo, das noch NICHT existiert (eigenes/neues Projekt), dann lege es SELBST an — NICHT den Commander fragen, NICHT git init: bash ~/.hermes/scripts/gitea-repo-create.sh "" (immer privat, letzte Ausgabezeile = CLONE ), dann voll klonen und darin arbeiten. NUR mission-control-v2 und das Lucy-Repo NIE neu anlegen/veraendern. +KONTEXT-BUDGET & ETAPPEN-REGEL (15.07./20.07. — Worker starben an finish_reason=length UND an vollgelaufenem Kontext): Dein Kontext ist ENDLICH und fuellt sich vor allem durch GROSSE Datei- und Terminal-Ausgaben. Lies NIE eine grosse Datei am Stueck — nutze grep und Zeilenbereiche (read mit offset/limit); Datei-Reads sind auf 50k Zeichen gedeckelt, aber auch viele mittlere summieren sich. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage. EOF )" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 2abe218..e06764d 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -191,6 +191,18 @@ done for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do [ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true done +# Hermes-Runtime-Patch-Traeger (20.07.2026): MC2-eigene Fixes im Hermes-Quellcode +# (needs_input-wartet-auf-Commander, Decompose-erst-bei-Startreife, Orphan-Guard, +# Projekt-Kontext-Vererbung, Etappen-Auto-Kette) idempotent (re-)eintragen — so +# ueberleben sie ein `hermes update`, das den Quellbaum sonst zuruecksetzt. +python3 "$SRC/deploy/hermes-patches/apply.py"; _patch_rc=$? +if [ "$_patch_rc" -eq 10 ]; then + # Patches haben den Quellcode geaendert → Gateway muss die neue Laufzeit laden. + systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true + echo "[deploy] Hermes-Runtime-Patches (neu) angewandt → hermes-gateway neu gestartet." +elif [ "$_patch_rc" -ne 0 ]; then + echo "[deploy] ‼ WARNUNG: hermes-patches/apply.py meldete Fehler — ein Patch passt nicht mehr (Update hat den Kontext geaendert?). Fixes pruefen!" +fi # Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py → # same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst # kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem diff --git a/deploy/hermes-patches/apply.py b/deploy/hermes-patches/apply.py new file mode 100644 index 0000000..5a6e6fc --- /dev/null +++ b/deploy/hermes-patches/apply.py @@ -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()) diff --git a/deploy/hermes-patches/mc2_kanban_reaper.py b/deploy/hermes-patches/mc2_kanban_reaper.py new file mode 100644 index 0000000..8294891 --- /dev/null +++ b/deploy/hermes-patches/mc2_kanban_reaper.py @@ -0,0 +1,84 @@ +"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker. + +Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach +``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher +(``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen. + +Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B. +über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task`` +``worker_pid = NULL`` — der Worker-PROZESS läuft aber weiter. Die DB zählt ihn +nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker +möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren +Task nicht mehr ``running`` ist. + +Linux-only (/proc-Scan). Best-effort: jeder Fehler → leere Liste, nie werfen. +""" +from __future__ import annotations + +import os +import re +import signal +import sys + +_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)") + + +def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]": + """Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist. + + ``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus + ``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste + der beendeten ``(pid, task_id)`` zurück. + """ + if sys.platform != "linux": + return [] + candidates: "dict[int, str]" = {} + try: + entries = os.listdir("/proc") + except OSError: + return [] + for entry in entries: + if not entry.isdigit(): + continue + try: + with open(f"/proc/{entry}/cmdline", "rb") as fh: + cmd = fh.read().replace(b"\x00", b" ") + except OSError: + continue + m = _TASK_RE.search(cmd) + if m: + candidates[int(entry)] = m.group(1).decode() + if not candidates: + return [] + + task_ids = list(set(candidates.values())) + placeholders = ",".join("?" * len(task_ids)) + running: "set[str]" = set() + try: + with connect_closing() as conn: + running = { + row[0] + for row in conn.execute( + f"SELECT id FROM tasks WHERE id IN ({placeholders}) " + "AND status = 'running'", + task_ids, + ) + } + except Exception: + return [] + + killed: "list[tuple[int, str]]" = [] + for pid, task_id in candidates.items(): + if task_id in running: + continue + # Prozessgruppe zuerst (Worker starten start_new_session=True → eigene + # Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit). + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except OSError: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + continue + killed.append((pid, task_id)) + return killed diff --git a/deploy/hermes-postcheck.sh b/deploy/hermes-postcheck.sh index 7ce9cf5..1f1aeae 100644 --- a/deploy/hermes-postcheck.sh +++ b/deploy/hermes-postcheck.sh @@ -30,6 +30,25 @@ else echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1 fi +# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum --- +# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, --- +# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu --- +# --- angewandt → Gateway neu laden; Exit 1 = ein Patch passt nicht mehr → ROT. --- +PATCH_APPLY="${MC2_SRC:-$HOME/mission-control-v2}/deploy/hermes-patches/apply.py" +if [ -f "$PATCH_APPLY" ]; then + python3 "$PATCH_APPLY" >/dev/null 2>&1; _prc=$? + if [ "$_prc" -eq 10 ]; then + systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true + echo "PASS · Hermes-Runtime-Patches nach Update RE-angewandt (Gateway neu geladen)" + elif [ "$_prc" -eq 0 ]; then + echo "PASS · Hermes-Runtime-Patches unveraendert vorhanden" + else + echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1 + fi +else + echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)" +fi + if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \ "import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \ >/dev/null 2>&1 ); then