Files
manga-recap-pipeline/session_manager.py
T
kami ff6a512630 Reconstruct repo from Claude Code + codex transcripts
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/
Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch
blocks into one timestamp-ordered timeline.

Verified against ground truth recorded in the transcripts: wc -l on 10 files and
ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are
byte-identical to their newest ~/.claude/file-history blob.

See HANDOFF.md for sources, gaps, and how to rebuild .venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:42:41 +04:00

239 lines
9.7 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")}
@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."""
with _lock:
global _active
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
print(f"[supervise] gemma subprocess died (rc={proc.returncode}) session {sess['session_id']}; "
f"respawning", flush=True)
try:
sess["proc"] = _start_subprocess(MODELS[sess["model"]])
sess["last_beat"] = time.time() # don't count the downtime against the TTL reaper
return True
except Exception as e:
print(f"[supervise] respawn failed: {e}; clearing session so the mutex frees", flush=True)
_active = None
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
# 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")