Skip to content

feat(fleet): workers advertise their capabilities in status and worker list #519

Closed

Description

@V3RON

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 *]
Loading

(* = 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
Loading

(* = changed.)

  • Schema (src/contract/schemas.ts). A new capabilityNameSchema:
    ^[a-z][a-z0-9-]{0,31}$, exported on its own so other tasks reuse it.
    capabilitySchema: name; state available, unavailable or off;
    reason, required when unavailable and 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.get output gains capabilities (optional list), absent while
    daemon.health is starting, like devices and leases. A gateway's own
    status.get leaves it out: the fleet's lists are on each entry of
    workers.
  • Worker view. workerViewSchema gains capabilities (optional), and
    workerViewFields copies it from the status it reads. A worker's own
    worker.list answer then carries it, since it is built by the same
    function. withoutReadFields in src/gateway/worker-registry.ts drops it
    too, so a worker that restarts into starting shows no stale list.
  • Worker dispatcher. A new neutral option, passed through
    DaemonServerEngineOptions and buildDispatcher in
    src/daemon/server.ts: a list of capability sources, each
    { name, read(): Promise<{ state, reason? }> }. #statusGet reads them
    all at once with a 1 s budget each. A read past the budget, or one that
    throws, gives unavailable with reason could not read <name>'s status.
    A read whose answer is not one of the three states gives unavailable
    with reason reported an invalid status. A reason with available or
    off is dropped; unavailable with no reason gets unknown; any other
    reason is passed through capabilityReason. So the entries always pass the schema, and a module
    can never make status.get fail or time out, which would cut the worker
    off from the gateway.
  • Composition (src/daemon/main.ts). With agentDevice.enabled
    true, the source comes from the agent-device edge. With it false, the
    source named agent-device always 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 → unavailable with that status's reason. 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.get does not pay for that load. A read that still takes over 1 s
    shows could not read agent-device's status until the next refresh.
    The supervisor's off state, which an enabled worker reaches only while
    it stops, is not seen: a stopping worker answers no status.get.
  • CLI (formatWorkers in src/cli/index.ts). Appends, on the worker's
    own line and before any install lines, one -- <name> available,
    -- <name> off or -- <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 with available in the ok tone,
    off in the neutral tone, or unavailable in 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.get output: capabilities (new, optional).
  • workerViewSchema: capabilities (new, optional). This reaches
    status.get's workers and worker.list on 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) reads link.status()
    before each request. The capability reads the same
    AgentDeviceHost.status(); neither writes it.
  • The doctor finding agent-device-unavailable reads the supervisor's
    availability through agentDeviceFindings. 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
    says unavailable; 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.
    A starting refresh drops the read fields, now including
    capabilities. incompatible(), which keeps the rest of an old view,
    drops capabilities too, since an incompatible worker is never asked. A disconnect keeps the list, as it keeps every read field
    but installs and waiting.
  • 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.enabled unset answers status.get with capabilities: [{name: "agent-device", state: "off"}]
  • a worker with agent-device running answers [{name: "agent-device", state: "available"}], with no reason
  • a worker whose agent-device is unavailable answers state: "unavailable" with the same reason its front's notAvailable reply names
  • a worker whose agent-device is starting answers unavailable with reason starting, and simlock worker list on 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 unavailable with reason reported an invalid status; one that answers available with a reason gives available with none; one that answers unavailable with no reason gives reason unknown; status.get answers each time
  • a worker whose capability read takes 2 s answers status.get within 1.5 s, with unavailable and reason could not read agent-device's status; one whose read throws answers the same
  • a worker whose daemon.health is starting answers status.get with no capabilities field
  • after a worker that reported a list restarts into starting, the gateway's view, status.get workers and simlock worker list show no list for it; the same after it comes back incompatible
  • after the worker's agent-device goes from unavailable to running, the gateway's view of it shows available after the next refresh tick
  • the gateway's status.get workers entry and worker.list entry carry the worker's capabilities; the gateway's own top-level status.get has no capabilities
  • a worker's own worker.list answer carries its capabilities
  • a disconnected worker's view keeps the list it last read
  • a worker status whose capabilities has 17 entries, a name with an upper-case letter, a name starting with a digit or -, a 33-character name, a duplicate name, a reason on an available or off entry, an unavailable entry 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
  • formatWorkers prints -- agent-device available, -- agent-device off and -- 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 lines
  • simlock status against a gateway prints the same suffix in its workers block
  • the console worker page shows a Capabilities row with available in the ok tone, off in the neutral tone, or unavailable in 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.get and worker views), docs/CLI.md
    (simlock worker list) and docs/CONSOLE.md (Workers) describe
    capabilities.

Out of scope

Depends on

Approval

  • Approved for delivery

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 13, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 13, 2026
  3. github-actions commented on Oct 13, 2026

    @github-actions

    Now task:ready: approved, technical spec present.

  4. self-assigned this
    on Oct 13, 2026
  5. added a commit that references this issue on Oct 14, 2026
    5a9f425
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions