Files
manga-recap-pipeline/session_manager.py
T
kami 0cc6302245 Audit Phase 1: correctness and scheduling safety
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
2026-08-11 10:16:24 +04:00

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