0cc6302245
Implements every P0 from AUDIT.md plus four P1s, across both halves of the pipeline. Verified by CPU-only self-checks and the orchestrator test suite. No GPU work ran and no pipeline ran. workpc: - worker_scene: read speaker_ref, not the rewritten speaker field. Every line narrated as "Someone" before this. Emit `actions` for the verifier. - worker_script: declare beat + verifier_feedback (pydantic dropped both, so the retry was blind) and render them as a repair prompt. - worker_vision: gate face->identity pairing on containment, assign globally shortest-first, map an out-of-range resolver index to `unresolved` instead of minting a character, parse JSON with raw_decode. - session_manager: tear down a server whose lease vanished mid-load, and spawn the supervisor respawn unlocked. orchestrator (edited in place, NOT committed there): - tracklets: canonicalize gender, add co-presence cannot-links, block transitive bridges across a hard constraint. - correctness: stop failing valid narration on sentence-initial capitals and short quotes; read action evidence from the singular key. - db: stop orphan flags leaking into every chapter; resolve by flag id. - service: TTS returns instead of raising under GATES, auto-resolves under autonomous mode; job admission control; registry names on dialogue resume. - session_proxy: queue on 409 instead of stealing the lease; run heartbeats. Docs restructured per the repo-structure layout: CLAUDE.md is a pointer table, NEXT.md replaces HANDOFF.md, plus ROADMAP.md, JOURNAL.md, decisions/ and caveats/. AUDIT.md now points at those instead of restating them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XD7cAy81MZrc7gCr6aZGWr
275 lines
11 KiB
Python
275 lines
11 KiB
Python
# session_manager.py — the GPU mutex. one warm model at a time on workpc.
|
|
# owns: model registry, llama-server subprocess lifecycle, session leases + reaper.
|
|
# workpc holds no durable state; this only guards the single local GPU.
|
|
#
|
|
# subprocess models (gemma4): this process starts/stops llama-server and health-waits.
|
|
# in-process models (siglip2, dots): no server — the lease just reserves the GPU; the
|
|
# worker loads the transformers model itself after /session/open returns (port=None).
|
|
# the guarantee that matters is the mutex: a second open() gets 409 until the first closes.
|
|
import os, time, uuid, threading, subprocess
|
|
import requests
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
app = FastAPI()
|
|
|
|
MODELS = {
|
|
"gemma4": {
|
|
"binary": "/mnt/D/AI/llama-cpp/llama.cpp/build/bin/llama-server",
|
|
"model": "/mnt/D/AI/gemma4/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
|
|
"mmproj": "/mnt/D/AI/gemma4/mmproj-F16.gguf",
|
|
"port": 8090, "ctx": 32768, # 12B fits 32k KV unquantized on 16GB ROCm; do NOT -ctk/-ctv quant (leaks on ROCm)
|
|
# MTP speculative decoding: the ~430M gemma-4 draft head drafts up to 4 tokens the 12B verifies.
|
|
# needs `--spec-type draft-mtp` to actually engage (without it llama-server loads the draft but
|
|
# silently runs no speculation). ~42% accept on our JSON stages -> faster decode; ~0.9GB VRAM.
|
|
"args_extra": ["--jinja", "--reasoning-budget", "0",
|
|
"-md", "/mnt/D/AI/gemma4/mtp-gemma-4-12b-it-BF16.gguf", "-ngld", "99",
|
|
"--spec-type", "draft-mtp", "--spec-draft-n-max", "4"],
|
|
},
|
|
# in-process models: the worker loads them and holds ~5GB resident. `worker` is the port whose
|
|
# /unload frees that GPU memory on close -- the mutex alone can't reclaim it (leaked across runs,
|
|
# starving the next model on open). subprocess models (gemma4) free via terminate instead.
|
|
"siglip2": {"binary": "transformers", "model": "google/siglip2-so400m-patch16-384", "port": None, "worker": 8003},
|
|
"dots": {"binary": "transformers", "model": "rednote-hilab/dots.tts-base", "port": None, "worker": 8006},
|
|
}
|
|
|
|
SKIP_HEALTH = False # self-check flips this; real runs health-wait the llama-server
|
|
|
|
_lock = threading.Lock()
|
|
# the single resident session, or None. {model, session_id, opened_at, port, ttl, last_beat, proc}
|
|
_active = None
|
|
|
|
|
|
class OpenReq(BaseModel):
|
|
model: str
|
|
ttl: int = 3600
|
|
|
|
class SessionReq(BaseModel):
|
|
session_id: str
|
|
|
|
|
|
def _health_wait(port: int, timeout: int = 300):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
if requests.get(f"http://127.0.0.1:{port}/health", timeout=2).status_code == 200:
|
|
return
|
|
except requests.RequestException:
|
|
pass
|
|
time.sleep(1)
|
|
raise TimeoutError(f"llama-server on :{port} never became healthy")
|
|
|
|
|
|
def _start_subprocess(cfg):
|
|
"""spawn llama-server for a subprocess-type model, return the Popen once healthy."""
|
|
cmd = [cfg["binary"], "-m", cfg["model"], "--port", str(cfg["port"]),
|
|
"-c", str(cfg.get("ctx", 4096)), "-ngl", "99"]
|
|
if cfg.get("mmproj"):
|
|
cmd += ["--mmproj", cfg["mmproj"]]
|
|
cmd += cfg.get("args_extra", [])
|
|
# keep llama-server's stderr — it prints the assert / CUDA error / context-overflow that kills it.
|
|
# DEVNULL here is why crashes showed up as "no error in the logs". append so a respawn keeps history.
|
|
log = open(f"/tmp/llama-server-{cfg['port']}.log", "a")
|
|
proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
|
|
if not SKIP_HEALTH and cfg["port"]:
|
|
_health_wait(cfg["port"])
|
|
return proc
|
|
|
|
|
|
def _teardown(sess):
|
|
proc = sess.get("proc")
|
|
if proc:
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=15) # wait() even on an already-dead proc, else it stays a zombie
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
# in-process models live in the worker, not here -- ask it to free the GPU (best-effort;
|
|
# if the worker is down the VRAM is already gone with it).
|
|
worker = sess.get("worker")
|
|
if worker:
|
|
try:
|
|
requests.post(f"http://127.0.0.1:{worker}/unload", timeout=30)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
|
|
@app.post("/session/open")
|
|
def open_session(req: OpenReq):
|
|
if req.model not in MODELS:
|
|
raise HTTPException(400, f"unknown model {req.model}")
|
|
global _active
|
|
cfg = MODELS[req.model]
|
|
session_id = uuid.uuid4().hex
|
|
with _lock:
|
|
if _active is not None:
|
|
raise HTTPException(409, f"{_active['model']} resident (session {_active['session_id']})")
|
|
# ponytail: claim the slot before the slow spawn/health-wait so /active and /close
|
|
# for other sessions aren't blocked behind it; only the actual subprocess start runs unlocked.
|
|
_active = {
|
|
"model": req.model, "session_id": session_id, "opened_at": time.time(),
|
|
"port": cfg.get("port"), "ttl": req.ttl, "last_beat": time.time(), "proc": None,
|
|
"worker": cfg.get("worker"),
|
|
}
|
|
proc = _start_subprocess(cfg) if cfg["binary"] != "transformers" else None
|
|
with _lock:
|
|
if _active is not None and _active["session_id"] == session_id:
|
|
_active["proc"] = proc
|
|
return {"session_id": session_id, "port": cfg.get("port")}
|
|
# The lease was closed or reaped while we were spawning (close saw proc=None and tore down
|
|
# nothing). The server we just started is unreferenced and would hold its VRAM until someone
|
|
# killed it by hand, and the next open would spawn a SECOND one on the same port.
|
|
_teardown({"proc": proc})
|
|
raise HTTPException(409, "session closed while the model was loading")
|
|
|
|
|
|
@app.post("/session/close")
|
|
def close_session(req: SessionReq):
|
|
with _lock:
|
|
global _active
|
|
if _active is None or _active["session_id"] != req.session_id:
|
|
raise HTTPException(404, "no such active session")
|
|
_teardown(_active)
|
|
_active = None
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/session/active")
|
|
def active():
|
|
with _lock:
|
|
if _active is None:
|
|
return None
|
|
return {k: _active[k] for k in ("model", "session_id", "opened_at", "port")}
|
|
|
|
|
|
@app.post("/session/heartbeat")
|
|
def heartbeat(req: SessionReq):
|
|
with _lock:
|
|
if _active is None or _active["session_id"] != req.session_id:
|
|
raise HTTPException(404, "no such active session")
|
|
_active["last_beat"] = time.time()
|
|
return {"ok": True}
|
|
|
|
|
|
def _reap_once():
|
|
with _lock:
|
|
global _active
|
|
if _active and time.time() - _active["last_beat"] > _active["ttl"]:
|
|
_teardown(_active)
|
|
_active = None
|
|
return True
|
|
return False
|
|
|
|
|
|
def _supervise_once():
|
|
"""Respawn a crashed subprocess model in place. llama-server can OOM/die mid-session; the worker
|
|
talks to the port directly, so if we don't bring it back every remaining panel of the stage fails
|
|
with 'connection refused'. Restarting under the SAME session keeps the lease + port valid so the
|
|
orchestrator never has to re-open. Returns True if a respawn happened."""
|
|
global _active
|
|
with _lock:
|
|
sess = _active
|
|
if not sess:
|
|
return False
|
|
proc = sess.get("proc")
|
|
if proc is None or proc.poll() is None:
|
|
return False # in-process model, or subprocess still alive
|
|
try:
|
|
proc.wait(timeout=1) # reap the zombie
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
# claim the respawn before releasing the lock: proc=None makes the next _supervise_once pass
|
|
# return early, so the health wait can't be entered twice for one death.
|
|
sess["proc"] = None
|
|
session_id, model = sess["session_id"], sess["model"]
|
|
# ponytail: spawn + health-wait UNLOCKED, like open_session. Holding _lock here blocked
|
|
# /session/active, /close and /open for the full health wait (up to 300s).
|
|
print(f"[supervise] gemma subprocess died (rc={proc.returncode}) session {session_id}; "
|
|
f"respawning", flush=True)
|
|
try:
|
|
new_proc = _start_subprocess(MODELS[model])
|
|
except Exception as e:
|
|
print(f"[supervise] respawn failed: {e}; clearing session so the mutex frees", flush=True)
|
|
with _lock:
|
|
if _active is not None and _active["session_id"] == session_id:
|
|
_active = None
|
|
return False
|
|
with _lock:
|
|
if _active is not None and _active["session_id"] == session_id:
|
|
_active["proc"] = new_proc
|
|
_active["last_beat"] = time.time() # don't count the downtime against the TTL reaper
|
|
return True
|
|
_teardown({"proc": new_proc}) # lease went away mid-respawn; don't orphan the server
|
|
return False
|
|
|
|
|
|
def _reaper():
|
|
while True:
|
|
time.sleep(1)
|
|
if _reap_once():
|
|
continue
|
|
_supervise_once()
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _startup():
|
|
threading.Thread(target=_reaper, daemon=True).start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# self-check: mutex (409 on second open), close releases, reaper force-closes a stale lease.
|
|
SKIP_HEALTH = True
|
|
MODELS["gemma4"] = {"binary": "/bin/sleep", "model": "5", "port": None, "args_extra": []}
|
|
|
|
s1 = open_session(OpenReq(model="gemma4", ttl=3600))
|
|
assert active()["model"] == "gemma4"
|
|
try:
|
|
open_session(OpenReq(model="siglip2"))
|
|
assert False, "second open must 409"
|
|
except HTTPException as e:
|
|
assert e.status_code == 409
|
|
close_session(SessionReq(session_id=s1["session_id"]))
|
|
assert active() is None
|
|
|
|
s2 = open_session(OpenReq(model="gemma4", ttl=2))
|
|
_active["last_beat"] -= 5 # simulate a lease that missed its heartbeat window
|
|
assert _reap_once() is True
|
|
assert active() is None
|
|
|
|
# supervisor: a crashed subprocess is respawned in place, keeping the same session_id + port.
|
|
s4 = open_session(OpenReq(model="gemma4", ttl=3600))
|
|
old_proc = _active["proc"]
|
|
old_proc.terminate(); old_proc.wait() # simulate the llama-server crashing mid-session
|
|
assert _supervise_once() is True
|
|
assert active()["session_id"] == s4["session_id"] # lease survived
|
|
assert _active["proc"] is not old_proc and _active["proc"].poll() is None # fresh live proc
|
|
close_session(SessionReq(session_id=s4["session_id"]))
|
|
assert active() is None
|
|
|
|
# a close landing DURING the spawn must not orphan the server: open reports 409 and tears it down.
|
|
MODELS["gemma4"]["binary"] = "/bin/sleep"
|
|
MODELS["gemma4"]["port"] = None
|
|
real_start = _start_subprocess
|
|
|
|
def racing_start(cfg):
|
|
p = real_start(cfg)
|
|
close_session(SessionReq(session_id=_active["session_id"])) # close mid-load
|
|
return p
|
|
_start_subprocess = racing_start
|
|
try:
|
|
open_session(OpenReq(model="gemma4", ttl=3600))
|
|
assert False, "open must 409 when its lease vanished mid-load"
|
|
except HTTPException as e:
|
|
assert e.status_code == 409
|
|
_start_subprocess = real_start
|
|
assert active() is None
|
|
|
|
# in-process model: close must POST /unload to the worker so its resident VRAM is freed.
|
|
calls = []
|
|
requests.post = lambda url, **kw: calls.append(url) or type("R", (), {"status_code": 200})()
|
|
s3 = open_session(OpenReq(model="siglip2", ttl=3600))
|
|
close_session(SessionReq(session_id=s3["session_id"]))
|
|
assert any("/unload" in u and ":8003" in u for u in calls), calls
|
|
print("session_manager self-check ok")
|