feat(agent): global bandwidth cap — the agent can't saturate the desktop's network

One shared TokenBucket (default 8 MB/s; BANDWIDTH_LIMIT_MB_S, 0 = unlimited;
live MB/s dial + net readout in the control UI) is charged by every still
download (streamed chunk reads) and every ffmpeg video stream (metered from
outside via /proc/<pid>/io and SIGSTOP/SIGCONTed into budget).

Why: D1 re-measurement 2026-07-02 — the idle link moves ~38 MB/s, but 8
unthrottled downloaders bufferbloated it to ~1-1.5 MB/s PER STREAM (operator's
browser included). Capping the aggregate keeps the desktop usable and still
beats the collapsed sweep throughput it replaces. Agent build 2026-07-02.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 11:20:45 -04:00
parent 31c416bc7b
commit 95d2ae1d58
8 changed files with 304 additions and 8 deletions
+22 -1
View File
@@ -42,6 +42,7 @@ from .client import FcClient
from .config import Config
from .crops import crop_region
from .detectors import dedupe_crops
from .throttle import TokenBucket
# Cap on the lease-retry backoff: when curator is unreachable (e.g. you redeploy
# it while away), a downloader retries leasing with exponential backoff up to this
@@ -181,6 +182,10 @@ class Worker:
f"Authorization: Bearer {cfg.token}\r\n" if cfg.token else ""
)
self._lock = threading.Lock()
# ONE bandwidth budget for everything the agent pulls (still downloads
# and ffmpeg video streams): the agent shares a desktop's network, so
# the polite bound is on the aggregate — see throttle.py for why.
self.throttle = TokenBucket(cfg.bandwidth_limit_mb_s * 1_048_576)
self._running = False
# The lifecycle state the UI shows (see the STOPPED/STARTING/... consts).
# It stays STOPPING — a truthful "winding down" — from the Stop press until
@@ -219,6 +224,7 @@ class Worker:
# often the browser polls (see RATE_INTERVAL). Decay to 0 when work stops.
self._jpm = 0.0
self._dpm = 0.0
self._net_mb_s = 0.0 # smoothed aggregate download rate (UI readout)
self._util_smooth: float | None = None # EWMA GPU util (set by control loop)
# Curator queue snapshot, refreshed by a background poller so the UI
# /status read is instant — never an inline curator HTTP call (which
@@ -382,6 +388,7 @@ class Worker:
this here (not in the browser) makes the numbers independent of the poll
rate, so a throttled/unfocused tab still shows a real rate."""
prev_p, prev_d, prev_t = self.processed, self.downloaded, time.monotonic()
prev_b = self.throttle.consumed
while True:
time.sleep(RATE_INTERVAL)
now = time.monotonic()
@@ -389,9 +396,12 @@ class Worker:
if dt > 0:
jp = max(0.0, 60.0 * (self.processed - prev_p) / dt)
dp = max(0.0, 60.0 * (self.downloaded - prev_d) / dt)
nb = max(0.0, (self.throttle.consumed - prev_b) / dt / 1_048_576)
self._jpm = RATE_ALPHA * jp + (1 - RATE_ALPHA) * self._jpm
self._dpm = RATE_ALPHA * dp + (1 - RATE_ALPHA) * self._dpm
self._net_mb_s = RATE_ALPHA * nb + (1 - RATE_ALPHA) * self._net_mb_s
prev_p, prev_d, prev_t = self.processed, self.downloaded, now
prev_b = self.throttle.consumed
# --- control -----------------------------------------------------------
def start(self):
@@ -494,6 +504,14 @@ class Worker:
with self._lock:
self._auto = bool(on)
def set_bandwidth(self, mb_s: float):
# Live-retunes the shared bucket; a downloader blocked mid-wait re-checks
# immediately (set_rate notifies), so raising the cap takes effect now.
self.throttle.set_rate(max(0.0, float(mb_s)) * 1_048_576)
log.info("bandwidth cap set to %s",
"unlimited" if self.throttle.rate <= 0
else f"{self.throttle.rate / 1_048_576:g} MB/s")
def set_concurrency(self, n: int):
# The UI dial tunes the DOWNLOADER count. A manual set is an override →
# leave Auto so the autoscaler stops fighting the operator.
@@ -564,6 +582,8 @@ class Worker:
"downloads_per_min": round(self._dpm, 1),
"errors": self.errors,
"transient": self.transient,
"bandwidth_limit_mb_s": round(self.throttle.rate / 1_048_576, 1),
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
}
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
@@ -682,6 +702,7 @@ class Worker:
job.get("max_frames", 64),
headers=self._auth_header, timeout=self.cfg.ffmpeg_timeout,
should_stop=lambda: self._stopped(stop_evt),
governor=self.throttle,
)
if not frames:
# Stop killed ffmpeg → NOT the job's fault; raise transient so the
@@ -709,7 +730,7 @@ class Worker:
return frames
# Stills: download the bytes and decode.
with self._timed("download"):
data = self.client.fetch_image(job["image_url"])
data = self.client.fetch_image(job["image_url"], throttle=self.throttle)
with self._timed("decode"):
frames = [(None, media.load_image(data))]
return frames