Files
mission-control-v2/backend/tests/test_hirn_und_laden.py
T
HitonabiandClaude Opus 5.5 93f6613146 box-wart: Hirn gezielt pruefen, Laden meldet das echte Ergebnis, Radar-Suche als Auftrag
- Waechter (pruefe_kern): prueft jetzt gezielt das Modell mit der Rolle hermes und seinen
  Zustand in llama-swap (/running, v257 listet auch ladende Modelle), statt das Hirn fuer
  bereit zu halten, sobald irgendein Modell laeuft. Ein Ladevorgang ist kein Ausfall, aber
  nur bis 10 Minuten (MC_WAECHTER_HIRN_LADEN_S), sonst bliebe ein immer neu startendes Hirn
  unbemerkt. Sonst die ueblichen Takte (FAIL_AFTER); Knopf "Protokoll des Motors".
- llamaswap: modell_zustaende() und hirn_zustand(); lade_modell() meldet ok false mit
  deutschem Grund und der Meldung der Engine, "laedt noch" nach der Wartezeit ist kein Fehler,
  ein geladenes Modell ohne Chat (embed) zaehlt als geladen. POST /api/models/{id}/load
  reicht das Ergebnis durch (Router duenn).
- Radar "Jetzt suchen": startet die Suche als Auftrag (Job-Engine, Gruppe radar, hoechstens
  einer, radar_lauf.py --nur-suche) und antwortet sofort mit der Auftrags-ID. Die Ansicht
  zeigt "Sucht ..." bis der Auftrag fertig ist, liest danach das Radar neu und nennt eine
  gescheiterte Suche; die Auftragskarte zeigt den Stand.
- Tests: Hirn-Zustaende und Frist, Laden gegen eine llama-swap-Attrappe, Radar-Auftrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:39:01 +02:00

157 lines
8.0 KiB
Python

"""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"}