Merge branch 'worktree-agent-a1c799645a0ade657' into wartung/restarbeiten

This commit is contained in:
Hitonabi
2026-09-24 20:45:51 +02:00
29 changed files with 1494 additions and 131 deletions
-27
View File
@@ -1,27 +0,0 @@
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
+28 -1
View File
@@ -12,6 +12,9 @@ Nachtlauf das Hirn braucht. Ablauf:
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
Mit --nur-suche (seit 24.09.2026) nur Schritt 1, jederzeit: So startet der Knopf „Jetzt suchen“ die Suche als
Auftrag (services/radar.suche_starten). Getestet wird dabei nichts.
"""
import logging
@@ -108,5 +111,29 @@ def main() -> int:
bremse.cancel()
def nur_suche() -> int:
"""„Jetzt suchen“ aus der Oberfläche: nur die Suche, kein Test. Die letzte Zeile sagt das Ergebnis in Worten —
die Auftragskarte zeigt sie. Code 1, wenn die Suche scheitert oder keine Quelle erreichbar war."""
# Das Protokoll des Auftrags zeigt die Oberfläche: ohne eine Zeile je Hugging-Face-Abruf (samt signierter
# Download-Adressen), nur die Schritte des Radars und das Ergebnis.
logging.getLogger("httpx").setLevel(logging.WARNING)
try:
ergebnis = radar.suche()
except Exception as exc:
log.exception("Radar: Suche fehlgeschlagen")
print(f"Die Suche ist gescheitert: {exc.__class__.__name__}: {exc}", flush=True)
return 1
neu = ergebnis.get("neu") or []
quellen = ergebnis.get("quellen") or {}
teile = [f"{len(neu)} neu ({', '.join(neu)})" if neu else "nichts Neues",
f"{ergebnis.get('wartend', 0)} warten auf den Nachttest"]
if not quellen.get("discover", True):
teile.append("Hugging Face war nicht erreichbar")
if not quellen.get("watchlist", True):
teile.append("die Merkliste war nicht lesbar")
print(f"Suche fertig: {', '.join(teile)}.", flush=True)
return 0 if any(quellen.values()) else 1
if __name__ == "__main__":
sys.exit(main())
sys.exit(nur_suche() if "--nur-suche" in sys.argv[1:] else main())
+2 -1
View File
@@ -1,2 +1,3 @@
# Nur fürs Prüftor (deploy/pruefen.sh) — die Dienste selbst brauchen das nicht.
pytest>=8
# Gepinnt am 24.09.2026 auf den Stand im venv der Box (der Container installiert diese Datei nicht).
pytest==9.1.1
+16 -9
View File
@@ -1,9 +1,16 @@
fastapi>=0.115
python-multipart>=0.0.9
uvicorn[standard]>=0.30
httpx>=0.27
ruamel.yaml>=0.18
psutil>=5.9
huggingface_hub>=0.27
mcp>=1.2.0
tzdata>=2024.1
# Gepinnt am 24.09.2026 auf das, was nachweislich läuft: `pip freeze` im venv der KI-Box (Python 3.14) und im
# Homelab-Container 107 (/opt/mc2/backend/.venv, Python 3.13). Gleiche Version = „==“, sonst ein Bereich, der
# beide Stände abdeckt — so installiert weder deploy.sh (Box) noch einrichten.sh (Container) etwas Neues.
# Wer eine Grenze anhebt: vorher deploy/probelauf-box.sh, und die neue Version in beiden Umgebungen prüfen.
fastapi>=0.139.0,<=0.141.1
python-multipart==0.0.32
uvicorn[standard]>=0.51.0,<=0.53.0
httpx==0.28.1
ruamel.yaml==0.19.1
psutil==7.2.2
huggingface_hub>=1.23.0,<=2.0.0
# mcp braucht nur die MCP-Server in mcp/ (Hermes startet sie mit diesem venv, auf der Box). mcp 2.x hat FastMCP
# entfernt — `from mcp.server.fastmcp import FastMCP` scheitert dort. Der Container hat 2.2.0, nutzt es aber nicht;
# deshalb hier der Stand der Box (einrichten.sh zieht den Container beim nächsten Ausrollen auf 1.28.1).
mcp==1.28.1
tzdata>=2026.3,<=2026.4
+2 -15
View File
@@ -209,21 +209,8 @@ def unload_model(model_id: str) -> dict:
@router.post("/models/{model_id}/load")
def load_model(model_id: str) -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
# Trigger load by sending a lightweight completion request.
body = {
"model": model_id,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
# High timeout because model loading might take time
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, str(exc))
"""Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap)."""
return llamaswap.lade_modell(model_id)
@router.delete("/models/{model_id}")
+3 -4
View File
@@ -2,7 +2,7 @@
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung)
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
@@ -29,9 +29,8 @@ def radar_stand() -> dict:
@router.post("/radar/suche")
def radar_suche() -> dict:
"""Sucht sofort (Netz: Hugging Face) und liefert danach den neuen Stand."""
ergebnis = radar.suche()
return {**radar.uebersicht(), "suche": ergebnis}
"""Startet die Suche als Auftrag (sie kann Minuten dauern) und antwortet sofort mit der Auftrags-ID."""
return radar.suche_starten()
@router.post("/radar/{kandidat_id}/uebernehmen")
+101
View File
@@ -659,3 +659,104 @@ def get_running_models() -> list[str]:
except Exception:
log.warning("get_running_models fehlgeschlagen", exc_info=True)
return []
def modell_zustaende() -> dict[str, str] | None:
"""Zustand je Modell aus /running: Name → "ready" | "starting" | "stopping". Wer fehlt, ist nicht geladen.
llama-swap v257 listet dort jedes Modell, das weder „stopped“ noch „shutdown“ ist (internal/router/base.go,
RunningModels) — also auch eines, das gerade lädt. Ältere Fassungen lieferten nur Namen; die gelten als bereit.
None = /running nicht lesbar."""
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code != 200:
return None
eintraege = r.json().get("running") or []
except Exception:
log.warning("modell_zustaende: /running nicht lesbar", exc_info=True)
return None
zustaende: dict[str, str] = {}
for x in eintraege:
if isinstance(x, dict):
if x.get("model"):
zustaende[str(x["model"])] = str(x.get("state") or "ready")
elif x:
zustaende[str(x)] = "ready"
return zustaende
def hirn_zustand() -> dict:
"""Wie steht Lucys Hirn (Modell mit dem Alias/der Rolle „hermes“) in llama-swap? Gezielt dieses eine Modell —
bis 24.09.2026 galt das Hirn als bereit, sobald IRGENDEIN Modell lief (ein abgestürztes Hirn neben einem
geladenen Coder blieb unbemerkt).
Rückgabe {"modell": Name oder None, "zustand": "bereit" | "laedt" | "fehlt" | "unbekannt"};
„unbekannt“ heißt: /running antwortet nicht. Trägt kein Modell die Rolle, ist modell None und zustand "fehlt"."""
name = brain_model_name()
zustaende = modell_zustaende()
if zustaende is None:
return {"modell": name, "zustand": "unbekannt"}
zustand = zustaende.get(name or "")
if zustand == "ready":
return {"modell": name, "zustand": "bereit"}
if zustand == "starting":
return {"modell": name, "zustand": "laedt"}
return {"modell": name, "zustand": "fehlt"}
# Wie lange „Jetzt laden“ auf die Engine wartet. Große Modelle brauchen eine Weile (Lesen + Hochladen in den Speicher);
# was danach noch lädt, meldet lade_modell als „lädt noch“ statt als Fehler.
LADEN_WARTE_S = float(os.environ.get("MC_LADEN_WARTE_S", "90"))
def _engine_grund(r: httpx.Response) -> str:
"""Die Fehlermeldung der Engine aus ihrer Antwort (OpenAI-Format {"error": {"message"}}, {"error": "…"},
{"detail": "…"} oder reiner Text), auf eine Zeile gekürzt."""
text = ""
try:
daten = r.json()
except ValueError:
daten = None
if isinstance(daten, dict):
fehler = daten.get("error")
if isinstance(fehler, dict):
text = str(fehler.get("message") or "")
elif fehler:
text = str(fehler)
elif daten.get("detail"):
text = str(daten["detail"])
text = " ".join((text or r.text or "").split())
return text[:200] or "ohne Begründung"
def lade_modell(model_id: str, warte_s: float | None = None) -> dict:
"""Ein Modell laden und das ECHTE Ergebnis melden (seit 24.09.2026 — vorher hieß jede Antwort der Engine „ok“).
llama-swap lädt ein Modell mit der ersten Anfrage; dafür reicht eine Mini-Anfrage mit einem Token. Ob das Modell
danach wirklich geladen ist, entscheidet /running — so zählen auch Modelle, die gar nicht chatten (embed,
reranker), und ein Fehler der Anfrage selbst macht ein geladenes Modell nicht zum Fehlschlag.
Rückgabe: {"ok": True, "text": …} (bei "laedt": True lädt es nach der Wartezeit noch) oder
{"ok": False, "detail": deutscher Grund, mit der Meldung der Engine}."""
warte = LADEN_WARTE_S if warte_s is None else warte_s
anfrage = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
try:
with httpx.Client(timeout=warte) as c:
r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=anfrage)
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine (llama-swap) ist nicht "
f"erreichbar ({exc.__class__.__name__})."}
except httpx.TimeoutException:
if (modell_zustaende() or {}).get(model_id) == "starting":
return {"ok": True, "laedt": True,
"text": f"{model_id} lädt noch. Große Modelle brauchen etwas; der Stand erscheint gleich unter Modelle."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine hat nach {warte:.0f} Sekunden "
"nicht geantwortet, und das Modell lädt auch nicht."}
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: {exc.__class__.__name__}: {exc}"[:300]}
if r.status_code == 200:
return {"ok": True, "text": f"{model_id} ist geladen."}
zustand = (modell_zustaende() or {}).get(model_id)
if zustand == "ready":
return {"ok": True, "text": f"{model_id} ist geladen."}
if zustand == "starting":
return {"ok": True, "laedt": True, "text": f"{model_id} lädt noch."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden. Die Engine meldet (HTTP {r.status_code}): "
f"{_engine_grund(r)}"}
+2 -1
View File
@@ -25,7 +25,8 @@ SYSTEM_SERVICES = {"llama-swap"}
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
"projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate"}
"projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate",
"pbs-backup", "mc2-probe-wiederherstellung"}
# Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die
# Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann
+415
View File
@@ -0,0 +1,415 @@
"""
Monatliche Probe-Wiederherstellung (seit 24.09.2026): Eine Sicherung ist erst eine, wenn sie sich zurückspielen lässt.
Packt die jüngste Sicherung (deploy/backup.sh → /srv/models/mc2-backups) probeweise in einen Tmp-Ordner aus, prüft die
Pflichtinhalte und räumt den Ordner wieder weg. Lebende Dateien fasst sie nie an; geschrieben werden nur der Tmp-Ordner
und die Zustandsdatei ZUSTAND_PATH. Die liest der Wächter (Rolle box): gelb, wenn die letzte Probe rot war oder älter
als WARN_TAGE ist. Bei Rot geht zusätzlich eine Meldung über deploy/notify.sh raus, in normaler Dringlichkeit — nachts
sammelt notify.sh sie für die Morgenmeldung um 07:00.
Pflichtinhalte (was restore.sh zurückspielt oder was ohne Sicherung verloren wäre):
• Lucys Gedächtnis (hermes/memories) — dazu ein Abgleich mit dem lebenden Gedächtnis: Was schon vor der Sicherung so
dastand, muss darin unverändert stehen. Ebenso SOUL.md.
• Skills (SKILL.md) und Cron-Skripte (hermes/scripts), die Hermes-Cron-Jobs (cron/jobs.json)
• Hermes-Config (config.yaml lesbar; .env vorhanden) und die llama-swap-Config (Modelle eingetragen)
• Box-Wart-Zustand (box-wart/mc2-*.json lesbar) und die systemd-Units (samt llama-swap-Drop-ins)
Geheimnisse (.env, Token-Dateien) und Verknüpfungen werden nicht ausgepackt, nur ihr Vorhandensein geprüft.
Aufruf: mc2-probe-wiederherstellung.service (Timer: erster Montag im Monat, 05:15) oder von Hand auf der Box:
cd ~/mission-control-v2/backend && .venv/bin/python -m services.probe_wiederherstellung
Exit 0 = grün, 1 = rot.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import zlib
from datetime import datetime
from pathlib import Path
from config import HERMES_HOME
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import backup as sicherung
log = logging.getLogger(__name__)
EINHEIT = "mc2-probe-wiederherstellung"
ZUSTAND_PATH = Path(os.environ.get("MC_PROBE_ZUSTAND",
str(einstellungen().daten_dir / "mc2-probe-wiederherstellung.json")))
SICHERUNGEN_DIR = sicherung.BACKUP_DIR
# Die Sicherung läuft täglich um 03:30. Ist die jüngste älter, steht sie — das ist ein roter Befund.
MAX_ALTER_H = float(os.environ.get("MC_PROBE_MAX_ALTER_H", "48"))
# Der Wächter zeigt gelb, wenn die letzte Probe älter ist (monatlicher Takt plus Spielraum).
WARN_TAGE = 40
# Lebender Stand, gegen den verglichen wird (Tests setzen eigene Pfade).
HERMES_LIVE = HERMES_HOME
DATEN_LIVE = einstellungen().daten_dir
LLAMA_SWAP_DROPINS = Path("/etc/systemd/system/llama-swap.service.d")
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
# Nicht auspacken: Geheimnisse. Geprüft wird nur, dass sie in der Sicherung stehen.
_GEHEIM = {".env", "desktop-gateway-token", "session-token.conf"}
_PRAEFIX = "mc2-probe-"
_NAME_ZEIT = re.compile(r"mc2-state-(\d{8}-\d{6})\.tar\.gz$")
# Eine Datei, die bis so kurz vor dem Start der Sicherung geändert wurde, zählt als „danach geändert“.
_SPIELRAUM_S = 5.0
# --- Hilfen ------------------------------------------------------------------------------------
def _tmp_basis() -> str | None:
"""Wohin ausgepackt wird: MC_PROBE_TMP, sonst /var/tmp (Platte; /tmp ist auf der Box ein RAM-Laufwerk)."""
if (basis := os.environ.get("MC_PROBE_TMP", "").strip()):
return basis
return "/var/tmp" if os.path.isdir("/var/tmp") else None
def _alte_tmp_ordner_entfernen() -> None:
"""Reste einer abgebrochenen Probe (nur eigene Ordner mit unserem Präfix, älter als ein Tag)."""
basis = Path(_tmp_basis() or tempfile.gettempdir())
try:
kandidaten = list(basis.glob(f"{_PRAEFIX}*"))
except OSError:
return
for ordner in kandidaten:
try:
if ordner.is_dir() and not ordner.is_symlink() and time.time() - ordner.stat().st_mtime > 86400:
_entfernen(ordner)
except OSError:
pass
def _entfernen(ordner: Path) -> bool:
"""Tmp-Ordner löschen, auch wenn die Sicherung schreibgeschützte Ordner mitbrachte. True = er ist weg."""
def schreibbar_machen(funktion, pfad, _fehler) -> None:
try:
os.chmod(os.path.dirname(pfad), 0o700)
os.chmod(pfad, 0o700)
funktion(pfad)
except OSError:
pass
shutil.rmtree(ordner, onexc=schreibbar_machen)
return not ordner.exists()
def _rel(name: str) -> str:
"""Name im Archiv ohne führendes „./“ („./hermes/SOUL.md“ → „hermes/SOUL.md“)."""
while name.startswith("./"):
name = name[2:]
return name.rstrip("/")
def _dateien(ordner: Path) -> list[Path]:
if not ordner.is_dir():
return []
return sorted(p for p in ordner.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
def _geaendert(pfad: Path) -> float:
"""Letzte Änderung einer lebenden Datei. ctime zählt mit: Wer eine Datei samt alter mtime auspackt (Skill-Paket,
Restore), erzeugt sie trotzdem neu — sie kann dann gar nicht in einer älteren Sicherung stehen."""
st = pfad.stat()
return max(st.st_mtime, st.st_ctime)
def sicherungs_zeit(tarball: Path) -> float:
"""Wann die Sicherung begann: Zeitstempel im Namen (Ortszeit der Box), sonst die Dateizeit."""
if (m := _NAME_ZEIT.search(tarball.name)):
try:
return datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=LOCAL_TZ).timestamp()
except ValueError:
pass
return tarball.stat().st_mtime
def juengste_sicherung(ordner: Path | None = None) -> Path | None:
ordner = SICHERUNGEN_DIR if ordner is None else ordner
try:
liste = sorted(ordner.glob("mc2-state-*.tar.gz"), key=lambda p: p.name, reverse=True)
except OSError:
return None
return liste[0] if liste else None
def _yaml(pfad: Path):
from ruamel.yaml import YAML
return YAML(typ="safe").load(pfad.read_text(encoding="utf-8"))
# --- Die Prüfung ---------------------------------------------------------------------------------
def _sammelzeile(bereich: str, namen: list[str], einzeln: str, mehrere: str) -> str:
"""Eine Fehlerzeile je Art und Bereich, auch wenn hunderte Dateien betroffen sind."""
if len(namen) == 1:
return f"{bereich}: {namen[0]} {einzeln}."
beispiele = ", ".join(namen[:3]) + (" …" if len(namen) > 3 else "")
return f"{bereich}: {len(namen)} Dateien {mehrere} ({beispiele})."
def _pruefe_abgleich(bereich: str, kopie: Path, live: Path | None, beginn: float, fehler: list[str],
inhalt_gleich: bool = False) -> None:
"""Jede lebende Datei, die schon vor Beginn der Sicherung so dastand, muss in der Sicherung stehen
(bei inhalt_gleich auch mit demselben Inhalt). Leere Dateien und Sperrdateien (*.lock) zählen nicht."""
if live is None or not live.exists():
return
fehlend: list[str] = []
anders: list[str] = []
unlesbar: list[str] = []
for datei in [live] if live.is_file() else _dateien(live):
name = datei.name if live.is_file() else datei.relative_to(live).as_posix()
try:
# Verknüpfungen packt die Probe nicht aus (backup.sh sichert sie als Verknüpfung) — nicht vergleichen.
if (datei.is_symlink() or datei.name.endswith(".lock") or datei.stat().st_size == 0
or _geaendert(datei) >= beginn - _SPIELRAUM_S):
continue
gegenstueck = kopie if live.is_file() else kopie / datei.relative_to(live)
if not gegenstueck.is_file():
fehlend.append(name)
elif inhalt_gleich and gegenstueck.read_bytes() != datei.read_bytes():
anders.append(name)
except OSError:
unlesbar.append(name)
if fehlend:
fehler.append(_sammelzeile(bereich, fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
if anders:
fehler.append(_sammelzeile(bereich, anders, "steht in der Sicherung anders als auf der Box",
"stehen in der Sicherung anders als auf der Box"))
if unlesbar:
fehler.append(_sammelzeile(bereich, unlesbar, "ließ sich nicht vergleichen", "ließen sich nicht vergleichen"))
def _pruefe_inhalt(wurzel: Path, mitglieder: dict[str, tarfile.TarInfo], beginn: float,
geprueft: list[str], fehler: list[str]) -> None:
"""Die Pflichtinhalte im ausgepackten Ordner (Geheimnisse nur über die Liste der Mitglieder)."""
hermes = wurzel / "hermes"
# Lucys Gedächtnis: das Wertvollste, deshalb auch Inhalt gegen den lebenden Stand.
gedaechtnis = [p for p in _dateien(hermes / "memories") if p.stat().st_size > 0 and not p.name.endswith(".lock")]
if gedaechtnis:
geprueft.append(f"Gedächtnis: {len(gedaechtnis)} Dateien ({', '.join(p.name for p in gedaechtnis[:4])})")
else:
fehler.append("Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.")
_pruefe_abgleich("Gedächtnis", hermes / "memories", HERMES_LIVE / "memories", beginn, fehler, inhalt_gleich=True)
seele = hermes / "SOUL.md"
if seele.is_file() and seele.stat().st_size > 0:
geprueft.append("SOUL.md")
else:
fehler.append("SOUL.md fehlt in der Sicherung oder ist leer.")
_pruefe_abgleich("SOUL.md", seele, HERMES_LIVE / "SOUL.md", beginn, fehler, inhalt_gleich=True)
skills = [p for p in _dateien(hermes / "skills") if p.name == "SKILL.md"]
if skills:
geprueft.append(f"Skills: {len(skills)}")
else:
fehler.append("Keine Skills (hermes/skills/…/SKILL.md) in der Sicherung.")
_pruefe_abgleich("Skills", hermes / "skills", HERMES_LIVE / "skills", beginn, fehler)
skripte = _dateien(hermes / "scripts")
if skripte:
geprueft.append(f"Cron-Skripte: {len(skripte)}")
else:
fehler.append("Keine Cron-Skripte (hermes/scripts) in der Sicherung.")
_pruefe_abgleich("Cron-Skripte", hermes / "scripts", HERMES_LIVE / "scripts", beginn, fehler)
try:
jobs = json.loads((hermes / "cron" / "jobs.json").read_text(encoding="utf-8"))
liste = jobs.get("jobs", jobs) if isinstance(jobs, dict) else jobs
geprueft.append(f"Hermes-Cron-Jobs: {len(liste)}")
except (OSError, ValueError, TypeError) as exc:
fehler.append(f"Die Hermes-Cron-Jobs (cron/jobs.json) fehlen oder sind unlesbar ({exc.__class__.__name__}).")
try:
cfg = _yaml(hermes / "config.yaml")
if not isinstance(cfg, dict) or not cfg:
raise ValueError("leer")
geprueft.append("Hermes-Config lesbar")
except Exception as exc:
fehler.append(f"Die Hermes-Config (config.yaml) fehlt oder ist unlesbar ({exc.__class__.__name__}).")
env = mitglieder.get("hermes/.env")
if env is not None and env.isfile() and env.size > 0:
geprueft.append("Zugangsdaten (.env) enthalten (nicht ausgepackt)")
else:
fehler.append("Die Zugangsdaten (hermes/.env) fehlen in der Sicherung.")
try:
modelle = (_yaml(wurzel / "llama-swap" / "config.yaml") or {}).get("models")
if not isinstance(modelle, dict) or not modelle:
raise ValueError("ohne Modelle")
geprueft.append(f"llama-swap-Config: {len(modelle)} Modelle")
except Exception as exc:
fehler.append(f"Die llama-swap-Config fehlt oder ist unbrauchbar ({exc.__class__.__name__}: {exc})"[:200])
zustand = sorted((wurzel / "box-wart").glob("mc2-*.json")) if (wurzel / "box-wart").is_dir() else []
kaputt = []
for datei in zustand:
try:
json.loads(datei.read_text(encoding="utf-8"))
except (OSError, ValueError):
kaputt.append(datei.name)
if not zustand:
fehler.append("Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt in der Sicherung.")
elif kaputt:
fehler.append(f"Box-Wart-Zustand unlesbar: {', '.join(kaputt)}")
else:
geprueft.append(f"Box-Wart-Zustand: {len(zustand)} Dateien")
fehlend = []
for datei in sorted(DATEN_LIVE.glob("mc2-*.json")) if DATEN_LIVE.is_dir() else []:
try:
vorher = _geaendert(datei) < beginn - _SPIELRAUM_S and datei.stat().st_size > 0
except OSError:
continue
if vorher and not (wurzel / "box-wart" / datei.name).is_file():
fehlend.append(datei.name)
if fehlend:
fehler.append(_sammelzeile("Box-Wart-Zustand", fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
units = sorted(p.name for p in (wurzel / "systemd" / "user").glob("*.service")) \
if (wurzel / "systemd" / "user").is_dir() else []
if "mission-control-2.service" in units:
geprueft.append(f"systemd-Units: {len(units)} Dienste")
else:
fehler.append("Die systemd-Units (systemd/user, mindestens mission-control-2.service) fehlen in der Sicherung.")
if LLAMA_SWAP_DROPINS.is_dir() and not (wurzel / "systemd" / LLAMA_SWAP_DROPINS.name).is_dir():
fehler.append(f"Die llama-swap-Drop-ins ({LLAMA_SWAP_DROPINS.name}) fehlen in der Sicherung.")
def pruefe_sicherung(tarball: Path) -> tuple[list[str], list[str]]:
"""Eine Sicherung probeweise auspacken und prüfen. Rückgabe (Geprüftes, Fehler); der Tmp-Ordner ist danach weg."""
geprueft: list[str] = []
fehler: list[str] = []
beginn = sicherungs_zeit(tarball)
try:
with tarfile.open(tarball, "r:gz") as tar:
alle = tar.getmembers() # liest das ganze Archiv: ein abgeschnittenes fällt hier auf
mitglieder = {_rel(m.name): m for m in alle}
auspacken = [m for m in alle if _rel(m.name) and (m.isfile() or m.isdir())
and Path(_rel(m.name)).name not in _GEHEIM]
tmp = Path(tempfile.mkdtemp(prefix=_PRAEFIX, dir=_tmp_basis()))
try:
tar.extractall(tmp, members=auspacken, filter="data")
geprueft.append(f"ausgepackt: {sum(1 for m in auspacken if m.isfile())} Dateien")
_pruefe_inhalt(tmp, mitglieder, beginn, geprueft, fehler)
finally:
if not _entfernen(tmp):
fehler.append(f"Der Tmp-Ordner {tmp} ließ sich nicht entfernen.")
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
fehler.append(f"Die Sicherung lässt sich nicht auspacken: {exc.__class__.__name__}: {exc}"[:240])
return geprueft, fehler
def probe() -> dict:
"""Die ganze Probe: jüngste Sicherung finden, Alter prüfen, auspacken und prüfen, Ergebnis ablegen, bei Rot melden."""
start = time.time()
geprueft: list[str] = []
fehler: list[str] = []
tarball = None
try:
_alte_tmp_ordner_entfernen()
tarball = juengste_sicherung()
if tarball is None:
fehler.append(f"Keine Sicherung gefunden ({SICHERUNGEN_DIR}).")
else:
alter_h = (time.time() - tarball.stat().st_mtime) / 3600
if alter_h > MAX_ALTER_H:
fehler.append(f"Die jüngste Sicherung ist {alter_h / 24:.0f} Tage alt: Die tägliche Sicherung "
"(mc2-backup.timer) läuft nicht.")
else:
geprueft.append(f"jüngste Sicherung {alter_h:.0f} Stunden alt")
g, f = pruefe_sicherung(tarball)
geprueft += g
fehler += f
except Exception as exc: # die Probe selbst darf nie still scheitern
log.exception("Probe-Wiederherstellung abgestürzt")
fehler.append(f"Die Probe ist abgestürzt: {exc.__class__.__name__}: {exc}"[:240])
stand = {
"version": 1,
"zeit": start,
"datum": datetime.fromtimestamp(start, LOCAL_TZ).isoformat(timespec="seconds"),
"ergebnis": "rot" if fehler else "gruen",
"sicherung": tarball.name if tarball else None,
"dauer_s": round(time.time() - start, 1),
"geprueft": geprueft,
"fehler": fehler,
}
speichere(stand)
if fehler:
melden(stand)
return stand
# --- Ablage und Meldung ------------------------------------------------------------------------------
def speichere(stand: dict) -> None:
try:
ZUSTAND_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = ZUSTAND_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(stand, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, ZUSTAND_PATH)
except OSError:
log.warning("Probe-Wiederherstellung: %s nicht schreibbar", ZUSTAND_PATH, exc_info=True)
def lese_stand() -> dict | None:
"""Letztes Ergebnis für den Wächter. None = noch nie gelaufen (oder Datei unlesbar)."""
try:
daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(daten, dict) or not isinstance(daten.get("zeit"), (int, float)):
return None
return daten
def meldetext(stand: dict) -> str:
fehler = stand.get("fehler") or []
text = (f"Die Probe-Wiederherstellung ist rot ({stand.get('sicherung') or 'keine Sicherung'}): "
+ " ".join(fehler[:3]))
if len(fehler) > 3:
text += f" (und {len(fehler) - 3} weitere)"
return text + " Der Wächter zeigt es auch im Cockpit."
def _bash() -> str | None:
"""bash für notify.sh; auf Windows (Entwicklung) gibt es keinen Meldeweg."""
return shutil.which("bash") if os.name == "posix" else None
def melden(stand: dict) -> None:
"""Rot → Meldung über notify.sh in normaler Dringlichkeit: kein -d, kein „Alarm“ im Betreff — nachts sammelt
notify.sh sie für die Morgenmeldung um 07:00."""
if not (bash := _bash()):
log.warning("Probe-Wiederherstellung rot, aber hier gibt es keinen Meldeweg: %s", meldetext(stand))
return
try:
subprocess.run([bash, str(NOTIFY_SH), "-s", "[Sicherung]", meldetext(stand)], timeout=120, check=False,
stdin=subprocess.DEVNULL, capture_output=True)
except (OSError, subprocess.SubprocessError):
log.warning("Probe-Wiederherstellung: Meldung ging nicht raus", exc_info=True)
def main() -> int:
logging.basicConfig(level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
stand = probe()
print(f"Probe-Wiederherstellung von {stand['sicherung'] or '–'}: "
f"{'grün' if stand['ergebnis'] == 'gruen' else 'ROT'} ({stand['dauer_s']} s)")
for zeile in stand["geprueft"]:
print(f" ok {zeile}")
for zeile in stand["fehler"]:
print(f" ! {zeile}")
return 0 if stand["ergebnis"] == "gruen" else 1
if __name__ == "__main__":
sys.exit(main())
+21 -1
View File
@@ -52,7 +52,7 @@ 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
from services import discover, geheimnisse, hf, jobengine, llamaswap
from services.fit import extract_active_params_b, extract_params_b
try:
@@ -795,6 +795,26 @@ def suche() -> dict:
return ergebnis
SUCHE_ZEITLIMIT_S = 20 * 60
_LAUF_SKRIPT = Path(__file__).resolve().parents[1] / "radar_lauf.py"
def suche_starten() -> dict:
"""„Jetzt suchen“ als Auftrag (seit 24.09.2026): Die Suche fragt Hugging Face je Fund ab und kann Minuten dauern —
synchron hielt sie die Anfrage der Oberfläche so lange fest. Jetzt läuft sie in der Job-Engine (Gruppe „radar“,
höchstens eine zugleich, radar_lauf.py --nur-suche); die Antwort kommt sofort mit der Auftrags-ID, den Stand
zeigt die Auftragskarte. Läuft schon eine Suche, kommt deren ID zurück."""
job_id, laeuft = jobengine.start_job_exklusiv(
"radar", [sys.executable, str(_LAUF_SKRIPT), "--nur-suche"], "Modell-Radar: Suche nach neuen Modellen",
zeitlimit_s=SUCHE_ZEITLIMIT_S)
if job_id is None:
return {"ok": True, "job_id": (laeuft or {}).get("id"), "laeuft_schon": True,
"text": "Die Suche läuft schon. Den Stand zeigt der Auftrag unter „Aufträge“."}
return {"ok": True, "job_id": job_id,
"text": "Das Radar sucht jetzt nach neuen Modellen. Das dauert ein paar Minuten; den Stand zeigt der "
"Auftrag unter „Aufträge“."}
# --- Test ---------------------------------------------------------------------------------
_PS = None
+83 -9
View File
@@ -11,6 +11,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
• 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
@@ -45,8 +46,9 @@ 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, update_verlauf
from services import announce, llamaswap, maintenance, probe_wiederherstellung, update_verlauf
log = logging.getLogger(__name__)
ROLLE = einstellungen().rolle
@@ -98,6 +100,9 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"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"
@@ -386,17 +391,52 @@ 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))
if engine_ok:
st = llamaswap.brain_status()
hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models())
proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen",
"Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_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())))
@@ -409,8 +449,9 @@ def pruefe_kern() -> list[Befund]:
"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")))
return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
for (i, t, x, ok) in proben if not ok]
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]:
@@ -429,6 +470,39 @@ def pruefe_platte() -> list[Befund]:
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."""
@@ -484,7 +558,7 @@ def pruefe_gaeste() -> list[Befund]:
# 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_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung),
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste),
}[ROLLE]
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
+118
View File
@@ -0,0 +1,118 @@
"""Units, die deploy.sh ausspielt (24.09.2026): Probe-Wiederherstellung und PBS-Sicherung.
Prüft die Listen in deploy.sh gegen die Dateien in deploy/, den Takt der Probe (nie sonntags) und das PBS-Skript mit
einer Attrappe statt proxmox-backup-client — ohne Box, ohne PBS."""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
DEPLOY_SH = (DEPLOY / "deploy.sh").read_text(encoding="utf-8")
BASH = shutil.which("bash")
def _liste(name: str) -> list[str]:
m = re.search(rf'^{name}="([^"]*)"', DEPLOY_SH, re.MULTILINE)
assert m, f"{name} fehlt in deploy.sh"
return m.group(1).split()
def test_jede_unit_aus_deploy_sh_liegt_im_repo():
units, aktiv = _liste("UNITS"), _liste("AKTIV")
assert [u for u in units if not (DEPLOY / u).is_file()] == []
assert set(aktiv) <= set(units)
def test_probe_timer_laeuft_mit_pbs_nur_von_hand():
units, aktiv = _liste("UNITS"), _liste("AKTIV")
assert {"mc2-probe-wiederherstellung.service", "mc2-probe-wiederherstellung.timer",
"pbs-backup.service", "pbs-backup.timer"} <= set(units)
assert "mc2-probe-wiederherstellung.timer" in aktiv
assert "pbs-backup.timer" not in aktiv # Einschalten ist ein Schritt des Leads (docs/BETRIEB.md)
start = re.search(r"systemctl --user start (mc2-radar\.timer(?:[^\n]*\\\n)*[^\n]*)", DEPLOY_SH)
assert start and "mc2-probe-wiederherstellung.timer" in start.group(1)
def _timer(name: str) -> dict[str, str]:
werte = {}
for zeile in (DEPLOY / name).read_text(encoding="utf-8").splitlines():
if "=" in zeile and not zeile.startswith("#"):
k, v = zeile.split("=", 1)
werte[k.strip()] = v.strip()
return werte
def test_probe_monatlich_nie_sonntags_und_nicht_um_die_sicherung():
t = _timer("mc2-probe-wiederherstellung.timer")
assert t["OnCalendar"] == "Mon *-*-01..07 05:15:00" # erster Montag im Monat
assert t["Persistent"] == "true"
assert _timer("mc2-backup.timer")["OnCalendar"].endswith("03:30:00")
assert _timer("mc2-autoupdate.timer")["OnCalendar"].startswith("Sun ")
def test_units_und_skripte_haben_lf():
dateien = [*DEPLOY.glob("*.service"), *DEPLOY.glob("*.timer"), *DEPLOY.glob("*.sh")]
assert [p.name for p in dateien if b"\r" in p.read_bytes()] == []
# --- pbs-backup.sh mit Attrappe --------------------------------------------------------------------------
# Unter Windows kann „bash“ die WSL-Hülle sein (System32 oder WindowsApps), die Windows-Pfade nicht versteht.
pytestmark_bash = pytest.mark.skipif(
BASH is None or (sys.platform == "win32" and any(s in BASH.lower() for s in ("system32", "windowsapps"))),
reason="braucht eine bash (Git-Bash oder Linux)",
)
@pytest.fixture
def pbs(tmp_path):
(tmp_path / "hermes").mkdir()
(tmp_path / "lswap").mkdir()
env = tmp_path / "pbs.env"
env.write_text("PBS_REPOSITORY=aibox@pbs@192.0.2.1:8007:qnap\nPBS_PASSWORD=nur-ein-test\n", encoding="utf-8")
client = tmp_path / "client"
client.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$STUB_ARGS"\n'
'printf "%s\\n" "${PBS_REPOSITORY:-}" > "$STUB_ENV"\n', encoding="utf-8", newline="\n")
client.chmod(0o755)
umgebung = {**os.environ, "MC_PBS_ENV": env.as_posix(), "MC_PBS_CLIENT": client.as_posix(),
"HERMES_HOME": (tmp_path / "hermes").as_posix(), "MC_PBS_LLAMASWAP_DIR": (tmp_path / "lswap").as_posix(),
"MC_PBS_VAULT_DIR": (tmp_path / "vault").as_posix(),
"STUB_ARGS": (tmp_path / "args.txt").as_posix(), "STUB_ENV": (tmp_path / "env.txt").as_posix()}
return tmp_path, umgebung
def _pbs(umgebung: dict) -> subprocess.CompletedProcess:
return subprocess.run([BASH, (DEPLOY / "pbs-backup.sh").as_posix()], env=umgebung,
capture_output=True, text=True, encoding="utf-8", timeout=60)
@pytestmark_bash
def test_pbs_sichert_ohne_mem0_und_ueberspringt_fehlende_sammlung(pbs):
tmp, umgebung = pbs
r = _pbs(umgebung)
assert r.returncode == 0, r.stdout + r.stderr
args = (tmp / "args.txt").read_text(encoding="utf-8").splitlines()
assert args[0] == "backup" and args[-2:] == ["--backup-id", "aibox"]
assert [a.split(":", 1)[0] for a in args[1:-2]] == ["hermes.pxar", "llamaswap.pxar"] # kein mem0.pxar mehr
assert "wird übersprungen" in r.stdout
assert (tmp / "env.txt").read_text(encoding="utf-8").strip() == "aibox@pbs@192.0.2.1:8007:qnap"
(tmp / "vault").mkdir()
assert _pbs(umgebung).returncode == 0
assert "vault.pxar" in (tmp / "args.txt").read_text(encoding="utf-8")
@pytestmark_bash
def test_pbs_bricht_ohne_zugang_oder_pflichtordner_ab(pbs):
tmp, umgebung = pbs
r = _pbs({**umgebung, "MC_PBS_ENV": (tmp / "fehlt.env").as_posix()})
assert r.returncode == 1 and "! Zugangsdatei fehlt" in r.stdout
r = _pbs({**umgebung, "HERMES_HOME": (tmp / "weg").as_posix()})
assert r.returncode == 1 and "! Ordner fehlt" in r.stdout
assert not (tmp / "args.txt").exists()
+156
View File
@@ -0,0 +1,156 @@
"""Lucys Hirn gezielt prüfen (Wächter, 24.09.2026) und „Jetzt laden“ mit echtem Ergebnis.
Bis 24.09. galt das Hirn als bereit, sobald irgendein Modell lief, und POST /api/models/{id}/load meldete „ok“,
egal was die Engine sagte. llama-swap wird hier durch httpx.MockTransport ersetzt (Format von /running wie in
llama-swap v257: jedes Modell, das nicht „stopped“ ist, mit seinem Zustand)."""
import httpx
import pytest
from services import llamaswap, waechter
_ECHTER_CLIENT = httpx.Client
def _engine(monkeypatch, handler) -> None:
"""llama-swap durch eine Attrappe ersetzen (alle httpx.Client in services.llamaswap)."""
monkeypatch.setattr(llamaswap.httpx, "Client", lambda *a, **kw: _ECHTER_CLIENT(
transport=httpx.MockTransport(handler), timeout=kw.get("timeout")))
def _running(*eintraege) -> httpx.Response:
return httpx.Response(200, json={"running": list(eintraege)})
# --- Zustände aus /running ------------------------------------------------------------------------
def test_modell_zustaende_liest_objekte_und_alte_namenslisten(monkeypatch):
_engine(monkeypatch, lambda req: _running({"model": "Hirn", "state": "starting"},
{"model": "Coder", "state": "ready"}, "Alt"))
assert llamaswap.modell_zustaende() == {"Hirn": "starting", "Coder": "ready", "Alt": "ready"}
_engine(monkeypatch, lambda req: httpx.Response(500))
assert llamaswap.modell_zustaende() is None
@pytest.mark.parametrize("running, zustand", [
([{"model": "Hirn", "state": "ready"}], "bereit"),
([{"model": "Hirn", "state": "starting"}], "laedt"),
([{"model": "Hirn", "state": "stopping"}], "fehlt"),
([{"model": "Coder", "state": "ready"}], "fehlt"), # der alte Fehler: Coder läuft, Hirn ist weg
(None, "unbekannt"),
])
def test_hirn_zustand_prueft_gezielt_das_hirn(monkeypatch, running, zustand):
monkeypatch.setattr(llamaswap, "brain_model_name", lambda: "Hirn")
monkeypatch.setattr(llamaswap, "modell_zustaende",
lambda: None if running is None else {e["model"]: e["state"] for e in running})
assert llamaswap.hirn_zustand() == {"modell": "Hirn", "zustand": zustand}
# --- Wächter ------------------------------------------------------------------------------------------
@pytest.fixture
def hirn(monkeypatch):
"""Der Wächter sieht den Zustand, den der Test vorgibt; seine Frist beginnt frisch."""
zustand = {"modell": "Qwen3.6-35B-A3B", "zustand": "bereit"}
monkeypatch.setattr(waechter, "_hirn", {"fehlt_seit": None, "geprueft": None})
monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: dict(zustand))
return zustand
def test_abgestuerztes_hirn_neben_geladenem_coder_ist_ein_befund(monkeypatch, hirn):
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
monkeypatch.setattr(waechter.llamaswap, "get_running_models", lambda: ["Qwen3.8-27B"]) # Coder läuft
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: True)
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"ActiveState": "active"})
hirn["zustand"] = "fehlt"
befunde = {b.id: b for b in waechter.pruefe_kern()}
assert befunde["kern:hirn"].titel == "Lucys Hirn ist nicht geladen"
assert "Qwen3.6-35B-A3B läuft nicht" in befunde["kern:hirn"].text
assert befunde["kern:hirn"].aktionen == [{"id": "protokoll", "label": "Protokoll des Motors", "dienst": "llama-swap"}]
assert not befunde["kern:hirn"].sofort # zählt erst nach den üblichen Takten
def test_laden_ist_kein_ausfall_bis_zur_frist(monkeypatch, hirn):
monkeypatch.setattr(waechter, "HIRN_LADEN_MAX_S", 600)
hirn["zustand"] = "laedt"
assert waechter._hirn_befund(jetzt=1000.0) is None
assert waechter._hirn_befund(jetzt=1060.0) is None
# Startet es immer wieder neu, wechseln sich „fehlt“ und „lädt“ ab: „fehlt“ ist ein Befund, und nach der Frist
# ist es auch „lädt“ — sonst käme nie ein Hinweis zustande.
hirn["zustand"] = "fehlt"
assert waechter._hirn_befund(jetzt=1120.0).titel == "Lucys Hirn ist nicht geladen"
hirn["zustand"] = "laedt"
for jetzt in range(1180, 1600, 60): # Takt wie im Betrieb: jede Minute
assert waechter._hirn_befund(jetzt=float(jetzt)) is None
b = waechter._hirn_befund(jetzt=1600.0)
assert b.titel == "Lucys Hirn lädt nicht fertig" and "seit über 10 Minuten" in b.text
hirn["zustand"] = "bereit"
assert waechter._hirn_befund(jetzt=1660.0) is None
hirn["zustand"] = "laedt" # nach „bereit“ beginnt die Frist neu
assert waechter._hirn_befund(jetzt=1720.0) is None
def test_lange_pause_setzt_die_frist_zurueck(hirn):
"""Während eines Updates prüft der Wächter den Kern nicht. Danach darf das Hirn wieder in Ruhe laden."""
hirn["zustand"] = "laedt"
assert waechter._hirn_befund(jetzt=0.0) is None
assert waechter._hirn_befund(jetzt=5000.0) is None
def test_ohne_hirn_rolle_ist_es_ein_befund(hirn):
hirn.update(modell=None, zustand="fehlt")
assert "Kein Modell trägt die Rolle „hermes“" in waechter._hirn_befund(jetzt=0.0).text
# --- Jetzt laden -----------------------------------------------------------------------------------------
def _laden(monkeypatch, antwort, running=()) -> dict:
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/running":
return _running(*running)
if isinstance(antwort, Exception):
raise antwort
return antwort
_engine(monkeypatch, handler)
return llamaswap.lade_modell("Qwen3.8-27B", warte_s=5)
def test_laden_meldet_den_fehler_der_engine(monkeypatch):
ergebnis = _laden(monkeypatch, httpx.Response(502, text="upstream command exited prematurely but successfully"))
assert ergebnis["ok"] is False
assert ergebnis["detail"] == ("Qwen3.8-27B ließ sich nicht laden. Die Engine meldet (HTTP 502): "
"upstream command exited prematurely but successfully")
ergebnis = _laden(monkeypatch, httpx.Response(404, json={"error": {"message": "model not found"}}))
assert ergebnis["ok"] is False and ergebnis["detail"].endswith("(HTTP 404): model not found")
def test_laden_gelingt(monkeypatch):
assert _laden(monkeypatch, httpx.Response(200, json={"choices": []})) == {"ok": True, "text": "Qwen3.8-27B ist geladen."}
# Ein Modell, das nicht chattet (embed), antwortet mit Fehler — geladen ist es trotzdem.
ergebnis = _laden(monkeypatch, httpx.Response(501, text="no chat"), running=[{"model": "Qwen3.8-27B", "state": "ready"}])
assert ergebnis["ok"] is True
def test_laden_nach_der_wartezeit(monkeypatch):
zeitueber = httpx.ReadTimeout("zu langsam")
ergebnis = _laden(monkeypatch, zeitueber, running=[{"model": "Qwen3.8-27B", "state": "starting"}])
assert ergebnis["ok"] is True and ergebnis["laedt"] is True
ergebnis = _laden(monkeypatch, zeitueber)
assert ergebnis["ok"] is False and "nach 5 Sekunden nicht geantwortet" in ergebnis["detail"]
ergebnis = _laden(monkeypatch, httpx.ConnectError("refused"))
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectError)" in ergebnis["detail"]
# Unter Windows scheitert ein Verbindungsaufbau zu einem toten Port als Zeitüberschreitung — das ist
# „nicht erreichbar“, nicht „lädt noch“.
ergebnis = _laden(monkeypatch, httpx.ConnectTimeout("timed out"))
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectTimeout)" in ergebnis["detail"]
def test_route_reicht_das_ergebnis_durch(monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import models
monkeypatch.setattr(models.llamaswap, "lade_modell", lambda mid: {"ok": False, "detail": f"{mid}: kaputt"})
app = FastAPI()
app.include_router(models.router)
antwort = TestClient(app).post("/api/models/Qwen3.8-27B/load")
assert antwort.status_code == 200 and antwort.json() == {"ok": False, "detail": "Qwen3.8-27B: kaputt"}
@@ -0,0 +1,252 @@
"""Monatliche Probe-Wiederherstellung (services/probe_wiederherstellung.py, 24.09.2026) und ihr Wächter-Hinweis.
Die Tests bauen Sicherungen so, wie deploy/backup.sh sie schreibt (Mitglieder „./hermes/…“, absolute Verknüpfungen
unter systemd/user), mit einem „lebenden“ Hermes-Ordner daneben — alles im Temp-Ordner, ohne Box und ohne Telegram."""
import io
import json
import os
import tarfile
import time
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from services import probe_wiederherstellung as pw
from services import waechter
GEDAECHTNIS = {"MEMORY.md": b"Tobi mag kurze Antworten.\n", "USER.md": b"Name: Tobi\n"}
def _lebend(wurzel: Path) -> Path:
"""Der lebende Stand: Hermes-Ordner und Datenordner."""
hermes = wurzel / "live" / "hermes"
for name, inhalt in GEDAECHTNIS.items():
(hermes / "memories").mkdir(parents=True, exist_ok=True)
(hermes / "memories" / name).write_bytes(inhalt)
(hermes / "memories" / "USER.md.lock").write_bytes(b"")
(hermes / "SOUL.md").write_bytes(b"Du bist Lucy.\n")
(hermes / "skills" / "news").mkdir(parents=True)
(hermes / "skills" / "news" / "SKILL.md").write_bytes(b"# News\n")
(hermes / "scripts").mkdir()
(hermes / "scripts" / "stack-ist.sh").write_bytes(b"#!/bin/bash\n")
(wurzel / "daten").mkdir()
(wurzel / "daten" / "mc2-waechter.json").write_text('{"hinweise": {}}', encoding="utf-8")
return hermes
def _inhalt(hermes: Path, wurzel: Path) -> dict[str, bytes]:
"""Was backup.sh aus dem lebenden Stand einpacken würde."""
inhalt = {f"hermes/memories/{n}": b for n, b in GEDAECHTNIS.items()}
inhalt.update({
"hermes/memories/USER.md.lock": b"",
"hermes/SOUL.md": (hermes / "SOUL.md").read_bytes(),
"hermes/skills/news/SKILL.md": b"# News\n",
"hermes/scripts/stack-ist.sh": b"#!/bin/bash\n",
"hermes/cron/jobs.json": json.dumps({"jobs": [{"id": "a", "name": "Daily News Report"}]}).encode(),
"hermes/config.yaml": b"model:\n default: hermes\n",
"hermes/.env": b"TELEGRAM_BOT_TOKEN=geheim\n",
"hermes/desktop-gateway-token": b"geheim\n",
"llama-swap/config.yaml": b"models:\n Qwen3.6-35B-A3B:\n cmd: llama-server\n",
"box-wart/mc2-waechter.json": (wurzel / "daten" / "mc2-waechter.json").read_bytes(),
"systemd/user/mission-control-2.service": b"[Service]\n",
"MANIFEST.txt": b"mc2-state backup\n",
})
return inhalt
def _sicherung(ordner: Path, inhalt: dict[str, bytes | None], beginn: datetime | None = None) -> Path:
"""Ein Tarball wie von backup.sh: Name mit Startzeit, Mitglieder „./…“, dazu eine absolute Verknüpfung."""
beginn = beginn or datetime.now(pw.LOCAL_TZ) + timedelta(minutes=10) # „nach“ dem lebenden Stand
ordner.mkdir(parents=True, exist_ok=True)
tarball = ordner / f"mc2-state-{beginn:%Y%m%d-%H%M%S}.tar.gz"
with tarfile.open(tarball, "w:gz") as tar:
for rel, daten in inhalt.items():
if daten is None:
continue
info = tarfile.TarInfo(f"./{rel}")
info.size, info.mode, info.mtime = len(daten), 0o600, time.time()
tar.addfile(info, io.BytesIO(daten))
link = tarfile.TarInfo("./systemd/user/default.target.wants/mission-control-2.service")
link.type, link.linkname = tarfile.SYMTYPE, "/home/hitonabi/.config/systemd/user/mission-control-2.service"
tar.addfile(link)
return tarball
@pytest.fixture
def box(tmp_path, monkeypatch):
hermes = _lebend(tmp_path)
monkeypatch.setattr(pw, "SICHERUNGEN_DIR", tmp_path / "backups")
monkeypatch.setattr(pw, "ZUSTAND_PATH", tmp_path / "daten" / "mc2-probe-wiederherstellung.json")
monkeypatch.setattr(pw, "HERMES_LIVE", hermes)
monkeypatch.setattr(pw, "DATEN_LIVE", tmp_path / "daten")
monkeypatch.setattr(pw, "LLAMA_SWAP_DROPINS", tmp_path / "gibt-es-hier-nicht")
(tmp_path / "tmp").mkdir()
monkeypatch.setenv("MC_PROBE_TMP", str(tmp_path / "tmp"))
meldungen: list[dict] = []
monkeypatch.setattr(pw, "melden", meldungen.append)
return tmp_path, hermes, meldungen
def test_gruene_probe_packt_aus_prueft_und_raeumt_auf(box, monkeypatch):
tmp, hermes, meldungen = box
_sicherung(tmp / "backups", _inhalt(hermes, tmp))
gesehen: dict = {}
echt = pw._pruefe_inhalt
def spion(wurzel, *args):
gesehen.update(wurzel=wurzel, env=(wurzel / "hermes" / ".env").exists(),
token=(wurzel / "hermes" / "desktop-gateway-token").exists(),
seele=(wurzel / "hermes" / "SOUL.md").is_file())
return echt(wurzel, *args)
monkeypatch.setattr(pw, "_pruefe_inhalt", spion)
stand = pw.probe()
assert stand["ergebnis"] == "gruen", stand["fehler"]
assert meldungen == []
assert gesehen["seele"] and not gesehen["env"] and not gesehen["token"] # Geheimnisse bleiben im Archiv
assert not gesehen["wurzel"].exists() and list((tmp / "tmp").iterdir()) == [] # Tmp-Ordner ist weg
assert any(z.startswith("Gedächtnis: 2 Dateien") for z in stand["geprueft"])
assert "Zugangsdaten (.env) enthalten (nicht ausgepackt)" in stand["geprueft"]
assert pw.lese_stand()["ergebnis"] == "gruen"
def test_fehlendes_gedaechtnis_ist_rot_und_wird_gemeldet(box):
tmp, hermes, meldungen = box
inhalt = {k: v for k, v in _inhalt(hermes, tmp).items() if not k.startswith("hermes/memories/")}
_sicherung(tmp / "backups", inhalt)
stand = pw.probe()
assert stand["ergebnis"] == "rot"
assert any("Lucys Gedächtnis (hermes/memories) fehlt" in f for f in stand["fehler"])
# Abgleich mit dem lebenden Stand: eine Zeile je Bereich, auch bei vielen Dateien.
assert "Gedächtnis: 2 Dateien fehlen in der Sicherung (MEMORY.md, USER.md)." in stand["fehler"]
assert len(meldungen) == 1 and pw.lese_stand()["ergebnis"] == "rot"
def test_viele_fehlende_dateien_ergeben_eine_zeile(box):
tmp, hermes, _ = box
for i in range(40):
(hermes / "skills" / f"s{i:02d}").mkdir()
(hermes / "skills" / f"s{i:02d}" / "SKILL.md").write_bytes(b"# x\n")
_sicherung(tmp / "backups", _inhalt(hermes, tmp))
fehler = pw.probe()["fehler"]
assert fehler == ["Skills: 40 Dateien fehlen in der Sicherung (s00/SKILL.md, s01/SKILL.md, s02/SKILL.md …)."]
def test_abweichendes_gedaechtnis_faellt_auf(box):
"""Stand eine Datei schon vor der Sicherung so da, muss die Sicherung sie unverändert enthalten."""
tmp, hermes, _ = box
inhalt = _inhalt(hermes, tmp)
inhalt["hermes/memories/USER.md"] = b"Name: jemand anderes\n"
_sicherung(tmp / "backups", inhalt)
fehler = pw.probe()["fehler"]
assert fehler == ["Gedächtnis: USER.md steht in der Sicherung anders als auf der Box."]
def test_nach_der_sicherung_geaenderte_dateien_zaehlen_nicht(box):
"""Die Sicherung begann vor dem lebenden Stand: Abweichungen sind dann neuere Erinnerungen, kein Fehler."""
tmp, hermes, _ = box
inhalt = _inhalt(hermes, tmp)
inhalt["hermes/memories/USER.md"] = b"alter Stand\n"
_sicherung(tmp / "backups", inhalt, beginn=datetime.now(pw.LOCAL_TZ) - timedelta(hours=2))
assert pw.probe()["ergebnis"] == "gruen"
@pytest.mark.parametrize("weg, text", [
("hermes/SOUL.md", "SOUL.md fehlt in der Sicherung"),
("hermes/skills/news/SKILL.md", "Keine Skills"),
("hermes/scripts/stack-ist.sh", "Keine Cron-Skripte"),
("hermes/config.yaml", "Die Hermes-Config (config.yaml) fehlt"),
("hermes/.env", "Die Zugangsdaten (hermes/.env) fehlen"),
("box-wart/mc2-waechter.json", "Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt"),
("systemd/user/mission-control-2.service", "Die systemd-Units"),
("llama-swap/config.yaml", "Die llama-swap-Config fehlt"),
("hermes/cron/jobs.json", "Die Hermes-Cron-Jobs"),
])
def test_jeder_pflichtinhalt_zaehlt(box, weg, text):
tmp, hermes, _ = box
inhalt = _inhalt(hermes, tmp)
inhalt[weg] = None
_sicherung(tmp / "backups", inhalt)
stand = pw.probe()
assert stand["ergebnis"] == "rot"
assert any(text in f for f in stand["fehler"]), stand["fehler"]
def test_unlesbarer_zustand_und_alte_sicherung(box):
tmp, hermes, _ = box
inhalt = _inhalt(hermes, tmp)
inhalt["box-wart/mc2-radar.json"] = b"{kaputt"
tarball = _sicherung(tmp / "backups", inhalt)
alt = time.time() - 3 * 86400
os.utime(tarball, (alt, alt))
fehler = pw.probe()["fehler"]
assert any("Die jüngste Sicherung ist 3 Tage alt" in f for f in fehler)
assert "Box-Wart-Zustand unlesbar: mc2-radar.json" in fehler
def test_kaputte_und_fehlende_sicherung(box):
tmp, hermes, meldungen = box
assert pw.probe()["fehler"] == [f"Keine Sicherung gefunden ({tmp / 'backups'})."]
tarball = _sicherung(tmp / "backups", _inhalt(hermes, tmp))
tarball.write_bytes(tarball.read_bytes()[:200]) # abgeschnitten wie nach einer vollen Platte
stand = pw.probe()
assert stand["ergebnis"] == "rot" and stand["fehler"][0].startswith("Die Sicherung lässt sich nicht auspacken")
assert list((tmp / "tmp").iterdir()) == [] and len(meldungen) == 2
def test_meldung_geht_in_normaler_dringlichkeit_raus(monkeypatch):
"""Rot → notify.sh mit Betreff [Sicherung], ohne -d: nachts sammelt notify.sh sie für die Morgenmeldung."""
aufrufe: list[list[str]] = []
monkeypatch.setattr(pw, "_bash", lambda: "bash")
monkeypatch.setattr(pw.subprocess, "run", lambda befehl, **kw: aufrufe.append(befehl))
pw.melden({"sicherung": "mc2-state-x.tar.gz", "fehler": ["eins", "zwei", "drei", "vier"]})
befehl = aufrufe[0]
assert befehl[:2] == ["bash", str(pw.NOTIFY_SH)] and befehl[2:4] == ["-s", "[Sicherung]"]
assert "-d" not in befehl and "Alarm" not in befehl[3] and not befehl[4].startswith("KRITISCH")
assert "eins zwei drei (und 1 weitere)" in befehl[4]
# --- Wächter ------------------------------------------------------------------------------------------
def _stand(ergebnis: str, tage: float, fehler: list[str] | None = None) -> dict:
return {"zeit": time.time() - tage * 86400, "ergebnis": ergebnis, "sicherung": "mc2-state-x.tar.gz",
"fehler": fehler or []}
def test_waechter_zeigt_gelb_bei_rot_oder_zu_alt(monkeypatch):
stand: dict = {}
monkeypatch.setattr(waechter.probe_wiederherstellung, "lese_stand", lambda: stand.get("x"))
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"LoadState": "loaded"})
stand["x"] = _stand("gruen", 3)
assert waechter.pruefe_probe_wiederherstellung() == []
stand["x"] = _stand("rot", 3, ["Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.", "b"])
(b,) = waechter.pruefe_probe_wiederherstellung()
assert (b.id, b.stufe, b.sofort) == ("probe:wiederherstellung", "gelb", True)
assert "Lucys Gedächtnis" in b.text and "(und 1 weitere)" in b.text
assert {a["id"] for a in b.aktionen} == {"neustart", "protokoll"}
assert all(a["dienst"] == "mc2-probe-wiederherstellung" for a in b.aktionen)
stand["x"] = _stand("gruen", 41)
(b,) = waechter.pruefe_probe_wiederherstellung()
assert b.stufe == "gelb" and "seit 41 Tagen" in b.titel
def test_waechter_vor_der_ersten_probe(monkeypatch):
"""Noch nie gelaufen: kein Hinweis, solange der Timer fehlt (PC, frische Box) — danach gelb mit „Jetzt prüfen“."""
monkeypatch.setattr(waechter.probe_wiederherstellung, "lese_stand", lambda: None)
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"LoadState": "not-found"})
assert waechter.pruefe_probe_wiederherstellung() == []
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"LoadState": "loaded"})
(b,) = waechter.pruefe_probe_wiederherstellung()
assert b.stufe == "gelb" and b.aktionen[0]["label"] == "Jetzt prüfen"
def test_knoepfe_des_hinweises_sind_erlaubt():
"""„Jetzt prüfen“ und „Protokoll“ laufen über die Allowlist in services.maintenance."""
from services import maintenance
assert {"mc2-probe-wiederherstellung", "pbs-backup"} <= maintenance.USER_SERVICES
assert "pbs-backup" in waechter.TIMER_DIENSTE and waechter.TIMER_DIENSTE["pbs-backup"][1] is False
assert waechter.pruefe_probe_wiederherstellung in waechter.PRUEFUNGEN
+77
View File
@@ -0,0 +1,77 @@
"""„Jetzt suchen“ im Radar als Auftrag (24.09.2026): Die Route antwortet sofort mit einer Auftrags-ID, gesucht wird
in radar_lauf.py --nur-suche. Vorher lief die Suche synchron in der Anfrage und konnte Minuten dauern."""
import sys
import pytest
import radar_lauf
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import radar as radar_router
from services import radar
@pytest.fixture
def auftraege(monkeypatch):
"""Job-Engine-Attrappe: merkt sich den Start; läuft schon einer, kommt der zurück."""
stand: dict = {"gestartet": [], "laeuft": None}
def start_job_exklusiv(gruppe, args, label, **kw):
if stand["laeuft"]:
return None, stand["laeuft"]
stand["gestartet"].append({"gruppe": gruppe, "args": args, "label": label, **kw})
return "job123", None
monkeypatch.setattr(radar.jobengine, "start_job_exklusiv", start_job_exklusiv)
return stand
def test_suche_startet_einen_auftrag(auftraege):
ergebnis = radar.suche_starten()
assert ergebnis["ok"] is True and ergebnis["job_id"] == "job123" and "Aufträge" in ergebnis["text"]
(start,) = auftraege["gestartet"]
assert start["gruppe"] == "radar" and start["zeitlimit_s"] == radar.SUCHE_ZEITLIMIT_S
assert start["args"][0] == sys.executable
assert start["args"][1].endswith("radar_lauf.py") and start["args"][2:] == ["--nur-suche"]
def test_laufende_suche_wird_nicht_doppelt_gestartet(auftraege):
auftraege["laeuft"] = {"id": "job-alt", "state": "running"}
ergebnis = radar.suche_starten()
assert (ergebnis["job_id"], ergebnis["laeuft_schon"]) == ("job-alt", True)
assert auftraege["gestartet"] == []
def test_route_antwortet_sofort_ohne_selbst_zu_suchen(auftraege, monkeypatch):
def nicht_hier():
raise AssertionError("Die Route darf nicht selbst suchen.")
monkeypatch.setattr(radar, "suche", nicht_hier)
app = FastAPI()
app.include_router(radar_router.router)
antwort = TestClient(app).post("/api/radar/suche")
assert antwort.status_code == 200 and antwort.json()["job_id"] == "job123"
@pytest.mark.parametrize("ergebnis, code, zeile", [
({"neu": ["Ornith 1.5"], "wartend": 2, "quellen": {"watchlist": True, "discover": True}}, 0,
"Suche fertig: 1 neu (Ornith 1.5), 2 warten auf den Nachttest."),
({"neu": [], "wartend": 1, "quellen": {"watchlist": True, "discover": False}}, 0,
"Suche fertig: nichts Neues, 1 warten auf den Nachttest, Hugging Face war nicht erreichbar."),
({"neu": [], "wartend": 0, "quellen": {"watchlist": False, "discover": False}}, 1,
("Suche fertig: nichts Neues, 0 warten auf den Nachttest, Hugging Face war nicht erreichbar, "
"die Merkliste war nicht lesbar.")),
])
def test_nur_suche_sagt_das_ergebnis_in_der_letzten_zeile(monkeypatch, capsys, ergebnis, code, zeile):
monkeypatch.setattr(radar_lauf.radar, "suche", lambda: ergebnis)
assert radar_lauf.nur_suche() == code
assert capsys.readouterr().out.strip().splitlines()[-1] == zeile
def test_nur_suche_bei_absturz(monkeypatch, capsys):
def kaputt():
raise OSError("Platte voll")
monkeypatch.setattr(radar_lauf.radar, "suche", kaputt)
assert radar_lauf.nur_suche() == 1
assert capsys.readouterr().out.strip().endswith("Die Suche ist gescheitert: OSError: Platte voll")
+1 -1
View File
@@ -169,7 +169,7 @@ def test_ausblenden_gilt_bis_zum_naechsten_lauf(monkeypatch, tmp_path):
def test_schlafende_spracherkennung_ist_kein_kernbefund(monkeypatch):
"""24.09.2026: Die Spracherkennung schläft bis zur Android-App — kein roter Hinweis mehr."""
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
monkeypatch.setattr(waechter.llamaswap, "brain_status", lambda: {"ready": True})
monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: {"modell": "Hirn", "zustand": "bereit"})
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: "8650" not in url)
monkeypatch.setattr(waechter, "_systemctl_show",
lambda name, system: {"ActiveState": "inactive", "UnitFileState": "disabled"})
+9 -3
View File
@@ -24,13 +24,18 @@ DEPLOY_LOG="/srv/models/mc2-deploy.log"
# Units, die ganz dem Repo gehören (deploy.sh spielt sie aus). Aktiviert wird nur, was laufen soll:
# Konsole und Spracherkennung schlafen seit 24.09.2026 (disable --now), ihre Units bleiben aber aktuell.
# Die Sicherung auf den PBS (pbs-backup) liegt seit 24.09.2026 im Repo; eingeschaltet wird sie von Hand
# (docs/BETRIEB.md, Sicherung) — deploy.sh schaltet sie weder ein noch aus.
UNITS="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service
box-console.service voice-service.service
mc2-radar.service mc2-radar.timer mc2-backup.service mc2-backup.timer
mc2-morgenmeldung.service mc2-morgenmeldung.timer mc2-autoupdate.service mc2-autoupdate.timer
projekte-sync.service projekte-sync.timer"
projekte-sync.service projekte-sync.timer
mc2-probe-wiederherstellung.service mc2-probe-wiederherstellung.timer
pbs-backup.service pbs-backup.timer"
AKTIV="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service
mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer"
mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer
mc2-probe-wiederherstellung.timer"
# Skills, die abgelöst sind: aus Lucys Skill-Index nehmen (verschoben, nicht gelöscht). Dazu die
# alten Bindestrich-Doppel der übrigen Skills (deploy.sh schreibt die Unterstrich-Fassung).
SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator projekt-start pruefstand
@@ -125,7 +130,8 @@ stufe2() {
# shellcheck disable=SC2086
systemctl --user enable $AKTIV >/dev/null 2>&1
# shellcheck disable=SC2086
systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer
systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer \
mc2-probe-wiederherstellung.timer
# Den Update-Timer nur starten, wenn er nicht schon läuft: Mit Persistent=true holt ein frisch
# gestarteter Timer einen verpassten Sonntagslauf sofort nach (so am 24.09.2026 passiert). Neu
# gestartet wird die Box dabei ohnehin nur sonntags zwischen 04 und 07 Uhr (autoupdate.sh).
+3 -22
View File
@@ -17,28 +17,9 @@ fail=0
echo "=== Hermes Post-Update: Gehirn-Check ==="
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
# --- angewandt → Gateway neu laden; Exit 1 = ein Patch passt nicht mehr → ROT. ---
PATCH_APPLY="${MC2_SRC:-$HOME/mission-control-v2}/deploy/hermes-patches/apply.py"
if [ -f "$PATCH_APPLY" ]; then
python3 "$PATCH_APPLY" >/dev/null 2>&1; _prc=$?
if [ "$_prc" -eq 10 ]; then
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
echo "PASS · Hermes-Runtime-Patches nach Update RE-angewandt (Gateway neu geladen)"
elif [ "$_prc" -eq 0 ]; then
echo "PASS · Hermes-Runtime-Patches unveraendert vorhanden"
else
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
fi
else
# Kein Fehler: Der Patch-Traeger wurde mit dem MC2-Kahlschlag (1e68f62) entfernt. Seine
# Fixes zielten auf einen Hermes-Stand, der inzwischen tausende Commits zurueckliegt —
# sie wuerden auf dem heutigen Quellcode ohnehin nicht mehr greifen. WARN statt FAIL, damit
# es sichtbar bleibt, falls jemand wieder Runtime-Patches braucht.
echo "WARN · hermes-patches/apply.py nicht vorhanden (mit 1e68f62 bewusst entfernt) — uebersprungen"
fi
# Der Block fuer den Hermes-Runtime-Patch-Traeger (deploy/hermes-patches/apply.py) ist am 24.09.2026 entfallen:
# Der Traeger wurde mit 1e68f62 bewusst entfernt (Hermes-Quellcode wird nicht gepatcht, AGENTS.md), und die
# Pruefung meldete seitdem bei jedem Lauf nur noch „WARN · nicht vorhanden“.
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
# --- Config-Werten STILL auf Defaults zurück (approvals.mode 'auto' -> manual -> alle ---
@@ -0,0 +1,23 @@
# systemd-USER-Unit: monatliche Probe-Wiederherstellung (seit 24.09.2026), gestartet von
# mc2-probe-wiederherstellung.timer. Packt die jüngste Sicherung aus /srv/models/mc2-backups probeweise in
# einen Tmp-Ordner unter /var/tmp aus, prüft die Pflichtinhalte (Lucys Gedächtnis, SOUL.md, Skills,
# Cron-Skripte, Hermes-Config, Box-Wart-Zustand, Units) und löscht den Ordner wieder. Lebende Dateien fasst
# sie nie an. Ergebnis: /srv/models/mc2-probe-wiederherstellung.json (liest der Wächter); bei Rot eine
# Meldung über notify.sh (nachts in die Morgenmeldung). Logik: backend/services/probe_wiederherstellung.py.
# Von Hand: systemctl --user start mc2-probe-wiederherstellung.service
[Unit]
Description=MC2 Probe-Wiederherstellung (jüngste Sicherung probeweise auspacken und prüfen)
Documentation=file:%h/mission-control-v2/docs/BETRIEB.md
After=mc2-backup.service
[Service]
Type=oneshot
WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m services.probe_wiederherstellung
Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_MODELS_DIR=/srv/models
Environment=MC_LOCAL_TZ=Europe/Berlin
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=15min
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Monatliche Probe-Wiederherstellung (erster Montag im Monat, 05:15)
[Timer]
# Erster Montag im Monat: nie sonntags (Updates So 04:30, Neustart bis 07:00) und lange nach der Sicherung
# (03:30, +≤5 min), deren Ergebnis sie prüft. Rot geht nachts in die Morgenmeldung um 07:00.
# Persistent=true holt einen verpassten Lauf nach — ungefährlich, die Probe schreibt nur in /var/tmp und in
# ihre Zustandsdatei.
OnCalendar=Mon *-*-01..07 05:15:00
Persistent=true
[Install]
WantedBy=timers.target
+17
View File
@@ -0,0 +1,17 @@
# systemd-USER-Unit: Sicherung der Box auf den Proxmox Backup Server (PBS 192.168.178.156, Datastore „qnap“ =
# NFS-Freigabe auf dem QNAP), gestartet von pbs-backup.timer. Lief seit 09.07.2026 täglich (Unit lag nur auf der
# Box), brach am 21.08. ab (Quellordner des alten Gedächtnis-Dienstes weg) und war seitdem aus. Seit 24.09.2026
# im Repo: deploy.sh spielt die Unit aus, eingeschaltet wird sie von Hand (docs/BETRIEB.md, Sicherung).
# Skript: deploy/pbs-backup.sh (Zugang und Client liegen nur auf der Box, nicht im Repo).
[Unit]
Description=Sicherung der Box auf den PBS (~/.hermes, Wissens-Sammlung, llama-swap-Config)
Documentation=file:%h/mission-control-v2/docs/BETRIEB.md
After=mc2-backup.service
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/pbs-backup.sh
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=1h
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# pbs-backup.sh — Sicherung der Box auf den Proxmox Backup Server (Backup-ID aibox, Datastore „qnap“).
#
# Zweite Schicht neben der täglichen Tarball-Sicherung (backup.sh): ~/.hermes ganz (auch Sitzungen und Protokolle,
# die der Tarball bewusst weglässt), die Wissens-Sammlung und die llama-swap-Config als pxar-Archive. Der PBS
# dedupliziert und hebt Stände auf (Prune auf dem PBS: 7 Tage, 4 Wochen, 3 Monate); die Daten liegen auf dem QNAP.
# Inkrementell dauert ein Lauf rund 30 Sekunden.
#
# Geschichte: lief vom 09.07.2026 an täglich um 03:50 (damals ~/bin/pbs-backup.sh, nicht im Repo). Am 21.08. brach
# sie ab, weil ihr erster Quellordner (/srv/models/mem0, das abgelöste Gedächtnis) fehlte, und wurde im KISS-Umbau
# abgeschaltet. Seit 24.09.2026 im Repo und ohne mem0. Fehlt ein optionaler Ordner, läuft sie ohne ihn weiter.
#
# Braucht (nur auf der Box, nie im Repo):
# ~/.config/pbs-backup/env PBS_REPOSITORY, PBS_PASSWORD, PBS_FINGERPRINT (Rechte 600)
# ~/bin/proxmox-backup-client statisches Client-Programm
# Aufruf: systemctl --user start pbs-backup.service (Protokoll: journalctl --user -u pbs-backup)
set -euo pipefail
ENV_DATEI="${MC_PBS_ENV:-$HOME/.config/pbs-backup/env}"
CLIENT="${MC_PBS_CLIENT:-$HOME/bin/proxmox-backup-client}"
BACKUP_ID="${MC_PBS_BACKUP_ID:-aibox}"
HERMES_DIR="${HERMES_HOME:-$HOME/.hermes}"
LSWAP_DIR="${MC_PBS_LLAMASWAP_DIR:-/etc/llama-swap}"
VAULT_DIR="${MC_PBS_VAULT_DIR:-$HOME/wissens-vault}"
[ -r "$ENV_DATEI" ] || { echo "! Zugangsdatei fehlt: $ENV_DATEI"; exit 1; }
[ -x "$CLIENT" ] || { echo "! proxmox-backup-client fehlt: $CLIENT"; exit 1; }
# Pflicht: ohne diese beiden ist die Sicherung wertlos.
for pflicht in "$HERMES_DIR" "$LSWAP_DIR"; do
[ -d "$pflicht" ] || { echo "! Ordner fehlt: $pflicht"; exit 1; }
done
archive=("hermes.pxar:$HERMES_DIR" "llamaswap.pxar:$LSWAP_DIR")
# Optional: fehlt die Wissens-Sammlung, läuft die Sicherung ohne sie (am 21.08. brach sie an so etwas ab).
if [ -d "$VAULT_DIR" ]; then
archive+=("vault.pxar:$VAULT_DIR")
else
echo "Hinweis: $VAULT_DIR fehlt, wird übersprungen."
fi
# Zugang erst hier einlesen: PBS_PASSWORD und Co. landen nur in der Umgebung des Clients, nie auf der Befehlszeile.
set -a
# shellcheck disable=SC1090
. "$ENV_DATEI"
set +a
exec "$CLIENT" backup "${archive[@]}" --backup-id "$BACKUP_ID"
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Tägliche Sicherung auf den PBS (03:50)
[Timer]
# Nach der Tarball-Sicherung (03:30, +≤5 min) und vor dem Sonntags-Update (04:30). Dauert inkrementell ~30 s.
# Persistent=true holt einen verpassten Lauf nach. Achtung beim ersten Einschalten: Der Timer lief zuletzt am
# 21.08.2026 — er holt deshalb sofort einen Lauf nach (ungefährlich, nur eine zusätzliche Sicherung).
OnCalendar=*-*-* 03:50:00
Persistent=true
[Install]
WantedBy=timers.target
+49 -2
View File
@@ -50,6 +50,7 @@ sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst)
| `[Box wieder ok]` | Wächter | ein gemeldeter roter Hinweis ist erledigt |
| `[Homelab-Problem]` / `[Homelab wieder ok]` | Wächter der Homelab-Instanz | wie oben, sobald diese Instanz läuft |
| `[Modell-Radar]` | `backend/radar_lauf.py` | ein Kandidat hat den Nachttest bestanden |
| `[Sicherung]` | `backend/services/probe_wiederherstellung.py` | die monatliche Probe-Wiederherstellung war rot (normale Dringlichkeit) |
| `[Stack-Radar]` | `jobs/stack-radar.sh` | Samstagsbericht |
| `[Morgenmeldung]` | `morgenmeldung.sh` | Sammelmeldung der Nacht, 07:00 |
| `[Alarm] Deploy` | `deploy.sh` | Deploy gescheitert, der alte Stand läuft wieder (dringend) |
@@ -70,6 +71,7 @@ soll kein Alarm sein).
| Platte (`MC_DATEN_DIR`, auf der Box `/srv/models`) | ab 90 % | ab 80 % |
| Festgehaltene Updates | – | jeder Pin |
| Partner-Instanz (nur mit `MC_PARTNER_URL`) | „<Name> antwortet nicht" | – |
| Sicherung (seit 24.09.) | – | `pbs-backup` scheitert; Probe-Wiederherstellung rot, älter als 40 Tage oder noch nie gelaufen (sobald ihr Timer eingerichtet ist) |
| Abgestürzte Prüfung | – | „Eine Prüfung des Wächters lief nicht" |
In der Rolle `homelab` prüft der Wächter Platte, Partner, den Ausführer und die Weboberflächen der freigegebenen
@@ -90,6 +92,9 @@ es für einen Gast zweimal hintereinander, gibt es einen gelben Hinweis „<App>
erscheint der Hinweis erneut).
- Der Wächter ist der einzige Schreiber von `/srv/models/mc2-waechter.json`. Ist sein letzter Takt älter als
5 Minuten, zeigt die Startseite „Wächter schweigt".
- **Hirn (seit 24.09.):** Geprüft wird gezielt das Modell mit der Rolle `hermes` und sein Zustand in llama-swap
(`/running`), nicht mehr „irgendein Modell läuft". Lädt es gerade (`starting`), ist das kein Befund — höchstens
10 Minuten lang (`MC_WAECHTER_HIRN_LADEN_S`), danach heißt der Hinweis „Lucys Hirn lädt nicht fertig".
## Dienste schlafen legen und wecken
@@ -220,8 +225,50 @@ laufen lassen. Achtung: `ausfuehrer-einrichten.sh` schreibt `/etc/mc2-ausfuehrer
die Liste der Modelldateien.
- Eine Sicherung ist seit 24.09. rund 12 MB groß.
- **Nicht enthalten:** Modelle (neu ladbar), Code (Git), venvs, `~/.ssh` (auch nicht der Spiegel-Schlüssel).
- **Weitere Schichten:** Die Sicherung der Box nach PBS (`pbs-backup.timer`) ist seit 21.08. aus. PBS sichert die
Proxmox-Container, das QNAP macht Snapshots; beides läuft auf den anderen Geräten und ist hier nicht geprüft.
- **Zweite Schicht: PBS** (`pbs-backup.timer`, täglich 03:50; Units und Skript seit 24.09. im Repo):
`deploy/pbs-backup.sh` sichert `~/.hermes` ganz (auch Sitzungen und Protokolle, die der Tarball weglässt),
`~/wissens-vault` (optional) und `/etc/llama-swap` als pxar-Archive auf den Proxmox Backup Server
(`192.168.178.156:8007`, Container 105, Datastore `qnap` = NFS-Freigabe auf dem QNAP, Backup-ID `aibox`). Der PBS
dedupliziert (ein Lauf dauert inkrementell rund 30 s) und hebt 7 Tage, 4 Wochen und 3 Monate auf (Prune-Job auf dem
PBS). Zugang `~/.config/pbs-backup/env` (Benutzer `aibox@pbs`, Rolle DatastoreBackup, Rechte 600) und der Client
`~/bin/proxmox-backup-client` liegen nur auf der Box, nie im Repo. Die Sicherung lief vom 09.07. bis 20.08. täglich
grün; am 21.08. brach sie ab (ihr erster Quellordner `/srv/models/mem0`, das abgelöste Gedächtnis, fehlte) und wurde
im KISS-Umbau abgeschaltet. Das neue Skript kennt mem0 nicht mehr. `deploy.sh` spielt die Units aus, schaltet sie
aber weder ein noch aus. Scheitert ein Lauf, zeigt der Wächter gelb.
- **Außerdem:** Der PBS sichert die Proxmox-Container, das QNAP macht Snapshots; beides läuft auf den anderen Geräten
und ist hier nicht geprüft.
**PBS-Sicherung einschalten** (einmalig nach dem Deploy, der die neuen Units ausspielt):
```bash
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user cat pbs-backup.service | grep ExecStart # muss auf deploy/pbs-backup.sh zeigen
systemctl --user start pbs-backup.service # Probelauf; kehrt nach dem Lauf zurück (~30 s)
journalctl --user -u pbs-backup.service -n 20 --no-pager # erwartet: „Duration: …“ und „End Time: …“, kein „Error“
systemctl --user enable --now pbs-backup.timer # holt beim ersten Start den seit 21.08. verpassten Lauf nach
systemctl --user list-timers pbs-backup.timer # nächster Lauf 03:50
```
Ausschalten: `systemctl --user disable --now pbs-backup.timer`. Die alte Fassung `~/bin/pbs-backup.sh` wird danach
nicht mehr gebraucht; der Client daneben schon.
## Probe-Wiederherstellung (monatlich, seit 24.09.)
- **Wann:** `mc2-probe-wiederherstellung.timer`, am ersten Montag im Monat um 05:15 — nie sonntags (Updates ab 04:30),
lange nach der Sicherung um 03:30. `Persistent=true` holt einen verpassten Lauf nach. Von Hand:
`systemctl --user start mc2-probe-wiederherstellung.service` oder Knopf „Jetzt prüfen" am Hinweis.
- **Was:** `backend/services/probe_wiederherstellung.py` packt die jüngste Sicherung aus `/srv/models/mc2-backups` in
einen eigenen Tmp-Ordner `/var/tmp/mc2-probe-*` aus, prüft und löscht ihn wieder. Lebende Dateien fasst sie nie
an. `.env` und die Token-Dateien packt sie nicht aus, sie prüft nur, dass sie in der Sicherung stehen.
- **Geprüft:** Die jüngste Sicherung ist höchstens 48 Stunden alt und lässt sich vollständig lesen. Lucys Gedächtnis
(`memories/`) und `SOUL.md` sind da und stimmen mit dem lebenden Stand überein, soweit er vor der Sicherung schon so
dastand. Skills, Cron-Skripte und Hermes-Cron-Jobs sind da, `config.yaml` ist lesbar, `.env` vorhanden, die
llama-swap-Config hat Modelle, jede `mc2-*.json` des Box-Wart-Zustands ist lesbar, die systemd-Units samt
llama-swap-Drop-ins sind drin.
- **Ergebnis:** `/srv/models/mc2-probe-wiederherstellung.json` (Zeit, `gruen`/`rot`, Sicherung, Geprüftes, Fehler)
und `journalctl --user -u mc2-probe-wiederherstellung`. Bei Rot: Meldung „[Sicherung]" in normaler Dringlichkeit
(nachts in die Morgenmeldung) und ein gelber Hinweis des Wächters. Gelb auch, wenn die letzte Probe älter als
40 Tage ist.
## Zurückspielen
+3 -9
View File
@@ -41,15 +41,9 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“;
und `restore.sh` sichern es samt Kopie `desktop-gateway-token`. Entfernen ist Security-Config.
2. **Units nur auf der Box:** `hermes-builtin-ui.service` samt Drop-ins und die llama-swap-Drop-ins (darunter
`warmset.conf`) liegen nicht im Repo; seit 24.09. stehen sie als Kopie in jeder Sicherung.
3. **Ungepinnte Abhängigkeiten:** `backend/requirements.txt` nur mit `>=`, jeder Deploy zieht die neueste Version.
Was nachweislich zusammen lief, steht in `known-good/` jeder Sicherung.
4. **Der Wächter hält das Hirn für bereit, sobald irgendein Modell läuft** (`pruefe_kern()`). Ein abgestürztes Hirn
neben einem geladenen Coder bliebe unbemerkt. Klären, ob gewollt.
5. **Radar „Jetzt suchen" läuft synchron** und kann Minuten dauern; `POST /api/models/{id}/load` meldet `ok`, egal
was die Engine antwortet.
6. **Kleinkram im Code:** `mcp/mcp_mc.py` (MCP aus) ruft die nie vorhandene Route `/api/routing/route`;
`hermes-postcheck.sh` warnt bei jedem Lauf über den entfernten Patch-Träger; `voice_service/app.py` nennt
faster-whisper statt Parakeet; `.aiexclude` gilt nur Antigravity.
3. **PBS-Sicherung einschalten:** Units und Skript liegen seit 24.09. im Repo (`deploy/pbs-backup.*`), Zugang und
Client sind auf der Box, der PBS ist erreichbar. Nach dem Deploy einmal von Hand einschalten
([BETRIEB.md](../BETRIEB.md), Sicherung); danach die Zeile vom 21.08. in [VERDIKTE.md](VERDIKTE.md) nachziehen.
## Aufräumen auf der Box (nur per User-Klick)
+3 -2
View File
@@ -250,10 +250,11 @@ export const useRadarAktion = () =>
},
)
/** „Jetzt suchen“: Die Box startet die Suche als Auftrag (Gruppe „radar“) und antwortet sofort. */
export const useRadarSuche = () =>
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
erfolg: () => "Radar sucht nach neuen Modellen.",
neuLaden: ["radar"],
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Das Radar sucht jetzt nach neuen Modellen.",
neuLaden: ["jobs"],
})
/** Wer liefert die Seite aus, und gibt es die zweite Instanz? (ändert sich nur mit einem Deploy) */
+27 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useLocation } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { Auftraege } from "@/components/cockpit/Auftraege"
@@ -10,10 +11,11 @@ import {
useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
useRadarSuche, useStart,
} from "@/lib/abfragen"
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { ApiFehler } from "@/lib/api"
import { useLetzteMetrik } from "@/lib/strom"
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
import { letzteMeldung } from "@/lib/wartung"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { ModellSuche } from "./ModellSuche"
@@ -243,6 +245,21 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
const radar = useRadar()
const suche = useRadarSuche()
const jobs = useJobs()
const qc = useQueryClient()
// „Jetzt suchen“ läuft als Auftrag (Gruppe „radar“); der jüngste sagt, ob gerade gesucht wird. Bis die Liste
// der Aufträge den eben gestarteten kennt, gilt er als laufend (sonst sprang der Knopf kurz zurück).
const liste = jobs.data?.jobs
const auftrag = (liste ?? []).filter((j) => j.group === "radar").at(-1)
const gestartet = suche.data?.job_id
const nochNichtGelistet = !!gestartet && !!liste && !liste.some((j) => j.id === gestartet)
const sucht = suche.isPending || nochNichtGelistet || (auftrag !== undefined && laeuftNoch(auftrag))
// Ist die Suche eben fertig geworden, das Radar gleich neu lesen (sonst erst nach fünf Minuten).
const suchteEben = useRef(sucht)
useEffect(() => {
if (suchteEben.current && !sucht) qc.invalidateQueries({ queryKey: ["radar"] })
suchteEben.current = sucht
}, [sucht, qc])
if (radar.isError && !radar.data) {
// 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar.
const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404
@@ -263,15 +280,21 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
id="radar"
className="scroll-mt-4"
rechts={
<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>
{suche.isPending ? "Sucht …" : "Jetzt suchen"}
<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={sucht}>
{sucht ? "Sucht …" : "Jetzt suchen"}
</Button>
}
>
<p className="-mt-2 text-sm text-text-2">
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
{sucht ? " Sucht gerade bei Hugging Face. Das dauert ein paar Minuten; den Stand zeigt der Auftrag oben unter „Aufträge“." : ""}
</p>
{!sucht && auftrag?.state === "failed" && (
<p role="alert" className="text-sm text-rot-text">
Die letzte Suche ist gescheitert: {letzteMeldung(auftrag) ?? "ohne Meldung"}
</p>
)}
{!d ? (
<p className="text-[15px] text-text-2">Wird gelesen …</p>
) : d.kandidaten.length === 0 ? (
+4 -14
View File
@@ -3,7 +3,7 @@
Mission Control 2.0 — Stack-Management MCP Server (für Hermes).
Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern:
Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle
Modelle ansehen/entdecken/installieren/löschen, Rollen setzen, Routing ansehen, Modelle
laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden,
System-Status lesen. Spricht die MC-2-REST-API (/api/*).
@@ -45,12 +45,6 @@ def _post(path: str, data: dict | None = None):
return r.json()
def _put(path: str, data: dict):
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20)
r.raise_for_status()
return r.json()
def _delete(path: str):
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30)
r.raise_for_status()
@@ -180,13 +174,9 @@ def set_model_role(model_id: str, role: str = "") -> str:
return f"Rolle für '{model_id}': {role or '— (entfernt)'}"
@mcp.tool()
def set_route(name: str, target_alias: str) -> str:
"""Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias."""
_put("/api/routing/route", {"name": name, "target_alias": target_alias})
return f"Routing gesetzt: {name} → {target_alias}"
# Kein Werkzeug zum Umstellen des Routings mehr (24.09.2026): set_route rief PUT /api/routing/route, eine Route,
# die es nie gab. Die Routing-Policy steht in mc2-routing.json und hat seit dem Box-Wart-Umbau keine Schreib-Route;
# lesen geht weiter über routing_overview.
@mcp.tool()
def routing_overview() -> str:
"""Zeigt das aktuelle Gateway-Routing + Fallbacks."""
+8 -6
View File
@@ -2,14 +2,16 @@
"""
Voice-Sidecar für Mission Control 2.0 — lokales STT + gestuftes TTS für „Mit Hermes reden".
WARUM ein eigener Dienst? Die ML-Stacks (faster-whisper, piper, chatterbox + torch) brauchen
ihr eigenes Python-3.12-venv — das MC2-Backend läuft auf Python 3.14 und kann sie nicht
importieren. Genau wie der Mem0-Sidecar (mem0_service/) kapselt dieser schlanke FastAPI-Dienst
die schwere Voice-Logik und exponiert sie auf localhost. MC2 (backend/routers/voice.py) proxyt
ihn nach außen; der Browser-Voice-Client (Frontend „Sprechen"-Tab) redet nie direkt mit ihm.
WARUM ein eigener Dienst? Die ML-Stacks (onnx-asr/Parakeet, faster-whisper, piper, chatterbox + torch)
brauchen ihr eigenes Python-3.12-venv — das MC2-Backend läuft auf Python 3.14 und kann sie nicht
importieren. Dieser schlanke FastAPI-Dienst kapselt die schwere Voice-Logik und exponiert sie auf
localhost. MC2 (backend/routers/voice.py) proxyt ihn nach außen; der Browser-Voice-Client (Frontend
„Sprechen"-Tab) redet nie direkt mit ihm.
Pipeline-Rolle:
- STT : faster-whisper (Default `medium`, int8, CPU, Sprache=de) — Mikro-Audio → Text.
- STT : Parakeet-TDT 0.6B v3 über onnx-asr (Standard, int8, CPU) — Mikro-Audio → Text.
faster-whisper (`medium`, int8, CPU, Sprache=de) nur als Rückfall, wenn Parakeet nicht lädt
oder eine Anfrage `engine=whisper` verlangt.
- TTS : GESTUFT, Engine im Request wählbar (kein Lock-in):
* `piper` — schneller Standard, CPU, quasi-sofort, robustes Deutsch (thorsten).
* `chatterbox` — Premium/Wunschstimme (MIT), Voice-Cloning, dt. über Multilingual.