Repository navigation
feat(fleet): workers advertise their capabilities in status and worker list #519
Description
Part of #511.
Scope
After this PR, an operator sees each worker's agent-device state on the
gateway: simlock worker list, simlock status and the console show
whether the worker serves agent-device (available), has it turned off
(off), or cannot serve it and why (unavailable with a reason).
status.get on a worker carries the same list, so a gateway reads it with
the rest of the worker's status.
Before this PR, only simlock doctor on the worker itself says why
agent-device is not available. A gateway cannot run doctor, so the fleet
operator cannot see it at all.
In short
A worker's status gains a capabilities list: each optional module the
worker knows of, with its state. The gateway keeps the list in its worker
view, so every reader of the view shows it.
New term:
- capability: an optional module a worker serves, by name, with a
state. Example:{ "name": "agent-device", "state": "unavailable", "reason": "found 0.20.1, need 0.21.25 or later" }. simlock's core names no
module in code: the worker's composition supplies the entries.
flowchart LR
E[agent-device edge: host status] -->|capability entry, new| S[worker status.get *]
C[agentDevice.enabled false] -->|off entry, new| S
S -->|uplink| WL[gateway worker link]
WL --> V[worker view *]
V --> CLI[worker list / status *]
V --> UI[console worker card and page *]
(* = changed.)
# before
$ simlock worker list
w-1a2b (mac-mini): connected -- ios 1/4, android 0/2, 1 lease -- macOS 26.0 arm64
w-5e6f (laptop): connected -- ios 0/2, android 0/0, 0 leases -- macOS 26.0 arm64
# after
$ simlock worker list
w-1a2b (mac-mini): connected -- ios 1/4, android 0/2, 1 lease -- macOS 26.0 arm64 -- agent-device available
w-5e6f (laptop): connected -- ios 0/2, android 0/0, 0 leases -- macOS 26.0 arm64 -- agent-device unavailable: found 0.20.1, need 0.21.25 or later
w-3c4d (ci-07): connected -- ios 0/4, android 0/0, 0 leases -- macOS 26.0 arm64 -- agent-device off
| Worker | capabilities in its status |
worker list suffix |
|---|---|---|
agentDevice.enabled unset or false |
[{name: "agent-device", state: "off"}] |
-- agent-device off |
| enabled, agent-device daemon running | [{name: "agent-device", state: "available"}] |
-- agent-device available |
| enabled, daemon starting | [{…, state: "unavailable", reason: "starting"}] |
-- agent-device unavailable: starting |
| enabled, version too old | [{…, state: "unavailable", reason: "found 0.20.1, need 0.21.25 or later"}] |
-- agent-device unavailable: found 0.20.1, need 0.21.25 or later |
| enabled, status read takes over 1 s or throws | [{…, state: "unavailable", reason: "could not read agent-device's status"}] |
-- agent-device unavailable: could not read agent-device's status |
daemon health is starting |
absent | none |
gateway view after that worker restarted into starting, or came back incompatible |
absent (the old list is dropped) | none |
| disconnected worker | its last-read list | as read |
If this goes wrong, an operator sees a worker as able to serve agent-device
when it cannot, or the reverse.
Technical spec
Modules touched
flowchart LR
M["daemon: main.ts (worker composition) *"] --> SV["daemon: server.ts buildDispatcher, engine options *"]
SV --> D["daemon: dispatcher #statusGet *"]
M --> IDX["agent-device: index.ts *"]
IDX --> H["host: AgentDeviceHost.status"]
IDX --> HO["host: host.ts (early proxy load) *"]
D --> C["contract: operations status.get, schemas *"]
WV["contract: worker-view workerViewFields *"] --> C
WR["gateway: worker-registry withoutReadFields, incompatible *"] --> WV
CLI["cli: formatWorkers *"] --> C
UI["ui: worker-facts, workers-model *"] --> C
(* = changed.)
- Schema (
src/contract/schemas.ts). A newcapabilityNameSchema:
^[a-z][a-z0-9-]{0,31}$, exported on its own so other tasks reuse it.
capabilitySchema:name;stateavailable,unavailableoroff;
reason, required whenunavailableand absent otherwise, 1 to 500
characters counted as JavaScript string length (UTF-16 units), with no
control character. A control character here is U+0000–U+001F,
U+007F–U+009F, U+2028 or U+2029. A list holds at most 16 entries and no
name twice. Every field is bounded and checked because a gateway parses
every worker's answer. - One cleaner. The contract exports
capabilityReason(text): each
control character becomes a space, then the text is cut to 500 units
(never splitting a surrogate pair), and an empty result becomes
unknown. The edge and the dispatcher both use it, so what they produce
always passes the schema. status.getoutput gainscapabilities(optional list), absent while
daemon.healthisstarting, likedevicesandleases. A gateway's own
status.getleaves it out: the fleet's lists are on each entry of
workers.- Worker view.
workerViewSchemagainscapabilities(optional), and
workerViewFieldscopies it from the status it reads. A worker's own
worker.listanswer then carries it, since it is built by the same
function.withoutReadFieldsinsrc/gateway/worker-registry.tsdrops it
too, so a worker that restarts intostartingshows no stale list. - Worker dispatcher. A new neutral option, passed through
DaemonServerEngineOptionsandbuildDispatcherin
src/daemon/server.ts: a list of capability sources, each
{ name, read(): Promise<{ state, reason? }> }.#statusGetreads them
all at once with a 1 s budget each. A read past the budget, or one that
throws, givesunavailablewith reasoncould not read <name>'s status.
A read whose answer is not one of the three states givesunavailable
with reasonreported an invalid status. A reason withavailableor
offis dropped;unavailablewith no reason getsunknown; any other
reason is passed throughcapabilityReason. So the entries always pass the schema, and a module
can never makestatus.getfail or time out, which would cut the worker
off from the gateway. - Composition (
src/daemon/main.ts). WithagentDevice.enabled
true, the source comes from the agent-device edge. With itfalse, the
source namedagent-devicealways reads{ state: "off" }: the name is
the config key's, data only. - Edge (
src/agent-device/index.ts). Exports the source that turns
AgentDeviceHost.status()into one entry:running→available;
unavailable→unavailablewith that status'sreason. It reads the
same answer the front reads before every request, so the operator sees the
reason an agent would get. The host starts loading the proxy module when
the edge is built, without waiting for it, so the gateway's first
status.getdoes not pay for that load. A read that still takes over 1 s
showscould not read agent-device's statusuntil the next refresh.
The supervisor'soffstate, which an enabled worker reaches only while
it stops, is not seen: a stopping worker answers nostatus.get. - CLI (
formatWorkersinsrc/cli/index.ts). Appends, on the worker's
own line and before any install lines, one-- <name> available,
-- <name> offor-- <name> unavailable: <reason>per entry, in the
order the worker sent them. Nothing is appended for an absent list. - Console (
ui/src/views/worker-facts.tsx, shown on the worker card and
page). A "Capabilities" row: each name withavailablein the ok tone,
offin the neutral tone, orunavailablein the warn tone with its
reason. "Not reported" for an absent list on a worker that is not
starting (an incompatible one, say). The row is left out for a starting
worker, like the device rows.
Contract and event changes
status.getoutput:capabilities(new, optional).workerViewSchema:capabilities(new, optional). This reaches
status.get'sworkersandworker.liston both modes.- This PR bumps the protocol by one.
- No event changes.
Other code on the same state
The state is the agent-device host's status, and the gateway's worker view.
- The front (
src/agent-device/front/front.ts) readslink.status()
before each request. The capability reads the same
AgentDeviceHost.status(); neither writes it. - The doctor finding
agent-device-unavailablereads the supervisor's
availability throughagentDeviceFindings. It stays as it is. Where the
host's status is unavailable for a reason the supervisor does not see (a
proxy module that failed to load), doctor says nothing and the capability
saysunavailable; the capability is what agents get. - The gateway's worker registry (
src/gateway/worker-registry.ts)
merges each refresh over the existing view. A refresh comes on worker
events about leases, devices and installs, and on the periodic tick
(refreshIntervalMs, 30 s). A change of agent-device state raises no
event, so the gateway sees it at the next refresh, at most one tick later.
Astartingrefresh drops the read fields, now including
capabilities.incompatible(), which keeps the rest of an old view,
dropscapabilitiestoo, since an incompatible worker is never asked. A disconnect keeps the list, as it keeps every read field
butinstallsandwaiting. aggregateStatus(src/gateway/aggregate.ts) copies whole views into
workers, so it carries the field with no change.
Rules in play
architecture.md: the core names no optional module in code; the
composition supplies entries through a neutral option. Every wait is
bounded (the 1 s budget).safety.md: a capability list from a worker is a claim; it is bounded and
checked by schema before the gateway keeps or prints it.testing.md: each table row above is a test.
Tests
Seam: real gateway and worker daemons in one process, joined over a real
uplink (startDaemon, FakeDriver, FakeAgentDevice as the worker's
agent-device daemon), modelled on src/daemon/gateway-fleet.e2e.test.ts
and src/agent-device/edge.test.ts; formatWorkers through
src/cli/index.test.ts; the console through
ui/src/views/worker-detail.test.tsx.
- a worker with
agentDevice.enabledunset answersstatus.getwithcapabilities: [{name: "agent-device", state: "off"}] - a worker with agent-device running answers
[{name: "agent-device", state: "available"}], with noreason - a worker whose agent-device is unavailable answers
state: "unavailable"with the same reason its front'snotAvailablereply names - a worker whose agent-device is starting answers
unavailablewith reasonstarting, andsimlock worker liston the gateway prints-- agent-device unavailable: starting - a reason of 600 characters with a newline, an escape, a DEL and a U+2028 is answered as 500 characters with spaces in their place; a reason that is only control characters is answered
unknown - a source that answers a state outside the three gives
unavailablewith reasonreported an invalid status; one that answersavailablewith a reason givesavailablewith none; one that answersunavailablewith no reason gives reasonunknown;status.getanswers each time - a worker whose capability read takes 2 s answers
status.getwithin 1.5 s, withunavailableand reasoncould not read agent-device's status; one whose read throws answers the same - a worker whose
daemon.healthisstartinganswersstatus.getwith nocapabilitiesfield - after a worker that reported a list restarts into
starting, the gateway's view,status.getworkersandsimlock worker listshow no list for it; the same after it comes backincompatible - after the worker's agent-device goes from unavailable to running, the gateway's view of it shows
availableafter the next refresh tick - the gateway's
status.getworkersentry andworker.listentry carry the worker'scapabilities; the gateway's own top-levelstatus.gethas nocapabilities - a worker's own
worker.listanswer carries itscapabilities - a disconnected worker's view keeps the list it last read
- a worker status whose
capabilitieshas 17 entries, a name with an upper-case letter, a name starting with a digit or-, a 33-character name, a duplicate name, areasonon anavailableoroffentry, anunavailableentry with no reason or an empty one, a 501-character reason, or a reason with a newline, an escape or a U+2028 fails the schema; a 32-character name and a 500-character reason pass formatWorkersprints-- agent-device available,-- agent-device offand-- agent-device unavailable: <reason>for those entries; two entries print in the order given; an absent list prints nothing; the suffix sits on the worker's line, before its install linessimlock statusagainst a gateway prints the same suffix in its workers block- the console worker page shows a Capabilities row with
availablein the ok tone,offin the neutral tone, orunavailablein the warn tone with its reason, and "Not reported" for an absent list on a running worker - the console leaves the Capabilities row out for a starting worker
Done when
- Every Tests line passes.
docs/HTTP-API.md(status.getand worker views),docs/CLI.md
(simlock worker list) anddocs/CONSOLE.md(Workers) describe
capabilities.
Out of scope
- A lease request that requires a capability, and routing on it (feat(fleet): a lease request may require a capability #513).
- Any event when a capability changes state.
simlock statustext on a worker for its own capabilities (its JSON
carries them).
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
github-actions commentedon Oct 13, 2026
Now
task:ready: approved, technical spec present.feat: workers advertise capabilities in status and worker list (#519)
chore: fallow entries for capability schemas (#519)
docs: workerViewSchema keeps its doc comment (#519)
feat(fleet): workers advertise their capabilities in status and worke…