"""Abläufe der KI-Box — der Totmannschalter (Ausbauplan Welle 1, Punkt 1, seit 25.09.2026). Der Wächter sah bisher nur, wenn ein geplanter Ablauf SCHEITERTE (pruefe_timer_dienste, pruefe_hermes_jobs). Ein Ablauf, der gar nicht erst läuft, fiel niemandem auf: Vom 06. bis 17.09.2026 stand die Sonntags-Update-Kette still, bis es jemand zufällig bemerkte. Hier steht deshalb je Ablauf, wie lange er höchstens schweigen darf (Takt + Karenz), und was darüber liegt, wird ein Hinweis: systemd-Timer letzter Start (LastTriggerUSec) älter als erlaubt → überfällig; der Timer selbst aus (eingetragen, aber nicht aktiv) → aus. Bewusst abgeschaltet (inaktiv und ausgetragen, z. B. das Radar in den Einstellungen) ist kein Befund. Hermes-Cron-Jobs der nächste Lauf (next_run_at) liegt mehr als eine Stunde zurück → der Planer von Hermes lief nicht (Gateway hing oder war aus). Gitea-Ampel der Actions-Runner meldet sich in Gitea nicht als online (seit dem 28.08. stand er still, weil er von Hand gestartet war; seit 25.09. ein Dienst auf arcane). Nur lesend. Die Liste für die Oberfläche (Seite „Dienste und Protokolle“ → Zeitpläne) kommt aus ablaeufe(). """ import logging import os import re import subprocess import threading import time from dataclasses import dataclass from datetime import datetime from pathlib import Path import httpx from kern.zeit import LOCAL_TZ log = logging.getLogger(__name__) STUNDE = 3600 TAG = 24 * STUNDE @dataclass(frozen=True) class Zeitplan: unit: str # systemd-User-Timer ohne „.timer“ name: str erlaubt_s: int # so lange darf er höchstens schweigen (Takt + Karenz) takt: str # in Worten, für die Oberfläche # Takt aus deploy/*.timer; Karenz: täglich 3 h, wöchentlich 6 h (RandomizedDelaySec, Neustart am Sonntag), monatlich # fünf Wochen (erster Montag im Monat). ZEITPLAENE: tuple[Zeitplan, ...] = ( Zeitplan("mc2-autoupdate", "Updates am Sonntag", 7 * TAG + 6 * STUNDE, "sonntags 04:30"), Zeitplan("mc2-backup", "Sicherung der Box", TAG + 3 * STUNDE, "täglich 03:30"), Zeitplan("pbs-backup", "Sicherung auf den PBS", TAG + 3 * STUNDE, "täglich 03:50"), Zeitplan("mc2-morgenmeldung", "Morgenmeldung", TAG + 3 * STUNDE, "täglich 07:00"), Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"), Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"), Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"), ) HERMES_KARENZ_S = STUNDE CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/") GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials"))) _cache: dict[str, tuple[float, object]] = {} _lock = threading.Lock() def _gemerkt(schluessel: str, holen): with _lock: eintrag = _cache.get(schluessel) if eintrag and time.monotonic() - eintrag[0] < CACHE_S: return eintrag[1] wert = holen() with _lock: _cache[schluessel] = (time.monotonic(), wert) return wert def cache_leeren() -> None: with _lock: _cache.clear() # --- systemd -------------------------------------------------------------------------------------------------------- def _unix(wert: str | None) -> float | None: """„@1790300009“ (systemctl --timestamp=unix) → Sekunden; leer oder „n/a“ → None.""" m = re.fullmatch(r"@(\d+(?:\.\d+)?)", (wert or "").strip()) return float(m.group(1)) if m else None def _timer_zustand(unit: str) -> dict[str, str]: """Zustand eines User-Timers. Ohne systemd (Windows, Tests): leer.""" try: out = subprocess.run( ["systemctl", "--user", "show", f"{unit}.timer", "--timestamp=unix", "-p", "LoadState,ActiveState,UnitFileState,ActiveEnterTimestamp,LastTriggerUSec,NextElapseUSecRealtime"], capture_output=True, text=True, timeout=10).stdout except Exception: return {} return dict(zeile.split("=", 1) for zeile in out.splitlines() if "=" in zeile) def bewerte_timer(z: Zeitplan, zustand: dict[str, str], jetzt: float) -> dict | None: """Ein Eintrag der Liste: Zustand in Worten und ob es ein Befund ist. None = gibt es hier nicht (kein systemd).""" if not zustand or zustand.get("LoadState") in ("", "not-found"): return None letzter = _unix(zustand.get("LastTriggerUSec")) naechster = _unix(zustand.get("NextElapseUSecRealtime")) eintrag = {"id": f"timer:{z.unit}", "name": z.name, "art": "zeitplan", "takt": z.takt, "letzter": letzter, "naechster": naechster, "status": "ok", "text": None} aktiv = zustand.get("ActiveState") == "active" eingetragen = zustand.get("UnitFileState") in ("enabled", "static", "enabled-runtime") if not aktiv and not eingetragen: return {**eintrag, "status": "aus", "text": "Bewusst abgeschaltet."} if not aktiv: return {**eintrag, "status": "gestoppt", "text": f"Der Zeitplan ist eingetragen, läuft aber nicht — {z.name} startet so nie."} # Nie gestartet: gezählt wird ab dem Moment, seit dem der Timer läuft (nach einer Neuinstallation ist das normal). bezug = letzter or _unix(zustand.get("ActiveEnterTimestamp")) if bezug is not None and jetzt - bezug > z.erlaubt_s: seit = _dauer(jetzt - bezug) text = (f"Zuletzt vor {seit} gestartet ({z.takt} erwartet)." if letzter else f"Seit {seit} kein einziger Start ({z.takt} erwartet).") return {**eintrag, "status": "ueberfaellig", "text": text} return eintrag def _dauer(s: float) -> str: if s < 2 * TAG: return f"{int(s // STUNDE)} Stunden" return f"{int(s // TAG)} Tagen" # --- Hermes --------------------------------------------------------------------------------------------------------- def _zeit(wert) -> float | None: if not wert: return None try: dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00")) except ValueError: return None return (dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)).timestamp() def bewerte_job(job: dict, jetzt: float) -> dict | None: if not job.get("enabled", True): return None naechster, letzter = _zeit(job.get("next_run_at")), _zeit(job.get("last_run_at")) takt = str((job.get("schedule") or {}).get("display") or (job.get("schedule") or {}).get("expr") or "") eintrag = {"id": f"hermes:{job.get('id')}", "name": str(job.get("name") or "Hermes-Job"), "art": "hermes", "takt": f"Hermes-Cron {takt}".strip(), "letzter": letzter, "naechster": naechster, "status": "ok", "text": None} if naechster is not None and jetzt - naechster > HERMES_KARENZ_S: return {**eintrag, "status": "ueberfaellig", "text": f"Sollte vor {_dauer(jetzt - naechster)} laufen — der Planer von Hermes hat ihn nicht gestartet."} return eintrag # --- Gitea-Ampel ------------------------------------------------------------------------------------------------------ def _gitea_token() -> str | None: """Das Token aus ~/.git-credentials (Zeile für die Gitea-Adresse). Erscheint nie in einer Ausgabe.""" try: zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines() except OSError: return None host = GITEA_URL.split("//", 1)[-1].split(":", 1)[0] for z in zeilen: m = re.match(r"https?://[^:/@]+:([^@]+)@([^/:]+)", z.strip()) if m and (m.group(2) == host or "ddnsfree" in m.group(2)): return m.group(1) return None def _runner() -> list[dict] | None: token = _gitea_token() if not token: return None try: r = httpx.get(f"{GITEA_URL}/api/v1/admin/actions/runners", headers={"Authorization": f"token {token}"}, timeout=10) r.raise_for_status() daten = r.json() except Exception: log.info("ablaeufe: Gitea-Runner nicht abfragbar", exc_info=True) return None return [x for x in (daten.get("runners") or []) if isinstance(x, dict)] if isinstance(daten, dict) else None def bewerte_runner(runner: list[dict] | None) -> list[dict]: if runner is None: return [] if not runner: return [{"id": "gitea:runner", "name": "Gitea-Ampel", "art": "ampel", "takt": "bei jedem Push", "letzter": None, "naechster": None, "status": "gestoppt", "text": "In Gitea ist kein Runner eingetragen — die Ampel prüft nichts."}] liste = [] for x in runner: online = x.get("status") == "online" and not x.get("disabled") liste.append({"id": f"gitea:runner:{x.get('id')}", "name": f"Gitea-Ampel ({x.get('name')})", "art": "ampel", "takt": "bei jedem Push", "letzter": None, "naechster": None, "status": "ok" if online else "gestoppt", "text": None if online else (f"Der Runner {x.get('name')} meldet sich in Gitea als " f"„{x.get('status')}“ — Prüfläufe bleiben stehen.")}) return liste # --- Zusammen ------------------------------------------------------------------------------------------------------- def _jobs() -> list[dict]: from services import waechter # waechter importiert dieses Modul return waechter._hermes_jobs() def ablaeufe(jetzt: float | None = None) -> list[dict]: """Alle Abläufe mit Zustand, Probleme zuerst — für die Oberfläche und den Wächter.""" jetzt = time.time() if jetzt is None else jetzt liste: list[dict] = [] for z in ZEITPLAENE: if (e := bewerte_timer(z, _gemerkt(f"timer:{z.unit}", lambda u=z.unit: _timer_zustand(u)), jetzt)): liste.append(e) for job in _jobs(): if (e := bewerte_job(job, jetzt)): liste.append(e) liste += bewerte_runner(_gemerkt("gitea", _runner)) rang = {"ueberfaellig": 0, "gestoppt": 0, "ok": 1, "aus": 2} return sorted(liste, key=lambda e: rang.get(e["status"], 1)) def pruefe_ueberfaellig(): """Befunde für den Wächter: überfällige oder gestoppte Abläufe (gelb). Die Sonntags-Updates rot — deren Stillstand kostete im September elf Tage.""" from services.waechter import Befund, _aktion befunde = [] for e in ablaeufe(): if e["status"] not in ("ueberfaellig", "gestoppt"): continue wichtig = e["id"] == "timer:mc2-autoupdate" titel = f"{e['name']} ist überfällig" if e["status"] == "ueberfaellig" else f"{e['name']} läuft nicht" aktionen = [] if e["art"] == "zeitplan": unit = e["id"].split(":", 1)[1] aktionen = [_aktion("protokoll", "Protokoll", dienst=unit)] befunde.append(Befund(id=f"ablauf:{e['id']}", stufe="rot" if wichtig else "gelb", titel=titel, text=e["text"] or "", quelle=e["id"], aktionen=aktionen, nach_takten=5)) return befunde