fix(agent): prompt stop + lazy curator polling + build marker; add agent to CI
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s

Addresses operator reports: Stop never finishes, the agent polls curator
constantly, and stale-cached pages get mistaken for a failed deploy.

- Stop is prompt: flip _running BEFORE any lock so /status + worker loops see
  "stopped" immediately, and add a stop/shrink checkpoint in _process (after
  decode, before the expensive detect+embed) that releases the job and bails —
  so a Stop doesn't wait out heavy GPU work.
- Lazy curator polling: the queue snapshot is fetched only while a browser is
  actually watching (a /status hit within UI_IDLE_GRACE) and on a 5s cadence,
  not a constant background loop. The work loop's own lease/submit is curator's
  only visitor otherwise — nothing polls just to poll.
- Build marker: VERSION is embedded in the page and reported on /status; the UI
  shows a "reload" banner when they differ, so a browser-cached page can't be
  mistaken for "the new image didn't deploy" (complements the no-store header).

CI: the lint lane now also `ruff check`s agent/ and compileall-parses it, so the
GPU agent is linted + syntax-checked before its image builds (build.yml only
`docker build`s it). Fixed the agent's pre-existing UP037/B905 so it passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-30 21:39:00 -04:00
parent e6a7fe7d03
commit 79269da802
4 changed files with 62 additions and 17 deletions
+8 -1
View File
@@ -27,7 +27,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Ruff lint - name: Ruff lint
run: ruff check backend/ tests/ alembic/ # agent/ included so the GPU-agent is linted before its image is built
# (build.yml only `docker build`s it — this is where it gets checked).
run: ruff check backend/ tests/ alembic/ agent/
- name: Agent syntax check
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
# CI image, so we can't import it — but compileall parses every module,
# catching syntax errors before the image build.
run: python -m compileall -q agent/fc_agent
backend-lint-and-test: backend-lint-and-test:
runs-on: python-ci runs-on: python-ci
+16 -2
View File
@@ -12,6 +12,11 @@ from .config import Config
from .gpu import read_gpu from .gpu import read_gpu
from .worker import Worker 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() logbuf.install()
cfg = Config.from_env() cfg = Config.from_env()
worker = Worker(cfg) worker = Worker(cfg)
@@ -41,7 +46,7 @@ def _maybe_autostart() -> None:
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index() -> str: def index() -> str:
return _PAGE return _PAGE.replace("__BUILD__", VERSION)
@app.post("/start") @app.post("/start")
@@ -92,10 +97,12 @@ def status():
# Pure in-memory read: worker.status() is lock-free and the queue snapshot is # 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 # 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. # 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 = worker.status()
s["fc_url"] = cfg.fc_url s["fc_url"] = cfg.fc_url
s["configured"] = bool(cfg.token) s["configured"] = bool(cfg.token)
s["queue"] = worker.latest_queue() s["queue"] = worker.latest_queue()
s["build"] = VERSION
return JSONResponse(s) return JSONResponse(s)
@@ -179,8 +186,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div> <div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div> <div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
</header> </header>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code></p> <p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__BUILD__</code></p>
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
</div>
<div id=banner class=banner style=display:none> <div id=banner class=banner style=display:none>
curator unreachable — holding work + retrying, resumes on its own (no restart needed) curator unreachable — holding work + retrying, resumes on its own (no restart needed)
</div> </div>
@@ -227,6 +237,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
</section> </section>
</div> </div>
<script> <script>
const PAGE_BUILD="__BUILD__"
let CAP=8 let CAP=8
// Optimistic transitional state on click, then apply the POST's own status // Optimistic transitional state on click, then apply the POST's own status
// response (it returns worker.status()) for instant feedback — don't wait on the // response (it returns worker.status()) for instant feedback — don't wait on the
@@ -262,6 +273,9 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
function applyStatus(s){ function applyStatus(s){
CAP=s.max_concurrency||8; capn.textContent=CAP CAP=s.max_concurrency||8; capn.textContent=CAP
const running=s.state==='running' const running=s.state==='running'
// Stale-page guard: if the server is a newer build than this page, the cached
// controls may misbehave — tell the operator to reload.
if(s.build && s.build!==PAGE_BUILD) verbanner.style.display='block'
// "stopping" = stopped but in-flight jobs are still draining; resolves to // "stopping" = stopped but in-flight jobs are still draining; resolves to
// "stopped" on its own once active hits 0. // "stopped" on its own once active hits 0.
const draining=!running && s.active>0 const draining=!running && s.active>0
+1 -1
View File
@@ -31,7 +31,7 @@ class Config:
max_panels: int # cap panel crops per frame max_panels: int # cap panel crops per frame
@classmethod @classmethod
def from_env(cls) -> "Config": def from_env(cls) -> Config:
return cls( return cls(
fc_url=os.environ.get("FC_URL", "http://localhost:8000").rstrip("/"), fc_url=os.environ.get("FC_URL", "http://localhost:8000").rstrip("/"),
token=os.environ.get("FC_TOKEN", ""), token=os.environ.get("FC_TOKEN", ""),
+33 -9
View File
@@ -28,7 +28,7 @@ from .crops import crop_region
MAX_BACKOFF_SECONDS = 60.0 MAX_BACKOFF_SECONDS = 60.0
def _is_transient(exc: "requests.RequestException") -> bool: def _is_transient(exc: requests.RequestException) -> bool:
"""A server/transport problem (wait it out) vs a job-specific fault (fail it). """A server/transport problem (wait it out) vs a job-specific fault (fail it).
No response → connection refused/timeout → curator is down → transient. With No response → connection refused/timeout → curator is down → transient. With
a response: 5xx, auth (401/403, e.g. a token blip on redeploy), 408/409/429 a response: 5xx, auth (401/403, e.g. a token blip on redeploy), 408/409/429
@@ -75,6 +75,13 @@ REPROBE_TICKS = 8 # decisions to hold after settling before re-probi
# data that decides whether a download/compute split is worth building. # data that decides whether a download/compute split is worth building.
STATS_INTERVAL = 30.0 STATS_INTERVAL = 30.0
# The queue snapshot exists only to populate the UI's counts, so it's polled
# lazily — only while a browser is actually watching (a /status hit in the last
# UI_IDLE_GRACE seconds), and not on a tight loop. The work loop's own lease/
# submit calls are the real "is curator up?" signal; nothing polls just to poll.
QUEUE_POLL_INTERVAL = 5.0
UI_IDLE_GRACE = 20.0
log = logging.getLogger("fc_agent.worker") log = logging.getLogger("fc_agent.worker")
@@ -109,6 +116,7 @@ class Worker:
# /status read is instant — never an inline curator HTTP call (which # /status read is instant — never an inline curator HTTP call (which
# stalls the whole status view when curator is busy). # stalls the whole status view when curator is busy).
self._queue: dict | None = None self._queue: dict | None = None
self._ui_seen = 0.0 # monotonic time of the last UI /status hit
self._queue_thread = threading.Thread(target=self._queue_poll_loop, daemon=True) self._queue_thread = threading.Thread(target=self._queue_poll_loop, daemon=True)
self._queue_thread.start() self._queue_thread.start()
# Per-stage timing: stage -> [sum_seconds, count], summarised to the log # Per-stage timing: stage -> [sum_seconds, count], summarised to the log
@@ -126,15 +134,21 @@ class Worker:
self._proposers_lock = threading.Lock() self._proposers_lock = threading.Lock()
def _queue_poll_loop(self): def _queue_poll_loop(self):
"""Refresh the curator queue snapshot in the background (every ~3s) so """Refresh the curator queue snapshot so /status is a pure in-memory read
/status is a pure in-memory read. Errors just leave the last snapshot — but ONLY while the UI is being watched (a recent /status hit). No
(or None) — the UI shows 'unreachable' without ever blocking.""" browser open → no polling; the work loop is curator's only visitor.
Errors just leave the last snapshot (or None) — never blocks the UI."""
while True: while True:
if time.monotonic() - self._ui_seen <= UI_IDLE_GRACE:
try: try:
self._queue = self.client.queue_status() self._queue = self.client.queue_status()
except Exception: except Exception:
self._queue = None self._queue = None
time.sleep(3.0) time.sleep(QUEUE_POLL_INTERVAL)
def note_ui(self) -> None:
"""The UI polled /status — keep the queue snapshot warm for a while."""
self._ui_seen = time.monotonic()
def latest_queue(self) -> dict | None: def latest_queue(self) -> dict | None:
return self._queue return self._queue
@@ -189,9 +203,12 @@ class Worker:
self._ctrl_thread.start() self._ctrl_thread.start()
def stop(self): def stop(self):
# Flip the flag FIRST (atomic bool), before any lock, so /status and the
# worker loops observe "stopped" immediately even if _lock is momentarily
# held — the state can never lag behind the click.
self._running = False
self._ctrl_stop.set() self._ctrl_stop.set()
with self._lock: with self._lock:
self._running = False
slots, self._slots = self._slots, [] slots, self._slots = self._slots, []
for s in slots: for s in slots:
s.stop.set() # each slot releases its inflight on exit s.stop.set() # each slot releases its inflight on exit
@@ -272,7 +289,7 @@ class Worker:
for job in jobs: for job in jobs:
if slot.stop.is_set() or not self._running: if slot.stop.is_set() or not self._running:
break break
ok = self._process(job) ok = self._process(job, slot)
slot.inflight = [i for i in slot.inflight if i != job["job_id"]] slot.inflight = [i for i in slot.inflight if i != job["job_id"]]
if not ok: if not ok:
# Server went away mid-batch: hand the rest back (best effort) # Server went away mid-batch: hand the rest back (best effort)
@@ -407,7 +424,7 @@ class Worker:
self._proposers = Proposers(self.cfg) self._proposers = Proposers(self.cfg)
return self._proposers return self._proposers
def _process(self, job: dict) -> bool: def _process(self, job: dict, slot: _Slot) -> bool:
"""Process one job. Returns True when handled (completed, or hard-failed """Process one job. Returns True when handled (completed, or hard-failed
because the job itself is bad) and False on a TRANSPORT error (curator because the job itself is bad) and False on a TRANSPORT error (curator
unreachable / 5xx / our lease was reclaimed mid-flight) — which is not unreachable / 5xx / our lease was reclaimed mid-flight) — which is not
@@ -429,6 +446,13 @@ class Worker:
frames = [(None, media.load_image(data))] frames = [(None, media.load_image(data))]
self._record("decode", time.monotonic() - _t) self._record("decode", time.monotonic() - _t)
# Stop/shrink checkpoint: bail BEFORE the expensive detect+embed so a
# Stop finishes promptly instead of waiting out heavy GPU work. Hand
# the job back to pending for another agent.
if not self._running or slot.stop.is_set():
self.client.release([job["job_id"]])
return True
task = job.get("task") or "ccip" task = job.get("task") or "ccip"
embed_version = job.get("embed_version") or DEFAULT_EMBED_VERSION embed_version = job.get("embed_version") or DEFAULT_EMBED_VERSION
model_name = ( model_name = (
@@ -527,7 +551,7 @@ class Worker:
})) }))
if pending: if pending:
vecs = embedder.embed_batch([c for c, _ in pending]) vecs = embedder.embed_batch([c for c, _ in pending])
for (_c, tmpl), vec in zip(pending, vecs): for (_c, tmpl), vec in zip(pending, vecs, strict=True):
tmpl["siglip_embedding"] = vec tmpl["siglip_embedding"] = vec
tmpl["embedding_version"] = embed_version tmpl["embedding_version"] = embed_version
regions.append(tmpl) regions.append(tmpl)