Skip to content

agent-device's gateway side runs as an optional module #598

Open

Description

@V3RON

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>.enabled is true and the module has loadGateway.
  • agent-device builds its fleet port inside the module, from a gateway
    context bound to its name.
  • src/daemon/main.ts imports 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"]
Loading

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

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:

  • WorkerView and FleetGrantedDevice are inferred from the contract's
    schemas, which src/modules may import (F15). No copy is needed.
  • FleetLeases (all, resolve, onRemoved) and FleetLease (the
    entry's fields) are declared by shape. A type test checks each against
    the Pick of 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 named name is
    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.maxTtlMs known) by filtering the views itself.
  • relay calls GatewayRelay.relay with target: name (F7).
  • main.ts builds 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 loadGateway is never loaded on a gateway, and mounts
    and runs nothing.
  • For each module turned on that has loadGateway, it awaits
    loadGateway() and calls createGateway(ctx).
  • A loadGateway() or createGateway that throws stops the gateway's
    start with an error naming the module. Creating starts nothing.
  • It plugs in mount under /v1/<name>.
  • It runs hooks.beforeDrain from the gateway's stopAuxiliary, 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.beforeDrain run F8's steps, through ctx.cancelPending.

agentDeviceModuleWith keeps the real loadGateway, so a gateway test
loads the real gateway side.

Other changes:

  • GatewayDaemonOptions gains optionalModules, passed from
    StartDaemonOptions (F10).
  • src/agent-device/index.ts becomes one line,
    export { agentDeviceModule } from "./definition.js", and
    src/module-registry imports it from there. Its only importer was
    main.ts (F17), so the component keeps an entry and nothing unused.
  • main.ts loses its dynamic import from agent-device runs as an optional module on workers #597, agentDeviceFleetPort,
    FleetPortParts, AGENT_DEVICE and F2's helpers.
  • Everything else index.ts holds 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 the FleetPort
    and FleetWorker types. worker.ts, gateway.ts and dispose.test.ts
    import them from there (F13).
  • agent-device runs as an optional module on workers #597's dependency-cruiser rule tightens: main.ts imports 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 through ctx.cancelPending, as F8 does
    today.
  • The gateway's routing (has-capabilities) reads the same capability
    lists serving() reads (F14). Neither writes them, so the order between
    them does not matter.
  • The fleet coordinator's dispose (F16): when agent-device's
    beforeDrain runs past the deadline, dispose rejects the allocates
    still waiting, which then end DAEMON_STOPPING instead of cancelled.
    A cancelPending that settles after that gets not-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): beforeDrain runs where agent-device's
    steps run today, before the HTTP front stops.

Rules in play

  • architecture.md rules 18, 20 and 21: one context shape for every
    module, and main.ts imports none.
  • architecture.md rule 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's loadGateway (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.maxTtlMs is not in serving()
  • a fake gateway module's relay sends 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 is available, drained and catalog-unread ones included
  • a module turned on with no loadGateway is never loaded on a gateway, and mounts nothing
  • a loadGateway() that throws, and a createGateway that throws, each stop the gateway's start with an error naming the module
  • the gateway's daemon stop cancels a waiting agent-device allocate through the fleet coordinator before the HTTP front stops (as today)
  • a fake gateway module whose beforeDrain never settles is logged as ran out of time at 30 s on a fake clock, and the HTTP front still stops and daemon.stop() resolves
  • gateway mode builds no supervisor: no ps, no agent-device process, no signal (as today, F12)
  • FleetLeases and FleetLease and the Pick of their originals are assignable both ways
  • the conformance suite covers each registered gateway side: createGateway starts nothing, and its beforeDrain settles within the hook deadline
  • dependency-cruiser fails any import of src/agent-device from main.ts

Done when

  • Every Tests line passes, and every existing test in
    gateway-front.test.ts and lease-requires.e2e.test.ts passes with only
    its setup changed.
  • git grep -n "agent-device\|agentDevice" -- src/daemon/main.ts prints
    nothing.
  • On a Mac with an iOS runtime:
    scripts/slow-e2e.sh e2e/slow-agent-device-gateway.test.ts passes.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 15, 2026
  2. V3RON commented on Oct 15, 2026

    @V3RON
    ContributorAuthor

    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.

  3. 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 15, 2026
  4. V3RON commented on Oct 15, 2026

    @V3RON
    ContributorAuthor

    Spec updated: closure-check findings folded in; set task:ready.

    Written by an agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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