From a9c0239f8a5a2b7becb429444b66cd60406bf0d6 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Wed, 15 Jul 2026 14:15:36 +0200 Subject: [PATCH] =?UTF-8?q?UMBAU=20v3=20P2:=20Steward=20=E2=80=94=20Waecht?= =?UTF-8?q?er-Loops=20als=20eigener=20Prozess=20mc2-steward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-Warm, Health-Sentry und Mem0-Dedupe laufen als eigener Dienst (backend/steward.py, Restart=always) statt im Steuerpult-Lifespan: MC2-Neustarts nehmen den Waechtern nicht mehr Timing/Flanken-Gedaechtnis, und der Sentry ueberwacht erstmals MC2 SELBST + mc2-gateway (Telegram funktioniert auch bei totem Steuerpult; Briefkasten-Abgabe per HTTP via MC_ANNOUNCE_HTTP, Store bleibt exklusiv beim MC2-Prozess). Warm-Nudge nach Config-Aenderung via mtime-Watch (5 s) statt In-Process-Signal. Reiner Konfig-Split: MC2-Unit setzt die drei ENABLED-Schalter auf 0, Zeilen entfernen = Rollback. reminders_loop bleibt bewusst in MC2 (teilt Datei+CRUD mit /api/reminders, Zwei-Schreiber-Risiko). Stellt ausserdem den beim Karten-Neuaufbau (ccc9a25) verlorenen stack-postcheck-Block fuer mc2-gateway wieder her (+ Steward-Check 4c). Baut auf feature/von-allein-und-gateway-p1 auf; Annahme schliesst P1 ein. Co-Authored-By: Claude Fable 5 --- backend/services/announce.py | 24 +++++++++ backend/services/sentry.py | 14 ++++++ backend/steward.py | 80 ++++++++++++++++++++++++++++++ deploy/deploy.sh | 13 +++++ deploy/mc2-steward.service | 27 ++++++++++ deploy/mission-control-2.service | 7 +++ deploy/stack-postcheck.sh | 20 ++++++++ docs/wissen/UMBAUPLAN-ABLOESUNG.md | 24 +++++++-- 8 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 backend/steward.py create mode 100644 deploy/mc2-steward.service diff --git a/backend/services/announce.py b/backend/services/announce.py index 8e7f657..cbb2261 100644 --- a/backend/services/announce.py +++ b/backend/services/announce.py @@ -19,6 +19,8 @@ import threading import time from pathlib import Path +import httpx + from config import MODELS_DIR log = logging.getLogger(__name__) @@ -28,6 +30,12 @@ NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "noti STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json"))) MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200")) +# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses +# (mc2-steward), darf er den Store NICHT direkt schreiben — Datei + In-Memory-Cache +# gehören exklusiv dem Steuerpult. Gesetzt (Unit-Env, z. B. http://127.0.0.1:9001) +# liefert add() die Meldung stattdessen per HTTP am /api/voice/announce-Endpunkt ab. +ANNOUNCE_HTTP = os.environ.get("MC_ANNOUNCE_HTTP", "").rstrip("/") + _lock = threading.Lock() _state: dict | None = None # {"next_id": int, "items": [...]} @@ -58,6 +66,22 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal" text = (text or "").strip() if not text: raise ValueError("Leere Meldung.") + if ANNOUNCE_HTTP: + # Fremdprozess-Modus (mc2-steward): per HTTP beim Steuerpult abliefern. + payload = {"text": text[:4000], "subject": (subject or "").strip()[:120], + "source": (source or "").strip()[:60], "priority": priority} + try: + with httpx.Client(timeout=5.0) as c: + r = c.post(f"{ANNOUNCE_HTTP}/api/voice/announce", json=payload) + if r.status_code == 200: + return (r.json() or {}).get("item") or payload + except Exception: + pass + # MC2 down/Endpunkt weg: Alarm ist nicht verloren — der Telegram-Direktweg des + # Aufrufers (notify.sh) läuft separat; hier bleibt nur der Log-Nachweis. + log.warning("announce: HTTP-Abgabe an %s fehlgeschlagen — nur im Log: [%s] %.120s", + ANNOUNCE_HTTP, subject or "-", text) + return payload with _lock: state = _load() item = { diff --git a/backend/services/sentry.py b/backend/services/sentry.py index 50ff2cf..4b461c8 100644 --- a/backend/services/sentry.py +++ b/backend/services/sentry.py @@ -85,6 +85,20 @@ CHECKS: dict[str, tuple] = { } +# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward), +# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei +# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit). +if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1": + _MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001") + _GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010") + CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"), + "Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.", + "Das Steuerpult ist wieder da.") + CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"), + "Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.", + "Der Modell-Gateway läuft wieder.") + + class _Watch: __slots__ = ("fails", "alerted", "alert_ts") diff --git a/backend/steward.py b/backend/steward.py new file mode 100644 index 0000000..b77ab76 --- /dev/null +++ b/backend/steward.py @@ -0,0 +1,80 @@ +""" +MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2). + +Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am +Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss +den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte +sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte +Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always): + + • warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den + In-Process-Nudge aus llamaswap.write_config) + • sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram; + beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway + • memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar) + +BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem +/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im +Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser +Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP). + +Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED) +stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein +Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback. +""" + +import asyncio +import logging +import os + +from config import CONFIG_PATH +from services import memory as memory_svc +from services import sentry, warmer + +logging.basicConfig( + level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", +) +log = logging.getLogger("steward") + +# Wie oft nach einer llama-swap-Config-Änderung geschaut wird (mtime-Watch). Ersetzt +# warmer.nudge() aus dem MC2-Prozess: ein -watch-config-Reload verwirft das Warm-Set, +# wir sehen die Änderung binnen Sekunden statt erst beim nächsten 90s-Tick. +CONFIG_WATCH_S = int(os.environ.get("MC_STEWARD_CONFIG_WATCH_S", "5")) + + +async def config_watch() -> None: + last: float | None = None + while True: + try: + mtime = CONFIG_PATH.stat().st_mtime + except OSError: + mtime = None + if last is not None and mtime is not None and mtime != last: + log.info("steward: %s geändert → Warm-Set-Nudge", CONFIG_PATH) + warmer.nudge() + last = mtime + await asyncio.sleep(CONFIG_WATCH_S) + + +async def main() -> None: + tasks: list[asyncio.Task] = [] + if warmer.ENABLED: + tasks.append(asyncio.create_task(warmer.rewarm_loop())) + tasks.append(asyncio.create_task(config_watch())) + log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)", + warmer.INTERVAL, CONFIG_WATCH_S) + if sentry.ENABLED: + tasks.append(asyncio.create_task(sentry.sentry_loop())) + if memory_svc.AUTO_DEDUPE_ENABLED: + tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) + log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", + memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD) + if not tasks: + log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.") + return + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a446bf9..ae23144 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -60,6 +60,9 @@ cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.ser # MC2-Gateway (UMBAU v3 P1): der /v1-Datenpfad als eigener Prozess (Loopback :9010, # Restart=always) — Lucys Hirn + Nacht-Autonomie überleben damit jeden MC2-Neustart. cp "$SRC/deploy/mc2-gateway.service" "$HOME/.config/systemd/user/mc2-gateway.service" +# MC2-Steward (UMBAU v3 P2): Wächter-Loops (Re-Warm/Sentry/Dedupe) als eigener Dienst — +# überleben MC2-Neustarts und melden erstmals auch ein totes Steuerpult (Telegram). +cp "$SRC/deploy/mc2-steward.service" "$HOME/.config/systemd/user/mc2-steward.service" # Mem0-Sidecar-Unit (nur wenn das venv existiert). [ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service" # Voice-Sidecar-Unit (nur wenn das venv existiert). @@ -175,6 +178,7 @@ fi systemctl --user daemon-reload systemctl --user enable mission-control-2 >/dev/null 2>&1 || true systemctl --user enable mc2-gateway >/dev/null 2>&1 || true +systemctl --user enable mc2-steward >/dev/null 2>&1 || true systemctl --user enable box-console >/dev/null 2>&1 || true systemctl --user enable mem0-service >/dev/null 2>&1 || true systemctl --user enable voice-service >/dev/null 2>&1 || true @@ -198,6 +202,8 @@ systemctl --user restart hermes-builtin-ui 2>/dev/null || true # Gateway VOR dem Steuerpult (re)starten — MC2s /v1-Weiterleiter braucht ihn sofort. # KEIN `|| true`: ohne Gateway ist der LLM-Datenpfad tot, das MUSS den Deploy stoppen. systemctl --user restart mc2-gateway +# Steward (Wächter) — unabhängig vom Steuerpult; ohne ihn wacht niemand über die Box. +systemctl --user restart mc2-steward systemctl --user restart mission-control-2 command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true @@ -212,6 +218,13 @@ for _i in $(seq 1 10); do done curl -sf http://127.0.0.1:9010/gw/health && echo curl -sf http://127.0.0.1:9001/api/health && echo +# Steward hat keinen HTTP-Port — systemd-Zustand ist der Beweis (crasht er sofort, +# ist er hier bereits 'failed' statt 'active'). +if systemctl --user is-active --quiet mc2-steward; then + echo "mc2-steward aktiv (Wächter laufen)" +else + echo "FEHLER: mc2-steward läuft nicht (journalctl --user -u mc2-steward -n 30)"; exit 1 +fi # Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw. # watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist diff --git a/deploy/mc2-steward.service b/deploy/mc2-steward.service new file mode 100644 index 0000000..d5a36e4 --- /dev/null +++ b/deploy/mc2-steward.service @@ -0,0 +1,27 @@ +# systemd-USER-Unit für den MC2-Steward — die Wächter-Loops als eigener Prozess (UMBAU v3 P2). +# Re-Warm-Wächter, Health-Sentry und Mem0-Dedupe überleben damit jeden Steuerpult-Neustart, +# und der Sentry kann erstmals auch das Steuerpult SELBST + den mc2-gateway überwachen +# (MC_SENTRY_WATCH_MC2=1). Meldungen gehen per HTTP in den MC2-Briefkasten (MC_ANNOUNCE_HTTP); +# ist MC2 down, greift weiterhin der Telegram-Direktweg (notify.sh). +# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user. + +[Unit] +Description=MC2 Steward (Wächter: Re-Warm, Health-Sentry, Mem0-Dedupe) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/mission-control-v2/backend +ExecStart=%h/mission-control-v2/backend/.venv/bin/python steward.py +Environment=PYTHONPATH=%h/mission-control-v2 +Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080 +Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml +Environment=MC_MODELS_DIR=/srv/models +Environment=MC_ANNOUNCE_HTTP=http://127.0.0.1:9001 +Environment=MC_SENTRY_WATCH_MC2=1 +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/deploy/mission-control-2.service b/deploy/mission-control-2.service index a9a6521..15c8f90 100644 --- a/deploy/mission-control-2.service +++ b/deploy/mission-control-2.service @@ -25,6 +25,13 @@ Environment=MC_MEMORY_DB=/srv/models/mission-control-memory.db # durchreichen. Diese Zeile entfernen (+ daemon-reload + restart) = Rollback, MC2 # bedient /v1 wieder selbst. Environment=MC_V1_UPSTREAM=http://127.0.0.1:9010 +# Steward-Auszug (UMBAU v3 P2): Die Wächter-Loops (Re-Warm, Health-Sentry, Mem0-Dedupe) +# laufen jetzt im eigenen Dienst mc2-steward.service — hier deshalb AUS. Diese drei +# Zeilen entfernen (+ daemon-reload + restart) = Rollback (Loops wieder im Steuerpult). +# Der reminders_loop bleibt bewusst HIER (teilt Datei+CRUD mit /api/reminders). +Environment=MC_REWARM_ENABLED=0 +Environment=MC_SENTRY_ENABLED=0 +Environment=MC_MEM_DEDUPE_ENABLED=0 # KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den # ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates # liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default diff --git a/deploy/stack-postcheck.sh b/deploy/stack-postcheck.sh index 1d10d4d..1d675e1 100644 --- a/deploy/stack-postcheck.sh +++ b/deploy/stack-postcheck.sh @@ -65,6 +65,26 @@ else echo "FAIL · MC2 /api/health meldet Engine nicht erreichbar"; fail=1 fi +# 4b. MC2-Gateway (eigenständiger /v1-Datenpfad, UMBAU v3 P1) gesund? Nur prüfen, wenn +# die Unit enabled ist — Postchecks können auf Ständen vor dem Gateway-Auszug laufen. +GW_URL="${MC2_GATEWAY_URL:-http://127.0.0.1:9010}" +if systemctl --user is-enabled --quiet mc2-gateway 2>/dev/null; then + if curl -sf -m 8 "$GW_URL/gw/health" 2>/dev/null | grep -qE '"engine_reachable":[[:space:]]*true'; then + echo "PASS · mc2-gateway gesund (Prozess + Engine)" + else + echo "FAIL · mc2-gateway antwortet nicht/Engine weg — LLM-DATENPFAD BETROFFEN"; fail=1 + fi +fi + +# 4c. MC2-Steward (Wächter-Dienst, UMBAU v3 P2) aktiv? (nur wenn Unit enabled) +if systemctl --user is-enabled --quiet mc2-steward 2>/dev/null; then + if systemctl --user is-active --quiet mc2-steward; then + echo "PASS · mc2-steward aktiv (Wächter laufen)" + else + echo "FAIL · mc2-steward NICHT aktiv — Box ohne Wächter"; fail=1 + fi +fi + # 5. Mem0-Sidecar (Gedächtnis) erreichbar? if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then echo "PASS · Mem0-Sidecar erreichbar" diff --git a/docs/wissen/UMBAUPLAN-ABLOESUNG.md b/docs/wissen/UMBAUPLAN-ABLOESUNG.md index 2d16593..bd3d418 100644 --- a/docs/wissen/UMBAUPLAN-ABLOESUNG.md +++ b/docs/wissen/UMBAUPLAN-ABLOESUNG.md @@ -60,7 +60,8 @@ alles Weitere. Recherche-Verdikte dazu (15.07.): FastAPI bleibt (Litestar erwoge Coder-Modell-Wissen gewinnen), React bleibt (Svelte/Solid schneller, aber irrelevant für 1-Nutzer-LAN), SSE ist 2026 der klare Dashboard-Standard (einweg, Auto-Reconnect, ~95 % der Fälle). -- **P1 · Gateway-Auszug** ✅ GEBAUT (Branch `umbau/p1-gateway-auszug`, wartet auf Klick): +- **P1 · Gateway-Auszug** ✅ GEBAUT (Karte `feature/von-allein-und-gateway-p1` @ ccc9a25, + wartet auf Klick — Annahme AKTIVIERT Stufe 1): Der /v1-Datenpfad wird eigener Prozess `mc2-gateway.service` (Loopback **:9010**, `Restart=always`, gleicher Router-Code `routers/gateway_proxy.py`, neuer Einstieg `backend/gateway_app.py`). MC2 :9001/v1 wird dünner Roh-Weiterleiter (`MC_V1_UPSTREAM` in der @@ -71,10 +72,23 @@ Coder-Modell-Wissen gewinnen), React bleibt (Svelte/Solid schneller, aber irrele `127.0.0.1:9001/v1` auf `127.0.0.1:9010/v1` um (Backups, `--revert`, bewusst NICHT im deploy.sh — config.yaml ist Zwei-Schreiber-sensibel). Danach überlebt Lucys Hirn + die ganze Nacht-Autonomie jeden MC2-Neustart. Postcheck prüft den Gateway mit (nur wenn Unit enabled). -- **P2 · Steward** (Karte, nach P1): die 4 Endlos-Loops (warmer, sentry, reminders, Mem0-dedupe) - aus dem Webserver-Lifespan in einen eigenen Mini-Dienst — ein Steuerpult-Neustart darf keinem - Wächter Timing/Gedächtnis nehmen. Danach gibt es genau ZWEI Zeitplan-Systeme mit klaren Rollen: - systemd-Timer (Box-Pflege) + Hermes-Crons (Agenten-Arbeit). + **Nach der Annahme prüfen (Fahrplan):** ① `curl 127.0.0.1:9010/gw/health` (Box) + Dashboard + normal; ② self-smoke Check 4 „Repo-Wächter" grün; ③ Bild-Weiche-E2E durch den NEUEN Prozess: + Bild an `model:"coder"` → Header `x-mc-route-reason: Bild beschrieben (Vision) -> bleibt beim + Coder`; ④ DANN Stufe 2: `bash ~/mission-control-v2/deploy/gateway-cutover.sh` + `hermes doctor` + + Beweis „MC2-Neustart tut Lucy nichts": `systemctl --user restart mission-control-2`, dabei + 1-Token-Call auf `127.0.0.1:9010/v1` → antwortet weiter. Rückweg: `--revert`. +- **P2 · Steward** ✅ GEBAUT (Karte `feature/p2-steward`, baut auf P1-Karte auf — Annahme + schließt P1 mit ein; Reihenfolge P1→P2 ist sauberer): Re-Warm-Wächter, Health-Sentry und + Mem0-Dedupe laufen als eigener Dienst `mc2-steward.service` (`backend/steward.py`, + Restart=always) — unveränderte Loop-Module, reiner Konfig-Split (MC2-Unit setzt die drei + ENABLED-Schalter auf 0; Zeilen entfernen = Rollback). Neu dadurch: Sentry überwacht erstmals + auch MC2 SELBST + den mc2-gateway (`MC_SENTRY_WATCH_MC2=1`) und meldet ein totes Steuerpult + per Telegram (Briefkasten-Abgabe an MC2 läuft per HTTP, `MC_ANNOUNCE_HTTP`); Warm-Nudge nach + Config-Änderung kommt aus einem mtime-Watch (5 s) statt In-Process. **Bewusst NICHT bewegt:** + reminders_loop (teilt Datei+CRUD mit /api/reminders — Zwei-Schreiber-Risiko) bleibt in MC2. + Danach gibt es genau ZWEI Zeitplan-Systeme mit klaren Rollen: systemd-Timer (Box-Pflege) + + Hermes-Crons (Agenten-Arbeit) — plus den Steward als dritten, eigenen Wächter-Prozess. - **P3 · Steuerpult-Modernisierung** (Kartenserie, reine UI/API-Arbeit, berührt nach P1 nie mehr den Datenpfad): (a) EIN SSE-Eventstrom `/api/events` ersetzt die 53 `refetchInterval`-Poller in `queries.ts`; (b) TanStack Router → echte URLs (`/modelle/coder`, `/auftrag/t_…`), Deep-Links,