Repository navigation
agent-device runs as an optional module on workers #597
Description
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
agentDevicekey goes. - A worker with it turned off never loads its code.
src/daemon/main.tsno 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
--versioncheck or reap (ps5 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
commanduses 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 *"]
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 *"]
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 thatsrc/core/index.tsnow exports
(objectValidator,nonEmptyString,integerInRange,
invalidValue); - F8's defaults without
enabled; gatewayKeys: ["leaseTtlCapMs"];- a
checkthatleaseTtlCapMsis at mostlease.maxTtlMs, failing
withinvalidValue's text,
Invalid config value for "capabilities.agent-device.leaseTtlCapMs": expected at most lease.maxTtlMs.
- F8's validator without
- Every agent-device piece F8 lists leaves
src/core/config.ts. loadWorker: () => import("./worker.js"). NoloadGatewayuntil 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 usingsrc/http's
helpers, F19; ADR 0030 §6), withctx.config.leaseTtlCapMsand
maxRunMs = ctx.daemonConfig.lease.maxTtlMs;findings, today'sagentDeviceFindingsreturning
{ message, remedy }; the host now stampsmodule(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
throughctx.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.tskeeps only what the gateway path uses
(F16).main.ts's gateway path loads it withawait import(...)only
whencapabilities.agent-device.enabledistrue. It reads the section
through the typedefinition.tsexports, and a missing section counts
as off. Every other name F1 lists leavesmain.ts.StartDaemonOptionsdropsagentDeviceUpstreamandagentDeviceHealth.- The supervisor drops its
enabledbranch (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
importssrc/agent-deviceonly asdynamic-import,type-onlyor
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 toconfig.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()atmain.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.mdrules 18 and 21:main.tsnames 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, plusCLI.md,HTTP-API.mdandCLIENT.md
wherever they writeagentDevice.
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 withenabled: false - a worker with agent-device off never calls agent-device's
loadWorker(a spying definition that wrapsagentDeviceModule) - 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
agentDevicekey warnsUnknown config key: "agentDevice"and reports agent-deviceoff - a worker with agent-device off starts no supervisor: no
psruns and no signal is sent, even with a recorded daemon on disk - F9's tests move to
src/agent-device/definition.test.ts, undercapabilities.agent-device(as today):- the defaults, and an explicit section kept as written;
commandrefused when empty, and when a number;leaseTtlCapMsaccepted at 5000 and atlease.maxTtlMs, refused as a fraction, refused below 5000, and refused abovelease.maxTtlMswithinvalidValue's text namingcapabilities.agent-device.leaseTtlCapMs;- a loaded config parsed through
configSchemakeeps the section; - on a gateway,
enabledandleaseTtlCapMsread without a warning, andcommandwarned about with the generic text
startDaemonwith 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 stopcancels an agent-device allocate waiting in the queue before leasing settles (as today)doctor.runreports{ 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.getshows agent-device's section undercapabilities["agent-device"]and noagentDevicekey- the conformance suite runs over agent-device as a registered module
- dependency-cruiser fails a value import of
src/agent-devicefrommain.ts(a lint-harness case);pnpm lintpassing on this PR'smain.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.mddocumentscapabilities.agent-device.*. Its
leftover-daemon line says it applies with
capabilities.agent-device.enabledtrue.- 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
andscripts/slow-e2e.sh e2e/slow-agent-device-gateway.test.tspass.
Out of scope
- The gateway side moving onto the host (agent-device's gateway side runs as an optional module #598).
- The doctor finding's shape (Doctor reports every optional module with one module-unavailable finding #596).
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.
feat: agent-device runs as an optional module on workers (#597)
feat: agent-device config moves under capabilities, tests and docs fo…
docs: registry and modules manuals name agent-device as the first mod…
chore: fallow ignores for exports reached through dynamic imports (#597)
test: main.ts's lazy-load rule also fails a re-export and a bare impo…
docs: stale claims about agent-device wiring; drain test no longer as…
V3RON commentedon Oct 15, 2026
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
srcdefects.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.jsonand 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,mapErrorandTokenIdentityaway fromsrc/http's dependency onsrc/daemon. That is a larger refactor outside F19. #598 waits on this task.Written by an agent.
docs: startup step 10 lists core before the module host (#597)
feat(agent-device): agent-device runs as an optional module on workers (