feat(agent): Status shows smoothed jobs/min + downloads/min (replace jumpy gauges)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m26s

The buffer / on-GPU / downloader counts flip many times a second, so a 3s
status poll only ever samples noise — the tiles looked frozen (same value
twice) or random (wildly different), reading as "the Status section doesn't
update" when the backend was in fact live (operator-flagged 2026-07-01).

Replace the three instantaneous gauge tiles with two derived RATE tiles:
  - jobs / min      — GPU throughput, from the monotonic `processed` counter
  - downloads / min — fetch throughput, from a new monotonic `downloaded`
                      counter (bumped when a job is decoded into the buffer)
Together they also show pipeline balance (dl/min > j/min ⇒ GPU-bound; the
reverse ⇒ GPU starved). Both are EWMA-smoothed over the poll deltas, clamped
at 0 (agent restart resets the counters), and skip a backgrounded-tab gap.

The still-useful instantaneous state is demoted, not lost: buffer stays as
the occupancy bar; downloaders/consumers/on-GPU move to the sub-line. `waited
out` (transient) gets promoted to a tile.

backend: worker.status() gains `downloaded`; `_bump(downloaded=)`.
frontend: retiled Status + rate math in applyStatus; VERSION → .7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 16:01:29 -04:00
parent 98b2ac90dd
commit 91ea06be79
2 changed files with 39 additions and 10 deletions
+31 -9
View File
@@ -17,7 +17,7 @@ from .worker import Worker
# Bump on every agent change. The page embeds this and /status reports it; the UI # 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 # 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.) # mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-01.6 · stream videos via ffmpeg-from-URL (no full download)" VERSION = "2026-07-01.7 · status: smoothed jobs/min + downloads/min rates"
logbuf.install() logbuf.install()
cfg = Config.from_env() cfg = Config.from_env()
@@ -218,11 +218,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<div class=card-h>Status</div> <div class=card-h>Status</div>
<div class=tiles> <div class=tiles>
<div class=tile><div class=n id=state>—</div><div class=l>state</div></div> <div class=tile><div class=n id=state>—</div><div class=l>state</div></div>
<div class=tile><div class=n id=dln>0</div><div class=l>downloaders</div></div> <div class=tile><div class=n id=jpm>—</div><div class=l>jobs / min</div></div>
<div class=tile><div class=n id=bufn>—</div><div class=l>buffer</div></div> <div class=tile><div class=n id=dpm>—</div><div class=l>downloads / min</div></div>
<div class=tile><div class=n id=active>0</div><div class=l>on GPU</div></div>
<div class=tile><div class="n ok" id=done>0</div><div class=l>processed</div></div> <div class=tile><div class="n ok" id=done>0</div><div class=l>processed</div></div>
<div class=tile><div class=n id=err>0</div><div class=l>errors</div></div> <div class=tile><div class=n id=err>0</div><div class=l>errors</div></div>
<div class=tile><div class=n id=waited>0</div><div class=l>waited out</div></div>
</div> </div>
<div class=meters> <div class=meters>
<div class=meter><div class=meter-h><span>GPU util</span><b id=utillbl>—</b></div> <div class=meter><div class=meter-h><span>GPU util</span><b id=utillbl>—</b></div>
@@ -232,7 +232,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<div class=meter><div class=meter-h><span>buffer occupancy</span><b id=buflbl>—</b></div> <div class=meter><div class=meter-h><span>buffer occupancy</span><b id=buflbl>—</b></div>
<div class=bar><i id=bufbar></i></div></div> <div class=bar><i id=bufbar></i></div></div>
</div> </div>
<div class=queue id=pipe>consumers — · waited out 0</div> <div class=queue id=pipe>downloaders — · consumers — · on GPU 0</div>
<div class=queue id=queue>queue —</div> <div class=queue id=queue>queue —</div>
</section> </section>
@@ -246,6 +246,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<script> <script>
const PAGE_BUILD="__BUILD__" const PAGE_BUILD="__BUILD__"
let CAP=8 let CAP=8
// Smoothed derived rates (jobs/min, downloads/min) + the deltas they're built
// from. The raw buffer/on-GPU/downloader counts change many times a second, so
// a poll only ever samples noise; these EWMA the two monotonic counters into a
// stable, readable throughput instead. Reset to null on load → re-converge.
let jpmv=null, dpmv=null, lastP=null, lastD=null, lastRateT=null
// 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
// separate /status poll, which can lag behind the curator queue call. // separate /status poll, which can lag behind the curator queue call.
@@ -288,12 +293,29 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
const draining=!running && s.active>0 const draining=!running && s.active>0
state.textContent=draining?'stopping':s.state state.textContent=draining?'stopping':s.state
state.className='n'+(draining?' busy':'') state.className='n'+(draining?' busy':'')
dln.textContent=(s.downloaders!=null?s.downloaders:'') // Derived rates over the poll deltas — j/min ≈ GPU throughput, dl/min ≈ fetch
bufn.textContent=(s.buffer!=null?(s.buffer+'/'+s.buffer_max):'') // throughput. EWMA-smoothed so a lull between jobs doesn't blank them; clamped
active.textContent=s.active; active.className='n'+(s.active>0?' busy':'') // at 0 so an agent restart (counters reset to 0 → negative delta) can't spike
// them; the dt<30s guard drops a backgrounded-tab gap that would read as huge.
const now=performance.now()
if(lastRateT!=null && s.processed!=null){
const dt=(now-lastRateT)/1000
if(dt>0.5 && dt<30){
const jp=Math.max(0,60*(s.processed-lastP)/dt)
const dp=Math.max(0,60*((s.downloaded||0)-lastD)/dt)
jpmv=(jpmv==null)?jp:0.4*jp+0.6*jpmv
dpmv=(dpmv==null)?dp:0.4*dp+0.6*dpmv
}
}
lastP=s.processed; lastD=(s.downloaded||0); lastRateT=now
jpm.textContent=(jpmv==null)?'':Math.round(jpmv)
dpm.textContent=(dpmv==null)?'':Math.round(dpmv)
done.textContent=s.processed done.textContent=s.processed
err.textContent=s.errors; err.className='n'+(s.errors>0?' warn':'') err.textContent=s.errors; err.className='n'+(s.errors>0?' warn':'')
pipe.textContent='consumers '+(s.consumers!=null?s.consumers:'')+' · waited out '+(s.transient||0) waited.textContent=s.transient||0
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
// as live churn rather than a "broken" headline metric.
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'')+' · consumers '+(s.consumers!=null?s.consumers:'')+' · on GPU '+(s.active||0)
// Buffer occupancy bar (also driven here so it tracks the /status cadence). // Buffer occupancy bar (also driven here so it tracks the /status cadence).
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max) if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
buflbl.textContent=s.buffer+' / '+s.buffer_max; bufbar.style.width=p+'%' } buflbl.textContent=s.buffer+' / '+s.buffer_max; bufbar.style.width=p+'%' }
+8 -1
View File
@@ -162,6 +162,10 @@ class Worker:
self._held: set[int] = set() self._held: set[int] = set()
self._held_lock = threading.Lock() self._held_lock = threading.Lock()
self.processed = 0 self.processed = 0
self.downloaded = 0 # jobs fetched+decoded into the buffer (monotonic)
# — the UI derives a smoothed downloads/min from it,
# since the instantaneous buffer/active gauges move
# faster than any sane poll can show.
self.errors = 0 self.errors = 0
self.transient = 0 # jobs handed back due to a server outage (NOT self.transient = 0 # jobs handed back due to a server outage (NOT
# failed) — the "waiting out curator" counter # failed) — the "waiting out curator" counter
@@ -425,13 +429,15 @@ class Worker:
"buffer_max": BUFFER_MAX, "buffer_max": BUFFER_MAX,
"active": self._active, "active": self._active,
"processed": self.processed, "processed": self.processed,
"downloaded": self.downloaded,
"errors": self.errors, "errors": self.errors,
"transient": self.transient, "transient": self.transient,
} }
def _bump(self, *, processed=0, errors=0, active=0, transient=0): def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
with self._lock: with self._lock:
self.processed += processed self.processed += processed
self.downloaded += downloaded
self.errors += errors self.errors += errors
self.transient += transient self.transient += transient
# Clamp at 0: a Stop resets _active to 0, so a consumer that was # Clamp at 0: a Stop resets _active to 0, so a consumer that was
@@ -497,6 +503,7 @@ class Worker:
continue continue
# Blocks on a full buffer (backpressure) but wakes promptly on stop. # Blocks on a full buffer (backpressure) but wakes promptly on stop.
if self._put((job, frames), stop_evt): if self._put((job, frames), stop_evt):
self._bump(downloaded=1) # fetched+decoded into the buffer
owned.remove(jid) # ownership handed to the buffer/consumer owned.remove(jid) # ownership handed to the buffer/consumer
else: else:
break # stopped while waiting for buffer space break # stopped while waiting for buffer space