Files
FabledCurator/backend/app/api/gpu.py
T
bvandeusenandClaude Opus 5 dc8af8b1a7
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 30s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m45s
Build images / promote (push) Skipped
CI / integration (push) Successful in 1m49s
feat: a learned roster, so a stopped part is observable (milestone 365 steps 1-2)
Nothing in FabledCurator knew what was SUPPOSED to be running. `celery
inspect` reports the workers that ANSWER, so a dead worker was a shorter list
rather than a red light, and grep for any notion of expected services returned
nothing. That is why Portainer was the only place an operator could see it:
Portainer knows the intended set.

`service_seen` is the memory that makes an absence observable — every part
that has checked in, and when it last did.

**Keyed on the queue set, not the worker hostname.** Celery's worker names
here are `celery@<container id>`, minted fresh on every deploy. Keyed on those,
this table would record a death and a birth every time the stack updates — and
a status page that goes red on every deploy is a status page nobody reads,
which is worse than not having one. CELERY_QUEUES is assigned per role in
compose and survives container replacement, so it is the stable identity. Two
replicas of a role are therefore ONE row, which is right: the question is
whether the role is served, not how many containers exist.

The GPU agent is keyed on agent_id, the identity its lease protocol already
uses. gpu.py received it on both lease and heartbeat and threw it away — an
idle agent with nothing to lease left no trace and was indistinguishable from
one switched off a week ago. Now recorded on the calls that were already
happening.

**Who observes, corrected from the plan.** The plan said "record from the
existing inspect path", which would only run when someone opened the Activity
tab. Two other candidates and why they lost:

- A beat sweep. If the scheduler dies the sweep stops, every row goes stale,
  and the page says everything is down when one thing is. An alarm that cannot
  distinguish "a part died" from "the observer died" is worse than none.
- A background task in web. hypercorn runs --workers 4, so that is four
  concurrent inspect loops per container, forever.

Taken instead: refresh on demand, rate-limited by the newest last_seen_at that
every process can already see. The observer is then the thing serving the page
— if web is down you get a browser error, not a confidently green page — and
it self-limits with no coordination, since a race costs one redundant inspect
that writes identical values.

Migration 0090 is the first written on the collapsed baseline (milestone 328),
so it is also the first evidence the chain steps FORWARD from 0089 rather than
merely reproducing the schema. No secondary indexes: one row per moving part
means every read is a handful of rows, and #3301 is the record of what
speculative indexes cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
2026-09-02 17:15:46 -04:00

441 lines
18 KiB
Python

"""GPU-job API (#114): the HTTP surface the desktop agent pulls work from.
The agent stays HTTP-only — it leases jobs, fetches image pixels via the normal
FC image URLs, and submits embeddings/regions back, all over this API. Redis and
Postgres are never exposed. The agent endpoints are gated by a bearer token
(Authorization: Bearer <token>) stored in AppSetting; the admin endpoints
(token / backfill / status) ride the browser session like the rest of FC's
homelab admin.
"""
import secrets
from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..extensions import get_session
from ..models import AppSetting, GpuJob, ImageRecord, MLSettings
from ..services.gallery_service import image_url
from ..services.ml.gpu_jobs import GpuJobService, error_dedupe_statements
from ..services.ml.gpu_triage import classify_reason, recover_defective_image
from ..services.ml.regions import RegionService
from ..services.service_roster import touch_service
gpu_bp = Blueprint("gpu", __name__, url_prefix="/api/gpu")
# Same container mount the maintenance tasks use (tasks/admin.py) — recovery
# deletes the defective original + thumbnail under it.
_IMAGES_ROOT = Path("/images")
_TOKEN_KEY = "gpu_agent_token"
def _bearer() -> str | None:
h = request.headers.get("Authorization", "")
return h[7:].strip() if h.startswith("Bearer ") else None
async def _agent_authed(session) -> bool:
supplied = _bearer()
if not supplied:
return False
stored = (
await session.execute(
select(AppSetting.value).where(AppSetting.key == _TOKEN_KEY)
)
).scalar_one_or_none()
return stored is not None and secrets.compare_digest(supplied, stored)
# --- Admin (browser): token + backfill + status -------------------------
@gpu_bp.route("/token", methods=["GET"])
async def get_token():
async with get_session() as session:
tok = (
await session.execute(
select(AppSetting.value).where(AppSetting.key == _TOKEN_KEY)
)
).scalar_one_or_none()
return jsonify({"token": tok, "configured": tok is not None})
@gpu_bp.route("/token/rotate", methods=["POST"])
async def rotate_token():
token = secrets.token_urlsafe(32)
async with get_session() as session:
await session.execute(
pg_insert(AppSetting)
.values(key=_TOKEN_KEY, value=token)
.on_conflict_do_update(index_elements=["key"], set_={"value": token})
)
await session.commit()
return jsonify({"token": token})
@gpu_bp.route("/status", methods=["GET"])
async def status():
async with get_session() as session:
rows = (
await session.execute(
select(GpuJob.status, func.count()).group_by(GpuJob.status)
)
).all()
counts = dict(rows)
return jsonify({
"pending": counts.get("pending", 0),
"leased": counts.get("leased", 0),
"done": counts.get("done", 0),
"error": counts.get("error", 0),
})
@gpu_bp.route("/backfill", methods=["POST"])
async def backfill():
"""Enqueue a job for every image that doesn't already have one for `task`."""
body = await request.get_json(silent=True) or {}
task = str(body.get("task") or "ccip")
from ..tasks.gpu_queue import enqueue_gpu_backfill
r = enqueue_gpu_backfill.delay(task)
return jsonify({"celery_task_id": r.id, "task": task}), 202
@gpu_bp.route("/reprocess", methods=["POST"])
async def reprocess():
"""Reset every done/error job of `task` back to pending so the agent re-runs
the WHOLE library under the current pipeline (e.g. after adding crop
detectors). Heavy — the back-catalogue is otherwise skipped by the backfills."""
body = await request.get_json(silent=True) or {}
task = str(body.get("task") or "ccip")
from ..tasks.gpu_queue import reprocess_gpu_jobs
r = reprocess_gpu_jobs.delay(task)
return jsonify({"celery_task_id": r.id, "task": task}), 202
@gpu_bp.route("/retry_errors", methods=["POST"])
async def retry_errors():
"""Requeue every ERRORED job (all task types) back to pending — the scoped
recovery after an agent-side fix (e.g. the short-video sampler), where
/reprocess would needlessly re-run the whole done library too. Attempts and
the stored error reset so each job gets its full retry budget under the
fixed pipeline. Stale tombstones are pruned FIRST (loop-era duplicates and
rows a later success made moot — the same statements the backfills run), so
one failing file requeues as ONE job, never a fan-out of duplicates. Small
row count (errors only) → inline statements; the response carries the
counts for the UI toast. Triage-confirmed defects are NOT requeued (see
the WHERE below) — they stay on the recovery surface."""
async with get_session() as session:
pruned = 0
for stmt in error_dedupe_statements():
pruned += (await session.execute(stmt)).rowcount or 0
res = await session.execute(
update(GpuJob)
.where(
GpuJob.status == "error",
# Triage-confirmed DEFECTS stay errored: the integrity probe
# already proved the FILE is bad, so re-running the job just
# burns agent time re-minting the same tombstone — those go
# through /errors/<id>/recover instead.
or_(GpuJob.triage_status.is_(None),
GpuJob.triage_status != "defect"),
)
.values(
status="pending", attempts=0, error=None, lease_token=None,
leased_at=None, lease_expires_at=None, triage_status=None,
updated_at=func.now(),
)
)
kept = (
await session.execute(
select(func.count()).select_from(GpuJob)
.where(GpuJob.status == "error")
)
).scalar_one()
await session.commit()
return jsonify({
"requeued": res.rowcount or 0, "pruned": pruned, "defects_kept": kept,
})
# --- Failure triage + recovery (#125) ------------------------------------
@gpu_bp.route("/errors", methods=["GET"])
async def errors():
"""The triage view of the error tombstones: every errored job joined with
its image's integrity verdict, bucketed by reason for the overview. The
probe sweep (triage_gpu_errors, 15-min beat) fills triage_status; 'defect'
rows are the recovery surface's list."""
async with get_session() as session:
rows = (
await session.execute(
select(
GpuJob.id, GpuJob.image_record_id, GpuJob.task,
GpuJob.error, GpuJob.triage_status, GpuJob.updated_at,
ImageRecord.integrity_status, ImageRecord.mime,
ImageRecord.path, ImageRecord.thumbnail_path,
)
.join(ImageRecord, ImageRecord.id == GpuJob.image_record_id)
.where(GpuJob.status == "error")
.order_by(GpuJob.updated_at.desc())
.limit(500)
)
).all()
total = (
await session.execute(
select(func.count()).select_from(GpuJob)
.where(GpuJob.status == "error")
)
).scalar_one()
by_class: dict[str, int] = {}
triage = {"defect": 0, "file_ok": 0, "unclassified": 0}
items = []
for r in rows:
cls = classify_reason(r.error)
by_class[cls] = by_class.get(cls, 0) + 1
bucket = r.triage_status or "unclassified"
triage[bucket] = triage.get(bucket, 0) + 1
items.append({
"job_id": r.id,
"image_id": r.image_record_id,
"task": r.task,
"error": r.error,
"reason_class": cls,
"triage_status": r.triage_status,
"integrity_status": r.integrity_status,
"mime": r.mime,
"image_url": image_url(r.path),
"thumbnail_url": (
image_url(r.thumbnail_path) if r.thumbnail_path else None
),
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
})
return jsonify({
"total": total, "by_class": by_class, "triage": triage, "items": items,
})
@gpu_bp.route("/errors/triage", methods=["POST"])
async def errors_triage():
"""Run the probe sweep NOW (the card's button) instead of waiting out the
15-minute beat cadence."""
from ..tasks.maintenance import triage_gpu_errors
r = triage_gpu_errors.delay()
return jsonify({"celery_task_id": r.id}), 202
@gpu_bp.route("/errors/<int:image_id>/recover", methods=["POST"])
async def errors_recover(image_id: int):
"""Recover a defect-triaged original: delete the bad copy + record and
re-poll its subscription Source (a fresh fetch re-imports the file, which
re-enters the GPU pipeline). Returns status 'no_source' when nothing
pollable resolves — the file needs manual replacement there."""
async with get_session() as session:
result = await session.run_sync(
lambda s: recover_defective_image(
s, image_id, images_root=_IMAGES_ROOT,
)
)
return jsonify(result)
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
@gpu_bp.route("/jobs/lease", methods=["POST"])
async def lease():
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
try:
batch = min(max(int(body.get("batch_size", 8)), 1), 64)
except (TypeError, ValueError):
batch = 8
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
# The agent cannot be polled — it is HTTP-only and pulls from here, so
# web never dials it. A lease IS the check-in, and until milestone 365
# it was thrown away: an agent sitting idle with nothing to lease left
# no trace at all and was indistinguishable from one switched off a
# week ago. Recorded on the call that was already happening.
await touch_service(
session,
key=f"agent:{agent_id}",
kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={"agent_id": agent_id, "last_call": "lease", "leased": len(jobs)},
)
ml = await MLSettings.load(session)
# image rows for url/mime in one shot
ids = [j.image_record_id for j in jobs]
imgs = {
i.id: i for i in (
await session.execute(
select(ImageRecord).where(ImageRecord.id.in_(ids))
)
).scalars()
} if ids else {}
await session.commit()
# Crop-proposer config, announced FROM THE SETTING like embed_model_name
# (#134): the agent builds its detectors from this, rebuilding live when
# it changes — so tuning is a DB/UI edit, never an agent restart. Same
# block for every job in the batch (it's global), built once. An enabled
# toggle off is carried through so the agent skips that proposer.
detectors = {
"person": {
"enabled": ml.detector_person_enabled,
"weights": ml.detector_person_weights,
"conf": ml.detector_person_conf,
},
"anatomy": {
"enabled": ml.detector_anatomy_enabled,
"weights": ml.detector_anatomy_weights,
"conf": ml.detector_anatomy_conf,
},
"panel": {
"enabled": ml.detector_panel_enabled,
"weights": ml.detector_panel_weights,
"conf": ml.detector_panel_conf,
},
"max_figures": ml.detector_max_figures,
"max_components": ml.detector_max_components,
"max_panels": ml.detector_max_panels,
"max_regions": ml.detector_max_regions,
"dedupe_iou": ml.detector_dedupe_iou,
}
out = []
for j in jobs:
img = imgs.get(j.image_record_id)
if img is None:
continue
out.append({
"job_id": j.id,
"image_id": j.image_record_id,
"task": j.task,
"mime": img.mime,
"image_url": image_url(img.path),
# For video/animated: the agent samples at this cadence.
"frame_interval_seconds": ml.video_frame_interval_seconds,
"max_frames": ml.video_max_frames,
# The embedding model the agent must use for concept crops + the
# whole-image 'embed' task, so its vectors land in the SAME space
# the heads trained in. Server-announced FROM THE SETTING → the
# agent stays model-agnostic; an operator swap is a setting + a
# re-embed, never an agent change.
"embed_model_name": ml.embedder_model_name,
"embed_version": ml.embedder_model_version,
"detectors": detectors,
})
return jsonify({"jobs": out})
@gpu_bp.route("/jobs/heartbeat", methods=["POST"])
async def heartbeat():
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_ids = [int(x) for x in (body.get("job_ids") or [])]
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
n = await GpuJobService(session).heartbeat(agent_id, job_ids)
await touch_service(
session,
key=f"agent:{agent_id}",
kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={"agent_id": agent_id, "last_call": "heartbeat", "extended": n},
)
await session.commit()
return jsonify({"extended": n})
@gpu_bp.route("/jobs/submit", methods=["POST"])
async def submit():
"""Store a job's regions + close it. regions: [{kind, bbox:[x,y,w,h],
frame_time?, score?, *_version?, ccip_embedding?, siglip_embedding?}].
replace_kinds defaults to the kinds present in the submitted regions."""
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_id = body.get("job_id")
regions = body.get("regions") or []
if job_id is None:
return jsonify({"error": "job_id required"}), 400
kinds = body.get("replace_kinds") or sorted({r["kind"] for r in regions})
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
job = await session.get(GpuJob, int(job_id))
if job is None or job.status != "leased" or job.lease_token != agent_id:
return jsonify({"error": "lease_invalid"}), 409
if kinds:
await RegionService(session).replace_regions(
job.image_record_id, kinds, regions
)
await GpuJobService(session).complete(agent_id, int(job_id))
await session.commit()
return jsonify({"ok": True, "stored": len(regions)})
@gpu_bp.route("/jobs/submit_embedding", methods=["POST"])
async def submit_embedding():
"""Store a whole-image SigLIP embedding (the 'embed' task) on image_record +
close the job. Body: {agent_id, job_id, embedding:[...], embedding_version}.
This is how the GPU agent re-embeds the library under a new model (#1190) —
much faster than the CPU ml-worker at higher resolutions."""
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_id = body.get("job_id")
embedding = body.get("embedding")
version = body.get("embedding_version")
if job_id is None or not embedding or not version:
return jsonify({"error": "job_id, embedding, embedding_version required"}), 400
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
job = await session.get(GpuJob, int(job_id))
if job is None or job.status != "leased" or job.lease_token != agent_id:
return jsonify({"error": "lease_invalid"}), 409
img = await session.get(ImageRecord, job.image_record_id)
if img is not None:
img.siglip_embedding = embedding
img.siglip_model_version = version
await GpuJobService(session).complete(agent_id, int(job_id))
await session.commit()
return jsonify({"ok": True})
@gpu_bp.route("/jobs/fail", methods=["POST"])
async def fail():
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_id = body.get("job_id")
if job_id is None:
return jsonify({"error": "job_id required"}), 400
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
ok = await GpuJobService(session).fail(
agent_id, int(job_id), str(body.get("error") or "")
)
await session.commit()
return jsonify({"ok": ok})
@gpu_bp.route("/jobs/release", methods=["POST"])
async def release():
"""Graceful stop: the agent hands its still-leased jobs back to pending so
they're picked up immediately instead of waiting out the lease."""
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_ids = [int(x) for x in (body.get("job_ids") or [])]
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
n = await GpuJobService(session).release(agent_id, job_ids)
await session.commit()
return jsonify({"released": n})