phase2a: Kern fuer zwei Instanzen (Rolle, Partner, Telegram-Zweitweg)

- kern/einstellungen.py: MC_ROLLE (box|homelab), Instanzname, Datenordner, Partner-URL
- kern/zeit.py: eine Zeitzone statt sechs Kopien
- app.py/steward.py: Box-Router und Warm-Set nur in der Rolle box; der Homelab-Teil
  laedt nichts von der Box
- /api/health nennt Rolle und Instanz; Engine/Gateway/Hirn nur auf der Box
- /api/partner (Lebenszeichen) und /api/partner/<pfad> (Durchreiche, ohne Schleifen und SSE)
- Waechter prueft die andere Instanz (rot erst nach 5 Takten, ein Neustart ist kein Alarm)
- notify.sh: Zweitweg direkt an die Telegram-Bot-API, wenn hermes send scheitert oder fehlt;
  Token nicht in der Prozessliste, Tests greifen nie auf die echte .env
- update_verlauf erkennt "OK telegram direkt"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:41:08 +02:00
co-authored by Claude Opus 5.5
parent 99ba6a78ab
commit 75611be9a9
19 changed files with 637 additions and 98 deletions
+2 -2
View File
@@ -20,11 +20,11 @@ import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from kern.zeit import LOCAL_TZ
CACHE_S = 600
TAGE = 7
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_STANDARD_NAMEN = {
"127.0.0.1": "Lucy und OpenChamber über MC2",
+1 -2
View File
@@ -45,11 +45,11 @@ from datetime import date, datetime, timedelta
from datetime import time as uhrzeit
from pathlib import Path
from urllib.parse import quote
from zoneinfo import ZoneInfo
import httpx
import psutil
from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
from kern.zeit import LOCAL_TZ
from ruamel.yaml.scalarstring import LiteralScalarString
from services import discover, geheimnisse, hf, llamaswap
@@ -64,7 +64,6 @@ log = logging.getLogger(__name__)
# --- Grenzen (jede mit Env-Override) ----------------------------------------------------
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
FENSTER_START = os.environ.get("MC_RADAR_FENSTER_START", "00:30") # Tests nur nachts …
FENSTER_ENDE = os.environ.get("MC_RADAR_FENSTER_ENDE", "02:30") # … um 03:00 kommt NerdQuiz
KANDIDATEN_PRO_WOCHE = int(os.environ.get("MC_RADAR_PRO_WOCHE", "1"))
+1 -2
View File
@@ -21,16 +21,15 @@ import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR
from kern.zeit import LOCAL_TZ
from services import announce
log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
REPEATS = ("", "daily", "weekdays", "weekly")
+3 -3
View File
@@ -18,9 +18,9 @@ import re
import threading
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
from kern.zeit import LOCAL_TZ
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
@@ -39,7 +39,7 @@ _BAUSTEIN_IM_TEXT = (
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
_EINTRAG = re.compile(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
_UPDATE_MELDUNG = re.compile(
+40 -9
View File
@@ -42,11 +42,15 @@ from pathlib import Path
import httpx
import psutil
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL
from kern import partner
from kern.einstellungen import einstellungen
from services import announce, llamaswap, maintenance, 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"
@@ -57,10 +61,13 @@ REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json")))
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(MODELS_DIR / "mc2-quittiert.json")))
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.
@@ -112,6 +119,7 @@ class Befund:
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:
@@ -407,7 +415,7 @@ def pruefe_kern() -> list[Befund]:
def pruefe_platte() -> list[Befund]:
try:
pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
except Exception:
return []
if pct >= DISK_ROT_PCT:
@@ -433,8 +441,31 @@ def pruefe_festgehalten() -> list[Befund]:
for p in update_verlauf.festgehalten()]
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_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)]
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
# bekommt seine Prüfungen mit den Homelab-Bausteinen.
PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner),
"homelab": (pruefe_platte, pruefe_partner),
}[ROLLE]
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
@@ -525,7 +556,7 @@ def takt() -> None:
k["takte"] += 1
if not update and b.auto:
_selbst_beheben(b, jetzt)
if not (b.sofort or k["takte"] >= FAIL_AFTER):
if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)):
continue
h = hinweise.get(b.id)
neu = h is None
@@ -537,7 +568,7 @@ def takt() -> None:
_verlauf("neu", b.id, b.titel)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
vorsatz = "" if neu else "Immer noch: "
_telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}")
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
h["gemeldet"] = jetzt
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
@@ -550,7 +581,7 @@ def takt() -> None:
h = hinweise.pop(hid)
_verlauf("erledigt", hid, h.get("titel", hid))
if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.")
_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)
+2 -3
View File
@@ -7,16 +7,15 @@ Anfragen). Alles nur lesend.
"""
import json
import os
import subprocess
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from kern.zeit import LOCAL_TZ
from services import modell_nutzung, waechter
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
"mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"}
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
def _parse_iso(wert) -> datetime | None: