Skip to content

simlock runs the agent-device daemon #470

Closed

Description

@V3RON

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
Loading

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

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's engines.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, and agentDeviceLeaseMaxTtlMs(leaseMaxTtlMs) = the larger of 24 hours and twice lease.maxTtlMs.
  • host/install.ts: finds the install. A command with a / is used as given; a name is looked up in each PATH entry in order. An entry "has it" when Filesystem.stat (which follows symlinks) says file and mode & 0o111 is 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, else not-found. The resolved path's real path (Filesystem.realpath) is <package>/bin/agent-device.mjs. <package>/package.json must name agent-device. The daemon entry is <package>/dist/src/internal/daemon.js when 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> }, a GET <baseUrl>/health that rejects after its time limit (5 s; the constructor takes another for tests) or on a body that is not JSON. The Node adapter NodeAgentDeviceHealthProbe uses the global fetch with an AbortSignal.timeout. The fake is in testing.ts.
  • host/agent-device-supervisor.ts: AgentDeviceSupervisor with start(), stop() and availability():
    type 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 };
    Until start() first settles, availability is starting when agentDevice.enabled, else off. baseUrl is http://127.0.0.1:<httpPort>. generation goes up by one on every successful start, so later tasks can tell a restarted daemon. leaseMaxTtlMs is the AGENT_DEVICE_LEASE_MAX_TTL_MS the 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 forwarded ttlMs to. platforms lists the platforms whose simlock driver was built on this worker; The agent-device front refuses requests that reach past the agent's device, and devices lists only its own #475 refuses the others. A start-failed is retried (below). The other reasons need the operator, so their remedy ends by saying to restart the daemon (simlock daemon stop, then simlock 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 set agentDevice.command to its path, then restart the daemon;
    • not-agent-device: point agentDevice.command at 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 in start-failed with the error's message.
  • index.ts exports AgentDeviceSupervisor, AgentDeviceAvailability, AgentDeviceHealthProbe (the type StartDaemonOptions uses) and NodeAgentDeviceHealthProbe. testing.ts exports FakeAgentDeviceHealth.

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:

  1. 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 own daemon stop uses), and remove the record once the pid is gone. With no record, the same is done for a live pid that <stateDir>/daemon.json names (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. A ps that 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 report start-failed naming 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 stays off; it is not retried.
  2. Off. With agentDevice.enabled false, availability is off and nothing else runs.
  3. Find and check. not-found or not-agent-device from the lookup. Then <node> <real path> --version (10 s limit), with <node> the same process.execPath the daemon runs on, its first line read as the version. A --version that exits non-zero, prints nothing, or does not finish in 10 s is not-agent-device, its message naming which. Below MIN_AGENT_DEVICE_VERSION: wrong-version, message found <version>, need 0.21.25 or later. simlock's Node (process.versions.node, passed in by startDaemon) below MIN_NODE_VERSION: wrong-version, message agent-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.
  4. 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 a chmod that fails there is state-dir, its message naming the path and the cause; nothing is spawned.
  5. Policy file. <stateDir>/policy.json, written atomically before each spawn: {"version":1,"commands":{"deny":["boot","shutdown"]},"capabilities":{"deny":["device-shutdown"]}}.
  6. Spawn <node> <entry> --simlock-home=<real home>, where <real home> is SIMLOCK_HOME through Filesystem.realpath (the spelling the identity check compares), with stdio: "ignore", unreferenced. <node> is the simlock process's own process.execPath, passed in by startDaemon. The environment is the base environment startDaemon passes (process.env) with every AGENT_DEVICE_* key removed, then every driver's virtualLeaseEnvironment() 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 of SIMLOCK_IOS_DEVICE_SET when 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.
  7. 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-failed naming the write error. The window between the spawn and the write is covered by step 1's daemon.json fallback.
  8. Ready when <stateDir>/daemon.json names the child's pid with an httpPort and a token, TcpProbe finds that port listening, and /health answers service: "agent-device-daemon" with rpcProtocolVersion equal to AGENT_DEVICE_RPC_PROTOCOL_VERSION. A health read that rejects is "not ready yet". A JSON answer with another service, or with no numeric rpcProtocolVersion, stops the child and is not-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-version with message agent-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-failed with 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 is start-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 or stop(). 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 ends off either 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 driver leaseEnvironment and virtualLeaseEnvironment() may differ, and the fake lane's agent-device daemon gets no driver keys.

src/daemon/main.ts (worker mode only):

  • builds one AgentDeviceSupervisor from config, the ports startDaemon already has, process.execPath, process.versions.node, process.env, the merged virtualLeaseEnvironment() of the drivers it built, and which platforms' drivers it built;
  • calls void supervisor.start() in onSocketClaimed, not awaited, so a daemon that lost the socket race reaps nothing and simlock's start never waits for agent-device;
  • in dispose, awaits stop() before the drivers are disposed;
  • StartDaemonOptions gains agentDeviceHealth?: 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 (the config.get output) gains agentDevice: protocol +1 at merge. Rerun the tests of every surface that publishes config.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 through ANDROID_ADB_SERVER_PORT. An adb client that finds no server forks one on its port, without simlock's scoped environment and with no record. So dispose stops 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, then dispose, 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's AGENT_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 onSocketClaimed reaps 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 reports occupied as 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 by src/lint-rules.test.ts); every wait is bounded by a Clock timer (5 s ps, 10 s version read, 15 s start, 10 s + 2 s stop); every exit of start() and stop() 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.enabled false, start() reaps a recorded daemon, starts no process, runs no --version, and availability is off before and after; enabled, availability is starting before start() settles
  • a name is found in the first PATH entry 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 is not-found when not an executable file; the package root is two levels above the real path; --version runs as <node> <real path> --version
  • a command found nowhere is not-found with message agent-device not found: "agent-device" is not on PATH, and a command with a / that is not an executable file is not-found with agent-device not found: <path> is not an executable file; nothing runs
  • a package root whose package.json is missing or names another package, one with neither daemon entry, and a --version that exits non-zero, prints nothing, or runs past 10 s are each not-agent-device naming which, and nothing is spawned
  • --version printing 0.20.3 is wrong-version with found 0.20.3, need 0.21.25 or later; 0.21.25-dev, 0.21.25 and 0.22.0 start; abc is wrong-version; Node 22.3.0 with 0.21.25-dev is wrong-version naming both Node versions, and Node 22.12.0 starts
  • it runs <node> <real path> --version and spawns <node> <package>/dist/src/internal/daemon.js --simlock-home=<real home> when that entry exists, else dist/src/daemon.js, with stdio: "ignore" and the handle unreffed
  • the child's environment has no AGENT_DEVICE_* key from the base environment, keeps the base PATH and HOME, and sets the state dir, claims dir <stateDir>/device-claims, http mode, idle timeout 0, the policy path, and the lease ceiling (24 hours with the default lease.maxTtlMs; twice it when that is larger), and running carries the same ceiling as leaseMaxTtlMs
  • every key and value of the drivers' merged environment reaches the child unchanged; with SIMLOCK_IOS_DEVICE_SET the child also gets AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET with the same value; with the iOS driver off it gets the empty <stateDir>/no-ios-devices, and with the Android driver off AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST=simlock-no-android-device; running lists 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 chmod that throws there is state-dir naming the path, with nothing spawned
  • the pid record names the child's pid and entry while daemon.json is not yet written; a record write that throws kills the child and is start-failed naming the error
  • with daemon.json naming the child's pid, the port listening and /health at protocol 2, availability is running with the version, baseUrl, token and generation 1; a daemon.json naming another pid, a port not listening, or a health read that rejects is not ready
  • /health at protocol 3 stops the child and is wrong-version naming both protocols; a JSON answer with another service or no numeric protocol stops the child and is not-agent-device, not retried
  • no readiness within 15 s kills the child, removes the record after it exits, and is start-failed naming the timeout; an exit before readiness is start-failed naming the exit code; a spawn that throws is start-failed and 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 ps line ends with the marker and holds the recorded entry gets SIGTERM; still alive and identified after 10 s, SIGKILL, but a pid whose second ps line 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 is start-failed naming the pid
  • reap: with SIMLOCK_HOME reached through a symlink (/tmp/x resolving 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.json whose line ends with the marker is reaped the same way; with the state dir a symlink, daemon.json is not read and no signal is sent
  • reap: a dead recorded pid, a ps line 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 a daemon.json naming another pid each remove the record and send no signal
  • reap: a ps that fails, runs past 5 s or prints nothing sends no signal, keeps the record, starts nothing, and is start-failed naming the pid; with the edge off it logs a warning, availability stays off, 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 starting then running with generation 2, and a warning names the exit code; one that exits 5 s after ready (62 s after the spawn) is start-failed and 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 is running; not-found, not-agent-device, wrong-version and state-dir are not retried
  • a filesystem that throws an unexpected error while writing the policy file: start() resolves and availability is start-failed with 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 availability off
  • stop() during a start ends it with the child killed and availability off
  • every unavailable state carries the message and the remedy listed for its reason
  • NodeAgentDeviceHealthProbe against 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) passes
  • agentDevice.enabled defaults to false and agentDevice.command to agent-device; a non-boolean enabled and an empty or non-string command are refused naming the key; a loaded config parsed through configSchema keeps both; a gateway config with agentDevice loads with the worker-only warning
  • startDaemon with agentDevice.enabled and a scripted agent-device whose /health never 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, and daemon stop stops the child before the drivers are disposed
  • startDaemon whose agent-device is not-found still starts, and grants a lease to an ordinary client; a gateway-mode startDaemon builds no supervisor and runs no ps or agent-device process; a second startDaemon on the same home that loses the socket claim runs no ps and 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's leaseEnvironment for a virtual device equals it, and for a physical device is unchanged
  • src/lint-rules.test.ts: a file under src/core/, src/leasing/, src/drivers/ios/ or src/drivers/android/ importing src/agent-device/index.js fails; a daemon file importing src/agent-device/host/agent-device-supervisor.js fails and one importing its index.js passes; src/agent-device/host/x.ts, src/agent-device/shared/x.ts or src/agent-device/index.ts importing node:fs, fs, node:fs/promises, child_process or node:child_process fails, and src/agent-device/host/x.test.ts importing node:fs passes

Done when

  • The tests above pass, src/lint-rules.test.ts included.

Out of scope

Depends on

Approval

  • Approved for delivery

Written by an agent.

Activity

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

    @V3RON
    ContributorAuthor

    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.

  3. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: simlock nuke restarts the agent-device daemon (maintainer decision); signal reasons, supervisor restart(), and the accepted failed-release gap.

    Written by an agent.

  4. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    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.

  5. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: closure-check findings fixed (maintainer asked to fix all).

    Written by an agent.

  6. 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 12, 2026
  7. github-actions commented on Oct 12, 2026

    @github-actions

    Now task:ready: approved, technical spec present, #469 closed.

  8. self-assigned this
    on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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