UMBAU v3 P1: Gateway-Auszug — /v1 wird eigener Prozess mc2-gateway (:9010)

ACHTUNG: Annahme AKTIVIERT die Stufe 1 (deploy.sh installiert
mc2-gateway.service + setzt MC_V1_UPSTREAM in der MC2-Unit; Health mit
Kaltstart-Retry bis 12s, dann hart rot -> Runner-Rollback greift).
MC2 :9001/v1 wird duenner Roh-Weiterleiter, LAN-Clients merken nichts;
Rollback = MC_V1_UPSTREAM-Zeile aus der Unit entfernen. token_stats
laedt bei Fremd-Aenderung per mtime nach (Gateway schreibt, Steuerpult
liest). UMBAUPLAN Abschnitt 3b dokumentiert P1-P4. Stufe 2 (Lucy direkt
an :9010, ueberlebt MC2-Neustarts) = deploy/gateway-cutover.sh, separat.

Neu aufgesetzt 15.07. auf aktuellem main (a3d9c74): die urspruengliche
Karte trug die inzwischen veraltete Von-allein-View doppelt - die ist
laengst auf main live. Inhalt = P1 der Parallel-Session, unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-15 10:51:51 +02:00
parent a3d9c745cd
commit ccc9a25a25
7 changed files with 232 additions and 1 deletions
+64
View File
@@ -0,0 +1,64 @@
"""
MC2-Gateway — der /v1-Datenpfad als EIGENER Prozess (UMBAU v3, P1).
Befund der Live-Inspektion 15.07.2026: Jeder LLM-Aufruf der Box (Lucys Haupt-Hirn,
Nacht-Crons, Worker-Delegation, Vision, Decomposer/Specifier/Curator) lief durch den
Steuerpult-Prozess auf :9001 — den am häufigsten neu gestarteten Dienst des Stacks.
Dieser Einstieg hebt denselben Gateway-Router (routers/gateway_proxy.py) UNVERÄNDERT
in einen bewusst winzigen, langweiligen Prozess: Unit mc2-gateway.service, Loopback
:9010, Restart=always. Das Steuerpult darf beliebig neu starten — die Wirbelsäule steht.
Bewusst NICHT hier: weitere Router, Hintergrund-Loops, CORS, Frontend-Auslieferung.
LAN-Clients (IDE-Lane) erreichen /v1 weiter über MC2 :9001, das roh hierher
durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
"""
import logging
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import httpx
from fastapi import FastAPI, Request
from config import LLAMA_SWAP_URL, VERSION
from routers import gateway_proxy
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Gleiche Client-Parameter wie zuvor in app.py: Keep-Alive/Pooling statt neuer
# Client pro Anfrage (Sockets/TIME_WAIT unter parallelen Agent-Strömen).
app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
)
log.info("mc2-gateway bereit (Engine: %s)", LLAMA_SWAP_URL)
try:
yield
finally:
await app.state.gw_client.aclose()
app = FastAPI(title="MC2 Gateway", version=VERSION, lifespan=lifespan)
app.include_router(gateway_proxy.router)
@app.get("/gw/health")
async def health(request: Request):
"""Eigener Health-Pfad (nicht /api/health — das gehört dem Steuerpult):
beweist Prozess UND Engine-Erreichbarkeit, für deploy.sh/stack-postcheck.sh."""
engine = False
try:
r = await request.app.state.gw_client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=5.0)
engine = r.status_code == 200
except httpx.HTTPError:
pass
return {"status": "ok", "service": "mc2-gateway", "version": VERSION,
"engine_reachable": engine}
+26 -1
View File
@@ -26,9 +26,22 @@ _lock = threading.Lock()
_stats: dict | None = None
_dirty = False
_last_flush = 0.0
# mtime der Datei beim letzten eigenen Laden/Schreiben — seit dem Gateway-Auszug
# (UMBAU v3 P1) schreibt der mc2-gateway-Prozess die Datei, das Steuerpult liest nur
# noch: ohne mtime-Vergleich zeigte es ab Prozessstart eingefrorene Zahlen.
_disk_mtime: float | None = None
def _stat_mtime() -> float | None:
try:
return STATS_FILE.stat().st_mtime
except OSError:
return None
def _load_from_disk() -> dict:
global _disk_mtime
_disk_mtime = _stat_mtime()
if not STATS_FILE.exists():
return dict(_BASELINE)
try:
@@ -51,19 +64,31 @@ def _ensure_loaded() -> dict:
def _write(stats: dict) -> None:
global _disk_mtime
try:
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATS_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(stats, f)
tmp.replace(STATS_FILE)
_disk_mtime = _stat_mtime() # eigener Write ist kein Fremd-Update
except OSError:
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
def get_stats() -> dict:
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.
Multi-Prozess-fähig: Hat ein ANDERER Prozess (mc2-gateway) die Datei inzwischen
geschrieben und liegen hier keine ungeflushten Inkremente, wird frisch geladen.
Im Gateway-Prozess selbst ist nach jedem Flush Datei == Speicher → der
mtime-Vergleich lädt dort nie unnötig nach."""
global _stats
with _lock:
if _stats is not None and not _dirty:
mtime = _stat_mtime()
if mtime is not None and mtime != _disk_mtime:
_stats = _load_from_disk()
return json.loads(json.dumps(_ensure_loaded()))