Skip to content

A gateway carries each worker's load in its view and reports its own #533

Closed

Description

@V3RON

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)"]
Loading

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 *"]
Loading
  • workerViewSchema gains load: workerLoadSchema.optional(): { readAt, cpu?, memory?, disk? }. readAt is a non-negative integer. The parts reuse A worker's simlock status shows its CPU, memory and disk load #531's part schemas.
  • workerViewFields. WorkerReads gains receivedAt, the time on the caller's clock when the status.get answer arrived.
    • When the status carries load, the view's load is { readAt: max(0, receivedAt − ageMs), ...parts }. A worker's simlock status shows its CPU, memory and disk load #531's fitHostLoad already caps ageMs at one day on the worker, and the schema refuses more, so a worker cannot push readAt below zero or break the gateway's own answers.
    • This applies to the starting branch too, which today returns { health, host } only. WorkerViewReport lists load.
  • Callers. Each takes its clock's now the moment its status.get answer resolves, never later, so a slow read beside it cannot make the reading look fresher.
    • WorkerLink takes it when its status.get resolves.
    • The worker's own #workerList in daemon/dispatcher.ts reads 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 a connected view carries load". Every state change goes through it:
    • connected drops load, so a new session (including a reconnect that skipped disconnected) never shows the old session's reading.
    • disconnected and incompatible drop load, the way they drop installs and waiting today.
    • refresh sets load to exactly the snapshot's, removing it when the snapshot has none (an older worker). Today refresh spreads the snapshot over the view, which would keep the old value.
    • On a view that is not connected, refresh applies withoutLoad to the result. WorkerLink already checks #superseded() before every refresh, so through the link a late answer does not reach the registry after disconnected. The registry check is defence in depth, tested at the registry seam.
  • Gateway's own load.
    • GatewayDaemonOptions gains hostLoadProbe?: HostLoadProbe | null, passed from startDaemon's option with the same default: A worker's simlock status shows its CPU, memory and disk load #531's productionHostLoadProbe, or no sampler for null.
    • startGatewayDaemon builds a HostLoadSampler and hands it to DaemonServer as hostLoad. A worker's simlock status shows its CPU, memory and disk load #531 puts hostLoad on DaemonServerCommonOptions, the options a gateway's DaemonServer takes too. It makes DaemonServer start the sampler once the socket is bound and dispose it on stop and on a failed start, so server.ts does not change here.
    • GatewayDispatcher gets load: () => fitHostLoad(sampler.current()). Its starting branch adds load to the { daemon, host } it returns itself. The running branch passes load through AggregateStatusOptions to aggregateStatus.
    • Worker loads are not summed into it.
  • CLI. formatWorkers(workers, { now }) prints an indented Load: line right after each worker's line, before its installs. It prints the reading when the view has load, and no reading while disconnected for 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) and formatStatus on a gateway (the now it already receives). Both run on the gateway's machine, because the CLI speaks only the local socket.

Contract and event changes

Other code on the same state

  • WorkerRegistry.connected, refresh, disconnected and incompatible are the four places the view's load changes, all through withoutLoad as above. When a disconnected and a late refresh race, the view ends without load in either order.
  • aggregateStatus copies every view into status.get's workers, so they carry load with no extra code. The gateway's own load is a separate field.
  • Gateway routing reads views, but no stage reads load. A routing test shows that a view with and without load picks the same worker.
  • doc-samples.test.ts parses the { "workers": [...] } samples in docs/HTTP-API.md and docs/CLI.md. A sample that shows load must parse.
  • e2e/single-host-fleet.test.ts expects simlock worker list against a real worker to print exactly one line (about :65). That worker now also prints a Load: line once its first read lands, so the test could flake. It changes to wait until worker list --json carries load, then expect the worker line followed by its Load: line.

Rules in play

  • architecture.md rule 10: one helper owns "only a connected view carries load".
  • architecture.md rule 12: that holds at every exit of connected, disconnected, incompatible and refresh.
  • safety.md "wire input is a claim": a worker's ageMs is 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 ageMs into readAt on the given receive time, keeping every part, and floors readAt at 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 readAt taken 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: null answers status.get without load, and arms no host load timer
  • a gateway started with no hostLoadProbe builds 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 list prints a connected or starting worker's Load line after its own line and before its install lines
  • simlock worker list prints no reading while disconnected for a disconnected worker, and no Load line for an incompatible one or one that sent none
  • simlock worker list prints read 0s ago for a readAt ahead of its clock
  • simlock status against 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.ts waits for the reading, then sees the worker line and its Load: line

Done when

  • Every test above is green, and the protocol is one above the base branch's.
  • GET /v1/workers on a gateway carries load with readAt for each connected worker that sends one, and none for a disconnected worker.

Out of scope

Depends on

Approval

  • Approved for delivery

Written by an agent.


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    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