Repository navigation
Optional modules share one contract and load only when turned on #591
Description
Request: none
Problem
Simlock has optional modules: parts a worker runs only when the operator
turns them on. agent-device is the only one on main. A second one, screen
streaming, is being built in #576.
Each optional module is wired into the daemon by hand. The daemon's start-up
file imports 14 names from agent-device and wires them in about 12 places,
for the worker and the gateway. #576 adds about 180 lines more for streaming,
in the same file.
Because nothing ties a module's parts together, they already disagree:
- A module's name is typed out in several places. In feat(stream): a lease's screen, streamed to whoever is looking at it (PoC) #576, the capability is
calledscreen-streamand the relay target is calledstream. - A worker with agent-device turned off reports
agent-device: off. A
worker with streaming turned off reports nothing. - The daemon loads agent-device's code even when it is turned off.
simlock doctorhas a finding kind made for agent-device only, so every
new module would add its own kind to the wire contract.
Who it is for
- A maintainer or agent adding an optional module, who today has to edit
the daemon's start-up file, the core config, the doctor and the contract,
and keep the module's name in step by hand. - An operator reading
simlock doctoror a worker's status, who should see
every optional module reported the same way.
Outcome
- An optional module is one directory and one line in a list of modules.
Adding one changes nothing in the daemon's start-up file, the core config,
the doctor or the contract. - A module has one name. Its capability, its relay target, its doctor
findings and its config section undercapabilitiesall use it. - A module that is turned off is never loaded and runs nothing. Every
module that is turned off is reported asoff. simlock doctorreports any optional module that is turned on but cannot
run with one finding kind that names the module.- agent-device is the first module built this way. It behaves as today,
except the changes listed under "How it works for the user".
How it works for the user
flowchart TD
S[simlock daemon starts] --> C[reads config.json]
C --> E{module turned on?}
E -->|no| OFF["status: name off<br/>module not loaded"]
E -->|yes| L[module loaded and started]
L --> R{can it run?}
R -->|yes| AV["status: name available"]
R -->|no| UN["status: name unavailable, reason<br/>doctor: module-unavailable finding"]
An operator's config.json moves agent-device under capabilities, the
word status and lease requests already use.
Before:
{ "agentDevice": { "enabled": true, "command": "agent-device" } }After:
{ "capabilities": { "agent-device": { "enabled": true, "command": "agent-device" } } }simlock config get prints the same new layout.
simlock doctor --json with an agent-device that is too old.
Before:
{ "findings": [{ "kind": "agent-device-unavailable",
"message": "found 0.20.1, need 0.21.25 or later", "remedy": "…" }] }After:
{ "findings": [{ "kind": "module-unavailable", "module": "agent-device",
"message": "found 0.20.1, need 0.21.25 or later", "remedy": "…" }] }Five things change for users. agent-device's integration has not been
released yet, so none of them needs a migration:
- The config layout above. An old
agentDevicekey is warned about as an
unknown key and ignored. - The doctor finding above. This is a breaking change to the doctor's
JSON, and the protocol version moves. - A gateway's warning about agent-device's
commanduses the generic
text, shown under Examples. simlock daemon stopwaits at most 30 s for each of agent-device's stop
steps. Today it waits without a limit. agent-device's slowest stop takes
about 22 s, so this shows only when agent-device hangs.- If simlock crashes while agent-device is on, and the operator restarts it
with agent-device turned off, the agent-device daemon the crash left
keeps running. Today simlock stops it at that start. It is stopped once
agent-device is turned on again, or when the machine restarts.
Examples
| Config | Today | After |
|---|---|---|
| agent-device turned off, worker status | agent-device: off |
agent-device: off |
| no section for agent-device at all, worker status | agent-device: off |
agent-device: off |
| agent-device on and running | agent-device: available |
agent-device: available |
| agent-device on, too old | agent-device: unavailable and an agent-device-unavailable doctor finding |
agent-device: unavailable and a module-unavailable finding with module: "agent-device" |
| agent-device off, its daemon left by a crash | stopped at start | left running until agent-device is turned on |
command for agent-device set on a gateway |
Ignoring "agentDevice.command": it names the program a worker runs, and this daemon runs in gateway mode. |
Ignoring "capabilities.agent-device.command": it configures a worker, and this daemon runs in gateway mode. |
| unknown key inside agent-device's section | warning: unknown key, ignored | same warning |
capabilities.screen (no such module) |
— | warning: unknown key, ignored |
old top-level agentDevice key |
read | warning: unknown key, ignored; agent-device stays off |
What could go wrong
- A module stops one step too late or too early on daemon stop, and
simlock daemon stophangs or leaves an agent-device process behind.
Likely to show in tests, since today's stop order is kept and tested. - A
config.jsonwritten for the unreleased build keepsagentDevice, so
agent-device stays off with only a warning. Likely for anyone testing
that build; the warning names the key. - A script that matches
agent-device-unavailableinsimlock doctor --jsonstops matching. Certain for such scripts. The changelog says so. - An agent-device daemon left by a crash keeps running while agent-device
is off. Unlikely: it needs a crash and a config change at the same time.
Words used
- optional module: a part of simlock a worker runs only when its config
turns it on, e.g. agent-device. - capability: what an optional module offers, under its name. The config
turns it on (capabilities.agent-device.enabled), the worker's status
reports its state (available,unavailablewith a reason, oroff),
and a lease request can require it. - relay target: the name a gateway uses to pass an HTTP request to a
module on a worker, e.g.agent-device. - module contract: the shape every optional module has, so the daemon can
load, start and stop any module the same way.
Non-goals
- Moving screen streaming (feat(stream): a lease's screen, streamed to whoever is looking at it (PoC) #576) onto the contract. That happens when
streaming becomes a feature. The ADR names it as the second module. - Modules installed from outside this repository. Every module ships with
simlock. - Stopping a leftover agent-device daemon while agent-device is turned off.
- Any change to lease
requires,CAPABILITY_MISSING, relay behaviour or
the agent-device HTTP routes.
Completion conditions
- The daemon's start-up file names no optional module and imports nothing
from agent-device. - Adding a module means adding its directory and one line in the module
list. A test that registers a fake module proves it gets its capability,
relay target, routes, doctor findings and stop steps with no other edit. - With
capabilities.agent-device.enabled: false, or no section for it, a
worker reportsagent-device: off. agent-device's loaders are never
called, and the dependency rules allow no static import of its code. simlock config getshows module config undercapabilities, keyed by
the module's name.- With agent-device on but unable to run,
simlock doctor --jsonreports
{ "kind": "module-unavailable", "module": "agent-device", … }. - Every existing agent-device test still passes, with only the edits the
tasks name: renamed config keys and finding kinds, the gateway warning's
new text, theagentDevice*start options replaced by
optionalModules: [agentDeviceModuleWith(...)], imports that follow
moved code, and the config tests moved next to agent-device. The two tests for a supervisor turned off
(stopping a leftover daemon, and a failedpswhile off) are removed
with that path. simlock daemon stopstill stops agent-device's fronts before leasing
drains, and stops its daemon before the drivers are released.
Open questions
Decisions
- ADR 0030 — Optional modules share one contract and load only when turned
on (docs(adr): 0030 optional modules share one contract and load only when turned on #592): the module definition, its handle and hooks, config under
capabilities, the gateway context and the doctor finding.
Shared values
| Value | Rule | Source |
|---|---|---|
| Config namespace | capabilities.<name>, keyed by the module's name; a name no module has is an unknown key, warned and dropped |
ADR 0030 §5; src/core/config.ts:806-808 |
| Module name | matches ^[a-z][a-z0-9-]{0,31}$; the capability, relay target, config key and doctor module all use it |
ADR 0030 §1; src/contract/schemas.ts:1054 |
| Hook deadline | 30 s per step, for beforeDrain and beforeDriversDispose; past it the host logs Optional module hook ran out of time with { module, hook } and goes on |
ADR 0030 §3 |
| Lease-end bound | beforeDeviceReclaim stays under lease.endWaitMs, default 10 min |
ADR 0024 §2; src/core/config.ts:184, :724 |
| Test option | startDaemon({ optionalModules }) replaces the registry |
ADR 0030 §8 |
| Doctor finding | { kind: "module-unavailable", module, message, remedy }; a module's findings() returns { message, remedy }, and the host adds kind and module from the definition's name |
ADR 0030 §3, §7 |
| Loaders | a definition has loadWorker() and, when it has a gateway side, loadGateway() |
ADR 0030 §1 |
| Registry | optionalModules in the component src/module-registry; a module's entry is its definition.ts |
ADR 0030 §1 |
| Routes | a module's handler is served at /v1/<name> and /v1/<name>/*; a name that is a core route's first segment fails the start |
ADR 0030 §3 |
| Hook steps | every module's hook for one step starts at once; the step waits at most the hook deadline in total | ADR 0030 §3 |
| Hook shape | hooks are function-valued properties under hooks: readonly beforeDrain?: () => Promise<void> and so on |
ADR 0030 §3 |
enabled |
owned by the core: taken out of each section, validated as a boolean, false by default; a module's validator and defaults never name it |
#595 |
| Reserved names | a module may not be named after a core route's first segment (CORE_ROUTE_SEGMENTS in the contract) |
ADR 0030 §3 |
| agent-device test definition | agentDeviceModuleWith({ upstream?, health? }) from src/agent-device/testing.ts |
#597 |
| Protocol | each task that changes a contract shape bumps the protocol by one; the one that merges second bumps again, including against #348 of another feature | docs/internal/templates/task.md |
Tasks
flowchart LR
T596["#596 one doctor finding"] --> T595["#595 module host + capabilities config"]
T595 --> T597["#597 agent-device on workers"]
T597 --> T598["#598 agent-device on gateways"]
- Doctor reports every optional module with one module-unavailable finding #596 Doctor reports every optional module with one
module-unavailable
finding — a script readingsimlock doctor's report sees one kind with
the module's name. Risk: a client on the old protocol cannot read the
report. - Optional modules load through one host and are turned on under capabilities #595 Optional modules load through one host and are turned on under
capabilities—simlock config getshowscapabilities, and a module
added later needs no change outside its directory. Risk:daemon stop
hangs, or a worker's status stops parsing. - agent-device runs as an optional module on workers #597 agent-device runs as an optional module on workers — agent-device
is turned on undercapabilities.agent-device, and its code never loads
when off. Risk: agent-device does not start, or its process outlives
daemon stop. - agent-device's gateway side runs as an optional module #598 agent-device's gateway side runs as an optional module — nothing
changes for users. Risk: agents on a gateway are told no worker can
serve agent-device when one can.
Written by an agent.
Activity
V3RON commentedon Oct 15, 2026
Spec updated: module config moves under
capabilities(Outcome, How it works, Examples, What could go wrong, Non-goals, Completion conditions, Words used); Examples row for unknown keys corrected; Shared values added; Decisions links #592.Written by an agent.
V3RON commentedon Oct 15, 2026
Spec updated: Completion conditions (two supervisor tests), Shared values (finding shape, loaders, registry, routes, hook steps, test definition), Tasks added.
Written by an agent.
V3RON commentedon Oct 15, 2026
Spec updated: closure-check findings folded in (Five user changes, allowed test edits). Spec check done; tasks #596 → #595 → #597 → #598 set task:ready; feature planned.
Written by an agent.