Skip to content

agent-device runs as an optional module on workers #597

Closed

Description

@V3RON

Part of #591.

Scope

Moves agent-device's worker side onto the module host from #595. After
this PR, agent-device is the registry's first module:

  • It is turned on with capabilities.agent-device.enabled. The top-level
    agentDevice key goes.
  • A worker with it turned off never loads its code.
  • src/daemon/main.ts no longer wires it on a worker.

Its behaviour is otherwise unchanged, with three exceptions:

  • With agent-device off, simlock no longer stops an agent-device daemon
    left by a crash (ADR 0030 §2).
  • On daemon stop, agent-device's two stop steps are now bounded by the
    Hook deadline;
    today they have no limit. Its slowest stop is about 22 s: a running
    --version check or reap (ps 5 s, SIGTERM 10 s, SIGKILL 2 s,
    F18). Past 30 s the drivers are disposed with agent-device still
    stopping, and the warning names it.
  • A gateway's warning for command uses the generic text (below).

The gateway side stays hand-wired in main.ts until #598. In this PR it
loads agent-device with a dynamic import(), only when it is turned on.
That way no daemon loads agent-device's code through a static import.

Test fixtures that build a whole Config lose their agentDevice line.
That edit is mechanical and is not counted in this task's size. Setup
lines that turn agent-device on (edge.test.ts, gateway-front.test.ts,
main.test.ts, worker-capabilities.e2e.test.ts,
lease-requires.e2e.test.ts) move to capabilities["agent-device"]
instead. The slow-lane files are rewritten (F14).

In short

An operator turns agent-device on under capabilities:

{ "capabilities": { "agent-device": { "enabled": true, "command": "agent-device", "leaseTtlCapMs": 60000 } } }

A config that still says { "agentDevice": { "enabled": true } } gets
Unknown config key: "agentDevice", and agent-device stays off. The
integration is unreleased, so nothing migrates the old key.

flowchart LR
  R["module-registry: agent-device *"] --> H[module host]
  H -->|on| L["loadWorker() → createWorker *"]
  H -->|off| O["agent-device: off<br/>no code loaded, no reap *"]
Loading

If this goes wrong: agent-device does not start on a worker that turns it
on, or simlock daemon stop leaves its process behind.

Technical spec

Facts

# Fact Source
F1 main.ts statically imports 15 names from ../agent-device/index.js. src/daemon/main.ts:5-21
F2 On a worker, main.ts builds agent-device's supervisor, edge parts, lease-end participant, capability source (with an off stub when disabled), relay target, HTTP front, stop steps and start. src/daemon/main.ts:329-357, :372, :484-488, :522-527, :548-551, :571-575, :592-596, :630-645
F3 agentDevice.stop() runs in dispose before the drivers, because the Android driver stops the adb server agent-device uses. src/daemon/main.ts:548-551
F4 stopAuxiliary disposes the edge and cancels waiting allocates through leasing.cancelPending. src/daemon/main.ts:571-575
F5 onSocketClaimed starts the supervisor, not awaited, unless a test passed agentDeviceUpstream. src/daemon/main.ts:592-596
F6 With config.enabled false, the supervisor starts in off; start() reaps a recorded daemon, then stays off. src/agent-device/host/agent-device-supervisor.ts:163, :255-286
F7 Two supervisor tests pin the off path. src/agent-device/host/agent-device-supervisor.test.ts:346, :1161
F8 The agent-device config is { enabled, command, leaseTtlCapMs? }, default { enabled: false, command: "agent-device" }, leaseTtlCapMs from 5000 to lease.maxTtlMs; command is worker-only. The core holds its type, the cap check and its call, its gateway key, its gateway warning, its default and its validator. The validators come from src/core/validation.ts, which src/core/index.ts does not export; invalidValue words an error Invalid config value for "<path>": expected <what>. src/core/config.ts:253-257, :410, :452-459, :485, :494, :522-530, :763, :916-920; src/core/validation.ts:17-19, :17-118
F9 Config tests for agent-device: one describe block of 8 tests, plus a gateway test. src/core/config.test.ts:1961-2036, :1776
F10 configSchema names agentDevice. src/contract/schemas.ts:902-906
F11 Tests pass agentDeviceUpstream or agentDeviceHealth to startDaemon. src/agent-device/edge.test.ts:165; src/agent-device/gateway-front.test.ts:270; src/daemon/main.test.ts:2430; src/daemon/worker-capabilities.e2e.test.ts:66; src/daemon/lease-requires.e2e.test.ts:89
F12 Five assertions name the old key: three in those suites, two in the supervisor's tests. src/agent-device/gateway-front.test.ts:703, :709-711; src/agent-device/edge.test.ts:2098-2105; src/agent-device/host/agent-device-supervisor.test.ts:421, :480
F13 The supervisor's remedies tell the user to set agentDevice.command. src/agent-device/host/agent-device-supervisor.ts:228-230
F14 The slow-lane tests write and delete agentDevice in a config. e2e/slow-agent-device.test.ts; e2e/slow-agent-device-gateway.test.ts:160, :173, :264, :280
F15 docs/CONFIGURATION.md documents the agentDevice.* keys, the gateway slice, and "A daemon a killed Simlock left behind is stopped at the next start". docs/CONFIGURATION.md:38-40, :162-169
F16 The gateway path reads config.agentDevice for enabled and leaseTtlCapMs. src/daemon/main.ts:827, :958-960
F17 dependency-cruiser records import() as dynamic-import, import type as type-only, and import("x").T as type-import. node_modules/dependency-cruiser/src/extract/tsc/extract-typescript-deps.mjs:82, :424, :436 (18.4.0)
F18 The supervisor's waits: ps 5 s, --version 10 s, SIGTERM grace 10 s, SIGKILL grace 2 s. src/agent-device/host/agent-device-supervisor.ts:76-80
F19 agent-device's front imports src/http's helpers and types. src/agent-device/front/front.ts:1; src/agent-device/front/allocate.ts:4-6; src/agent-device/host/host.ts:3
F20 Today a gateway warns Ignoring "agentDevice.command": it names the program a worker runs, and this daemon runs in gateway mode. src/core/config.ts:528

Modules touched

flowchart LR
  main["daemon/main.ts *"] --> host["modules host"]
  main --> reg["module-registry *"]
  reg --> def["agent-device/definition *new*"]
  def -. loadWorker .-> w["agent-device/worker *new*"]
  main -. "dynamic import (gateway, until #598)" .-> idx["agent-device/index *"]
  w --> sup["agent-device supervisor *"]
  cfg["core/config.ts, core/index.ts *"]
  contract["contract/schemas.ts, protocol.ts *"]
Loading

Surface and extension points

src/agent-device/definition.ts exports agentDeviceModule, an
OptionalModuleDefinition:

  • name: "agent-device".
  • config:
    • F8's validator without enabled, which the core owns (Optional modules load through one host and are turned on under capabilities #595), built
      from validators that src/core/index.ts now exports
      (objectValidator, nonEmptyString, integerInRange,
      invalidValue);
    • F8's defaults without enabled;
    • gatewayKeys: ["leaseTtlCapMs"];
    • a check that leaseTtlCapMs is at most lease.maxTtlMs, failing
      with invalidValue's text,
      Invalid config value for "capabilities.agent-device.leaseTtlCapMs": expected at most lease.maxTtlMs.
  • Every agent-device piece F8 lists leaves src/core/config.ts.
  • loadWorker: () => import("./worker.js"). No loadGateway until agent-device's gateway side runs as an optional module #598.

src/agent-device/worker.ts exports createWorker(ctx). It builds the
supervisor and the edge parts from ctx (F2), and returns:

  • capability, from today's capability source;
  • relayTarget, from today's relay target;
  • mount, the HTTP front's handler (the front keeps using src/http's
    helpers, F19; ADR 0030 §6), with ctx.config.leaseTtlCapMs and
    maxRunMs = ctx.daemonConfig.lease.maxTtlMs;
  • findings, today's agentDeviceFindings returning
    { message, remedy }; the host now stamps module (Doctor reports every optional module with one module-unavailable finding #596's line moves
    here);
  • hooks.afterSocketClaimed: void supervisor.start() (F5);
  • hooks.beforeDeviceReclaim: the lease-end participant;
  • hooks.beforeDrain: dispose the edge, then cancel waiting allocates
    through ctx.cancelPending (F4);
  • hooks.beforeDriversDispose: supervisor.stop() (F3).

src/agent-device/testing.ts exports
agentDeviceModuleWith({ upstream?, health? }), a definition whose
createWorker uses the given fakes. With upstream, the supervisor is
never started, as F5 does today.

Other changes:

  • src/agent-device/index.ts keeps only what the gateway path uses
    (F16). main.ts's gateway path loads it with await import(...) only
    when capabilities.agent-device.enabled is true. It reads the section
    through the type definition.ts exports, and a missing section counts
    as off. Every other name F1 lists leaves main.ts.
  • StartDaemonOptions drops agentDeviceUpstream and agentDeviceHealth.
  • The supervisor drops its enabled branch (F6). It is built only for a
    turned-on module.
  • The supervisor's remedies say capabilities.agent-device.command (F13).
  • A gateway warns Ignoring "capabilities.agent-device.command": it configures a worker, and this daemon runs in gateway mode.,
    Optional modules load through one host and are turned on under capabilities #595's generic text, in place of F20's.
  • New dependency-cruiser rule main-loads-modules-lazily: main.ts
    imports src/agent-device only as dynamic-import, type-only or
    type-import (F17). agent-device's gateway side runs as an optional module #598 tightens it to no import at all.

Contract and event changes

configSchema drops agentDevice (F10); agent-device's section travels in
capabilities. This PR bumps the protocol by one. No event changes.

Other code on the same state

  • The gateway path in main.ts (F16) reads agent-device's config. It
    switches to config.capabilities["agent-device"], with the same
    behaviour.
  • Optional modules load through one host and are turned on under capabilities #595's host tests pin agent-device's hand-wired entry first in the
    capability list, and its stop steps before the hooks. With the hand
    wiring gone, those tests assert the registry order only, with
    agent-device as a registered module.
  • The core doctor gets only host.findings() at main.ts:329.
  • A leftover agent-device daemon while off: nothing acts on it any
    more. The next start with agent-device on reaps it, as today (F6).

Rules in play

  • architecture.md rules 18 and 21: main.ts names no module on the
    worker path.
  • safety.md: the reap's ownership proof is unchanged.
  • testing.md: delete only F7's two tests, whose path is gone. F9's tests
    move, they are not deleted.
  • documentation.md: F15, plus CLI.md, HTTP-API.md and CLIENT.md
    wherever they write agentDevice.

Tests

Seam:
startDaemon({ optionalModules: [agentDeviceModuleWith({ upstream: fake })], configOverrides: { capabilities: { "agent-device": … } } })
in place of agentDeviceUpstream in the suites F11 names, modelled on
src/agent-device/edge.test.ts. Those suites still pass, with their setup
changed and F12's three assertions renamed to the new key.

  • a worker with no capabilities["agent-device"] section answers status.get with { name: agent-device, state: off }, and so does one with enabled: false
  • a worker with agent-device off never calls agent-device's loadWorker (a spying definition that wraps agentDeviceModule)
  • a gateway with agent-device off, or with no section for it, mounts no /v1/agent-device (as today)
  • a worker whose config has only the old agentDevice key warns Unknown config key: "agentDevice" and reports agent-device off
  • a worker with agent-device off starts no supervisor: no ps runs and no signal is sent, even with a recorded daemon on disk
  • F9's tests move to src/agent-device/definition.test.ts, under capabilities.agent-device (as today):
    • the defaults, and an explicit section kept as written;
    • command refused when empty, and when a number;
    • leaseTtlCapMs accepted at 5000 and at lease.maxTtlMs, refused as a fraction, refused below 5000, and refused above lease.maxTtlMs with invalidValue's text naming capabilities.agent-device.leaseTtlCapMs;
    • a loaded config parsed through configSchema keeps the section;
    • on a gateway, enabled and leaseTtlCapMs read without a warning, and command warned about with the generic text
  • startDaemon with agent-device on stops the agent-device child before the drivers are disposed (as today, main.test.ts:2449)
  • with agent-device on, a release still waits for agent-device's lease end before the device is reclaimed (as today)
  • daemon stop cancels an agent-device allocate waiting in the queue before leasing settles (as today)
  • doctor.run reports { kind: "module-unavailable", module: "agent-device" }, with the module stamped by the host
  • the supervisor's not-found and not-agent-device remedies name capabilities.agent-device.command (F12's two supervisor assertions)
  • config.get shows agent-device's section under capabilities["agent-device"] and no agentDevice key
  • the conformance suite runs over agent-device as a registered module
  • dependency-cruiser fails a value import of src/agent-device from main.ts (a lint-harness case); pnpm lint passing on this PR's main.ts, with its dynamic and type imports, shows the allowed kinds pass
  • the protocol version is one above the base branch's

Done when

  • Every Tests line passes, and every other test in the suites F11 names
    passes with only its setup changed and F12's assertions renamed.
  • F7's two supervisor tests are deleted. No other agent-device test is
    deleted.
  • docs/CONFIGURATION.md documents capabilities.agent-device.*. Its
    leftover-daemon line says it applies with
    capabilities.agent-device.enabled true.
  • F14's config writes and deletes use capabilities["agent-device"]. On a
    Mac with an iOS runtime: scripts/slow-e2e.sh e2e/slow-agent-device.test.ts
    and scripts/slow-e2e.sh e2e/slow-agent-device-gateway.test.ts pass.

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.

  5. self-assigned this
    on Oct 15, 2026
  6. V3RON commented on Oct 15, 2026

    @V3RON
    ContributorAuthor

    Handoff

    Done: PR #605 built tests-first and audited. All 15 spec test files pass, and all 17 tests were probed. Review round 1 found 4 text fixes, all applied (head 3282d69). It found no src defects.

    Not done: review round 2 and the hardware check of Done when line 4 (the slow lane). Hardware checks are deferred to the end of the run.

    Findings: the spec keeps agent-device's front importing src/http (F19). Now that the daemon imports the module registry, which imports agent-device, this closes two new cycles: daemon → module-registry → agent-device → http → daemon. Green added them to .dependency-cruiser-known-cycles.json and raised the ratchet from 6 to 8. Architecture rule 14 says that list only shrinks. The spec and code reviewers both confirmed this.

    Blocked on: the maintainer's choice. Option A: accept the 8-entry ratchet (no user-visible change; this breaks rule 14 until agent-device stops importing http). Option B: break the cycle, for example by moving buildHttpSession, mapError and TokenIdentity away from src/http's dependency on src/daemon. That is a larger refactor outside F19. #598 waits on this task.

    Written by an agent.

  7. added
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    and removed
    task:readySpec checked. An agent may implement it once its Depends on allow.
    on Oct 15, 2026
  8. removed their assignment
    on Oct 15, 2026
  9. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    on Oct 15, 2026
  10. added a commit that references this issue on Oct 15, 2026
    b5fd42b
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