Ampel / ampel (push) Failing after 24s
Die Vorschlags-Inbox (routers/services auftragsbuch, AuftragsbuchView, /auftraege, Cockpit- Kachel + Handlungsbedarf-Eintrag, deploy/auftrag-annehmen.sh, lucy-annahme.sh, bagatell- annahme.sh, docs/AUFTRAGSBUCH.md) war seit 02.08.2026 ungenutzt: Kanban-Tools seit 21.08. aus, v3-Umbau lief ueber Branch -> Ampel -> Merge -> deploy.sh, PC-Executor-IP in Box und Config veraltet. Doku behauptete den Karten-Weg trotzdem als Standard. Jetzt: - Cockpit: Kachel "Ideen" (offen/haengend) statt "Auftragsbuch"; Handlungsbedarf zeigt haengende Ideen und springt in die Ideen-Ansicht. - Guide/Schaubild: Kreislauf Idee -> Ideen-Queue -> IDE am PC -> Ampel -> DEIN Merge -> Live. - events.py: kein Auftragsbuch-Fingerprint mehr; config.py: PC_EXECUTOR_URL-Default auf .22. - Skills/Skripte: selbst-inventur ohne Karten-Abschnitt, morning-report nennt offene Branches, konzept-fliessband verweist auf die Ideen-Ansicht. - Doku: STACK "Deploy & Pipeline" = Branch -> Ampel -> User-Merge -> deploy.sh (einziger Weg), GRENZEN/ARBEITSWEISE/FALLEN/README/BEDIENUNG/RUNBOOK/GEMINI_BRIEFING/gitea-workflow angepasst, ZIELBILD Punkt 9, OFFENE-FAEDEN + RAPHAEL.md: erledigt. - Bewusst geblieben: werkstatt-SOUL/projektstart-SOUL (Werkstatt-Persona, eigener Faden), Chronik-Kategorie "auftragsbuch" fuer historische Eintraege. Auf der Box bereits erledigt (Hand): mc2-bagatell.timer deaktiviert + Units archiviert, PC-Executor-URL in ~/.hermes/config.yaml auf .22. Baut auf wartung/lucy-stimme-proxy-raphael auf (Proxy /api/lucy/stimme/*). Gates: eslint 0 Fehler, vitest 59/59, tsc + vite build gruen, frontend/dist committet, py_compile gruen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
165 lines
7.1 KiB
Python
165 lines
7.1 KiB
Python
"""
|
||
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
|
||
|
||
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway,
|
||
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_KEY, HERMES_API_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", headers: dict[str, str] | None = None) -> bool:
|
||
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
|
||
"laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert.
|
||
|
||
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
|
||
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
|
||
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht —
|
||
und er haette einen echten Auth-Fehler unter sich begraben."""
|
||
try:
|
||
with httpx.Client(timeout=5.0) as c:
|
||
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _hermes_kopf() -> dict[str, str]:
|
||
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
|
||
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||
|
||
|
||
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, "/v1/models", _hermes_kopf()),
|
||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
||
"Der Agent-Dienst läuft wieder."),
|
||
"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 und Briefkasten 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)
|