Files
mission-control-v2/backend/services/waechter.py
T
HitonabiandClaude Opus 5.5 53ea0cd9b6 Welle 1 · Zertifikats-Wache: NPMplus, Proxmox-Host und PBS mit Grenzen nach Laufzeit
Der Ausführer liest alle 30 Minuten die https-Zertifikate (NPMplus samt dem, was es für den Namen ausliefert,
Proxmox-Host, PBS). Weil NPMplus das kurzlebige Profil von Let's Encrypt nutzt (6,7 Tage), gilt keine feste
30-Tage-Grenze: gelb „wird nicht erneuert“, sobald weniger als ein Drittel der Laufzeit übrig ist (langlebige: 30 Tage
vorher), rot unter einem Sechstel (1–7 Tage vor Ablauf), gelb auch, wenn NPMplus noch das alte ausliefert. Je Quelle
ein Hinweis; das Homelab-Cockpit zeigt die Liste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:45:57 +02:00

832 lines
41 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, seit 25.09.2026 auch das Tempo („in etwa N Tagen voll“)
• 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, prognose
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 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]:
"""Füllstand der eigenen Platte (gelb ab DISK_GELB_PCT, rot ab DISK_ROT_PCT). Auf der KI-Box seit 25.09.2026
auch das Tempo des Modell-Laufwerks (kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot —
als eigener Hinweis, wenn er ernster ist als der Füllstand."""
befunde, stufe = [], "ok"
try:
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
except Exception:
pct = None
if pct is not None and pct >= DISK_GELB_PCT:
stufe = "rot" if pct >= DISK_ROT_PCT else "gelb"
befunde.append(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))
if ROLLE == "box":
from services import messwerte
v = prognose.aus_messreihe(messwerte.QUELLE, "platte")
kuenftig, kommend = prognose.schlimmer(stufe, v)
if kommend:
befunde.append(Befund(
id="platte:tempo", stufe=kuenftig, titel=f"Die Platte ist {prognose.in_tagen(v['tage'])} voll",
text=f"{prognose.tempo(v)} Alte Modelldateien löschen hilft am meisten: KI-Box → Modelle → Aufräumen.",
quelle="platte"))
return befunde
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.80
GAST_PLATTE_ROT = 0.90 # ext4 hält 5 % für root zurück: bei 94 % war AdGuard innen schon voll
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. Seit 25.09.2026 auch nach dem Tempo
(kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot."""
from services.homelab import apps, kanal, speicher
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:
continue
anteil = belegt / gesamt
# Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten).
vm = g.get("art") != "lxc"
zid = f"{'vm' if vm else 'ct'}-{g['vmid']}"
v = speicher.platten_vorhersage(zid)
jetzt_stufe = "rot" if anteil >= GAST_PLATTE_ROT else "gelb" if anteil >= GAST_PLATTE_GELB else "ok"
stufe, kommend = prognose.schlimmer(jetzt_stufe, v)
if stufe == "ok":
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"])
vergroessern = ("VM → Hardware → Festplatte → Aktion → Größe ändern, danach in der VM das Dateisystem "
"mitwachsen lassen" if vm else "Container → Ressourcen → Laufwerk → Größe ändern")
befunde.append(Befund(
id=f"gast-platte:{g['vmid']}", stufe=stufe,
titel=(f"Platte von {name} {prognose.in_tagen(v['tage'])} voll" if kommend
else f"Platte von {name} zu {min(100, round(anteil * 100))} % voll"),
text=((f"{prognose.tempo(v)} " if kommend else "")
+ f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']})."
+ ("" if kommend else prognose.dazu(v))
+ f" Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."),
quelle=zid))
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 = {
# Seit 25.09.2026 auch der Totmannschalter (services/ablaeufe.py): Zeitpläne und Jobs, die gar nicht erst laufen.
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung,
__import__("services.ablaeufe").ablaeufe.pruefe_ueberfaellig),
"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 ()
# Messwerte des Proxmox-Hosts (seit 24.09.2026): zehn Minuten über 95 % RAM oder 90 °C = gelb.
PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe_host,) if ROLLE == "homelab" else ()
# Speicherpool, NAS und Sicherungen (seit 25.09.2026): gelb ab 80 %, rot bei 10 % frei; Sicherung älter als erlaubt.
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
# Zertifikats-Wache (seit 25.09.2026): gelb, wenn die Erneuerung hängt; rot kurz vor Ablauf.
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) 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, stufe: str | None = None) -> None:
eintrag = {"ts": time.time(), "art": art, "id": hinweis_id, "text": text}
if stufe: # seit 24.09.2026: das Protokoll des Homelab-Teils zeigt, wie ernst ein Hinweis war
eintrag["stufe"] = stufe
_stand["verlauf"].append(eintrag)
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")
# Bis 25.09.2026 hing hier „(Diese Meldung kam auch an Lucy.)“ an — als wäre Lucy jemand anderes als der Absender.
# Sie IST der Absender (ihr Bot, ihre Stimme, siehe notify.sh); im Briefkasten oben liegt die Meldung trotzdem.
announce.notify_telegram(betreff, text)
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, b.stufe)
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), h.get("stufe"))
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}."}