Files
mission-control-v2/backend/services/sentry.py
T
Hitonabi a9c0239f8a UMBAU v3 P2: Steward — Waechter-Loops als eigener Prozess mc2-steward
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 <noreply@anthropic.com>
2026-07-15 14:15:36 +02:00

156 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, LLAMA_SWAP_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__ = ("fails", "alerted", "alert_ts")
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)