phase1b: Backend entruempelt und robuster (35 tote Routen raus, Sperren, ehrliche Update-Pruefung)
Ampel / ampel (push) Successful in 26s
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:
co-authored by
Claude Opus 5.5
parent
c8718fcde0
commit
e9f488b56c
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user