Commit Graph
10 Commits
Author SHA1 Message Date
Palash Debnath 5a615d2c66 feat(workers): package headless GPU nodes (#1638) (#1648)
Closes #1638.\n\nPackages headless GPU workers with durable enrollment, bounded artifact handling, cross-platform lifecycle cleanup, and regression coverage. Incorporates CodeRabbit, Greptile, CodeQL, and platform-CI findings before merge.
2026-08-24 16:32:56 +05:30
Palash DebnathandClaude Opus 5 a310141114 feat(workers): join from the app, share by QR, and a status-bar Compute control (#1516)
* feat(workers): join from the app, share by QR, and a status-bar Compute control

Remote workers shipped with a hole in the middle: the control plane could
mint join codes, and on the other machine there was nothing to paste them
into. Becoming a worker meant launching with OMNIVOICE_WORKER_MODE and
OMNIVOICE_WORKER_TOKEN in the environment and relaunching — on the machine
that is usually the least convenient one to configure by hand.

Backend

- GET /workers/agent, POST /workers/agent/join, POST /workers/agent/enabled.
  Join redeems a code and starts the agent live; no restart.
- Worker mode now persists in settings as well as the environment (env still
  wins, and the panel is told so it can disable a switch it cannot honour),
  and it is written only after a join that actually worked — a failed
  enrolment must not have the app retrying on every launch.
- The endpoint carried by the redeemed code is remembered. Without that a
  machine that joined from the UI came back up enrolled but with nowhere to
  dial, and the only fix was OMNIVOICE_WORKER_ENDPOINT.

UI

- "Lend this machine's GPU": paste the code, Join. Once joined it offers a
  switch rather than another code, because the pinned certificate survives.
- <OneTimeSecret/> renders join codes and connection strings as a QR next to
  the text, with a live expiry countdown, and is used by both halves. QR
  generation is best-effort: a string past the format's capacity still shows
  the code and Copy, because losing the QR is a degraded share and losing the
  only copy of a one-time secret is data loss.
- Status-bar Compute control: pick local or a machine, flip the feature, mint
  a join code — without opening Settings. Absent entirely until the user has
  opted in or enrolled something.
- Remote workers now reads as a device list: status dot, address, latency,
  live task meter, resident models, last seen; housekeeping actions revealed
  on hover; a three-step empty state.
- Approve is on the row. A worker could connect, sit there labelled "Not
  approved" and never be usable, with no way out of it in the UI.

Fixes found on the way

- Status dots and menu surfaces in the GPU picker were painted from fixed
  Tailwind palette classes (bg-emerald-400, text-amber-400, hover:bg-white/5),
  so on Midnight or Catppuccin they showed Gruvbox colours next to the
  theme's own. Both controls now paint from themed --color-* tokens, shared
  in computeTarget.jsx along with the JSON wrapper all three copies duplicated.
- Button funnels every child into one <span>, so an icon passed as a child
  renders glued to its label — the flex gap only applies to the `leading`
  slot. Six buttons across these panels were affected.
- InboundNodePanel passed `variant="warning"` to Badge, which takes `tone`;
  the "on your network" warning rendered as an ordinary neutral pill.

Docs updated in the same change (docs/remote-workers.md): the join flow, the
QR, the status-bar control, and the new environment variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): stamp the remote-workers entries with their PR ref

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(workers): a join is not done until the control plane accepts it

Review findings on #1516:

- Greptile P1: `start()` only SCHEDULES the dial-out loop, so a control
  plane that rejected this worker — expired token, wrong address, a
  server that never answers — looked identical to a successful join. The
  route persisted worker mode, reported success, and the machine retried
  forever on every launch. The agent now signals first registration, and
  join waits for it before persisting anything.
- CodeRabbit: a failed REJOIN left the machine unable to reconnect to the
  control plane it was already serving, because pinning the new
  certificate overwrites the old one on disk. Snapshot the pinned
  certificate, endpoint and setting up front, and restore them (and the
  running agent) when the join fails.
- CodeRabbit: join and the enable toggle awaited stop()/start() with no
  exclusion, so two concurrent requests could interleave their pairs and
  have `start()` return early — reporting success for a control plane it
  never dialled. Both now hold one lifecycle lock.
- CodeRabbit: with OMNIVOICE_WORKER_MODE set, the toggle still started or
  stopped the agent and wrote a setting the rest of the app ignores,
  contradicting the env_pinned status it reports. It now answers 409 and
  says which variable is in charge.
- CodeRabbit: the QR code kept encoding the previous secret until the new
  one finished encoding, so the code on screen could disagree with the
  text beside it.

CI: regenerated tests/fixtures/api_routes.txt for the three
/workers/agent routes.

Tests: a join the control plane never accepts is a 409 that persists
nothing and leaves no agent dialling; a failed rejoin restores the
previous certificate, endpoint and setting; an env-pinned machine
refuses the toggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(workers): the environment pin governs joining too, not just the toggle

CodeRabbit, #1516:

- join_control_plane skipped the env_pinned guard set_agent_enabled
  enforces, and joining is precisely what ENABLES worker mode: under
  OMNIVOICE_WORKER_MODE it wrote a setting nothing consults, and with the
  variable pinned off it handed back a machine that reported a successful
  join and lent nothing. One shared guard now covers both routes.
- Two of the three rollback assertions could not fail before the fix
  (nothing wrote those settings on the failure path). The test now pins
  the behaviour only the rollback produces: the previous enrollment is
  dialling again, rather than left stopped until someone notices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:20:41 +00:00
debpalash b548a7ab9d fix(security): close worker transport disclosure flows 2026-08-12 00:22:59 +00:00
velixio b8fb5a14c2 feat(workers): let the idle-unload timings be shortened for testing
Watching a ten-minute rule take effect means waiting ten minutes, so it tends
not to get watched. Both numbers are now env-tunable:
OMNIVOICE_ENGINE_IDLE_UNLOAD_SECONDS and OMNIVOICE_IDLE_SWEEP_SECONDS.

They are documented as a pair, because shortening only the threshold still
means waiting a full sweep interval to see it fire — which reads as a broken
sweep and sends you looking for a bug that is not there.

Unparseable values and anything below the floor are ignored with a warning
rather than honoured. A zero threshold would hand back a model the instant it
went idle and reload it for the very next request, which is worse than the
behaviour being tuned.
2026-08-12 01:30:53 +05:30
velixio 1ac3dcf3fe fix(workers): unload idle models on an inbound-only node
The ten-minute idle sweep lived inside the dial-out agent. A node that only
accepts inbound connections never starts that agent — on gpu2 it fails outright
with 'Set OMNIVOICE_WORKER_ENDPOINT' — so a machine lending its GPU to panels
that dial IN held several GB of weights forever. That is precisely the cost the
sweep exists to avoid, and it was silently missing in the mode most likely to
be a shared box.

The loop moves to module scope and both transports use it. Inbound starts it
when the listener starts and cancels it when the listener stops, and passes a
callback that re-advertises capabilities to every attached panel, so a control
plane's view of what is resident does not go stale the moment it becomes
useful. Local behaviour is unchanged: nothing sweeps unless a worker role runs.
2026-08-12 01:14:16 +05:30
velixio bda169c900 feat(workers): pin work to the chosen GPU, and say when its model is missing
Three phases that only make sense together: a job that names a worker,
a worker that reports honestly what it can actually run, and the small
defects that made both lie.

**Pinning** (Phase 1). `pinned_worker_id` is now honoured in both places
that choose a worker — `eligible_workers` and `select_worker` build
independent lists, so applying it to one silently leaked work onto
whichever machine was least busy. The pin persists across a restart via
an additive column, deliberately not alembic (justified in the code, per
the precedent already in db.py): quitting mid-render used to drop it
without a word. `max_attempts=1` was rejected as the mechanism — it makes
the FIRST failure terminal, including the penalty-free ones a stale
advisory view produces routinely.

Cancel now actually reaches the worker. `WorkerServicer.cancel` had zero
callers, so cancelling released the slot while the GPU thread kept
running, and a late result could resurrect the task as COMPLETED —
`commit_result` assigned that state directly, bypassing the transition
table where CANCELLED is terminal by construction.

**Honest capabilities** (Phase 4). A worker now probes whether weights
are actually present, and a job stops BEFORE dispatch with a typed 409
naming the model and the machine, instead of failing mid-task. The probe
fails OPEN: `is_cached`/`cache_is_complete` cannot see a user-managed
clone outside the HF layout, so only a positive "absent" refuses.
Refusing an engine that works today would break the compatibility
promise. `pool.supports` deliberately still ignores `downloaded` — had it
not, the scheduler would drop the worker and answer with a terminal
NO_CAPABLE_WORKER, which tells the user to check their install when the
truth is one download away. The frontend no longer offers "Report this
bug" for that state; it offers the download.

Catalog tags resolve against the TARGET's OS/arch/backend, not this
machine's. From a Mac control plane, a CUDA worker's model list was
showing the mlx-community repos it cannot run and hiding the ones it
needs.

**And the quiet ones** (Phase 0 leftovers): a model's human label rides
its own proto field so renaming it cannot orphan breaker history; an
empty model_id no longer forks the capacity slot key into two slots for
one model; the idle sweep cannot evict an engine out from under a live
LOCAL render.

Verified on real hardware, which is the only verification that has ever
caught anything here: 2025 characters, default settings, routed to an
RTX 4090 over the wire — 100% GPU utilisation on the remote box, 119.6 s
of 24 kHz audio returned in 16.6 s, 5.7 MB delivered out of band through
the artifact path rather than the control stream.

Backend 5259 passed, frontend 1808 passed.
2026-08-11 15:24:11 +05:30
velixio c643706d07 feat(workers): make a remote GPU actually run a task, end to end
Selecting a remote worker repainted a badge and nothing else. The cause was
not subtle: `scheduler.submit` had no production caller, and `routing.decide()`
was read only by the status endpoint that paints the header. Remote execution
was a complete, tested pipeline with no producer at its head.

This adds the producer and fixes the defects that made the pipeline unable to
carry a real job:

- Nothing routed to the scheduler. Adds `POST /workers/tasks` (loopback-gated,
  **development-only** until the gateway lands) and `Scheduler.wait`, backed by
  per-task futures rather than the unregisterable `on_change` listener list.
- Every task over two minutes died. No worker ever sent `TaskProgress`, so the
  120s progress lease expired mid-render — including during the cold model
  load, which happens after `TaskStarted`. Workers now report progress and
  emit a keepalive, bounded by the phase's absolute budget so it renews the
  lease without deleting the only enforced bound in the system.
- The executor rebuilt its engine per task (`return cls()`), so every job paid
  a cold load. Engines now share one instance cache with the router, resolved
  by the assignment's engine — never `get_active_tts_backend()`, which returns
  the worker machine's own Settings preference and would silently run the
  wrong engine.
- One lease expiry took a worker offline permanently: parked slots were never
  reclaimed. Parks now expire on a TTL, and are deliberately NOT reconciled
  against the worker's own load report — at a ceiling of one the only task such
  a worker can report is the wedged one, so "busy" would drop the park and the
  next idle heartbeat would hand out a slot with a live GPU thread (#730/#1190).
- A worker that dropped and reconnected mid-render had every liveness frame
  discarded: task frames were fenced on the live session epoch, which bumps on
  every reconnect, while the worker echoes the ref stamped at dispatch. The
  control plane then expired a task whose GPU was still rendering, and swallowed
  the failure report when it went wrong. Fenced per attempt instead.
- A result from one worker could commit another's task, after which the owner's
  real delivery arrived as a duplicate and its audio was discarded. "Unknown
  attempt" and "another worker's attempt" are no longer the same answer.
- An oversized result was a poison pill, re-sent identically on every reconnect
  and permanently disconnecting the worker. It is now a terminal
  `RESULT_TOO_LARGE`, which is also classified — it was falling through to
  TRANSIENT and retrying a re-render that could never fit.
- `_store_inline` joined the artifact directory with worker-supplied ids, and
  `os.path.join` discards its prefix on an absolute component. Paths are now
  minted control-plane-side and resolved through `core.path_security`.
- Remote synthesis bypassed `mark_synthetic`, and the guard that exists to
  catch exactly that walked only `backend/api` and `backend/services` — so it
  stayed green while a fourth unmarked producer shipped. Marking moved to the
  worker's tensor stage; the guard now walks `backend/worker` too.

Also adds pre-rendered voice previews (`services/gallery.py`), so browsing the
gallery no longer needs a GPU or a downloaded model. The manifest is verified
against the updater's release key already baked into the binary; a fresh
install hears voices without downloading 2.4GB first, and everything falls back
to local rendering when the gallery is unreachable.

Verified on hardware, not just in CI: 1728 characters submitted to an RTX 4090
returned 105.94s of 24kHz audio in 23.9s, committed and served from the
artifact store.

Not yet done, and deliberately not claimed: the keepalive fix cannot be
exercised end-to-end on fast hardware, because any job long enough to reach the
120s lease produces audio past the 8MiB inline cap. Chunked `UploadResult` has
to land first. Pinning to the worker the user chose is also still absent, so
"Remote" reaches a remote GPU but not necessarily the one on the badge.
2026-08-11 07:16:04 +05:30
velixio 9eb1ec7591 feat(workers): choose where jobs run, and show whether that machine is well
Adds a GPU target picker to the header: Local, or one of the machines you
enrolled. Exactly one is active at a time; other connected workers are
standby and receive nothing.

The selection is the user's, not the scheduler's. The engine underneath can
rank many workers and the hosted platform will need that, but a desktop app
is better served by a choice you can predict and explain: "your worker is
offline, this ran locally" is a sentence, "least-busy ranking preferred the
laptop" is not. Picking an offline machine is allowed on purpose — you
choose your desktop, then go and switch it on.

`routing.decide()` is the single answer to "where does the next job run",
shared by the badge and (soon) the generation path, so the badge cannot
claim something the router will not do. It shows the RESOLVED answer rather
than the stored choice: pick your desktop, let it sleep, and the chip reads
Local with the reason, while the menu still shows your desktop selected.

Connection latency is now real. `latency_ms` existed but nothing measured
it — the protocol had Ping with no reply — so it was always zero. Adds Pong
(additive, field 12) and times the round trip on the control plane's
MONOTONIC clock, so an NTP step or a sleep/wake cannot produce a nonsense
reading, and no worker timestamp is trusted. Reported as a median of five
samples and withheld until a second sample exists: the first round trip
after connect lands while the worker is still importing torch, which
measured 139 ms on loopback and, averaged, carried that for a minute.

This is CONNECTION latency, not time-to-result. It is shown as information,
never as a routing input — RTT is milliseconds where inference is seconds,
so ranking on it would optimise noise.

Also fixes a bug the picker exposed: worker config was read from the pool,
which caches the row handed to it at connect time. Renaming a CONNECTED
worker updated the database and the API kept serving the old name until it
reconnected — same for priority and enable/disable. Config now comes from
the database and liveness from the pool, never the reverse, and writers
refresh the live copy so the scheduler's logs do not use a stale name.

Adds worker rename (the backend already supported it; no UI called it),
worker address as seen by the control plane rather than self-reported, and
ready/busy/offline status behind the header dot.
2026-08-10 21:40:07 +05:30
velixio 4f4d9c6e3e refactor(workers): give Remote workers its own System entry; ignore remote/
Remote workers was nested under Sharing, which reads backwards: everything
in Sharing is about letting something else reach THIS machine (a remote
backend, an MCP client, a share PIN), while remote workers sends work OUT
to machines you own. It is now its own System entry.

Docs-sync: every "Settings → Sharing → Remote workers" reference is
updated — the guide, the changelog, the two API error messages that tell a
user where to generate a token, and the agent's not-enrolled error.

Also ignores remote/ (local goal docs, review briefs, council reports) and
repoints the code comments that cited remote/goal_v2.md at the shipped
docs/remote-workers.md, so no committed file references a path that is not
in the repo.
2026-08-10 14:28:16 +05:30
velixio 43de1c794c feat(workers): remote GPU workers over a versioned gRPC protocol
Send individual jobs to GPUs on your other machines while everything else
stays local. Opt-in, off by default: with the toggle off there is no
listening socket, no certificate and no background loop.

Design follows remote/goal_v2.md, the council-revised goal doc. The
decisions that shaped the code, and why:

* A disconnect is an unknown outcome, not a failure. The original design
  reassigned on disconnect while also describing the case where the worker
  had already finished — following both guarantees duplicate execution. An
  attempt now holds a grace window; a worker returning inside it commits
  its result and no second attempt is ever made.
* At-least-once execution, exactly-once result commit. The result is
  persisted BEFORE it is acknowledged, so a crash between the two cannot
  silently lose a finished render.
* Deadlines are phased (accept -> model load -> execute -> deliver) and
  liveness is a progress lease. The old fixed 30s execution budget was two
  orders of magnitude below what this product actually does; silence is
  the failure signal, not slowness.
* Capacity is derived from free VRAM, never configured: a static value
  corrupts output under torch.compile thread affinity (#315) and aborts
  the process on small cards (#567).
* A circuit breaker replaces the reliability-score/quarantine machinery,
  which had no recovery path (no probation workload exists in a TTS
  product) and penalised consumer networks for existing.
* Identity is a keypair the worker generates and never sends. A
  server-assigned id is a name, not an authenticator, so revocation of one
  would be theatre. Enrollment tokens are single-use and carry the control
  plane's certificate fingerprint for pin-on-first-use.

Adds the domain core, scheduler, durable task store, gRPC transport,
worker agent, management API, Settings panel, and docs. Protobuf reserves
the tenant/trace/usage fields a hosted control plane would need, since
adding them later means upgrading a whole fleet.

Includes tests for the failure paths that matter: duplicate delivery,
stale-session fencing, reconnect reconciliation, grace expiry, breaker
attribution, and a real end-to-end TLS round trip.
2026-08-10 14:18:42 +05:30