Files
mission-control-v2/backend/services/waechter.py
T
HitonabiandClaude Opus 5.5 c08a144006 homelab: Wächter und Geräteliste sehen volle Gastplatten (AdGuard stand bei 100 %)
Der Ausfuehrer schickt die Belegung der rootfs laufender Container mit; ab 85 % gelb, ab 95 % rot, in der
Geraeteliste als eigene Spalte und auf der Lampe des Geraets. Anlass: AdGuard (2 GB) war voll, das
Abfrageprotokoll schrieb nicht mehr und die Paketsuche scheiterte, ohne dass es eine Anzeige sagte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:00:44 +02:00

800 lines
38 KiB
Python

"""
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
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
• Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb)
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, VOICE_SERVICE_URL
from kern import partner
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, llamaswap, maintenance, probe_wiederherstellung, update_verlauf
log = logging.getLogger(__name__)
ROLLE = einstellungen().rolle
DATEN_DIR = einstellungen().daten_dir
# 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(DATEN_DIR / "mc2-waechter.json")))
# „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist.
# Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos.
QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json")))
# Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update
# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird.
PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5"))
VERLAUF_MAX = 200
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
# 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, "Spracherkennung"),
"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),
"mc2-radar": ("Modell-Radar", False),
# Rot: scheitert sie, erfährst du nichts von dem, was nachts passiert ist.
"mc2-morgenmeldung": ("Morgenmeldung", True),
# Seit 24.09.2026 ein eigener Timer statt Hermes-Cron (Hermes startet sich beim Update selbst neu).
"mc2-autoupdate": ("Updates am Sonntag", True),
# Seit 24.09.2026 im Repo (vom 21.08. an aus, weil sie rot lief und niemand es sah). Gelb: Die tägliche
# Sicherung (mc2-backup) bleibt die erste Schicht, der PBS ist die Kopie auf dem QNAP. Ausgeschaltet kein Befund.
"pbs-backup": ("Sicherung auf den PBS", False),
}
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)
nach_takten: int = 0 # eigene Wartezeit statt FAIL_AFTER (0 = Standard)
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,UnitFileState"]
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:
"""Die Zeile mit der eigentlichen Ursache. Zuerst die Meldungen des Skripts selbst
(projekte-sync markiert Fehler mit „!“), erst dann allgemeine Fehlerwörter — und nie die
systemd-Rahmenzeilen („Failed to start …“, „Main process exited …“): Die sagen nur,
DASS es scheiterte, nicht warum (so stand es im ersten Probelauf am 23.09.)."""
rahmen = re.compile(r"Failed to start|Main process exited|Failed with result|Consumed .* CPU time")
stufen = (
re.compile(r"(^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+)?\s*!|fehlgeschlagen|ABBRUCH)", re.IGNORECASE),
re.compile(r"(error|failed|fatal|traceback)", re.IGNORECASE),
)
for muster in stufen:
for zeile in reversed(zeilen):
if muster.search(zeile) and not rahmen.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 dienst_zustand(name: str, system: bool = False) -> dict[str, str]:
"""systemd-Zustand einer Unit für andere Module (Dienste-Liste der Oberfläche)."""
return _systemctl_show(name, system)
def schlaeft(zustand: dict[str, str]) -> bool:
"""Abgeschaltet und nicht mehr für den Autostart eingetragen = bewusst schlafen gelegt
(24.09.2026: Konsole, Spracherkennung). Das ist kein Fehler; geweckt wird per Knopf."""
return zustand.get("ActiveState") == "inactive" and zustand.get("UnitFileState") == "disabled"
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
if schlaeft(z):
continue # bewusst schlafen gelegt (disable --now), z. B. die Spracherkennung bis zur App
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
# Der Dienst hinter einem Timer ist „static“ und schläft nie selbst — schlafen kann nur sein Timer
# (Einstellungen → Radar aus, seit 24.09.2026). Dann ist ein alter Fehlschlag kein Befund mehr.
if schlaeft(_systemctl_show(f"{name}.timer", False)):
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 _gelesene_seite(zeilen: list[str], i: int) -> bool:
"""Hermes hängt an jedes web_extract-Ergebnis ein leeres "error"-Feld und hält ein Ergebnis für
gescheitert, sobald '"error"' in seinen ersten 500 Zeichen steht — bei kurzen Seiten also auch
Erfolge (agent/display.py, 24.09. gesehen). Ein mehrzeiliges Ergebnis mit "results" ist eine
gelesene Seite, kein Werkzeugfehler; echte Fehler kommen als {"success": false, "error": …}."""
return (zeilen[i].rstrip().endswith("{") and i + 1 < len(zeilen)
and zeilen[i + 1].lstrip().startswith('"results"'))
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 i, z in enumerate(zeilen)
if marke in z and "returned error" in z and not _gelesene_seite(zeilen, i)]
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 _quittiert() -> dict[str, str]:
try:
daten = json.loads(QUITTIERT_PATH.read_text(encoding="utf-8"))
return daten if isinstance(daten, dict) else {}
except (OSError, ValueError):
return {}
def quittieren(hinweis_id: str, lauf: str) -> None:
"""Hinweis bis zum nächsten Lauf ausblenden (atomar schreiben, alte Einträge begrenzen)."""
daten = _quittiert()
daten[hinweis_id] = lauf
daten = dict(list(daten.items())[-50:])
tmp = QUITTIERT_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, QUITTIERT_PATH)
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"])
befund_id = f"job:{jid}:werkzeug"
if anzahl and _quittiert().get(befund_id) != lauf["started_at"]:
befunde.append(Befund(
id=befund_id, 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),
_aktion("ausblenden", "Ausblenden bis zum nächsten Lauf", lauf=lauf["started_at"])],
sofort=True))
return befunde
def _hermes_kopf() -> dict[str, str]:
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
# Lucys Hirn gezielt (seit 24.09.2026): Ein Ladevorgang ist kein Ausfall — aber nur bis zu dieser Frist. llama-swap
# bricht einen Start nach healthCheckTimeout (300 s) selbst ab; wer danach immer noch „lädt“, hängt oder startet
# immer wieder neu (dann wechseln sich „lädt“ und „fehlt“ ab, und ohne Frist käme nie ein Hinweis zustande).
HIRN_LADEN_MAX_S = int(os.environ.get("MC_WAECHTER_HIRN_LADEN_S", "600"))
_hirn: dict[str, float | None] = {"fehlt_seit": None, "geprueft": None}
def _hirn_befund(jetzt: float | None = None) -> Befund | None:
"""Ist Lucys Hirn (Rolle hermes) geladen? Bis 24.09.2026 galt es als bereit, sobald irgendein Modell lief —
ein abgestürztes Hirn neben einem geladenen Coder blieb so unbemerkt. Lädt es gerade, ist das kein Befund;
alles andere zählt wie jeder Befund erst nach FAIL_AFTER Takten."""
jetzt = time.monotonic() if jetzt is None else jetzt
zuletzt, _hirn["geprueft"] = _hirn["geprueft"], jetzt
if zuletzt is None or jetzt - zuletzt > 5 * 60:
_hirn["fehlt_seit"] = None # lange nicht geprüft (Update, Motor weg): die Frist beginnt neu
st = llamaswap.hirn_zustand()
name, zustand = st.get("modell"), st.get("zustand")
if zustand == "bereit":
_hirn["fehlt_seit"] = None
return None
if _hirn["fehlt_seit"] is None:
_hirn["fehlt_seit"] = jetzt
dauer = jetzt - _hirn["fehlt_seit"]
if zustand == "laedt" and dauer < HIRN_LADEN_MAX_S:
return None
if not name:
text = "Kein Modell trägt die Rolle „hermes“. Ohne sie hat Lucy kein Hirn; die Rolle vergibt die Modelle-Seite."
elif zustand == "laedt":
text = (f"{name} lädt seit über {int(dauer // 60)} Minuten und wird nicht fertig. Vermutlich startet es immer "
"wieder neu; das Protokoll des Motors sagt, warum.")
elif zustand == "unbekannt":
text = "Der Motor sagt nicht, welche Modelle laufen (llama-swap /running antwortet nicht)."
else:
text = f"{name} läuft nicht. Der Re-Warm-Wächter lädt es nach; das Protokoll des Motors sagt, warum es fehlt."
return Befund(id="kern:hirn", stufe="rot",
titel="Lucys Hirn lädt nicht fertig" if zustand == "laedt" else "Lucys Hirn ist nicht geladen",
text=text, quelle="hirn", aktionen=[_aktion("protokoll", "Protokoll des Motors", dienst="llama-swap")])
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))
hirn = _hirn_befund() if engine_ok else None
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())))
# Die Spracherkennung schläft seit 24.09.2026 (bis zur Android-App) — dann ist Schweigen kein Fehler.
if not schlaeft(_systemctl_show("voice-service", False)):
proben.append(("kern:hoeren", "Die Spracherkennung 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")))
befunde = [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
for (i, t, x, ok) in proben if not ok]
return befunde + ([hirn] if hirn else [])
def pruefe_platte() -> list[Befund]:
try:
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_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)]
def pruefe_probe_wiederherstellung() -> list[Befund]:
"""Monatliche Probe-Wiederherstellung (services/probe_wiederherstellung.py, seit 24.09.2026): gelb, wenn die
letzte Probe rot war oder älter als WARN_TAGE ist. Gab es noch keine, erst, wenn ihr Timer eingerichtet ist."""
einheit = probe_wiederherstellung.EINHEIT
aktionen = [_aktion("neustart", "Jetzt prüfen", dienst=einheit), _aktion("protokoll", "Protokoll", dienst=einheit)]
stand = probe_wiederherstellung.lese_stand()
if stand is None:
z = _systemctl_show(f"{einheit}.timer", False)
if not z or z.get("LoadState") in ("not-found", ""):
return []
return [Befund(id="probe:wiederherstellung", stufe="gelb",
titel="Die Sicherung wurde noch nie probeweise ausgepackt",
text=("Die Probe läuft am ersten Montag im Monat um 05:15. „Jetzt prüfen“ startet sie sofort; "
"sie braucht Sekunden und fasst nichts Lebendes an."),
quelle=einheit, aktionen=aktionen, sofort=True)]
datum = datetime.fromtimestamp(float(stand["zeit"]), LOCAL_TZ).strftime("%d.%m.%Y")
if stand.get("ergebnis") != "gruen":
fehler = [str(f) for f in stand.get("fehler") or []] or ["ohne Begründung"]
weitere = f" (und {len(fehler) - 1} weitere)" if len(fehler) > 1 else ""
return [Befund(id="probe:wiederherstellung", stufe="gelb",
titel="Die letzte Probe-Wiederherstellung war rot",
text=f"Probe vom {datum} ({stand.get('sicherung') or 'keine Sicherung'}): {fehler[0]}{weitere}"[:400],
quelle=einheit, aktionen=aktionen, sofort=True)]
tage = (time.time() - float(stand["zeit"])) / 86400
if tage > probe_wiederherstellung.WARN_TAGE:
return [Befund(id="probe:wiederherstellung", stufe="gelb",
titel=f"Die Sicherung wurde seit {int(tage)} Tagen nicht mehr probeweise ausgepackt",
text=(f"Die letzte Probe am {datum} war grün. Sie soll am ersten Montag im Monat laufen — "
f"ist {einheit}.timer eingeschaltet?"),
quelle=einheit, aktionen=aktionen, sofort=True)]
return []
def pruefe_festgehalten() -> list[Befund]:
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
titel=f"{p['name']} bekommt keine Updates mehr",
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
quelle="updates", sofort=True,
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
for p in update_verlauf.festgehalten()]
def pruefe_partner() -> list[Befund]:
"""Die andere Instanz (User-Entscheid 24.09.2026): Box und Homelab prüfen sich gegenseitig —
fällt eine aus, meldet die andere es."""
stand = partner.status(frisch=True)
if not stand.get("eingerichtet") or stand.get("erreichbar"):
return []
grund = stand.get("fehler") or "nicht erreichbar"
if ROLLE == "homelab":
text = f"Die KI-Box ({stand['url']}) ist {grund}. Lucy, die Modelle und der Box-Wart sind so lange weg."
else:
text = (f"Der Homelab-Teil auf dem Proxmox-PC ({stand['url']}) ist {grund}. "
"Updates im Homelab lassen sich so lange nicht anstoßen.")
return [Befund(id="partner", stufe="rot", titel=f"{stand['name']} antwortet nicht", text=text,
quelle="partner", nach_takten=PARTNER_TAKTE)]
def pruefe_ausfuehrer() -> list[Befund]:
"""Homelab: Der Ausführer auf dem Proxmox-Host meldet sich alle zehn Minuten. Schweigt er, zeigt die
Übersicht nichts Neues mehr, und „Jetzt updaten“ bliebe liegen. Vor seinem ersten Bericht: kein Alarm."""
from services.homelab import inventar, kanal
if kanal.bericht() is None:
return []
zuletzt = kanal.zuletzt()
if zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S:
return []
return [Befund(id="ausfuehrer", stufe="rot", titel="Der Ausführer auf dem Proxmox-Host schweigt",
text="Seit über 30 Minuten kein Bericht. Läuft mc2-ausfuehrer.service auf dem Proxmox-Host?",
quelle="ausfuehrer")]
def pruefe_gaeste() -> list[Befund]:
"""Homelab: Läuft jeder freigegebene Gast, und antwortet seine Weboberfläche?"""
from services.homelab import inventar
return [Befund(id=f"gast:{e['id']}", stufe="rot", titel=f"{e['name']} antwortet nicht",
text=(f"Die Weboberfläche ({e['url']}) antwortet nicht." if e["laeuft"]
else "Der Gast läuft nicht."), quelle=e["id"])
for e in inventar.erreichbarkeit() if not e["ok"]]
GAST_PLATTE_GELB = 0.85
GAST_PLATTE_ROT = 0.95
def _gb_komma(n: float) -> str:
return f"{n / 1e9:.1f}".replace(".", ",")
def pruefe_gast_platten() -> list[Befund]:
"""Homelab: Läuft die Platte eines Containers voll? Am 24.09.2026 stand AdGuard bei 100 %: Das Abfrageprotokoll
schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es."""
from services.homelab import apps, kanal
daten = kanal.bericht()
befunde = []
for g in (daten or {}).get("bericht", {}).get("gaeste") or []:
platte = g.get("platte") or {}
gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0
if not gesamt or belegt / gesamt < GAST_PLATTE_GELB:
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
name = app.name if app else str(g.get("name") or g["vmid"])
anteil = belegt / gesamt
befunde.append(Befund(
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll",
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt (Container {g['vmid']}). Platz schaffen "
"oder in Proxmox die Platte vergrößern: Container → Ressourcen → Laufwerk → Größe ändern."),
quelle=f"ct-{g['vmid']}"))
return befunde
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
# den Ausführer auf dem Proxmox-Host und seine Gäste.
PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung),
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste, pruefe_gast_platten),
}[ROLLE]
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
# --- 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:
if TROCKEN:
log.info("waechter (trocken): würde melden %s %s", betreff, text)
return
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
if TROCKEN:
_verlauf("auto", b.id, f"{b.titel}: würde automatisch neu starten (Trockenlauf)")
_stand["auto"][b.id].append(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 as exc:
# Bis 24.09.2026 stand das nur im Debug-Log: Die Prüfung war dann still aus, und das
# Cockpit blieb grün (z. B. wenn Hermes das Format seiner Job-Liste ändert).
log.warning("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
befunde.append(Befund(
id=f"pruefung:{pruefung.__name__}", stufe="gelb",
titel="Eine Prüfung des Wächters lief nicht",
text=f"{pruefung.__name__}: {exc.__class__.__name__}: {exc}"[:240],
quelle="mc2-steward"))
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"] >= (b.nach_takten or 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(BETREFF_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(BETREFF_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 == "freigeben":
if not update_verlauf.freigeben(aktion["baustein"]):
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
if aktion_id == "ausblenden":
quittieren(hinweis_id, str(aktion.get("lauf", "")))
return {"ok": True, "text": "Ausgeblendet. Der Hinweis verschwindet in spätestens einer Minute "
"und kommt nur wieder, wenn der nächste Lauf erneut Fehler hat."}
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}."}