umbau(boxwart): Backend auf Box-Wart umgestellt – Waechter, Modell-Nutzung, Zeitplan
MC2 wird Updater, Waechter und Modell-Radar (Konzept „MC2 als Box-Wart“, 23.09.2026). - Neuer Waechter (services/waechter.py) loest sentry.py ab: Dienste, Timer-Laeufe, Hermes-Jobs samt Werkzeugfehlern, Kern-HTTP-Proben, Platte. Abgestuerzte Dienste startet er selbst neu (max. 2/h), rote Hinweise gehen an Telegram und Lucy. Laeuft im mc2-steward; waehrend eines Updates haelt er still. - Neue Schnittstellen (routers/boxwart.py): /api/start, /api/hinweise (+ Aktionen), /api/modelle/nutzung, /api/zeitplan. - Modell-Nutzung aus dem llama-swap-Journal (wer fragt wie oft, 24 h je Stunde). - Entfernt: Ideen, Wissen, Chronik, Skills, Verbinden, Konsolen-Proxy, /api/events; Lucys Werkzeug idee_notieren; box_status nennt jetzt die offenen Hinweise. - Behoben: projekte-sync ueberspringt leere Gitea-Repos (lief seit 07.09. stuendlich rot); Motor-Version kam aus dem verwaisten /opt/llamacpp statt /opt/llamacpp-vulkan. - Erste Backend-Tests (8) fuer Waechter und Modell-Nutzung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3669a6e7dc
commit
12dadfe6ef
@@ -70,9 +70,11 @@ def list_backups() -> list[dict]:
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||
st = p.stat()
|
||||
out.append({
|
||||
"snapshot": _ts(p),
|
||||
"file": p.name,
|
||||
"size_mb": round(p.stat().st_size / 1_000_000, 2),
|
||||
"size_mb": round(st.st_size / 1_000_000, 2),
|
||||
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""
|
||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
||||
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
|
||||
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1).
|
||||
|
||||
Gedächtnis ist hier bewusst KEINE eigene Leitung mehr: bis August 2026 gab es dafür einen
|
||||
Memory-MCP-Server gegen MC2s /api/memory; seit dessen Ablösung (docs/wissen/VERDIKTE.md,
|
||||
07.08.2026) führt Hermes sein Gedächtnis
|
||||
selbst — es hängt am Agenten, nicht am Gateway. check_health() prüft es weiterhin (Leitung 2),
|
||||
es ist nur nichts mehr zu konfigurieren.
|
||||
|
||||
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
|
||||
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, PORT, V1_UPSTREAM
|
||||
|
||||
DEFAULT_HOST = "192.168.178.151"
|
||||
|
||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
||||
PRIMARY_MODEL = "coder"
|
||||
|
||||
|
||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
||||
"prompt_cache_key": False, "chat_completions": True,
|
||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
||||
|
||||
|
||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
||||
IDE_MODELS = [
|
||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
||||
]
|
||||
|
||||
|
||||
def _gw(host: str) -> str:
|
||||
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
|
||||
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
|
||||
return f"http://{host}:{PORT}/v1"
|
||||
|
||||
|
||||
def build_snippets(host: str = DEFAULT_HOST) -> dict:
|
||||
gw = _gw(host)
|
||||
mc_url = f"http://{host}:{PORT}"
|
||||
|
||||
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
|
||||
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
|
||||
kilo = json.dumps({
|
||||
"apiProvider": "openai",
|
||||
"openAiBaseUrl": gw,
|
||||
"openAiApiKey": "local",
|
||||
"openAiModelId": PRIMARY_MODEL,
|
||||
}, indent=2)
|
||||
|
||||
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
||||
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
||||
hermes_desktop = (
|
||||
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
||||
f"#\n"\
|
||||
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
||||
f"# (MC2-Proxy auf dem Gateway)\n"\
|
||||
f"#\n"\
|
||||
f"# 2. Session-Token: auf der Box liegen in\n"\
|
||||
f"# ~/.hermes/desktop-gateway-token\n"\
|
||||
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
||||
f"#\n"\
|
||||
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
||||
f"#\n"\
|
||||
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
||||
)
|
||||
|
||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
||||
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
|
||||
claude_code = (
|
||||
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
|
||||
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
||||
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
|
||||
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
|
||||
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
||||
f"\n"
|
||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
||||
f'export ANTHROPIC_MODEL="coder"'
|
||||
)
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"gateway_url": gw,
|
||||
"mc_url": mc_url,
|
||||
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
|
||||
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
|
||||
# Claude Code (starke Sessions).
|
||||
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
|
||||
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
|
||||
"tools": {
|
||||
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
||||
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
||||
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway."},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check_health() -> dict:
|
||||
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
||||
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
|
||||
sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway),
|
||||
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
|
||||
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
|
||||
if r.status_code == 200:
|
||||
n = len(r.json().get("data", []))
|
||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||
else:
|
||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
# Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
|
||||
r = c.get("http://127.0.0.1:8642/health")
|
||||
if r.status_code in (200, 404, 401):
|
||||
memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
|
||||
else:
|
||||
memory = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,11 @@ from services import catalog, discover, jobengine, llamaswap, system
|
||||
|
||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||
SYSTEM_SERVICES = {"llama-swap"}
|
||||
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
|
||||
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
||||
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||
"hermes-gateway", "hermes-builtin-ui", "voice-service"}
|
||||
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||
"projekte-sync", "mc2-backup"}
|
||||
|
||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
|
||||
|
||||
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
|
||||
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
|
||||
zuletzt geladen wurde ("Health check passed").
|
||||
|
||||
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
|
||||
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
|
||||
bevor jemand nachsieht (gemessen am 23.09.2026).
|
||||
|
||||
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
|
||||
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
CACHE_S = 600
|
||||
TAGE = 7
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
|
||||
_STANDARD_NAMEN = {
|
||||
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
||||
"::1": "Lucy und OpenChamber über MC2",
|
||||
"192.168.178.28": "NerdQuiz auf Arcane",
|
||||
"192.168.178.22": "NerdQuiz-Tests vom PC",
|
||||
}
|
||||
|
||||
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
|
||||
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"ts": 0.0, "daten": None}
|
||||
|
||||
|
||||
def _namen() -> dict[str, str]:
|
||||
namen = dict(_STANDARD_NAMEN)
|
||||
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
|
||||
if "=" in paar:
|
||||
ip, name = paar.split("=", 1)
|
||||
namen[ip.strip()] = name.strip()
|
||||
return namen
|
||||
|
||||
|
||||
def _journal(seit: str) -> list[str]:
|
||||
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
|
||||
am Tag. Ohne systemd (Windows-Dev) leer."""
|
||||
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
|
||||
" | grep -E 'POST /v1/|Health check passed'")
|
||||
try:
|
||||
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
|
||||
except Exception:
|
||||
return []
|
||||
return out.stdout.splitlines()
|
||||
|
||||
|
||||
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
|
||||
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
|
||||
je_absender: Counter = Counter()
|
||||
je_tag: dict[str, Counter] = defaultdict(Counter)
|
||||
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
|
||||
geladen: dict[str, str] = {}
|
||||
grenze_24h = jetzt - timedelta(hours=24)
|
||||
for z in zeilen:
|
||||
try:
|
||||
ts = datetime.fromisoformat(z.split(" ", 1)[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=LOCAL_TZ)
|
||||
if (m := _GELADEN.search(z)):
|
||||
geladen[m.group(1)] = ts.isoformat()
|
||||
continue
|
||||
m = _ANFRAGE.search(z)
|
||||
if not m or not m.group(2).startswith("/v1/chat/completions"):
|
||||
continue
|
||||
wer = namen.get(m.group(1), m.group(1))
|
||||
je_absender[wer] += 1
|
||||
je_tag[ts.date().isoformat()][wer] += 1
|
||||
if ts >= grenze_24h:
|
||||
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
|
||||
return {
|
||||
"tage": TAGE,
|
||||
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
|
||||
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
|
||||
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
|
||||
for h in range(24)],
|
||||
"zuletzt_geladen": geladen,
|
||||
}
|
||||
|
||||
|
||||
def nutzung() -> dict:
|
||||
"""Gecachte Auswertung der letzten TAGE Tage (10 min)."""
|
||||
with _lock:
|
||||
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
|
||||
return _cache["daten"]
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
daten = werte_aus(_journal(seit), jetzt, _namen())
|
||||
with _lock:
|
||||
_cache.update(ts=time.time(), daten=daten)
|
||||
return daten
|
||||
@@ -1,164 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
+20
-27
@@ -8,6 +8,7 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -127,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
|
||||
|
||||
|
||||
def get_engine_version() -> dict:
|
||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
||||
git_info = get_git_info(engine_path)
|
||||
if git_info:
|
||||
return {**git_info, "type": "git"}
|
||||
|
||||
candidates = [
|
||||
os.path.join(engine_path, "llama-server"),
|
||||
os.path.join(engine_path, "bin", "llama-server"),
|
||||
"/usr/local/bin/llama-server",
|
||||
"/usr/bin/llama-server",
|
||||
"llama-server"
|
||||
]
|
||||
|
||||
for binary in candidates:
|
||||
if binary != "llama-server" and not os.path.exists(binary):
|
||||
continue
|
||||
try:
|
||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
|
||||
output = (res.stdout or "").strip() or (res.stderr or "").strip()
|
||||
if output:
|
||||
lines = output.splitlines()
|
||||
ver = lines[0] if lines else "unknown"
|
||||
return {"version_text": ver, "type": "binary"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"type": "unknown"}
|
||||
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
|
||||
|
||||
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
|
||||
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
|
||||
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
|
||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
||||
binary = os.path.join(engine_path, "llama-server")
|
||||
if not os.path.exists(binary):
|
||||
return {"type": "unknown"}
|
||||
try:
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
|
||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
|
||||
text = (res.stdout or "") + (res.stderr or "")
|
||||
except Exception:
|
||||
return {"type": "unknown"}
|
||||
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
|
||||
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
|
||||
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
|
||||
return {"type": "binary", "version_text": zeile or "unbekannt"}
|
||||
|
||||
|
||||
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab.
|
||||
|
||||
Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09.
|
||||
stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie
|
||||
gesehen. Dieser Wächter schaut deshalb breiter hin:
|
||||
|
||||
• Dienste systemd-Zustand der Kern-Dienste (System + User)
|
||||
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
|
||||
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
|
||||
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
|
||||
• Platte Füllstand des Modell-Laufwerks
|
||||
|
||||
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
|
||||
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
|
||||
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
|
||||
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
|
||||
zusätzlich an Telegram und in Lucys Briefkasten.
|
||||
|
||||
Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE
|
||||
Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen
|
||||
selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis.
|
||||
|
||||
Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update)
|
||||
hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort
|
||||
zum Ablauf.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
|
||||
|
||||
from services import announce, llamaswap, maintenance
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
|
||||
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
|
||||
INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten
|
||||
START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen
|
||||
FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis
|
||||
REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h
|
||||
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
|
||||
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
|
||||
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
|
||||
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json")))
|
||||
VERLAUF_MAX = 200
|
||||
|
||||
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
|
||||
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
|
||||
MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
||||
GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
||||
|
||||
# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb.
|
||||
DIENSTE: dict[str, tuple[bool, bool, str]] = {
|
||||
"llama-swap": (True, True, "Motor (llama-swap)"),
|
||||
"hermes-gateway": (False, True, "Hermes"),
|
||||
"mc2-gateway": (False, True, "Modell-Gateway"),
|
||||
"mission-control-2": (False, True, "MC2"),
|
||||
"voice-service": (False, False, "Hör-Dienst"),
|
||||
"lucy-stimme": (False, False, "Lucys Stimme"),
|
||||
"hermes-builtin-ui": (False, False, "Hermes-Dashboard"),
|
||||
}
|
||||
|
||||
# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?)
|
||||
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||
"projekte-sync": ("Projekte-Abgleich", False),
|
||||
"mc2-backup": ("Sicherung", True),
|
||||
}
|
||||
|
||||
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||
HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db"
|
||||
HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log"
|
||||
|
||||
# Prozesse, an denen ein laufendes Update zu erkennen ist.
|
||||
_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Befund:
|
||||
"""Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis."""
|
||||
id: str
|
||||
stufe: str # "rot" | "gelb"
|
||||
titel: str
|
||||
text: str
|
||||
quelle: str
|
||||
aktionen: list[dict] = field(default_factory=list)
|
||||
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
|
||||
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
|
||||
|
||||
|
||||
def _aktion(aid: str, label: str, **extra: str) -> dict:
|
||||
return {"id": aid, "label": label, **extra}
|
||||
|
||||
|
||||
# --- Rohdaten -------------------------------------------------------------------
|
||||
|
||||
def _systemctl_show(name: str, system: bool) -> dict[str, str]:
|
||||
"""Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos)."""
|
||||
cmd = ["systemctl"] if system else ["systemctl", "--user"]
|
||||
cmd += ["show", name, "-p",
|
||||
"LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp"]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return {}
|
||||
return dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
|
||||
|
||||
|
||||
def _journal_fehlerzeile(name: str, system: bool = False) -> str:
|
||||
"""Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit."""
|
||||
cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"]
|
||||
try:
|
||||
zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
||||
except Exception:
|
||||
return ""
|
||||
return waehle_fehlerzeile(zeilen)
|
||||
|
||||
|
||||
def waehle_fehlerzeile(zeilen: list[str]) -> str:
|
||||
"""Letzte Zeile, die nach Fehler aussieht (projekte-sync markiert Fehler mit „!“)."""
|
||||
muster = re.compile(r"(^\s*!|fehlgeschlagen|ABBRUCH|error|failed|fatal)", re.IGNORECASE)
|
||||
for zeile in reversed(zeilen):
|
||||
if muster.search(zeile):
|
||||
# Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden
|
||||
return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300]
|
||||
return ""
|
||||
|
||||
|
||||
def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool:
|
||||
"""Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört."""
|
||||
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 _update_laeuft() -> bool:
|
||||
"""Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund."""
|
||||
try:
|
||||
for p in psutil.process_iter(["cmdline"]):
|
||||
cmd = " ".join(p.info.get("cmdline") or [])
|
||||
if cmd and _UPDATE_PROZESSE.search(cmd):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# --- Prüfungen ------------------------------------------------------------------
|
||||
|
||||
def pruefe_dienste() -> list[Befund]:
|
||||
befunde: list[Befund] = []
|
||||
for name, (system, wichtig, anzeige) in DIENSTE.items():
|
||||
z = _systemctl_show(name, system)
|
||||
if not z or z.get("LoadState") in ("not-found", ""):
|
||||
continue # nicht installiert oder kein systemd (Dev)
|
||||
zustand = z.get("ActiveState", "")
|
||||
if zustand in ("active", "activating", "reloading", "deactivating"):
|
||||
continue
|
||||
stufe = "rot" if wichtig else "gelb"
|
||||
aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)]
|
||||
if zustand == "failed":
|
||||
befunde.append(Befund(
|
||||
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt",
|
||||
text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.",
|
||||
quelle=name, aktionen=aktionen, auto="neustart"))
|
||||
else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten
|
||||
befunde.append(Befund(
|
||||
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt",
|
||||
text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.",
|
||||
quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]]))
|
||||
return befunde
|
||||
|
||||
|
||||
def pruefe_timer_dienste() -> list[Befund]:
|
||||
befunde: list[Befund] = []
|
||||
for name, (anzeige, wichtig) in TIMER_DIENSTE.items():
|
||||
z = _systemctl_show(name, False)
|
||||
if not z or z.get("LoadState") in ("not-found", ""):
|
||||
continue
|
||||
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||
continue
|
||||
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||
befunde.append(Befund(
|
||||
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||
titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name,
|
||||
aktionen=[_aktion("protokoll", "Protokoll", dienst=name),
|
||||
_aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)],
|
||||
sofort=True))
|
||||
return befunde
|
||||
|
||||
|
||||
def _hermes_jobs() -> list[dict]:
|
||||
try:
|
||||
daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten
|
||||
if isinstance(jobs, dict):
|
||||
jobs = list(jobs.values())
|
||||
return [j for j in jobs if isinstance(j, dict)]
|
||||
|
||||
|
||||
def _letzter_lauf(job_id: str) -> dict | None:
|
||||
"""Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet)."""
|
||||
if not HERMES_EXEC_DB.exists():
|
||||
return None
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
row = con.execute(
|
||||
"select status, started_at, finished_at, error from executions "
|
||||
"where job_id=? order by started_at desc limit 1", (job_id,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
|
||||
|
||||
|
||||
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
|
||||
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
|
||||
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
|
||||
try:
|
||||
tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d")
|
||||
except (TypeError, ValueError):
|
||||
return 0, ""
|
||||
marke = f"[cron_{job_id}_{tag}_"
|
||||
treffer = [z for z in zeilen if marke in z and "returned error" in z]
|
||||
if not treffer:
|
||||
return 0, ""
|
||||
m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0])
|
||||
beispiel = ""
|
||||
if m:
|
||||
beispiel = f"{m.group(1)}: {m.group(2)}"
|
||||
if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))):
|
||||
beispiel = f"{m.group(1)}: {fm.group(1)}"
|
||||
return len(treffer), beispiel[:220]
|
||||
|
||||
|
||||
def _errors_log_ende(max_bytes: int = 400_000) -> list[str]:
|
||||
try:
|
||||
with HERMES_ERRORS_LOG.open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.seek(max(0, f.tell() - max_bytes))
|
||||
return f.read().decode("utf-8", "replace").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def pruefe_hermes_jobs() -> list[Befund]:
|
||||
befunde: list[Befund] = []
|
||||
jobs = _hermes_jobs()
|
||||
if not jobs:
|
||||
return befunde
|
||||
log_zeilen = _errors_log_ende()
|
||||
jetzt = time.time()
|
||||
for job in jobs:
|
||||
if not job.get("enabled", True):
|
||||
continue
|
||||
jid, name = str(job.get("id", "")), str(job.get("name", "Job"))
|
||||
wichtig = "update" in name.lower()
|
||||
status = job.get("last_status")
|
||||
if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0:
|
||||
befunde.append(Befund(
|
||||
id=f"job:{jid}", stufe="rot" if wichtig else "gelb",
|
||||
titel=f"Job „{name}“ ist fehlgeschlagen",
|
||||
text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300],
|
||||
quelle=f"hermes-cron:{jid}",
|
||||
aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid),
|
||||
_aktion("protokoll", "Protokoll", job=jid)],
|
||||
sofort=True))
|
||||
continue
|
||||
if job.get("no_agent"):
|
||||
continue # Skript-Jobs haben keine Werkzeuge
|
||||
lauf = _letzter_lauf(jid)
|
||||
if not lauf or not lauf.get("started_at"):
|
||||
continue
|
||||
try:
|
||||
alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
if alter > 26 * 3600:
|
||||
continue # nur der aktuelle Lauf zählt
|
||||
anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"])
|
||||
if anzahl:
|
||||
befunde.append(Befund(
|
||||
id=f"job:{jid}:werkzeug", stufe="gelb",
|
||||
titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf",
|
||||
text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.",
|
||||
quelle=f"hermes-cron:{jid}",
|
||||
aktionen=[_aktion("protokoll", "Protokoll", job=jid)],
|
||||
sofort=True))
|
||||
return befunde
|
||||
|
||||
|
||||
def _hermes_kopf() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||||
|
||||
|
||||
def pruefe_kern() -> list[Befund]:
|
||||
"""HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)"""
|
||||
proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok)
|
||||
engine_ok = llamaswap.engine_reachable()
|
||||
proben.append(("kern:engine", "Der Motor antwortet nicht",
|
||||
"llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok))
|
||||
if engine_ok:
|
||||
st = llamaswap.brain_status()
|
||||
hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models())
|
||||
proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen",
|
||||
"Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_ok))
|
||||
proben.append(("kern:hermes", "Hermes antwortet nicht",
|
||||
"Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.",
|
||||
_reach(HERMES_API_URL, "/v1/models", _hermes_kopf())))
|
||||
proben.append(("kern:hoeren", "Der Hör-Dienst antwortet nicht",
|
||||
"Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health")))
|
||||
if WATCH_MC2:
|
||||
proben.append(("kern:mc2", "MC2 antwortet nicht",
|
||||
"Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health")))
|
||||
proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht",
|
||||
"Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health")))
|
||||
return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
|
||||
for (i, t, x, ok) in proben if not ok]
|
||||
|
||||
|
||||
def pruefe_platte() -> list[Befund]:
|
||||
try:
|
||||
pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent
|
||||
except Exception:
|
||||
return []
|
||||
if pct >= DISK_ROT_PCT:
|
||||
stufe = "rot"
|
||||
elif pct >= DISK_GELB_PCT:
|
||||
stufe = "gelb"
|
||||
else:
|
||||
return []
|
||||
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
|
||||
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
|
||||
quelle="platte", sofort=True)]
|
||||
|
||||
|
||||
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte)
|
||||
|
||||
|
||||
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
||||
|
||||
_lock = threading.Lock()
|
||||
_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}
|
||||
|
||||
|
||||
def _lade() -> None:
|
||||
global _stand
|
||||
try:
|
||||
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(daten.get("hinweise"), dict):
|
||||
_stand = {**_stand, **daten}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _speichere() -> None:
|
||||
try:
|
||||
tmp = STORE_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(STORE_PATH)
|
||||
except OSError:
|
||||
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
|
||||
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
|
||||
del _stand["verlauf"][:-VERLAUF_MAX]
|
||||
|
||||
|
||||
def _telegram(betreff: str, text: str) -> None:
|
||||
announce.add(text, subject=betreff, source="waechter")
|
||||
announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)")
|
||||
|
||||
|
||||
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
|
||||
versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600]
|
||||
_stand["auto"][schluessel] = versuche
|
||||
return len(versuche) < AUTO_MAX_PRO_STUNDE
|
||||
|
||||
|
||||
def _selbst_beheben(b: Befund, jetzt: float) -> None:
|
||||
if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt):
|
||||
return
|
||||
ergebnis = maintenance.restart_service(b.quelle)
|
||||
_stand["auto"][b.id].append(jetzt)
|
||||
ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0))
|
||||
_verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)"))
|
||||
log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen")
|
||||
|
||||
|
||||
def takt() -> None:
|
||||
"""Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben."""
|
||||
jetzt = time.time()
|
||||
update = _update_laeuft()
|
||||
befunde: list[Befund] = []
|
||||
for pruefung in PRUEFUNGEN:
|
||||
if update and pruefung in (pruefe_dienste, pruefe_kern):
|
||||
continue # während eines Updates sind Neustarts normal
|
||||
try:
|
||||
befunde += pruefung()
|
||||
except Exception:
|
||||
log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
|
||||
|
||||
with _lock:
|
||||
hinweise: dict = _stand["hinweise"]
|
||||
kandidaten: dict = _stand["kandidaten"]
|
||||
aktuell = {b.id for b in befunde}
|
||||
|
||||
for b in befunde:
|
||||
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
|
||||
k["takte"] += 1
|
||||
if not update and b.auto:
|
||||
_selbst_beheben(b, jetzt)
|
||||
if not (b.sofort or k["takte"] >= FAIL_AFTER):
|
||||
continue
|
||||
h = hinweise.get(b.id)
|
||||
neu = h is None
|
||||
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
|
||||
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
|
||||
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
|
||||
hinweise[b.id] = h
|
||||
if neu:
|
||||
_verlauf("neu", b.id, b.titel)
|
||||
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
|
||||
vorsatz = "" if neu else "Immer noch: "
|
||||
_telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}")
|
||||
h["gemeldet"] = jetzt
|
||||
|
||||
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
||||
# Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft).
|
||||
for hid in list(hinweise):
|
||||
if hid in aktuell:
|
||||
continue
|
||||
if update and hid.startswith(("dienst:", "kern:")):
|
||||
continue
|
||||
h = hinweise.pop(hid)
|
||||
_verlauf("erledigt", hid, h.get("titel", hid))
|
||||
if h.get("stufe") == "rot" and h.get("gemeldet"):
|
||||
_telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.")
|
||||
for kid in list(kandidaten):
|
||||
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
|
||||
kandidaten.pop(kid)
|
||||
|
||||
_stand["stand"] = jetzt
|
||||
_stand["update_laeuft"] = update
|
||||
_speichere()
|
||||
|
||||
|
||||
async def waechter_loop() -> None:
|
||||
"""Endlos-Schleife im mc2-steward."""
|
||||
_lade()
|
||||
await asyncio.sleep(START_DELAY)
|
||||
log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)",
|
||||
INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(takt)
|
||||
except Exception:
|
||||
log.warning("waechter: Takt fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
|
||||
|
||||
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
|
||||
|
||||
def lese_stand() -> dict:
|
||||
"""Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf."""
|
||||
try:
|
||||
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
daten = {}
|
||||
hinweise = sorted((daten.get("hinweise") or {}).values(),
|
||||
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
|
||||
for h in hinweise:
|
||||
h.pop("gemeldet", None)
|
||||
return {
|
||||
"hinweise": hinweise,
|
||||
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
|
||||
"stand": daten.get("stand"),
|
||||
"update_laeuft": bool(daten.get("update_laeuft")),
|
||||
"aktiv": bool(daten),
|
||||
}
|
||||
|
||||
|
||||
def _job_protokoll(job_id: str) -> dict:
|
||||
marke = f"[cron_{job_id}_"
|
||||
zeilen = [z for z in _errors_log_ende() if marke in z][-60:]
|
||||
return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."}
|
||||
|
||||
|
||||
def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||
"""Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet."""
|
||||
stand = lese_stand()
|
||||
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
|
||||
if hinweis is None:
|
||||
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
|
||||
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
|
||||
if aktion is None:
|
||||
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
|
||||
if aktion_id == "neustart":
|
||||
return maintenance.restart_service(aktion["dienst"])
|
||||
if aktion_id == "protokoll":
|
||||
if aktion.get("job"):
|
||||
return _job_protokoll(aktion["job"])
|
||||
return maintenance.logs(aktion["dienst"], lines=120)
|
||||
if aktion_id == "job-wiederholen":
|
||||
try:
|
||||
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return {"ok": r.returncode == 0,
|
||||
"text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"}
|
||||
return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
|
||||
|
||||
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
|
||||
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
|
||||
Anfragen). Alles nur lesend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services import modell_nutzung, waechter
|
||||
|
||||
TIMER = {"mc2-backup.timer": "Sicherung"}
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
|
||||
|
||||
def _parse_iso(wert) -> datetime | None:
|
||||
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
|
||||
if not wert:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)
|
||||
|
||||
|
||||
def _timer() -> list[dict]:
|
||||
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
|
||||
try:
|
||||
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
daten = json.loads(out or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
ergebnis = []
|
||||
for t in daten if isinstance(daten, list) else []:
|
||||
name = TIMER.get(t.get("unit", ""))
|
||||
if not name:
|
||||
continue
|
||||
def _us(v):
|
||||
try:
|
||||
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
|
||||
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
|
||||
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
|
||||
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
|
||||
if not anfragen:
|
||||
return None
|
||||
erste = next((s["stunde"] for s in stunden
|
||||
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
|
||||
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
|
||||
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
|
||||
"status": "erledigt"}
|
||||
|
||||
|
||||
def flugplan() -> dict:
|
||||
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
gelaufen: list[dict] = []
|
||||
geplant: list[dict] = []
|
||||
|
||||
for job in waechter._hermes_jobs():
|
||||
if not job.get("enabled", True):
|
||||
continue
|
||||
name = str(job.get("name", "Job"))
|
||||
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
|
||||
if letzter and letzter >= heute_start:
|
||||
ok = job.get("last_status") in (None, "ok", "success")
|
||||
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
|
||||
"status": "erledigt" if ok else "fehler"})
|
||||
if naechster and naechster > jetzt:
|
||||
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
|
||||
|
||||
for t in _timer():
|
||||
if t["letzter"] and t["letzter"] >= heute_start:
|
||||
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
|
||||
if t["naechster"] and t["naechster"] > jetzt:
|
||||
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
|
||||
|
||||
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
|
||||
gelaufen.append(nq)
|
||||
|
||||
def _aus(eintraege: list[dict]) -> list[dict]:
|
||||
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
|
||||
|
||||
grenze = jetzt + timedelta(days=8)
|
||||
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
|
||||
Reference in New Issue
Block a user