Repository navigation
agent-device's gateway side runs as an optional module #598
Description
Part of #591.
Scope
Moves agent-device's gateway side onto the module host. After this PR:
- A gateway loads a module's gateway side through the registry when
capabilities.<name>.enabledistrueand the module hasloadGateway. - agent-device builds its fleet port inside the module, from a gateway
context bound to its name. src/daemon/main.tsimports nothing from any module.
Behaviour is unchanged with one exception. On daemon stop, a gateway now
gives agent-device's stop step at most the
Hook deadline;
today that step has no limit.
In short
Nothing changes for a user. A gateway with agent-device on still serves
/v1/agent-device, and sends each agent to a worker whose agent-device is
available. The code that turns the gateway's parts into agent-device's port
moves from the start-up file into agent-device.
flowchart LR
G["gateway parts:<br/>worker views, lease index, relay"] --> X["GatewayContext *new*<br/>bound to agent-device"]
X --> P["agent-device fleet port *moved*"]
P --> F["/v1/agent-device front"]
Words: gateway context is what the host hands a module on a gateway.
It holds the worker views, the lease index, and a relay that can only reach
that module's own target.
If this goes wrong: agents on a gateway get "no worker can serve
agent-device" while a worker can, or a relay goes to the wrong target.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | The gateway builds agent-device's front parts from agentDeviceFleetPort(...) before DaemonServer, when enabled is set. |
src/daemon/main.ts:827-838 |
| F2 | agentDeviceFleetPort and its helpers listedLease, describeDevice and leaseFacts live in main.ts. They read FleetViews, grantedDevices(), the lease index and GatewayRelay, and relay with target: AGENT_DEVICE. |
src/daemon/main.ts:1010-1150, :1091 |
| F3 | A worker serves agent-device when it is connected, not drained (unless asked), its agent-device capability is available, its catalog is read, and its lease.maxTtlMs is known. |
src/daemon/main.ts:1030-1043 |
| F4 | agent-device's FleetPort interface, with FleetWorker, PlacedLease and FleetLeaseFacts. |
src/agent-device/front/relay-host-link.ts:16-61 |
| F5 | FleetViews has views(), routingViews(), view(id) and onViewsChanged. |
src/gateway/fleet-ports.ts:86-91 |
| F6 | FleetLeaseIndex has requesterPrefix, all(), resolve(id) and onRemoved(listener). |
src/gateway/lease-index.ts:98-116, :359-361 |
| F7 | GatewayRelay.relay(workerId, call) takes { request, target, tenant, leaseId? }. |
src/gateway/relay.ts:30 |
| F8 | The gateway's stopAuxiliary disposes the edge and cancels waiting allocates through fleetCoordinator.cancelPending, with no deadline. |
src/daemon/main.ts:915-919 |
| F9 | The gateway mounts agent-device's front with leaseTtlCapMs and maxRunMs. |
src/daemon/main.ts:949-962 |
| F10 | GatewayDaemonOptions has no module option. |
src/daemon/main.ts:695-712 |
| F11 | The gateway fleet suite passes agentDeviceUpstream to workers and drives the gateway's /v1/agent-device. It pins the drained and catalog-not-read cases of F3. |
src/agent-device/gateway-front.test.ts:270, :747, :799 |
| F12 | main.test.ts checks that gateway mode builds no supervisor. |
src/daemon/main.test.ts:2545 |
| F13 | dispose.test.ts imports disposeAgentDeviceEdge from the index; that function and AgentDeviceFrontParts are declared in index.ts itself. |
src/agent-device/dispose.test.ts:3; src/agent-device/index.ts:24-28, :93-97 |
| F14 | The gateway's routing stage has-capabilities reads each view's capability list and keeps a worker only when every required name is available; it writes nothing. |
src/gateway/routing/stages/has-capabilities.ts:3-19 |
| F15 | WorkerView and the granted device are z.infer of contract schemas; FleetLeaseEntry is a plain interface; FleetLeaseIndex is a class with private fields and more public members than the port needs. |
src/gateway/worker-registry.ts:15-25, :51; src/gateway/lease-index.ts:25-32, :60-116 |
| F16 | When the gateway's dispose runs, the fleet coordinator rejects every waiter still queued; an existing test expects a waiting allocate to end cancelled on stop. |
src/gateway/fleet-coordinator.ts:311-321, :634-637; src/agent-device/gateway-front.test.ts:1233 |
| F17 | Outside the component, only main.ts imports src/agent-device/index.js; tests import testing.js. |
git grep -ln "agent-device/index.js" origin/main -- src e2e |
Modules touched
flowchart LR
main["daemon/main.ts *"] --> host["modules host *"]
host --> types["modules types *"]
reg["module-registry"] --> def["agent-device/definition *"]
def -. loadGateway .-> g["agent-device/gateway *new*"]
g --> port["fleet port *moved*"]
main --> gw["gateway: views, lease index, relay"]
sequenceDiagram
participant A as agent CLI
participant G as gateway front (agent-device)
participant C as GatewayContext
participant W as worker
A->>G: POST /v1/agent-device/...
G->>C: serving()
alt a worker serves agent-device
C->>W: relay(workerId, request), target agent-device
W-->>A: response
else none
G-->>A: no worker connected to this gateway can serve agent-device
end
Surface and extension points
src/modules/index.ts gains GatewayModule and GatewayContext<C> from
ADR 0030 §3 and §4. Under #595's modules-imports-little rule:
WorkerViewandFleetGrantedDeviceare inferred from the contract's
schemas, whichsrc/modulesmay import (F15). No copy is needed.FleetLeases(all,resolve,onRemoved) andFleetLease(the
entry's fields) are declared by shape. A type test checks each against
thePickof its original's members (F15), both ways.
export interface GatewayContext<C> {
readonly name: string;
readonly config: C;
readonly daemonConfig: Config;
readonly logger: Logger;
readonly clock: Clock;
readonly instanceId: string;
readonly startedAt: number;
readonly requesterPrefix: string;
workers(): readonly WorkerView[];
worker(id: string): WorkerView | undefined;
serving(): readonly WorkerView[];
grantedDevices(): readonly FleetGrantedDevice[];
readonly leases: FleetLeases; // all(), resolve(id), onRemoved(listener)
relay(workerId: string, request: Request, tenant: string, leaseId?: string): Promise<Response>;
cancelPending(requesterId: string): Promise<unknown>;
}serving()lists connected workers whose capability namednameis
available(ADR 0030 §4), nothing more. agent-device's own fleet port
keeps the rest of F3's rule (not drained unless asked, catalog read,
lease.maxTtlMsknown) by filtering the views itself.relaycallsGatewayRelay.relaywithtarget: name(F7).main.tsbuilds one context per module from the gateway's parts.
createModuleHost({ role: "gateway", … }), called in
startGatewayDaemon before DaemonServer (where F1 builds today):
- A module with no
loadGatewayis never loaded on a gateway, and mounts
and runs nothing. - For each module turned on that has
loadGateway, it awaits
loadGateway()and callscreateGateway(ctx). - A
loadGateway()orcreateGatewaythat throws stops the gateway's
start with an error naming the module. Creating starts nothing. - It plugs in
mountunder/v1/<name>. - It runs
hooks.beforeDrainfrom the gateway'sstopAuxiliary, before
the HTTP front stops, under the
Hook deadline.
agent-device's definition gains loadGateway: () => import("./gateway.js").
src/agent-device/gateway.ts's createGateway(ctx):
- builds today's fleet port from the context (F2, F4);
- returns its front's handler as
mount(F9); - has
hooks.beforeDrainrun F8's steps, throughctx.cancelPending.
agentDeviceModuleWith keeps the real loadGateway, so a gateway test
loads the real gateway side.
Other changes:
GatewayDaemonOptionsgainsoptionalModules, passed from
StartDaemonOptions(F10).src/agent-device/index.tsbecomes one line,
export { agentDeviceModule } from "./definition.js", and
src/module-registryimports it from there. Its only importer was
main.ts(F17), so the component keeps an entry and nothing unused.main.tsloses its dynamic import from agent-device runs as an optional module on workers #597,agentDeviceFleetPort,
FleetPortParts,AGENT_DEVICEand F2's helpers.- Everything else
index.tsholds after agent-device runs as an optional module on workers #597 moves to
src/agent-device/front/edge-parts.ts:AgentDeviceFrontParts,
disposeAgentDeviceEdge,createGatewayAgentDeviceFrontParts,
cancelWaitingAllocates,createAgentDeviceFront, and theFleetPort
andFleetWorkertypes.worker.ts,gateway.tsanddispose.test.ts
import them from there (F13). - agent-device runs as an optional module on workers #597's dependency-cruiser rule tightens:
main.tsimports nothing from
src/agent-device, of any type.
Contract and event changes
None. Searched the contract for agent-device's gateway shapes: the relay
and the routes are unchanged.
Other code on the same state
- The fleet coordinator places leases and cancels pending requests.
agent-device reaches it only throughctx.cancelPending, as F8 does
today. - The gateway's routing (
has-capabilities) reads the same capability
listsserving()reads (F14). Neither writes them, so the order between
them does not matter. - The fleet coordinator's dispose (F16): when agent-device's
beforeDrainruns past the deadline,disposerejects the allocates
still waiting, which then endDAEMON_STOPPINGinstead ofcancelled.
AcancelPendingthat settles after that getsnot-found, and the
host has already stopped waiting for it. In the normal case the step
finishes well inside 30 s, and F16's test still holds. - The gateway's stop order (F8):
beforeDrainruns where agent-device's
steps run today, before the HTTP front stops.
Rules in play
architecture.mdrules 18, 20 and 21: one context shape for every
module, andmain.tsimports none.architecture.mdrule 10: each by-shape fleet type has its type test.safety.md: the relay sends only to the module's own target, so a
module cannot reach another's.
Tests
Seam: the gateway fleet harness in src/agent-device/gateway-front.test.ts,
with optionalModules: [agentDeviceModuleWith({ upstream: fake })] on the
workers and on the gateway. A fake gateway module covers the host's own
rules. Every existing test in that suite and in
src/daemon/lease-requires.e2e.test.ts still passes, with only its setup
changed.
- a gateway with agent-device off mounts no
/v1/agent-device, and never calls agent-device'sloadGateway(a spying definition) - a gateway with agent-device on relays an agent's call to a worker whose agent-device is available (as today)
- a gateway answers that no worker can serve agent-device when every worker reports it
off(as today) - a drained worker keeps serving the agent-device leases it holds, and a worker whose catalog is unread serves none (as today)
- a worker whose view has no
lease.maxTtlMsis not inserving() - a fake gateway module's
relaysends with its own name as the target, and no other - a fake gateway module's
serving()lists only connected workers whose capability named after it isavailable, drained and catalog-unread ones included - a module turned on with no
loadGatewayis never loaded on a gateway, and mounts nothing - a
loadGateway()that throws, and acreateGatewaythat throws, each stop the gateway's start with an error naming the module - the gateway's
daemon stopcancels a waiting agent-device allocate through the fleet coordinator before the HTTP front stops (as today) - a fake gateway module whose
beforeDrainnever settles is logged as ran out of time at 30 s on a fake clock, and the HTTP front still stops anddaemon.stop()resolves - gateway mode builds no supervisor: no
ps, no agent-device process, no signal (as today, F12) FleetLeasesandFleetLeaseand thePickof their originals are assignable both ways- the conformance suite covers each registered gateway side:
createGatewaystarts nothing, and itsbeforeDrainsettles within the hook deadline - dependency-cruiser fails any import of
src/agent-devicefrommain.ts
Done when
- Every Tests line passes, and every existing test in
gateway-front.test.tsandlease-requires.e2e.test.tspasses with only
its setup changed. git grep -n "agent-device\|agentDevice" -- src/daemon/main.tsprints
nothing.- On a Mac with an iOS runtime:
scripts/slow-e2e.sh e2e/slow-agent-device-gateway.test.tspasses.
Out of scope
- The doctor finding's shape (Doctor reports every optional module with one module-unavailable finding #596).
- Streaming (feat(stream): a leased simulator's or emulator's screen, streamed to whoever is looking at it #576).
Depends on
Written by an agent.
Activity
V3RON commentedon Oct 15, 2026
Spec updated: findings from the first spec check folded in (order is now #596 → #595 → #597 → #598; split loaders, own registry component, routes under the module's name, concurrent stop steps per ADR 0030's amendment).
Written by an agent.
V3RON commentedon Oct 15, 2026
Spec updated: closure-check findings folded in; set task:ready.
Written by an agent.