Repository navigation
simlock doctor says why agent-device is not available #471
Description
Part of #466.
Scope
After this PR, simlock doctor on a worker with agentDevice.enabled: true reports an agent-device-unavailable finding when the agent-device daemon is not running, with the reason #470's supervisor found and what to do. Example: the wrong version is installed, and doctor says which version it found and which it needs. With the edge off, starting or running, doctor reports nothing for it.
In short
flowchart LR
O[operator: simlock doctor] --> D[Doctor.reconcile]
D --> F["optional-module findings (new)"]:::changed
F --> S[#470 supervisor availability]
D --> C["CLI: one stderr line per finding (new)"]:::changed
classDef changed stroke-width:3px
Before and after, with agent-device 0.20.3 installed and the edge on:
$ simlock doctor # today
{"findings":[]}
$ simlock doctor # after
agent-device fail found 0.20.3, need 0.21.25 or later; install agent-device 0.21.25 or later, then restart the daemon (`simlock daemon stop`, then `simlock daemon start`)
{"findings":[{"kind":"agent-device-unavailable","message":"found 0.20.3, need 0.21.25 or later","remedy":"install agent-device 0.21.25 or later, then restart the daemon (`simlock daemon stop`, then `simlock daemon start`)"}]}
| agent-device on the worker | doctor today | doctor after |
|---|---|---|
| edge off | nothing | nothing |
| edge on, running | nothing | nothing |
| edge on, still starting | nothing | nothing |
edge on, not on PATH |
nothing | agent-device-unavailable: not found, naming the command |
| edge on, too old, another RPC protocol, or Node too old | nothing | agent-device-unavailable: found and needed |
edge on, not agent-device (no package, no daemon entry, --version fails) |
nothing | agent-device-unavailable: what was wrong |
| edge on, state folder is a symlink or a file | nothing | agent-device-unavailable: the path |
| edge on, failed to start | nothing | agent-device-unavailable: the cause |
The exit code stays 0, as for the other findings doctor only reports.
If this goes wrong, an operator sees no reason why agents get "agent-device is not available", or doctor --fix acts on a finding it cannot fix.
Technical spec
Modules touched
flowchart LR
CLI[cli: runDoctor]:::changed --> DS[daemon: dispatcher doctor.run]
DS --> DOC[core: Doctor.reconcile]:::changed
M[daemon: startDaemon]:::changed --> CC[core: createCore]:::changed
CC --> DOC
M --> MAP[agent-device: agentDeviceFindings]:::changed
MAP --> SUP[agent-device: AgentDeviceSupervisor.availability]
SCH[contract: doctorFindingSchema]:::changed
classDef changed stroke-width:3px
src/core/doctor.ts:DoctorFindinggains{ kind: "agent-device-unavailable"; message: string; remedy: string }. ADR 0023 §1 (amended by docs(adr): ADR 0023 amendments from the #466 spec check #478) lets the core name agent-device as data in this one finding kind; the core reads neither field and imports nothing fromsrc/agent-device/.DoctorOptionsgainsoptionalModules?: () => readonly OptionalModuleFinding[], whereOptionalModuleFindingisExtract<DoctorFinding, { kind: "agent-device-unavailable" }>. The composition root fills the port.reconcilecallsoptionalModuleson every run, after the fix and purge passes and after#warmPoolFindings(), and appends what it returns, unchanged, at the end ofremaining. Likewarm-pool-target-unreachable, the finding is never in the list#applySafeFixesor#purgeOrphanswalks, so--fixand--purge-orphansnever see it;#applySafeFixgets no arm for it.- An
optionalModulesthat throws contributes nothing and is logged:this.#logger.warn("Could not read optional module findings", { error }). (Not a copy of#collectAdvisories, which swallows its error silently.) isDriftexcludes the kind, sodoctor.reconciled'sdriftFindingsnever carries it.isDriftdecides only that payload.
src/core/create-core.ts:CoreOptionsgainsoptionalModules, passed toDoctor.src/core/index.ts: exportsOptionalModuleFinding.src/agent-device/host/availability-findings.ts(exported fromsrc/agent-device/index.ts):agentDeviceFindings(availability: AgentDeviceAvailability): readonly OptionalModuleFinding[], one finding with simlock runs the agent-device daemon #470'smessageandremedyforunavailable, none foroff,startingorrunning. It imports the type fromsrc/core/index.ts.src/daemon/main.ts(worker mode): passesoptionalModules: () => agentDeviceFindings(supervisor.availability())tocreateCore. A gateway passes none.src/contract/schemas.ts:doctorFindingSchemagains the kind withmessage: z.string()andremedy: z.string().src/cli/index.ts:runDoctor(:1364) getswriteAgentDeviceUnavailable, called afterwriteUnreachableWarmTargets(:1393, defined :1435) and beforewriteResult. It writes one stderr line per finding,agent-device fail <message>; <remedy>. Each of message and remedy has every run of whitespace (newlines included) replaced by one space and is cut to 500 characters, so a finding is always one line. The remedy texts are simlock runs the agent-device daemon #470's, one per reason. stdout still carries the whole report. The exit code stays 0.docs/CLI.md: thesimlock doctorsection gets a paragraph on the finding, as it has forwarm-pool-target-unreachable.
Doctor reports problems only. A healthy agent-device prints nothing: no finding and no "ok" line.
Contract and event changes
doctorFindingSchema(thedoctor.runoutput) gainsagent-device-unavailable: protocol +1 at merge. Rerun the tests of every surface that publishesdoctor.run.- No event changes. The finding is not drift, so
doctor.reconcileddoes not carry it, and#emitFindingEventsemits nothing for it.
Other code on the same state
The state: the supervisor's availability, which this task only reads, and the doctor report.
AgentDeviceSupervisor(simlock runs the agent-device daemon #470) changes availability as it starts, restarts and retries. Doctor reads it once per run, at the momentoptionalModulesis called. A run during a start reports nothing; the next run after the start fails reports it. The supervisor wins: doctor never changes it.- The startup doctor pass (
convergeStartupinsrc/daemon/main.ts) also callsreconcile. The supervisor starts inonSocketClaimed, at the same time, so that pass usually seesstartingand reports nothing. No event fires for the finding either way. #applySafeFixesand#purgeOrphansrun onfindingsbefore the warm-pool and optional-module findings are appended, so neither acts on this kind.- The other stderr writers in
runDoctor(writeDriverAdvisoryWarnings,writeMissingPrerequisites,writeUnreachableWarmTargets) are unchanged and run first; the new lines follow them. - A gateway refuses
doctor.run(UNSUPPORTED_IN_GATEWAY_MODE), as today.
Rules in play
architecture.md: the core imports nothing from the edge and reads only what the port gives it; one place maps availability to a finding (agentDeviceFindings), and one place decides whether a finding is drift (isDrift).- ADR 0023 §1 (amended): the core names agent-device only as data, the config keys and this finding kind.
documentation.md: the printed line names no file path in this repo.toolchain.md, "Published surfaces":doctor.run's schema changes. HTTP does not serve it; the surfaces that publish it are the daemon socket (CLI tests), theDoctorReporttype insimlock/clientandsimlock/admin, and the operation sweep ine2e/contract-surface.test.ts. Those run again.
Tests
Seam: Doctor.reconcile with optionalModules, modelled on src/core/doctor.test.ts ("Doctor: warm pool targets"); the mapping on src/agent-device/host/availability-findings.test.ts (new); the CLI on src/cli/index.test.ts; the wiring on src/daemon/main.test.ts with #470's scripted agent-device. The wiring tests wait for the supervisor with expect.poll(() => doctor.run(...), { timeout: 5_000 }) until the report has the finding, so they neither race nor wait unbounded.
reconcilereturns each findingoptionalModulesgives, with its kind, message and remedy unchanged, after awarm-pool-target-unreachablefinding returned by the same runreconcilereturns noagent-device-unavailablewhenoptionalModulesis omitted or returns none- two
reconcileruns withoptionalModulesreturning a finding, then none, report it, then do not - with
fix: trueandpurgeOrphans: trueand aregistry-device-missingfinding to fix,optionalModulesis called only after that device is already marked missing, and its finding is in the report - the finding is not in
doctor.reconciled'sdriftFindings, and no other event is emitted for it - the finding is returned with
prerequisitesfalse too - an
optionalModulesthat throws logs a warning naming the error, contributes nothing, and the other findings are still returned agentDeviceFindingsreturns one finding with the availability's message and remedy for eachunavailablereason, and none foroff,startingandrunningdoctorReportSchemaparses a report with the finding, and refuses one withoutmessageor withoutremedysimlock doctorwritesagent-device fail <message>; <remedy>on stderr for the finding (a message holding a newline and 600 characters comes out as one line of at most 500 characters for it), after the advisory, prerequisite and warm-pool lines of the same report, prints the report on stdout unchanged, and exits 0simlock doctorwrites noagent-deviceline when the report has no such findingstartDaemonwithagentDevice.enabledand an agent-device whose--versionprints0.20.3:doctor.runreports the finding withfound 0.20.3, need 0.21.25 or later, and an ordinary client is still granted a leasestartDaemonwithagentDevice.commandfound nowhere:doctor.runreports the finding naming the commandstartDaemonwithagentDevice.enabledfalse reports noagent-device-unavailable; with an agent-device whose/healthnever answers and the daemon'sFakeClocknot advanced, so simlock runs the agent-device daemon #470's 15 s readiness budget never runs out, the test first polls (up to 5 s) until the scripted process runner has recorded the spawn of the daemon entry, so the supervisor is past its checks and waiting for readiness, thendoctor.runreports none
Done when
- The tests above pass.
- Removing the kind from
isDrift's exclusions makes thedoctor.reconciledtest fail.
Out of scope
- What an agent sees when agent-device is not available: An agent opens and closes a device through simlock #472.
- Checking the agent-device version again on every doctor run: the supervisor checks at start, and the remedy says to restart.
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the spec check (finding kind allowed by amended ADR 0023, --fix/isDrift reasoning corrected, warning logged on a throwing port, mapping moved to agentDeviceFindings and tested for starting/running, order and repeat-run tests, bounded expect.poll in wiring tests).
Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the second spec check (complete reason table, one-line stderr, published surfaces, starting test, remedy owner).
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, #470 closed.feat(doctor): report why agent-device is not available (#471)
test(gateway): pin protocol 31 (#471)
test(doctor): prove optionalModules order and the remedy cap (#471)
docs(cli): a failed agent-device start retries, so no restart is need…
feat(doctor): simlock doctor says why agent-device is not available (#…