MC2 Final (D16 a/b/d): verständliche Updates + ehrliche Speicher-Zahlen

a) Update-Meldungen: generischer Release-Summarizer in Lucys Stimme mit
   strukturiertem Aktions-Verdikt ("Musst du etwas tun? NEIN/JA") für
   Hermes, Engine (llama.cpp) und llama-swap; Fangnetz-Hinweis verheiratet
   Breaking-Change-Sorge mit dem Postcheck.
b) OS ehrlich: zurückgestellte Pakete (Phasen-Rollout / kept back) werden
   ausgewiesen statt scheinbar zu hängen.
d) Ehrliche Speicher-Zahlen: KV-Cache aus echten GGUF-Architektur-Daten
   (Layer × KV-Köpfe × head_dim) + KV-Quant aus dem cmd statt params-blinder
   Schätzung — footprint_gb als eine Zahlensprache (Zentrale, Modell-Manager,
   fits-Check auf warmset+largest). Auto-Rewarm-Nudge nach Config-Reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-03 19:40:59 +02:00
parent 612ce127fa
commit 4e5a7eed35
12 changed files with 578 additions and 225 deletions
+5 -4
View File
@@ -75,15 +75,16 @@ def hermes_brain_info() -> dict:
if m["name"] in persist and m["name"] != cur_name)
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
gtt = gtt_budget_gb()
# Brain muss immer resident sein → passt Brain + größtes on-demand zusammen?
# (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.)
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
budget = {
"gtt_gb": gtt,
"brain_gb": round(brain_gb, 1),
"warm_projected_gb": round(warm, 1),
"largest_ondemand_gb": round(largest_od, 1),
"fits": (brain_gb + largest_od) <= gtt,
"free_after_gb": round(gtt - brain_gb - largest_od, 1),
"fits": (warm + largest_od) <= gtt,
"free_after_gb": round(gtt - warm - largest_od, 1),
}
except Exception:
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
+65 -7
View File
@@ -54,15 +54,45 @@ def params_of_model(model: dict) -> float:
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
return ck, cv
def _real_kv_gb(model: dict, ctx: int) -> float | None:
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
from services import gguf_meta
path = model.get("gguf_path")
if not path:
return None
meta = gguf_meta.arch_meta(path)
if not meta:
return None
ck, cv = _cache_types(model.get("cmd") or "")
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
def footprint_gb(model: dict) -> float:
"""Loaded-Footprint eines Modells = Gewichte + kalibrierter KV-Anteil (bei seinem
aktuellen ctx)."""
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
quant = model.get("quant") or "Q4_K_M"
ctx = int(model.get("ctx") or 32768)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb = params_of_model(model)
weights = max(pb * bpp, size_gb)
kv = _real_kv_gb(model, ctx)
if kv is None:
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
return weights + max(kv, 0.0)
@@ -139,9 +169,37 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
}
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell (aus seiner Rolle,
Params & Quant). Für den 'Auto'-Button an der Modellkarte."""
return setup_aware_ctx(params_of_model(model),
model.get("quant") or "Q4_K_M",
role=model.get("role"))
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
+105
View File
@@ -49,6 +49,14 @@ class _Reader:
n = self.u64()
return self.read(n).decode("utf-8", "replace")
def scalar(self, vtype: int):
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
fmt = _SCALAR_FMT.get(vtype)
if not fmt:
self.skip_value(vtype)
return None
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
def skip_value(self, vtype: int) -> None:
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
weiterzuschieben). Arrays werden elementweise konsumiert."""
@@ -159,3 +167,100 @@ def compatible(target_path: str, draft_path: str) -> bool | None:
if a is None or b is None:
return None
return a == b
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
_ARCH_WANT = (
".block_count", ".attention.head_count_kv", ".attention.head_count",
".attention.key_length", ".attention.value_length", ".embedding_length",
".context_length",
)
def _read_arch_meta(path: str) -> dict | None:
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
raw: dict = {}
arch = None
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "general.architecture" and vtype == _T_STRING:
arch = r.gstr()
continue
if key == "tokenizer.ggml.tokens":
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
if suf is not None and vtype in _SCALAR_SIZE:
raw[suf] = r.scalar(vtype)
else:
r.skip_value(vtype)
n_layers = raw.get(".block_count")
n_head = raw.get(".attention.head_count")
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
n_embd = raw.get(".embedding_length")
hd_k = raw.get(".attention.key_length") \
or (int(n_embd / n_head) if (n_embd and n_head) else None)
hd_v = raw.get(".attention.value_length") or hd_k
if not (n_layers and n_head_kv and hd_k and hd_v):
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=128)
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
return _read_arch_meta(path)
def arch_meta(path: str) -> dict | None:
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
oder unvollständig."""
import os
try:
st = os.stat(path)
except OSError:
return None
return _arch_cached(path, st.st_mtime, st.st_size)
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
_KV_BPE = {
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
}
_GIB = 1024 ** 3
def _bpe(cache_type: str | None) -> float:
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
k = per_tok * meta["head_dim_k"] * _bpe(ck)
v = per_tok * meta["head_dim_v"] * _bpe(cv)
return (k + v) / _GIB
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
return kv_cache_gb(meta, 1, ck, cv)
+7
View File
@@ -197,6 +197,13 @@ def write_config(cfg: dict) -> None:
with tmp.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
os.replace(tmp, CONFIG_PATH)
# llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn.
# Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt.
try:
from services import warmer
warmer.nudge()
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
pass
except PermissionError as exc:
raise PermissionError(
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
+115 -29
View File
@@ -298,8 +298,42 @@ def updates() -> dict:
# ── 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: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
pass
held.sort(key=lambda p: p["name"])
return held
def os_update_details() -> dict:
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat)."""
"""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
@@ -314,7 +348,8 @@ def os_update_details() -> dict:
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}
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()}
except Exception as exc: # noqa: BLE001
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
@@ -334,6 +369,13 @@ def engine_update_details() -> dict:
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: # noqa: BLE001
info["error"] = str(exc)
return info
@@ -354,31 +396,54 @@ def swap_update_details() -> dict:
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: # noqa: BLE001
info["error"] = str(exc)
return info
# LLM-Zusammenfassung der anstehenden Hermes-Commits (die Box liest ihre Release-Notes selbst).
# Gecacht auf den neuesten Commit-Hash — das Modal darf beliebig oft geöffnet werden.
_relnotes_cache: dict = {"key": "", "summary": ""}
# 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_hermes_commits(commits: list[dict]) -> str:
key = commits[0]["hash"] if commits else ""
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 ""
if _relnotes_cache["key"] == key and _relnotes_cache["summary"]:
return _relnotes_cache["summary"]
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
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
prompt = (
"Du bist der Update-Berater einer lokalen AI-Box (Hermes-Agent auf Strix Halo; genutzt werden: "
"api_server/Gateway, memory-provider-Plugin 'mc2-memory', terminal-/web-Tools, approvals, cron). "
"Hier die Commit-Titel des anstehenden Hermes-Updates:\n\n" + subjects + "\n\n"
"Fasse auf DEUTSCH in maximal 6 Stichpunkten zusammen: (1) Breaking Changes oder geänderte/"
"entfernte Config-Schlüssel (WICHTIGSTES zuerst, explizit warnen), (2) was unser Setup betrifft, "
"(3) welche neuen Features sich für uns lohnen. Keine Einleitung, nur die Punkte."
"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={
@@ -389,21 +454,42 @@ def _summarize_hermes_commits(commits: list[dict]) -> str:
r.raise_for_status()
resp = r.json()
choice = (resp.get("choices") or [{}])[0]
summary = (choice.get("message") or {}).get("content") or ""
text = ((choice.get("message") or {}).get("content") or "").strip()
finish_reason = choice.get("finish_reason") or ""
summary = summary.strip()
# finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
# letzten (unvollständigen) Stichpunkt entfernen.
# Bei "stop" oder None → Antwort ist vollständig → unverändert lassen.
if finish_reason == "length" and summary:
lines = summary.rsplit("\n", 1)
summary = lines[0] if len(lines) > 1 else ""
if summary:
_relnotes_cache.update(key=key, summary=summary)
return summary
# 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: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
return f"(Zusammenfassung nicht verfügbar: {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_update_details() -> dict:
@@ -430,7 +516,7 @@ def hermes_update_details() -> dict:
info["commits"] = commits
info["behind"] = len(commits)
if commits:
info["summary"] = _summarize_hermes_commits(commits)
info.update(_summarize_hermes_commits(commits))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
+30 -2
View File
@@ -24,6 +24,24 @@ log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
_loop: asyncio.AbstractEventLoop | None = None
_wake: asyncio.Event | None = None
def nudge() -> None:
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
solange der Wächter (noch) nicht läuft."""
if _loop is not None and _wake is not None and not _loop.is_closed():
try:
_loop.call_soon_threadsafe(_wake.set)
except RuntimeError:
pass
def _brain_model() -> str:
@@ -64,7 +82,11 @@ async def _warm(model: str) -> None:
async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch, wärmt bei Idle vor."""
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch (und sofort nach einem Config-
Reload-Nudge), wärmt bei Idle vor."""
global _loop, _wake
_loop = asyncio.get_running_loop()
_wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True:
try:
@@ -74,4 +96,10 @@ async def rewarm_loop() -> None:
await _warm(model)
except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
try:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
except asyncio.TimeoutError:
pass
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BsGulpv8.js"></script>
<script type="module" crossorigin src="/assets/index-D0kUzkZ8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
</head>
<body>
@@ -129,8 +129,8 @@ export function WarmSetManager() {
{/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Vorsichts-Schätzung (Obergrenze): Modellgewichte + Arbeitsspeicher, falls jedes Modell seinen vollen Kontext ausreizt. Real belegt ist meist deutlich weniger — der echte Ist-Wert steht oben im Speicher-Balken.">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set (Obergrenze)
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span>
<span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
@@ -144,9 +144,9 @@ export function WarmSetManager() {
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Vorsichts-Schätzung: Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) könnte der
Speicher knapp werden. Das Set bleibt dabei immer geladen wird es wirklich eng, schlägt das Laden des großen
Modells fehl (es wartet dann, statt das Set zu verdrängen).
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
bleibt dabei immer geladen wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
statt das Set zu verdrängen).
</span>
</p>
)}
@@ -1,4 +1,4 @@
import { ArrowRight, Bot, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, Shuffle, X } from "lucide-react"
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Job, UpdateDetails } from "@/lib/api"
@@ -47,8 +47,36 @@ export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }:
</div>
) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
) : detail.kind === "os" ? (
(d?.count ?? 0) === 0 ? (
) : (
<>
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
{d?.action_needed != null && (
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
{d.action_needed
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
</div>
{d.action_needed && d.action_text && (
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
)}
</div>
</div>
)}
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
{d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</div>
)}
{detail.kind === "os" ? (
<>
{(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren System ist aktuell.</div>
) : (
<>
@@ -66,7 +94,33 @@ export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }:
))}
</div>
</>
)
)}
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
{(d?.held_back?.length ?? 0) > 0 && (
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt kein Handeln nötig
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
selbst das ist kein Fehler und nichts hängt fest.
</p>
<div className="space-y-1">
{d!.held_back!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
</span>
<span className="text-[9px] text-muted-foreground shrink-0">
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
</span>
</div>
))}
</div>
</div>
)}
</>
) : detail.kind === "engine" || detail.kind === "swap" ? (
<>
<div className="flex items-center gap-2 font-mono text-[11px]">
@@ -79,11 +133,14 @@ export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }:
)}
{d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
<details className="group">
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
<pre className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
</details>
)}
</>
) : (
@@ -92,12 +149,6 @@ export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }:
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div>
) : (
<>
{d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</div>
)}
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
<div className="space-y-1">
{d!.commits!.map((c) => (
@@ -110,10 +161,22 @@ export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }:
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
</>
)
)}
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
{detail.kind !== "os" && !nothing && (
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-muted-foreground leading-normal">
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
Stack per echter Anfrage läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
</p>
</div>
)}
</>
)}
</div>
{/* Footer */}
+6 -1
View File
@@ -306,11 +306,16 @@ export interface UpdatesResp {
export interface UpdateDetails {
kind: "os" | "engine" | "swap" | "hermes"
error?: string
// hermes: LLM-Zusammenfassung der anstehenden Commits (Breaking Changes zuerst)
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
summary?: string
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
action_needed?: boolean | null
action_text?: string
// os
count?: number
packages?: { name: string; current: string; candidate: string }[]
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
// engine
installed_build?: number | null
latest_build?: number | null