Repository navigation
A gateway carries each worker's load in its view and reports its own #533
Description
Part of #527.
Scope
On a gateway, simlock worker list, GET /v1/workers and the workers in simlock status (text and --json) carry each connected worker's latest load and when it was read. A disconnected or incompatible worker carries none. A gateway's own simlock status shows the gateway machine's load.
A worker's own worker.list (a fleet of one) carries its load the same way.
In short
The gateway already calls status.get on every worker. It now keeps the load block from that answer in the worker's view, as a time on its own clock.
flowchart LR
W["worker status.get: load.ageMs"] --> F["workerViewFields (changed): readAt = receivedAt - ageMs"]
F --> R["WorkerRegistry (changed): load only on connected views"]
R --> L["simlock worker list / status (changed)"]
R --> H["GET /v1/workers"]
G["gateway sampler (new wiring)"] --> S["gateway status.get load (changed)"]
simlock worker list before:
wrk_9f2c (mac-mini-1): connected -- ios 1/2, android 0/1, 1 lease(s) -- macOS 15.5 arm64; xcode 16.4 (16F6), emulator 35.4.9
Install ios 26.4: downloading for 42s, 1 waiter
wrk_4a10 (ci-runner-3): disconnected, drained -- ios 0/4, android 0/2, 0 lease(s) -- macOS 14.7 x64; xcode 16.2 (16C5032a)
After:
wrk_9f2c (mac-mini-1): connected -- ios 1/2, android 0/1, 1 lease(s) -- macOS 15.5 arm64; xcode 16.4 (16F6), emulator 35.4.9
Load: CPU 34% busy (10 cores) · memory 9.8 GiB of 32.0 GiB available · swap 1.2 GiB used · pressure normal · disk 112.4 GiB of 494.4 GiB free · read 12s ago
Install ios 26.4: downloading for 42s, 1 waiter
wrk_4a10 (ci-runner-3): disconnected, drained -- ios 0/4, android 0/2, 0 lease(s) -- macOS 14.7 x64; xcode 16.2 (16C5032a)
Load: no reading while disconnected
| Worker | Load line in worker list and in status on a gateway |
|---|---|
| connected, sent load | Load: ... as #531 formats it, age from readAt, before any install lines |
| connected, older worker sent no load | none |
| starting, sent load | Load: ... |
| disconnected | Load: no reading while disconnected |
| incompatible | none (it shows no host either) |
readAt ahead of the CLI's clock |
read 0s ago |
If this goes wrong, an operator sees a stale or missing load for a worker, or a load on a worker that is gone.
Technical spec
Follow ADR 0028 §5 and §6 (PR #529).
Modules touched
flowchart LR
WL["gateway/worker-link.ts *"] --> F["contract/worker-view.ts *"]
WD["daemon/dispatcher.ts (worker.list) *"] --> F
WL --> R["gateway/worker-registry.ts *"]
M["daemon/main.ts (gateway mode) *"] --> HL["host-load/"]
M --> SRV["daemon/server.ts"]
M --> GD["gateway/dispatcher.ts *"]
GD --> A["gateway/aggregate.ts *"]
CLI["cli/index.ts *"] --> K["contract: schemas.ts, protocol.ts *"]
workerViewSchemagainsload: workerLoadSchema.optional():{ readAt, cpu?, memory?, disk? }.readAtis a non-negative integer. The parts reuse A worker's simlock status shows its CPU, memory and disk load #531's part schemas.workerViewFields.WorkerReadsgainsreceivedAt, the time on the caller's clock when thestatus.getanswer arrived.- When the status carries
load, the view'sloadis{ readAt: max(0, receivedAt − ageMs), ...parts }. A worker's simlock status shows its CPU, memory and disk load #531'sfitHostLoadalready capsageMsat one day on the worker, and the schema refuses more, so a worker cannot pushreadAtbelow zero or break the gateway's own answers. - This applies to the starting branch too, which today returns
{ health, host }only.WorkerViewReportlistsload.
- When the status carries
- Callers. Each takes its clock's now the moment its
status.getanswer resolves, never later, so a slow read beside it cannot make the reading look fresher.WorkerLinktakes it when itsstatus.getresolves.- The worker's own
#workerListindaemon/dispatcher.tsreads status, devices and the catalog together. It takes the time as status's own promise resolves, not after all three.
WorkerRegistry. One helper,withoutLoad(view), enforces "only aconnectedview carriesload". Every state change goes through it:connecteddropsload, so a new session (including a reconnect that skippeddisconnected) never shows the old session's reading.disconnectedandincompatibledropload, the way they dropinstallsandwaitingtoday.refreshsetsloadto exactly the snapshot's, removing it when the snapshot has none (an older worker). Todayrefreshspreads the snapshot over the view, which would keep the old value.- On a view that is not
connected,refreshapplieswithoutLoadto the result.WorkerLinkalready checks#superseded()before everyrefresh, so through the link a late answer does not reach the registry afterdisconnected. The registry check is defence in depth, tested at the registry seam.
- Gateway's own load.
GatewayDaemonOptionsgainshostLoadProbe?: HostLoadProbe | null, passed fromstartDaemon's option with the same default: A worker's simlock status shows its CPU, memory and disk load #531'sproductionHostLoadProbe, or no sampler fornull.startGatewayDaemonbuilds aHostLoadSamplerand hands it toDaemonServerashostLoad. A worker's simlock status shows its CPU, memory and disk load #531 putshostLoadonDaemonServerCommonOptions, the options a gateway'sDaemonServertakes too. It makesDaemonServerstart the sampler once the socket is bound and dispose it on stop and on a failed start, soserver.tsdoes not change here.GatewayDispatchergetsload: () => fitHostLoad(sampler.current()). Its starting branch addsloadto the{ daemon, host }it returns itself. The running branch passesloadthroughAggregateStatusOptionstoaggregateStatus.- Worker loads are not summed into it.
- CLI.
formatWorkers(workers, { now })prints an indentedLoad:line right after each worker's line, before its installs. It prints the reading when the view hasload, andno reading while disconnectedfor a disconnected worker.- It reuses A worker's simlock status shows its CPU, memory and disk load #531's Load formatter.
- Age is
now − readAt, floored at 0, then rounded up to whole seconds. - Both callers pass a
now:simlock worker list(its clock's now, as for installs) andformatStatuson a gateway (thenowit already receives). Both run on the gateway's machine, because the CLI speaks only the local socket.
Contract and event changes
worker.listoutput (the worker view) gains optionalload. This PR bumps the protocol by one (src/contract/protocol.tsand its three test pins).- No event changes.
- feat(fleet): workers advertise their capabilities in status and worker list #519 (PR feat(fleet): workers advertise their capabilities in status and worker list #520) also changes
workerViewSchema,workerViewFields, the registry's read-field dropping andformatWorkers. feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink #514 and feat(agent-device): open, commands, heartbeats and close through a gateway #515 also changestartGatewayDaemon. feat(fleet): a lease request may require a capability #513,GET /v1/statsand thesimlock stats --csvledger #348 and Every device shape says when the device entered its state #318 also bump the protocol. They run at the same time by the maintainer's choice. Whichever merges second takes the base branch and bumps again.
Other code on the same state
WorkerRegistry.connected,refresh,disconnectedandincompatibleare the four places the view'sloadchanges, all throughwithoutLoadas above. When adisconnectedand a laterefreshrace, the view ends withoutloadin either order.aggregateStatuscopies every view intostatus.get'sworkers, so they carryloadwith no extra code. The gateway's ownloadis a separate field.- Gateway routing reads views, but no stage reads
load. A routing test shows that a view with and withoutloadpicks the same worker. doc-samples.test.tsparses the{ "workers": [...] }samples indocs/HTTP-API.mdanddocs/CLI.md. A sample that showsloadmust parse.e2e/single-host-fleet.test.tsexpectssimlock worker listagainst a real worker to print exactly one line (about :65). That worker now also prints aLoad:line once its first read lands, so the test could flake. It changes to wait untilworker list --jsoncarriesload, then expect the worker line followed by itsLoad:line.
Rules in play
architecture.mdrule 10: one helper owns "only aconnectedview carriesload".architecture.mdrule 12: that holds at every exit ofconnected,disconnected,incompatibleandrefresh.safety.md"wire input is a claim": a worker'sageMsis bounded before it becomes a time.testing.md.
Tests
The seams are workerViewFields, WorkerRegistry, startDaemon in gateway mode with a FakeHostLoadProbe, and formatWorkers / formatStatus through the CLI. Model them on these:
src/contract/worker-view.test.ts("copies a running worker's warm pool block into its view...")src/gateway/worker-registry.test.ts("drops a worker's installs from its view when it disconnects or turns incompatible")src/daemon/main.test.ts(gateway mode)src/cli/index.test.ts("prints each worker's operating system...")
The test lines:
- workerViewFields turns a status load's
ageMsintoreadAton the given receive time, keeping every part, and floorsreadAtat 0 - workerViewFields copies load from a starting worker's status
- workerViewFields leaves load out when the status has none
- the worker view schema round-trips load, and refuses a negative
readAt, a negative figure or a busy % over 100 - WorkerLink stamps a view's load with the gateway clock's time when status.get answered
- a worker's own worker.list carries its load with
readAttaken when its status answered, not after its catalog read - the registry drops a worker's load when it disconnects, turns incompatible, or connects again
- a refresh without load leaves the view without load, after one with load
- a refresh that reaches a disconnected or an incompatible view leaves no load on it
- the gateway picks the same worker for a request whether or not the views carry load
- a gateway's status.get carries the gateway machine's own load, not its workers', on the starting answer and the running one
- a gateway started with
hostLoadProbe: nullanswers status.get without load, and arms no host load timer - a gateway started with no
hostLoadProbebuilds the production probe, the same factory a worker uses - a stopped gateway, and one that lost the start race, leave no host load timer pending
simlock worker listprints a connected or starting worker's Load line after its own line and before its install linessimlock worker listprintsno reading while disconnectedfor a disconnected worker, and no Load line for an incompatible one or one that sent nonesimlock worker listprintsread 0s agofor areadAtahead of its clocksimlock statusagainst a gateway prints the gateway's own Load line, and each worker's Load line aged from the status time- every
{ "workers": [...] }sample in the docs parses as a worker list e2e/single-host-fleet.test.tswaits for the reading, then sees the worker line and itsLoad:line
Done when
- Every test above is green, and the protocol is one above the base branch's.
GET /v1/workerson a gateway carriesloadwithreadAtfor each connected worker that sends one, and none for a disconnected worker.
Out of scope
- The console (The console shows each worker's load on its card and detail page #534).
- Linux limits (Linux load is measured against a cgroup v2 CPU or memory limit and marked limit #532).
- Summing loads across the fleet.
Depends on
Approval
- Approved for delivery
Written by an agent.
Generated by Claude Code
Activity
fix(cli): worker list says which load parts are unavailable, as statu…
test: a gateway carries each worker's load in its view and reports it…
fix(cli): worker list says which load parts are unavailable, as statu…
feat(gateway): a gateway carries each worker's load in its view and r…