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:
co-authored by
Claude Opus 5.5
parent
99ba6a78ab
commit
75611be9a9
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user