phase1b: Backend entruempelt und robuster (35 tote Routen raus, Sperren, ehrliche Update-Pruefung)
Ampel / ampel (push) Successful in 26s

Ballast raus:
- 35 Routen ohne Nutzer entfernt (agent/*, fit, roles, ctx, drafts, groups, routing/policy,
  system/history, system/self-update, maintenance/reboot, zeitmaschine/inhalt, zeitplan,
  voice/health|metrics|trace|voices|reference|tts). Von 95 auf 60.
- Tote Module geloescht: agent-Router, roles, agent_aktivitaet, metrics_history (samt
  10-s-Sampler), voice_metrics, migrate_config, parse_mc2_timeout, scripts/.
- Unbenutzte Funktionen und Konstanten entfernt (Modell-Upgrade-Empfehlung, Draft-/Kontext-
  Setzer, Konsole, PC-Ausfuehrer-Probe, Routing-Policy-Editor ...).

Robuster:
- Jobs in eigener Prozessgruppe (Abbrechen beendet wirklich alles), Zeitlimit je Job-Art,
  start_job_exklusiv: zwei Klicks starten kein doppeltes Update mehr; alte Jobs raeumen sich auf.
- Update-Pruefung meldet Fehler (pruef_fehler, Lampe "Pruefung unklar") statt "aktuell".
- Nach jedem Update sofort neu pruefen (update_stand) statt 10 Minuten alten Stand zeigen.
- llama-swap-Config: Sperre (RLock + flock) fuer UI, Radar, Aufraeumen und Hirn-Umstellung.
- Hermes-Config: bei Lesefehler nichts schreiben, atomar, mit Sicherung.
- Live-Strom und Gateway-Warnung blockieren den Event-Loop nicht mehr (Lucy, OpenChamber).
- Gateway antwortet bei Engine-Ausfall im OpenAI-Fehlerformat (502) statt nacktem 500.
- Abgestuerzte Waechter-Pruefung wird ein gelber Hinweis statt still zu verschwinden.
- Download laedt nur den gewuenschten Quant (vorher bei Fehlen alle Teile aller Varianten),
  Download-Jobs in Gruppe "download"; HF-Suche kodiert den Suchbegriff.
- Herkunftspruefung: schreibende /api-Aufrufe fremder Webseiten werden abgelehnt (keine
  Anmeldung, User-Entscheid); Skripte, Desktop-Lucy und /v1 unveraendert.
- Modellpfade: Eintragen und Loeschen nur innerhalb von MODELS_DIR.
- Dienste-Liste fragt keine abgebauten Dienste mehr ab (PC-Ausfuehrer haette 3 s gekostet).
- SSE-Fehlerzeilen von /api/voice/chat als gueltiges JSON.
- mission-control-2.service: --timeout-graceful-shutdown 3 (Neustart ohne 10-s-Haenger).

Tests: 92 gruen (neu: Herkunft, Quant-Auswahl, abgestuerzte Pruefung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:08:44 +02:00
co-authored by Claude Opus 5.5
parent c8718fcde0
commit e9f488b56c
42 changed files with 799 additions and 2197 deletions
+67 -82
View File
@@ -6,9 +6,13 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
"""
import functools
import logging
import os
import re
import threading
from contextlib import contextmanager
from pathlib import Path
import httpx
from config import (
@@ -17,12 +21,18 @@ from config import (
DEFAULT_TTL,
DRAFTS_DIR,
LLAMA_SWAP_URL,
MODELS_DIR,
SPEC_DRAFT_MODEL_PATH,
SPEC_DRAFT_N_MAX,
SPEC_TYPE,
)
from ruamel.yaml.scalarstring import LiteralScalarString
try:
import fcntl
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
fcntl = None
log = logging.getLogger(__name__)
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
@@ -53,6 +63,44 @@ def _gguf_total_size(path: str) -> int | None:
return None
# --- Sperre ------------------------------------------------------------------
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
_SPERRE = threading.RLock()
_SPERR_TIEFE = threading.local()
@contextmanager
def config_sperre():
with _SPERRE:
tiefe = getattr(_SPERR_TIEFE, "n", 0)
_SPERR_TIEFE.n = tiefe + 1
datei = None
try:
if tiefe == 0 and fcntl is not None:
try:
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
fcntl.flock(datei, fcntl.LOCK_EX)
except OSError:
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
yield
finally:
_SPERR_TIEFE.n = tiefe
if datei is not None:
fcntl.flock(datei, fcntl.LOCK_UN)
datei.close()
def _mit_sperre(fn):
@functools.wraps(fn)
def huelle(*args, **kwargs):
with config_sperre():
return fn(*args, **kwargs)
return huelle
# --- Lesen -------------------------------------------------------------------
def read_config() -> dict:
if not CONFIG_PATH.exists():
@@ -236,7 +284,7 @@ def write_config(cfg: dict) -> None:
) from exc
@_mit_sperre
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False, set_alias: bool = True) -> str:
@@ -368,70 +416,8 @@ def spec_draft_flags(target_path: str) -> str:
return _spec_flags_for_draft(d) if d else ""
def drafts_for(target_path: str) -> dict:
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
from services import gguf_meta
exists = bool(target_path and os.path.exists(target_path))
drafts = list_drafts()
seen = {d["path"] for d in drafts}
for p in _sibling_mtp_drafters(target_path):
if p not in seen:
drafts.append({"path": p, "filename": os.path.basename(p),
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
"vocab": gguf_meta.fingerprint(p)})
for d in drafts:
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
d["mtp"] = _is_mtp_draft(d["path"])
return {
"target_path": target_path,
"target_exists": exists,
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
"drafts": drafts,
}
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
abgelehnt (idiotensicher). Returns {ok, reason}."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not isinstance(spec, dict):
return {"ok": False, "reason": "Modell nicht gefunden"}
cmd = str(spec.get("cmd", ""))
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
if draft_path:
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
if not os.path.isabs(draft_path) and "/" not in draft_path:
draft_path = str(DRAFTS_DIR / draft_path)
if not os.path.exists(draft_path):
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
from services import gguf_meta
target = ""
if (mt := _PATH_RE.search(cmd)):
target = mt.group(1).replace("'", "").replace('"', "")
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
if comp is not True:
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
"würde beim Laden scheitern."
if comp is False else
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
return {"ok": False, "reason": reason}
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
write_config(cfg)
return {"ok": True, "reason": ""}
# --- Groups (Ko-Residenz) ----------------------------------------------------
@_mit_sperre
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
@@ -467,6 +453,7 @@ def list_groups() -> dict:
return read_config().get("groups") or {}
@_mit_sperre
def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
@@ -478,6 +465,7 @@ def set_role(model_id: str, role: str | None) -> bool:
return True
@_mit_sperre
def add_role(model_id: str, role: str) -> bool:
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
@@ -493,22 +481,7 @@ def add_role(model_id: str, role: str) -> bool:
return True
def set_ctx(model_id: str, ctx: int) -> bool:
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not spec:
return False
cmd = str(spec.get("cmd", ""))
if _CTX_RE.search(cmd):
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
write_config(cfg)
return True
@_mit_sperre
def set_ttl(model_id: str, ttl: int) -> bool:
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
@@ -521,6 +494,15 @@ def set_ttl(model_id: str, ttl: int) -> bool:
return True
@_mit_sperre
def im_modellordner(pfad: str) -> bool:
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
try:
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
except (OSError, ValueError):
return False
def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
@@ -540,6 +522,9 @@ def delete_model(model_id: str) -> bool:
m = None
if m:
path = m.group(1).replace("'", "").replace('"', "")
if path and not im_modellordner(path):
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
path = ""
if path:
# 1. Haupt-GGUF-Datei löschen
if os.path.exists(path):
@@ -567,7 +552,7 @@ def delete_model(model_id: str) -> bool:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match:
m_path = mmproj_match.group(1)
if os.path.exists(m_path) and m_path not in andere:
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
try:
os.remove(m_path)
except Exception:
@@ -575,7 +560,7 @@ def delete_model(model_id: str) -> bool:
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
try:
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
os.rmdir(dirname)
except Exception:
pass