Files
mission-control-v2/backend/services/maintenance.py
T
HitonabiandClaude Opus 5.5 177c9a311c
Ampel / ampel (push) Failing after 21s
boxwart: Modell-Radar sucht, testet nachts selbst und empfiehlt (Hirn und Coder)
Suche aus Merkliste (deploy/radar-watchlist.json) und Hugging-Face-Entdeckung; nur
Kandidaten mit Bild-Projektor, die neben das Warm-Set passen (<= 115 GB inkl. KV-Cache).
Nachtlauf mc2-radar.timer 00:30, Tests nur bis 02:30 (um 03:00 kommt NerdQuiz),
hoechstens ein neuer Kandidat pro Woche, Notbremse 02:35, RuntimeMaxSec als letzte
Sicherung. Pruefstand als Modul (deploy/bench/pruefstand.py): Tempo, Werkzeuge,
Deutsch/JSON bzw. Programmieraufgaben und Bild-Probe gegen das heutige Modell der Rolle.
Durchgefallene werden geloescht, Bestandene gemeldet und erst nach "Uebernehmen" getauscht.

Beim Uebernehmen wandern die Zweitrollen mit (fast beim Hirn, heavy beim Coder), und das
Warm-Set des Stewards wird selbst umgestellt statt als Handgriff zu bleiben. Modell-Code
im Pruefstand darf keine Prozesse starten (RLIMIT_NPROC=0). Radar steht im Flugplan und
unter Waechter-Aufsicht; deploy.sh spielt seine Units ein. Oberflaeche: Vergleichswerte
je Kandidat, Rueckfrage vor Uebernehmen und Verwerfen, Status auf Deutsch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:29:38 +02:00

822 lines
39 KiB
Python

"""
Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware),
Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py).
Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig
erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job.
"""
import os
import re
import subprocess
import time
from datetime import datetime
from pathlib import Path
import httpx
import psutil
from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
# 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"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENGINE_UPDATE_CMD = os.environ.get(
"MC_ENGINE_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-engine.sh")
# Engine = offizieller Vulkan-Build von ggml-org/llama.cpp (RADV auf Strix Halo).
ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp")
_engine_cache = {"ts": 0.0, "avail": False}
# Neueste Release, die WIRKLICH ein Vulkan-x64-Binary trägt. Die allerneueste Release hat
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
def _latest_engine_asset_release() -> dict | None:
try:
rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
for rel in (rels if isinstance(rels, list) else []):
if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])):
return rel
except Exception:
pass
return None
# Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes
# Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt.
SWAP_UPDATE_CMD = os.environ.get(
"MC_SWAP_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-swap.sh")
SWAP_BIN = os.environ.get("MC_SWAP_BIN", "/usr/local/bin/llama-swap")
SWAP_REPO = os.environ.get("MC_SWAP_REPO", "mostlygeek/llama-swap")
_swap_cache = {"ts": 0.0, "avail": False}
# Stack-Funktionsprüfung NACH jedem Update (OS/Engine/Router): verifiziert per echter
# Inferenz, dass der Stack noch läuft → Job wird rot, wenn ein Update etwas zerschossen hat.
STACK_POSTCHECK = os.path.join(_REPO_ROOT, "deploy", "stack-postcheck.sh")
def _installed_engine_build() -> int | None:
"""Build-Nummer der installierten llama-server-Binary (z.B. 9821), oder None.
Vulkan-Build braucht LD_LIBRARY_PATH=ENGINE_PATH zum Start von --version."""
bin_path = os.path.join(ENGINE_PATH, "llama-server")
if not os.path.exists(bin_path):
return None
try:
env = dict(os.environ, LD_LIBRARY_PATH=ENGINE_PATH)
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
timeout=20, env=env)
txt = (out.stderr or "") + (out.stdout or "")
# Formate je nach Build: "version: 0.1.0-dev (build 10426, ...)", "version: 9821 (hash)", "build: <hash> (9821)", "b9821".
if (m := re.search(r"\bbuild\s+(\d{3,})\b", txt)) \
or (m := re.search(r"version:\s*(\d{3,})", txt)) \
or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \
or (m := re.search(r"\bb(\d{3,})\b", txt)):
return int(m.group(1))
except Exception:
return None
return None
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _os_upgradable() -> int:
try:
# LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt
# grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0.
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
capture_output=True, text=True, timeout=10)
return int((out.stdout or "0").strip() or 0)
except Exception:
return 0
def _engine_update_available() -> bool:
now = time.time()
if now - _engine_cache["ts"] < 3600:
return _engine_cache["avail"]
avail = False
try:
rel = _latest_engine_asset_release() or {}
tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
installed = _installed_engine_build()
if latest is not None and installed is not None:
avail = latest > installed # präziser Build-Nummer-Vergleich
elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
except Exception:
avail = False
_engine_cache.update(ts=now, avail=avail)
return avail
def _installed_swap_version() -> int | None:
"""Versions-Nummer der installierten llama-swap-Binary (z.B. 228), oder None."""
if not os.path.exists(SWAP_BIN):
return None
try:
out = subprocess.run([SWAP_BIN, "--version"], capture_output=True, text=True, timeout=15)
txt = (out.stdout or "") + (out.stderr or "")
if (m := re.search(r"version:\s*(\d+)", txt)) or (m := re.search(r"\bv?(\d{2,})\b", txt)):
return int(m.group(1))
except Exception:
return None
return None
def _swap_update_available() -> bool:
now = time.time()
if now - _swap_cache["ts"] < 3600:
return _swap_cache["avail"]
avail = False
try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
installed = _installed_swap_version()
if latest is not None and installed is not None:
avail = latest > installed
except Exception:
avail = False
_swap_cache.update(ts=now, avail=avail)
return avail
_comp_cache = {"ts": 0.0, "data": []}
def _hermes_agent_update() -> dict:
"""Hermes-Agent wird aus **git** aktualisiert (CLI `hermes update` = git pull origin <branch>).
Darum HEAD vs. origin/<branch> prüfen (fetch + behind-count) — NICHT GitHub-Releases: die
werden selten getaggt, main läuft ihnen voraus → sonst zeigt das UI nie ein Update an."""
info = {"key": "hermes_agent", "name": "Hermes Agent", "current": None,
"latest": None, "update": False, "reachable": None}
git = system.find_hermes_agent_git()
if not git or not git.get("path"):
return info
path = git["path"]
info["current"] = git.get("hash")
try:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
info["reachable"] = (fetch.returncode == 0)
if fetch.returncode == 0:
cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"],
capture_output=True, text=True, timeout=8)
behind = int(cnt.stdout.strip() or "0") if cnt.returncode == 0 else 0
info["behind"] = behind
info["update"] = behind > 0
oh = subprocess.run(["git", "-C", path, "rev-parse", "--short", f"origin/{branch}"],
capture_output=True, text=True, timeout=8).stdout.strip()
info["latest"] = (f"{oh} ({behind} neu)" if behind else (oh or info["current"]))
except Exception:
info["reachable"] = False
return info
def _components_cached() -> list[dict]:
"""Update-Status von Hermes-Agent (1h-Cache → GitHub schonen)."""
now = time.time()
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
return _comp_cache["data"]
data = [_hermes_agent_update()]
_comp_cache.update(ts=now, data=data)
return data
def _params_of(m: dict) -> float:
"""Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung
und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab)."""
from services.fit import QUANT_BYTES_PER_PARAM
caps = m.get("capabilities") or {}
bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55)
size_gb = (m.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 0.0)
# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?").
_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"),
("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)"))
def _gen_key(name: str):
"""(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0),
'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar."""
low = (name or "").lower()
sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \
"-coder" if ("coder" in low or "-code" in low) else ""
for fam, pat in _FAM_PATS:
m = re.search(pat, low)
if m:
ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0
return (fam + sub, ver)
return None
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
cm = catalog.meta_for_name(name)
if cm:
return {"family": cm.get("family"), "gen": cm.get("generation"),
"total": float(cm.get("total_params_b") or 0),
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
d = model_dict or {}
g = _gen_key(name)
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
return {"family": (d.get("family") or (g[0] if g else None)),
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
def model_upgrades() -> list[dict]:
"""Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
(MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
disc = discover.safe_discover(_ram_gb())
if not disc:
return []
installed = llamaswap.list_models()
inst_by_role = {m["role"]: m for m in installed if m.get("role")}
cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values())
out = []
for c in disc.get("categories", []):
role = c["role"]
im = inst_by_role.get(role)
if im is None:
continue
rec = c.get("recommended")
if not rec:
continue
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
i = _meta(im["name"], im=im)
r = _meta(rec, model_dict=rec_model)
if not i["family"] or not r["family"] or i["family"] != r["family"]:
continue # andere/unbekannte Familie → kein Upgrade
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
continue # ältere Generation → niemals
same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
if same_gen:
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
continue # gleiche Gen, nicht größer → kein Upgrade
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
continue
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
continue
base = rec.split("/")[-1].lower()
stem = base.removesuffix("-gguf")
if base in cmds or (stem and stem in cmds):
continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec})
return out
def _last_apt_update() -> float | None:
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
if os.path.exists(path):
try:
return os.path.getmtime(path)
except Exception:
pass
return None
def updates() -> dict:
ups = model_upgrades()
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
"swap": 1 if _swap_update_available() else 0,
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
"components": _components_cached()}
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
def _os_held_back() -> list[dict]:
"""Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund.
Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen:
• Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'.
• zurückgehalten wegen neuer Abhängigkeiten → 'kept back'.
Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl,
wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen."""
held: list[dict] = []
sections = {
"The following upgrades have been deferred due to phasing:": "phasing",
"The following packages have been kept back:": "kept_back",
}
try:
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
capture_output=True, text=True, timeout=25)
reason: str | None = None
for line in (out.stdout or "").splitlines():
hdr = sections.get(line.strip())
if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln
reason = hdr
continue
if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts
for name in line.split():
held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None
except Exception:
pass
held.sort(key=lambda p: p["name"])
return held
def os_update_details() -> dict:
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche
Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back)."""
out_pkgs: list[dict] = []
try:
# LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift
# (deutsche Box meldet sonst "[aktualisierbar von:]" und die Liste bliebe leer).
out = subprocess.run(["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null"],
capture_output=True, text=True, timeout=20)
for line in (out.stdout or "").splitlines():
# Format: name/repo neue_version arch [upgradable from: alte_version]
m = re.match(r"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s*([^\]]+)\]",
line.strip())
if m:
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
"current": m.group(3).strip()})
out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()}
except Exception as exc:
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
def engine_update_details() -> dict:
"""Installierte vs. neueste Engine-Build-Nummer + Release-Name/-Notizen/-Link."""
info: dict = {"kind": "engine", "installed_build": _installed_engine_build(),
"latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None}
try:
rel = _latest_engine_asset_release() or {}
tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
info["name"] = rel.get("name") or tag
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
# Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call,
# wenn das Modal bei aktuellem Stand geöffnet wird).
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc:
info["error"] = str(exc)
return info
def swap_update_details() -> dict:
"""Installierte vs. neueste llama-swap-Version + Release-Name/-Notizen/-Link."""
info: dict = {"kind": "swap", "installed_build": _installed_swap_version(),
"latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None}
try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
info["name"] = rel.get("name") or tag
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc:
info["error"] = str(exc)
return info
# LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes
# selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer
# (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …".
# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig
# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen.
_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}}
# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — <grund>".
# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut.
_ACTION_RX = re.compile(
r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE)
def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
"""Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt
ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`."""
empty = {"summary": "", "action_needed": None, "action_text": ""}
if not key:
return empty
cache = _relnotes_caches.setdefault(kind, {})
if cache.get("key") == key and cache.get("data"):
return cache["data"]
from config import LLAMA_SWAP_URL
prompt = (
"Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, "
"fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n"
f"{context}\n\n"
"Anstehende Änderungen:\n" + changes + "\n\n"
"Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n"
"Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n"
" • 'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das "
"Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der "
"Normalfall.\n"
" • 'AKTION: JA — <was er konkret tun/entscheiden muss>' — NUR wenn er wirklich selbst "
"handeln muss.\n"
"Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder "
"geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup "
"betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift."
)
try:
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
"model": "fast", "max_tokens": 550, "temperature": 0.2,
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": prompt}],
})
r.raise_for_status()
resp = r.json()
choice = (resp.get("choices") or [{}])[0]
text = ((choice.get("message") or {}).get("content") or "").strip()
finish_reason = choice.get("finish_reason") or ""
# finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
# letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig.
if finish_reason == "length" and text:
head, _, _tail = text.rpartition("\n")
text = head if head else ""
# Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1,
# aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile).
action_needed: bool | None = None
action_text = ""
lines = text.splitlines()
for idx, ln in enumerate(lines):
if m := _ACTION_RX.match(ln):
action_needed = m.group(1).upper() == "JA"
action_text = (m.group(2) or "").strip()
text = "\n".join(lines[:idx] + lines[idx + 1:]).strip()
break
data = {"summary": text, "action_needed": action_needed, "action_text": action_text}
if text:
cache.update(key=key, data=data)
return data
except Exception as exc:
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""}
def _summarize_hermes_commits(commits: list[dict]) -> dict:
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
"terminal-/web-Tools, approvals, cron).")
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
def _hermes_version(path: str) -> str | None:
"""Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“)."""
try:
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
except OSError:
return None
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
return m.group(1) if m else None
def hermes_update_details() -> dict:
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
git = system.find_hermes_agent_git()
if not git or not git.get("path"):
info["error"] = "Hermes-Agent-Repo nicht gefunden."
return info
path = git["path"]
try:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
info["branch"] = branch
subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr",
f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10)
commits = []
for line in (log.stdout or "").splitlines():
parts = line.split("\x1f")
if len(parts) == 3:
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
info["commits"] = commits
info["behind"] = len(commits)
info["installed_version"] = _hermes_version(path)
if commits:
info.update(_summarize_hermes_commits(commits))
except Exception as exc:
info["error"] = str(exc)
return info
def update_details(kind: str) -> dict:
return {"os": os_update_details, "engine": engine_update_details,
"swap": swap_update_details,
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
def _run(cmd: list[str]) -> dict:
"""Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
ganze Pfad war also totes Risiko ohne Gegenwert.
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
actual_cmd = list(cmd)
if cmd and cmd[0] == "sudo":
if "-S" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-S"]
if "-n" not in actual_cmd:
actual_cmd.insert(1, "-n")
try:
p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
err_msg = p.stderr or ""
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
return {"ok": False, "status": "password_required", "out": p.stdout or "",
"err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
"auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc:
return {"ok": False, "out": "", "err": str(exc)}
def check_sudo_needs_password() -> dict | None:
"""Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
res = _run(["sudo", "true"])
if not res["ok"]:
return res
return None
def restart_service(name: str) -> dict:
if name in SYSTEM_SERVICES:
if err := check_sudo_needs_password():
return err
return _run(["sudo", "systemctl", "restart", name])
if name in USER_SERVICES:
return _run(["systemctl", "--user", "restart", name])
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
def logs(service: str, lines: int = 200) -> dict:
lines = max(1, min(lines, 1000))
if service in USER_SERVICES:
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
return {"ok": r["ok"], "text": r["out"] or r["err"]}
if service in SYSTEM_SERVICES:
# Journal-Lesen braucht i.d.R. KEIN sudo (User ist in Gruppe adm/systemd-journal).
# Erst ohne sudo versuchen; nur bei fehlenden Rechten auf sudo zurückfallen.
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
if r["ok"]:
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
if err := check_sudo_needs_password():
return err
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
return {"ok": r["ok"], "text": r["out"] or r["err"]}
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
def check_updates_job() -> dict:
if err := check_sudo_needs_password():
return err
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
cmd = "sudo apt-get update"
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
return {"ok": True, "job_id": job_id}
def _maintenance_busy() -> dict | None:
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert
Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts)."""
if j := jobengine.active_in_group("maintenance"):
return {"ok": False, "status": "busy", "running": j.get("label")}
return None
def os_update_job() -> dict:
if busy := _maintenance_busy():
return busy
if err := check_sudo_needs_password():
return err
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
# sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab.
cmd = ("sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
group="maintenance")
return {"ok": True, "job_id": job_id}
def engine_update_job() -> dict | None:
if not ENGINE_UPDATE_CMD:
return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
# (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
# update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
"Engine-Update (llama.cpp Vulkan)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def swap_update_job() -> dict | None:
if not SWAP_UPDATE_CMD:
return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
def on_done():
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
# (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
"Router-Update (llama-swap)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def _hermes_update_cmd() -> str:
"""Die komplette Hermes-Update-Befehlskette (Backup → Update → Doctor → UI-Build →
Neustarts → Postcheck) — geteilt von hermes_update_job und update_all_job."""
git = system.find_hermes_agent_git()
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
py = os.path.join(path, "venv", "bin", "python")
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh")
# Backup → Stop UI (verhindert Crash durch Löschen der Sourcen) → update → doctor →
# UI-Build mit MC2-Basepath (/hermes-ui/) → Gateway + UI-Neustart → Smoke-Test.
hui_web = os.path.join(path, "web")
hui_dist = os.path.join(path, "hermes_cli", "web_dist")
npm_path = os.path.expanduser("~/.hermes/node/bin")
build_cmd = (
f"if [ -d {hui_web} ]; then "
f"cd {hui_web} && PATH={npm_path}:$PATH npx --no-install vite build --base=/hermes-ui/ --outDir /tmp/h-build --emptyOutDir "
f"&& rm -rf {hui_dist} && cp -r /tmp/h-build {hui_dist}; fi"
)
# EHRLICHER Exit-Code (Lehre 08.07.): Die Dienste müssen auch bei einem GESCHEITERTEN
# Update wieder hochkommen (die UI wurde ja gestoppt) — aber der Job darf dann nicht
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
return ("RC=0; "
f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart "
f"&& {py} -m hermes_cli.main doctor "
f"&& {build_cmd}; }} || RC=1; "
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
f"systemctl --user restart hermes-gateway hermes-builtin-ui || RC=1; "
f"sleep 6; bash {postcheck} || RC=1; "
f"if [ $RC -ne 0 ]; then echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; "
f"exit $RC")
def hermes_update_job() -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
if busy := _maintenance_busy():
return busy
def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def update_all_job() -> dict:
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy():
return busy
upd = updates()
parts: list[tuple[str, str]] = []
if upd.get("engine") and ENGINE_UPDATE_CMD:
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
if upd.get("swap") and SWAP_UPDATE_CMD:
parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD))
if any(c.get("update") is True for c in upd.get("components") or []):
parts.append(("Hermes-Agent", _hermes_update_cmd()))
os_pending = (upd.get("os") or 0) > 0
if os_pending:
if err := check_sudo_needs_password():
# sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
# statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
# den Fehler ehrlich durchreichen.
if not parts:
return err
os_pending = False
else:
parts.append(("OS (apt)", (
"sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")))
if not parts:
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
cmd = " && ".join(
f"(echo; echo '════════ [{i + 1}/{len(parts)}] {label} ════════'; {c})"
for i, (label, c) in enumerate(parts))
if not os_pending:
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
labels = " → ".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
def reboot() -> dict:
if err := check_sudo_needs_password():
return err
return _run(["sudo", "reboot"])