""" 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___]; 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}."}