"""FastAPI control surface for the agent (served on localhost). Start / stop the worker pool, tune the worker count live (trades desktop responsiveness for throughput), and watch GPU load + progress + the server-side queue. Config is env-seeded; the worker count is adjustable here on the fly. """ from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse from . import logbuf from .config import Config from .gpu import read_gpu from .worker import Worker # Bump on every agent change. The page embeds this and /status reports it; the UI # warns to reload when they differ — so a stale browser-cached page can't be # mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.) VERSION = "2026-06-30.5 · stop+poll+ci" logbuf.install() cfg = Config.from_env() worker = Worker(cfg) app = FastAPI(title="FabledCurator GPU agent") @app.middleware("http") async def _no_store(request, call_next): # The control page is a static string and the status/gpu/logs polls are # live data — never let the browser cache either, or a freshly-pulled agent # image still shows the OLD UI until a hard refresh (operator-flagged # 2026-06-30). resp = await call_next(request) resp.headers["Cache-Control"] = "no-store" return resp @app.on_event("startup") def _maybe_autostart() -> None: # With AUTO_START set, a container restart (host reboot, or `restart: # unless-stopped` after a crash) resumes the worker on its own — the slots # then ride out a still-down curator via lease backoff. Lets the agent # survive a redeploy with nobody at the desktop to click Start. if cfg.auto_start and cfg.token: worker.start() @app.get("/", response_class=HTMLResponse) def index() -> str: return _PAGE.replace("__BUILD__", VERSION) @app.post("/start") def start(): worker.start() return JSONResponse(worker.status()) @app.post("/stop") def stop(): worker.stop() return JSONResponse(worker.status()) @app.post("/concurrency") async def concurrency(request: Request): body = await request.json() worker.set_concurrency(int(body.get("value", 1))) return JSONResponse(worker.status()) @app.post("/auto") async def auto(request: Request): body = await request.json() worker.set_auto(bool(body.get("value", True))) return JSONResponse(worker.status()) @app.get("/gpu") def gpu(): # GPU meters poll this on their own fast cadence. It only reads local # nvidia-smi — no curator round-trip — so the util/VRAM bars stay live even # when /status is slow waiting on the (sometimes busy) curator queue call. g = read_gpu() or {} us = worker.util_smooth() if us is not None: g["util_smooth"] = round(us, 1) # autoscaler's EWMA — the UI bar tracks this return JSONResponse(g) @app.get("/logs") def logs(): return JSONResponse({"lines": list(logbuf.LINES)}) @app.get("/status") def status(): # Pure in-memory read: worker.status() is lock-free and the queue snapshot is # kept fresh by a background poller — NO inline curator call, so this can't # stall the status view when curator is buried under a big backlog. worker.note_ui() # a browser is watching → keep the queue snapshot warm s = worker.status() s["fc_url"] = cfg.fc_url s["configured"] = bool(cfg.token) s["queue"] = worker.latest_queue() s["build"] = VERSION return JSONResponse(s) _PAGE = """ FabledCurator · GPU agent
FabledCurator GPU agent

Server · token · build __BUILD__

Control
auto-tuning to fill the GPU · max 8
Status
state
0
active
0
processed
0
errors
0
waited out
GPU util
VRAM
queue —
Logs
waiting for activity…
"""