Merge branch 'worktree-agent-a1c799645a0ade657' into wartung/restarbeiten
This commit is contained in:
-27
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)}"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user