""" Health-Wächter der Box (Lucy-Proaktivität, Faden A3). Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0, Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten (services/announce.py → Lucy spricht es) und via notify.sh (Telegram). Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert. Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend (wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0. """ import asyncio import logging import os import time import httpx import psutil from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL from services import announce, llamaswap log = logging.getLogger(__name__) ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0" INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min) REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90")) def _reach(url: str, path: str = "/health") -> bool: try: with httpx.Client(timeout=5.0) as c: return c.get(f"{url}{path}").status_code < 500 except Exception: return False def _check_engine() -> bool: return llamaswap.engine_reachable() def _check_brain() -> bool: """Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL — Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt.""" st = llamaswap.brain_status() if st.get("ready"): return True return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt def _check_disk() -> bool: try: return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT except Exception: return True # kein Messwert ≠ Alarm # name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache). CHECKS: dict[str, tuple] = { "engine": (_check_engine, "Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.", "Die Modell-Engine ist wieder da."), "brain": (_check_brain, "Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.", "Mein Gehirn ist wieder geladen. Alles klar bei mir."), "hermes": (lambda: _reach(HERMES_API_URL), "Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.", "Der Agent-Dienst läuft wieder."), "mem0": (lambda: _reach(MEM0_SERVICE_URL), "Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.", "Mein Gedächtnis ist wieder online."), "voice": (lambda: _reach(VOICE_SERVICE_URL), "Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.", "Der Hör-Dienst läuft wieder."), "disk": (_check_disk, f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.", "Die Platte hat wieder genug Luft."), } # 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__ = ("alert_ts", "alerted", "fails") def __init__(self) -> None: self.fails = 0 # Fehl-Ticks in Folge self.alerted = False # Alarm ist raus, Entwarnung steht aus self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S) _watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS} def _notify_telegram(subject: str, text: str) -> None: """Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag legt der Wächter selbst ab.""" announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)") def _tick() -> None: now = time.time() for name, (check, fail_msg, ok_msg) in CHECKS.items(): w = _watches[name] try: ok = bool(check()) except Exception: ok = False if ok: w.fails = 0 if w.alerted: w.alerted = False announce.add(ok_msg, subject="[Box wieder ok]", source="sentry") _notify_telegram("[Box wieder ok]", ok_msg) continue w.fails += 1 due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S) if due: prefix = "" if not w.alerted else "Immer noch: " w.alerted = True w.alert_ts = now announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry") _notify_telegram("[Box-Problem]", prefix + fail_msg) log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails) async def sentry_loop() -> None: """Endlos-Schleife (Hintergrund-Task im MC2-Lifespan).""" await asyncio.sleep(START_DELAY) log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER) while True: try: await asyncio.to_thread(_tick) except Exception: log.debug("sentry: Tick fehlgeschlagen", exc_info=True) await asyncio.sleep(INTERVAL)