fix(agent): prompt stop + lazy curator polling + build marker; add agent to CI
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user