Skip to content

simlock doctor says why agent-device is not available #471

Closed

Description

@V3RON

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
Loading

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
Loading
  • src/core/doctor.ts:
    • DoctorFinding gains { 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 from src/agent-device/.
    • DoctorOptions gains optionalModules?: () => readonly OptionalModuleFinding[], where OptionalModuleFinding is Extract<DoctorFinding, { kind: "agent-device-unavailable" }>. The composition root fills the port.
    • reconcile calls optionalModules on every run, after the fix and purge passes and after #warmPoolFindings(), and appends what it returns, unchanged, at the end of remaining. Like warm-pool-target-unreachable, the finding is never in the list #applySafeFixes or #purgeOrphans walks, so --fix and --purge-orphans never see it; #applySafeFix gets no arm for it.
    • An optionalModules that 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.)
    • isDrift excludes the kind, so doctor.reconciled's driftFindings never carries it. isDrift decides only that payload.
  • src/core/create-core.ts: CoreOptions gains optionalModules, passed to Doctor.
  • src/core/index.ts: exports OptionalModuleFinding.
  • src/agent-device/host/availability-findings.ts (exported from src/agent-device/index.ts): agentDeviceFindings(availability: AgentDeviceAvailability): readonly OptionalModuleFinding[], one finding with simlock runs the agent-device daemon #470's message and remedy for unavailable, none for off, starting or running. It imports the type from src/core/index.ts.
  • src/daemon/main.ts (worker mode): passes optionalModules: () => agentDeviceFindings(supervisor.availability()) to createCore. A gateway passes none.
  • src/contract/schemas.ts: doctorFindingSchema gains the kind with message: z.string() and remedy: z.string().
  • src/cli/index.ts: runDoctor (:1364) gets writeAgentDeviceUnavailable, called after writeUnreachableWarmTargets (:1393, defined :1435) and before writeResult. 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: the simlock doctor section gets a paragraph on the finding, as it has for warm-pool-target-unreachable.

Doctor reports problems only. A healthy agent-device prints nothing: no finding and no "ok" line.

Contract and event changes

  • doctorFindingSchema (the doctor.run output) gains agent-device-unavailable: protocol +1 at merge. Rerun the tests of every surface that publishes doctor.run.
  • No event changes. The finding is not drift, so doctor.reconciled does not carry it, and #emitFindingEvents emits 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 moment optionalModules is 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 (convergeStartup in src/daemon/main.ts) also calls reconcile. The supervisor starts in onSocketClaimed, at the same time, so that pass usually sees starting and reports nothing. No event fires for the finding either way.
  • #applySafeFixes and #purgeOrphans run on findings before 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), the DoctorReport type in simlock/client and simlock/admin, and the operation sweep in e2e/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.

  • reconcile returns each finding optionalModules gives, with its kind, message and remedy unchanged, after a warm-pool-target-unreachable finding returned by the same run
  • reconcile returns no agent-device-unavailable when optionalModules is omitted or returns none
  • two reconcile runs with optionalModules returning a finding, then none, report it, then do not
  • with fix: true and purgeOrphans: true and a registry-device-missing finding to fix, optionalModules is called only after that device is already marked missing, and its finding is in the report
  • the finding is not in doctor.reconciled's driftFindings, and no other event is emitted for it
  • the finding is returned with prerequisites false too
  • an optionalModules that throws logs a warning naming the error, contributes nothing, and the other findings are still returned
  • agentDeviceFindings returns one finding with the availability's message and remedy for each unavailable reason, and none for off, starting and running
  • doctorReportSchema parses a report with the finding, and refuses one without message or without remedy
  • simlock doctor writes agent-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 0
  • simlock doctor writes no agent-device line when the report has no such finding
  • startDaemon with agentDevice.enabled and an agent-device whose --version prints 0.20.3: doctor.run reports the finding with found 0.20.3, need 0.21.25 or later, and an ordinary client is still granted a lease
  • startDaemon with agentDevice.command found nowhere: doctor.run reports the finding naming the command
  • startDaemon with agentDevice.enabled false reports no agent-device-unavailable; with an agent-device whose /health never answers and the daemon's FakeClock not 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, then doctor.run reports none

Done when

  • The tests above pass.
  • Removing the kind from isDrift's exclusions makes the doctor.reconciled test fail.

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 (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.

  3. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: fixes from the second spec check (complete reason table, one-line stderr, published surfaces, starting test, remedy owner).

    Written by an agent.

  4. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

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

    Written by an agent.

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

    @github-actions

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

  7. self-assigned this
    on Oct 12, 2026
  8. added a commit that references this issue on Oct 12, 2026
    90af700
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