Repository navigation
simlock runs the agent-device daemon #470
Description
Part of #466.
Scope
After this PR, an operator who sets agentDevice.enabled: true gets one agent-device daemon on the worker, started and watched by simlock. It sees simlock's devices and no others, refuses boot and shutdown, keeps its files (device claims included) in a private directory under SIMLOCK_HOME, and is restarted when it dies. An agent-device that is missing, too old, speaks another RPC protocol, or needs a newer Node than simlock runs on is not started; simlock runs on without it and says why. A daemon a killed simlock left behind is reaped at the next start. A platform whose simlock driver is off on this worker stays hidden from it. Nothing serves agent-device traffic yet (#472), and simlock doctor does not report it yet (#471).
Kept whole (31 Tests lines, many with several cases): the driver environment, the config keys and the supervisor are one seam. The supervisor cannot be tested without the environment it passes on, and the config keys only exist to turn it on. The maintainer chose not to split it.
In short
flowchart LR
S[simlock worker starts] --> R["reap a daemon simlock left behind (new)"]:::changed
R --> E{agentDevice.enabled}
E -->|false| O[off]
E -->|true| V["find agent-device, check version and Node (new)"]:::changed
V -->|outside| U[unavailable, with the reason]
V -->|ok| D["start it: private state dir, policy, simlock's device environment (new)"]:::changed
D --> H["ready: daemon.json, port, /health protocol (new)"]:::changed
H -->|dies| D
classDef changed stroke-width:3px
New config keys:
| Key | Default | Meaning |
|---|---|---|
agentDevice.enabled |
false |
Run the agent-device daemon on this worker. |
agentDevice.command |
agent-device |
The agent-device executable: a name looked up on PATH, or a path. |
| Installed agent-device | Today | After |
|---|---|---|
| none, edge on | nothing runs | unavailable: agent-device not found: "agent-device" is not on PATH |
| 0.20.3 | nothing runs | unavailable: found 0.20.3, need 0.21.25 or later |
| 0.21.25-dev (the integration build) | nothing runs | running |
| 0.21.25-dev, simlock on Node 22.3.0 | nothing runs | unavailable: agent-device needs Node 22.12.0 or later, simlock runs on Node 22.3.0 |
| a version that speaks RPC protocol 3 | nothing runs | unavailable: agent-device 0.30.0 speaks RPC protocol 3, need 2 |
| any, edge off | nothing runs | nothing runs |
If this goes wrong, the agent-device daemon sees devices simlock does not own, a stray daemon survives a crash, simlock signals a process it did not start, or a broken agent-device install takes simlock down with it.
Technical spec
Modules touched
flowchart LR
M[daemon: startDaemon]:::changed --> SUP[agent-device/host: AgentDeviceSupervisor]:::changed
M --> DRV[core: Driver.virtualLeaseEnvironment]:::changed
SUP --> INS[host: install lookup]:::changed
SUP --> HP[host: AgentDeviceHealthProbe]:::changed
SUP --> CMP[shared: compatibility, daemon environment]:::changed
SUP --> PR[ports: ProcessRunner, ProcessSupervisor, Filesystem, Clock, TcpProbe, Logger]
IOS[drivers/ios]:::changed --> DRV
AND[drivers/android]:::changed --> DRV
CFG[core: config agentDevice.*]:::changed --> M
LINT[.oxlintrc.json + src/lint-rules.test.ts]:::changed
classDef changed stroke-width:3px
sequenceDiagram
participant S as AgentDeviceSupervisor
participant A as agent-device CLI
participant D as agent-device daemon
S->>A: <node> <real path> --version
A-->>S: 0.21.25-dev
S->>D: spawn <node> <entry> --simlock-home=<real home> (env)
S->>S: write pid record
D->>D: write <stateDir>/daemon.json (pid, httpPort, token, version)
S->>D: GET http://127.0.0.1:<httpPort>/health
alt rpcProtocolVersion is 2
D-->>S: ok → running
else other protocol, no answer, or exit before ready
S->>D: SIGTERM, then SIGKILL
S->>S: unavailable, with the reason
end
New module src/agent-device/ (this task creates it; later tasks add front/ and more of host/):
shared/compatibility.ts:MIN_AGENT_DEVICE_VERSION = "0.21.25",AGENT_DEVICE_RPC_PROTOCOL_VERSION = 2,MIN_NODE_VERSION = "22.12.0"(agent-device'sengines.node; simlock itself accepts Node 22.0), and the version compare. A version is read as its first three dot-separated numbers; anything after-or+is ignored; a string without three numbers is too old. Both agent-device checks are needed: the RPC protocol tells whether simlock's pinned proxy and the CLI can talk to this daemon, and the version tells whether it carries the changes simlock needs, which agent-device added with no protocol bump.shared/daemon-environment.ts: the environment and the policy file below, as data, andagentDeviceLeaseMaxTtlMs(leaseMaxTtlMs)= the larger of 24 hours and twicelease.maxTtlMs.host/install.ts: finds the install. A command with a/is used as given; a name is looked up in eachPATHentry in order. An entry "has it" whenFilesystem.stat(which follows symlinks) saysfileandmode & 0o111is not 0; a missing entry, a directory, or a file with no execute bit is skipped, as a shell does. A command with a/must pass the same check, elsenot-found. The resolved path's real path (Filesystem.realpath) is<package>/bin/agent-device.mjs.<package>/package.jsonmust nameagent-device. The daemon entry is<package>/dist/src/internal/daemon.jswhen it exists, else<package>/dist/src/daemon.js, the same order agent-device's own client uses.host/health.ts:AgentDeviceHealthProbe { read(baseUrl: string): Promise<unknown> }, aGET <baseUrl>/healththat rejects after its time limit (5 s; the constructor takes another for tests) or on a body that is not JSON. The Node adapterNodeAgentDeviceHealthProbeuses the globalfetchwith anAbortSignal.timeout. The fake is intesting.ts.host/agent-device-supervisor.ts:AgentDeviceSupervisorwithstart(),stop()andavailability():Untiltype AgentDeviceAvailability = | { state: "off" } | { state: "starting" } | { state: "running"; version: string; baseUrl: string; token: string; generation: number; leaseMaxTtlMs: number; platforms: readonly ("ios" | "android")[] } | { state: "unavailable"; reason: "not-found" | "not-agent-device" | "wrong-version" | "state-dir" | "start-failed"; message: string; remedy: string };
start()first settles, availability isstartingwhenagentDevice.enabled, elseoff.baseUrlishttp://127.0.0.1:<httpPort>.generationgoes up by one on every successful start, so later tasks can tell a restarted daemon.leaseMaxTtlMsis theAGENT_DEVICE_LEASE_MAX_TTL_MSthe daemon was started with: the one value the front (An agent opens and closes a device through simlock #472, Heartbeats keep the simlock lease alive #473) raises a forwardedttlMsto.platformslists the platforms whose simlock driver was built on this worker; The agent-device front refuses requests that reach past the agent's device, anddeviceslists only its own #475 refuses the others. Astart-failedis retried (below). The other reasons need the operator, so their remedy ends by saying to restart the daemon (simlock daemon stop, thensimlock daemon start). The remedies, one per reason (simlock doctor says why agent-device is not available #471 prints them):not-found: install agent-device 0.21.25 or later, or setagentDevice.commandto its path, then restart the daemon;not-agent-device: pointagentDevice.commandat an agent-device install, then restart the daemon;wrong-version(version): install agent-device 0.21.25 or later, then restart the daemon; (Node): run simlock on Node 22.12.0 or later, then restart the daemon; (protocol): install an agent-device that speaks RPC protocol 2, then restart the daemon;state-dir: make<path>a directory owned by the user simlock runs as, then restart the daemon;start-failed: simlock tries again every 30 s; see the daemon log (simlock daemon logs).start()never rejects: an unexpected error inside it ends instart-failedwith the error's message.
index.tsexportsAgentDeviceSupervisor,AgentDeviceAvailability,AgentDeviceHealthProbe(the typeStartDaemonOptionsuses) andNodeAgentDeviceHealthProbe.testing.tsexportsFakeAgentDeviceHealth.
Time limits. Every limit below is a Clock timer raced against the call it bounds. When it passes, the call counts as failed with "timed out"; the runner's own kill of that child goes on. So the limits hold under ScriptedProcessRunner, which ignores timeoutMs.
Identity. A pid is "this simlock's daemon" when ps -ww -o args= -p <pid> (-ww: no width limit, so a long path is not cut) prints a line that ends with --simlock-home=<home>, where <home> is SIMLOCK_HOME through Filesystem.realpath (so /tmp and /private/tmp spell one home), and holds the recorded entry when there is a record, and <stateDir>/daemon.json is missing or names the same pid. The marker argument is what separates simlock's daemon from one the operator runs from the same install, including one that reused a recorded pid.
What start() does, in order:
- Reap. The pid record is
<SIMLOCK_HOME>/agent-device-daemon.json:{ "pid": number, "entry": string, "startedAt": number }. If it names a live pid that passes the identity check: SIGTERM, wait up to 10 s, check identity again, SIGKILL, wait up to 2 s (the grace times agent-device's owndaemon stopuses), and remove the record once the pid is gone. With no record, the same is done for a live pid that<stateDir>/daemon.jsonnames (read only when the state dir passes step 4's check): that is a daemon whose simlock died between the spawn and the record write. A dead pid, or a line that fails the identity check: remove the record (when there is one) and signal nothing. Apsthat fails, times out (5 s) or prints nothing, or a pid still alive 2 s after SIGKILL: keep the record, signal nothing more, start nothing, and reportstart-failednaming the pid (retried as below). The reap runs whether or not the edge is on. With the edge off, a reap that cannot finish logs a warning naming the pid and availability staysoff; it is not retried. - Off. With
agentDevice.enabledfalse, availability isoffand nothing else runs. - Find and check.
not-foundornot-agent-devicefrom the lookup. Then<node> <real path> --version(10 s limit), with<node>the sameprocess.execPaththe daemon runs on, its first line read as the version. A--versionthat exits non-zero, prints nothing, or does not finish in 10 s isnot-agent-device, its message naming which. BelowMIN_AGENT_DEVICE_VERSION:wrong-version, messagefound <version>, need 0.21.25 or later. simlock's Node (process.versions.node, passed in bystartDaemon) belowMIN_NODE_VERSION:wrong-version, messageagent-device needs Node 22.12.0 or later, simlock runs on Node <version>, remedy to run simlock on Node 22.12.0 or later, then restart the daemon. - State dir.
<SIMLOCK_HOME>/agent-device, created with mode 0700, or set to 0700 when it exists. A symlink (lstat), a non-directory, a directory owned by another user, or achmodthat fails there isstate-dir, its message naming the path and the cause; nothing is spawned. - Policy file.
<stateDir>/policy.json, written atomically before each spawn:{"version":1,"commands":{"deny":["boot","shutdown"]},"capabilities":{"deny":["device-shutdown"]}}. - Spawn
<node> <entry> --simlock-home=<real home>, where<real home>isSIMLOCK_HOMEthroughFilesystem.realpath(the spelling the identity check compares), withstdio: "ignore", unreferenced.<node>is the simlock process's ownprocess.execPath, passed in bystartDaemon. The environment is the base environmentstartDaemonpasses (process.env) with everyAGENT_DEVICE_*key removed, then every driver'svirtualLeaseEnvironment()merged, unread, then:AGENT_DEVICE_STATE_DIR= the state dir;AGENT_DEVICE_CLAIMS_DIR=<stateDir>/device-claims, so its device claims are not shared with an agent-device the operator runs by hand (its default is~/.agent-device/device-claims);AGENT_DEVICE_DAEMON_SERVER_MODE=http;AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS=0, so it never exits on its own after 5 idle minutes;AGENT_DEVICE_DAEMON_POLICY= the policy file;AGENT_DEVICE_LEASE_MAX_TTL_MS=agentDeviceLeaseMaxTtlMs(lease.maxTtlMs), so an agent-device lease never hits its ceiling before its simlock lease;AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET= the value ofSIMLOCK_IOS_DEVICE_SETwhen the merged driver environment has it. The daemon lists and drives only the simulators in that set (agent-device #3485, in the integration build). When the iOS driver is off on this worker, it is<stateDir>/no-ios-devices, an empty directory (0700) the supervisor creates, so the daemon sees no simulator at all.- When the Android driver is off,
AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST=simlock-no-android-device, a serial no device has, so the daemon drives no emulator or phone on the host's own adb server.
- Record the pid right after the spawn, before waiting for readiness, so a simlock that dies in the wait leaves a handle for the next one. A record write that throws: kill the child (SIGTERM, SIGKILL after 10 s, wait up to 2 s),
start-failednaming the write error. The window between the spawn and the write is covered by step 1'sdaemon.jsonfallback. - Ready when
<stateDir>/daemon.jsonnames the child's pid with anhttpPortand atoken,TcpProbefinds that port listening, and/healthanswersservice: "agent-device-daemon"withrpcProtocolVersionequal toAGENT_DEVICE_RPC_PROTOCOL_VERSION. A health read that rejects is "not ready yet". A JSON answer with anotherservice, or with no numericrpcProtocolVersion, stops the child and isnot-agent-device(/health did not answer as agent-device); it is not retried. All within 15 s of the spawn (agent-device's own client allows 15 s). A different protocol: stop the child,wrong-versionwith messageagent-device <version> speaks RPC protocol <n>, need 2. Timeout, exit before ready, or a spawn that throws: kill the child (SIGTERM, SIGKILL after 10 s, wait up to 2 s), remove the record once it is gone,start-failedwith the cause. A child still alive 2 s after SIGKILL keeps its record and the message names its pid.
After that:
- Restart on death. When a running child exits and no
stop()was asked, the supervisor logs a warning with the exit code or signal. A child that was ready for at least 60 s is started again at once from step 1. One that died sooner after ready isstart-failed(agent-device exited <code or signal> <n> s after it was ready) and goes through the retry below, so a daemon that crashes right after ready restarts every 30 s, not in a loop. - Retry. A
start-failed, at the first start or a restart, is retried from step 1 every 30 s until a start succeeds orstop(). The other reasons are not retried. stop()cancels a pending retry, ends a start in progress, sends SIGTERM to the child, SIGKILL after 10 s if it is still alive, waits up to 2 s more, and removes the record once the child is gone. A child still alive then keeps its record (the next start reaps it) and a warning names its pid. Availability endsoffeither way.
src/core/driver.ts: Driver gains virtualLeaseEnvironment(): Readonly<Record<string, string>>, the environment every virtual device's lease carries. leaseEnvironment for a virtual device returns it, so the two never differ:
src/drivers/ios/index.ts:{ SIMLOCK_IOS_DEVICE_SET: <device root> };src/drivers/android/index.ts:{ ANDROID_ADB_SERVER_PORT: <simlock's adb port> };src/core/fake-driver.ts: its configured lease environment;e2e/fake-driver/fake-driver.ts:{}. Its lease environment comes from the script at lease time, so for this one driverleaseEnvironmentandvirtualLeaseEnvironment()may differ, and the fake lane's agent-device daemon gets no driver keys.
src/daemon/main.ts (worker mode only):
- builds one
AgentDeviceSupervisorfrom config, the portsstartDaemonalready has,process.execPath,process.versions.node,process.env, the mergedvirtualLeaseEnvironment()of the drivers it built, and which platforms' drivers it built; - calls
void supervisor.start()inonSocketClaimed, not awaited, so a daemon that lost the socket race reaps nothing and simlock's start never waits for agent-device; - in
dispose, awaitsstop()before the drivers are disposed; StartDaemonOptionsgainsagentDeviceHealth?: AgentDeviceHealthProbe, for tests.
src/core/config.ts: Config.agentDevice: { enabled: boolean; command: string }, defaults false and "agent-device", enabled validated as a boolean and command as a non-empty string. Not in GATEWAY_CONFIG_KEYS, so a gateway warns about it as a worker-only key. Every test Config literal gains the block (mechanical).
src/contract/schemas.ts: configSchema gains agentDevice: { enabled: z.boolean(), command: z.string() }.
.oxlintrc.json: src/agent-device/ is entered through its index.js (and testing.js from test files); src/core/**, src/leasing/** and src/drivers/** import nothing from it; a non-test file in it imports no fs, node:fs, node:fs/promises, child_process or node:child_process. src/lint-rules.test.ts proves each rule.
Assumption: agent-device has no command that runs its daemon in the foreground, so the supervisor starts the daemon entry with node, found from the real path of agentDevice.command, as agent-device's own client does. If agent-device adds such a command, the lookup goes.
Assumption: agent-device's daemon entry reads no command-line arguments (0.21.25-dev src/daemon.ts), so the --simlock-home= marker changes nothing for it.
Assumption: MIN_AGENT_DEVICE_VERSION is the integration build's version, 0.21.25, which carries all seven agent-device changes #466 lists. The maintainer raises it to the first agent-device release that carries them.
Assumption: the policy also denies the device-shutdown capability, which close --shutdown needs. The feature refuses shutdown because simlock manages power, and close --shutdown is a shutdown too.
Assumption: a start-failed is retried every 30 s, with no limit, and a child that dies within 60 s of ready counts as one.
Assumption: agent-device's AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST drops every device whose serial is not listed, so a serial no device has hides all of them.
Contract and event changes
configSchema(theconfig.getoutput) gainsagentDevice: protocol +1 at merge. Rerun the tests of every surface that publishesconfig.get.- No event changes.
Other code on the same state
The state: the agent-device daemon process, its pid record, its state dir (claims included) and policy file, simlock's adb server and iOS device root that it reads.
AdbServerSupervisor(src/drivers/android/adb-server.ts:104) owns simlock's adb server and its record (adb-server.json). The agent-device daemon uses that server throughANDROID_ADB_SERVER_PORT. An adb client that finds no server forks one on its port, without simlock's scoped environment and with no record. Sodisposestops the agent-device daemon before the Android driver stops its server. The adb server outlives the agent-device daemon on every stop.- The iOS driver's device root (
SIMLOCK_IOS_DEVICE_SET): the agent-device daemon reads simulators there. simlock's reclaim and nuke still shut down and erase them; ending agent-device sessions first is simlock ending a simlock lease ends the agent-device session first #474. DaemonServer#stop(src/daemon/server.ts:600-618):stopAuxiliary,settle, thendispose, where the supervisor stops. Leases persist across the stop, as today; the agent-device leases do not (Heartbeats keep the simlock lease alive #473 answers them).- Two simlock daemons starting on one home: only the one that claims the socket reaches
onSocketClaimed, so only it reaps or starts. - An agent-device the operator runs by hand uses
~/.agent-device(state and device claims) and its own daemon. The supervised daemon uses neither: its state and claims are under<SIMLOCK_HOME>/agent-device, so the two never see each other's claims. The supervisor never touches~/.agent-device, strips the operator'sAGENT_DEVICE_*variables from its child, and signals only a pid that passes the identity check. A hand-run agent-device pointed at simlock's simulators can still drive them, as today; simlock's lease is what decides who owns a device. - A gateway builds no supervisor.
- Startup order: drivers are built (the Android driver adopts or starts simlock's adb server) before
onSocketClaimedreaps a stray agent-device daemon a killed simlock left. That stray daemon is idle, since no client reaches it once its simlock is gone, so it starts no adb server in the window. If it ever did, the Android driver reportsoccupiedas for any foreign server, and the next start, after the reap, succeeds.
Rules in play
architecture.md: the agent-device daemon, its health route and the process table are reached only through ports, faked in tests; the core, leasing and drivers import nothing from the edge (checked bysrc/lint-rules.test.ts); every wait is bounded by aClocktimer (5 sps, 10 s version read, 15 s start, 10 s + 2 s stop); every exit ofstart()andstop()leaves one named availability state, and a child whose exit was not seen keeps its record.safety.md: a pid is signalled only after its identity is proven (marker argument, entry,daemon.json), and an inconclusive check keeps the record; the state dir fails closed on a symlink; nothing is downloaded.- ADR 0023 §3 (amended by docs(adr): ADR 0023 amendments from the #466 spec check #478): driver environments pass through unread, except the one mapped key, the iOS device set copied to
AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET. Only the supervisor reads it; the iOS driver names nothing of agent-device.
Tests
Seam: AgentDeviceSupervisor with ScriptedProcessRunner, FakeProcessSupervisor, MemoryFilesystem, FakeClock, FakeTcpProbe and FakeAgentDeviceHealth; model src/drivers/android/adb-server.test.ts. New files: src/agent-device/host/agent-device-supervisor.test.ts, src/agent-device/host/health.test.ts. Config in src/core/config.test.ts; wiring in src/daemon/main.test.ts; driver environments in each driver's index.test.ts; import rules in src/lint-rules.test.ts.
- with
agentDevice.enabledfalse,start()reaps a recorded daemon, starts no process, runs no--version, and availability isoffbefore and after; enabled, availability isstartingbeforestart()settles - a name is found in the first
PATHentry holding an executable file of that name (an earlier directory, non-executable file or missing entry is skipped); a command with a/is used as given and isnot-foundwhen not an executable file; the package root is two levels above the real path;--versionruns as<node> <real path> --version - a command found nowhere is
not-foundwith messageagent-device not found: "agent-device" is not on PATH, and a command with a/that is not an executable file isnot-foundwithagent-device not found: <path> is not an executable file; nothing runs - a package root whose
package.jsonis missing or names another package, one with neither daemon entry, and a--versionthat exits non-zero, prints nothing, or runs past 10 s are eachnot-agent-devicenaming which, and nothing is spawned --versionprinting0.20.3iswrong-versionwithfound 0.20.3, need 0.21.25 or later;0.21.25-dev,0.21.25and0.22.0start;abciswrong-version; Node22.3.0with0.21.25-deviswrong-versionnaming both Node versions, and Node22.12.0starts- it runs
<node> <real path> --versionand spawns<node> <package>/dist/src/internal/daemon.js --simlock-home=<real home>when that entry exists, elsedist/src/daemon.js, withstdio: "ignore"and the handle unreffed - the child's environment has no
AGENT_DEVICE_*key from the base environment, keeps the basePATHandHOME, and sets the state dir, claims dir<stateDir>/device-claims,httpmode, idle timeout0, the policy path, and the lease ceiling (24 hours with the defaultlease.maxTtlMs; twice it when that is larger), andrunningcarries the same ceiling asleaseMaxTtlMs - every key and value of the drivers' merged environment reaches the child unchanged; with
SIMLOCK_IOS_DEVICE_SETthe child also getsAGENT_DEVICE_IOS_SIMULATOR_DEVICE_SETwith the same value; with the iOS driver off it gets the empty<stateDir>/no-ios-devices, and with the Android driver offAGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST=simlock-no-android-device;runninglists only the built platforms - the policy file holds exactly the JSON above before the spawn
- a missing state dir is created with mode 0700, an existing one is set to 0700, and a symlink, a file, a directory owned by another user, or a
chmodthat throws there isstate-dirnaming the path, with nothing spawned - the pid record names the child's pid and entry while
daemon.jsonis not yet written; a record write that throws kills the child and isstart-failednaming the error - with
daemon.jsonnaming the child's pid, the port listening and/healthat protocol 2, availability isrunningwith the version,baseUrl, token and generation 1; adaemon.jsonnaming another pid, a port not listening, or a health read that rejects is not ready /healthat protocol 3 stops the child and iswrong-versionnaming both protocols; a JSON answer with anotherserviceor no numeric protocol stops the child and isnot-agent-device, not retried- no readiness within 15 s kills the child, removes the record after it exits, and is
start-failednaming the timeout; an exit before readiness isstart-failednaming the exit code; a spawn that throws isstart-failedand writes no record; a child still alive 2 s after SIGKILL keeps its record and the message names its pid - reap: a live recorded pid whose
psline ends with the marker and holds the recorded entry gets SIGTERM; still alive and identified after 10 s, SIGKILL, but a pid whose secondpsline no longer passes gets no SIGKILL; the record is removed once it is gone and a new daemon starts; one still alive 2 s after SIGKILL keeps the record and isstart-failednaming the pid - reap: with
SIMLOCK_HOMEreached through a symlink (/tmp/xresolving to/private/tmp/x), the child is spawned with the resolved marker, and a later start reaps it - reap: with no record, a live pid named by
<stateDir>/daemon.jsonwhose line ends with the marker is reaped the same way; with the state dir a symlink,daemon.jsonis not read and no signal is sent - reap: a dead recorded pid, a
psline of another command, a line holding the entry but no marker or another home's marker (an operator's daemon on a reused pid), and adaemon.jsonnaming another pid each remove the record and send no signal - reap: a
psthat fails, runs past 5 s or prints nothing sends no signal, keeps the record, starts nothing, and isstart-failednaming the pid; with the edge off it logs a warning, availability staysoff, and nothing runs 30 s later - a child that exits 60 s after ready is started again at once with the same environment, availability goes
startingthenrunningwith generation 2, and a warning names the exit code; one that exits 5 s after ready (62 s after the spawn) isstart-failedand is not started again before 30 s - a
start-failed, at the first start and after a restart, is retried 30 s later and, when that succeeds, availability isrunning;not-found,not-agent-device,wrong-versionandstate-dirare not retried - a filesystem that throws an unexpected error while writing the policy file:
start()resolves and availability isstart-failedwith that error's message stop()sends SIGTERM, SIGKILL after 10 s if the child is still alive, removes the record once it is gone (keeps it, with a warning naming the pid, when it is alive 2 s after SIGKILL), starts nothing more, cancels a pending retry, and leaves availabilityoffstop()during a start ends it with the child killed and availabilityoff- every
unavailablestate carries the message and the remedy listed for its reason NodeAgentDeviceHealthProbeagainst a local HTTP server returns the JSON body, rejects on a body that is not JSON, and rejects a server that never answers once its time limit (50 ms in the test) passesagentDevice.enableddefaults tofalseandagentDevice.commandtoagent-device; a non-booleanenabledand an empty or non-stringcommandare refused naming the key; a loaded config parsed throughconfigSchemakeeps both; a gateway config withagentDeviceloads with the worker-only warningstartDaemonwithagentDevice.enabledand a scripted agent-device whose/healthnever answers resolves and grants a lease to an ordinary client while the start is still waiting; the supervisor starts only after the socket is claimed, anddaemon stopstops the child before the drivers are disposedstartDaemonwhose agent-device isnot-foundstill starts, and grants a lease to an ordinary client; a gateway-modestartDaemonbuilds no supervisor and runs nopsor agent-device process; a secondstartDaemonon the same home that loses the socket claim runs nopsand sends no signal- the iOS driver's
virtualLeaseEnvironment()is{ SIMLOCK_IOS_DEVICE_SET: <root> }and the Android driver's is{ ANDROID_ADB_SERVER_PORT: <port> }; each driver'sleaseEnvironmentfor a virtual device equals it, and for a physical device is unchanged src/lint-rules.test.ts: a file undersrc/core/,src/leasing/,src/drivers/ios/orsrc/drivers/android/importingsrc/agent-device/index.jsfails; a daemon file importingsrc/agent-device/host/agent-device-supervisor.jsfails and one importing itsindex.jspasses;src/agent-device/host/x.ts,src/agent-device/shared/x.tsorsrc/agent-device/index.tsimportingnode:fs,fs,node:fs/promises,child_processornode:child_processfails, andsrc/agent-device/host/x.test.tsimportingnode:fspasses
Done when
- The tests above pass,
src/lint-rules.test.tsincluded.
Out of scope
- Serving agent-device traffic, the proxy and
AgentDeviceUpstream: An agent opens and closes a device through simlock #472. simlock doctorreporting the reason: simlock doctor says why agent-device is not available #471.- What a daemon restart does to agent-device leases: Heartbeats keep the simlock lease alive #473.
- Physical devices: they use another adb server.
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the spec check (device set per amended ADR, claims dir, marker-based pid identity and daemon.json fallback reap, SIGKILL budget, crash backoff, Node check, PATH lookup defined, lint-rules tests, health/--version/un-awaited start tests, leaseMaxTtlMs on running).
Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated:
simlock nukerestarts the agent-device daemon (maintainer decision); signal reasons, supervisorrestart(), and the accepted failed-release gap.Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the second spec check,
restart()removed (nuke restart dropped), platforms whose driver is off are hidden from agent-device, remedy texts per reason.Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated: closure-check findings fixed (maintainer asked to fix all).
Written by an agent.
github-actions commentedon Oct 12, 2026
Now
task:ready: approved, technical spec present, #469 closed.feat(agent-device): simlock starts and watches the agent-device daemon (
test: config literals carry the agentDevice block (#470)
refactor(agent-device): split the supervisor's start into steps and s…
test(agent-device): policy-file test reuses the arranged spawn spy (#470
test(agent-device): pin the 60 s and 30 s limits and the recorded-ent…
fix(agent-device): stop cleanly, log start failures, leak no stop wai…
fix(agent-device): handle a late health rejection after stop, and do …
feat(contract): bump the daemon protocol to 30 for config.get's agent…