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
+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"})