Repository navigation
Doctor reports every optional module with one module-unavailable finding #596
Description
Part of #591.
Scope
Replaces the doctor's agent-device-only finding with one finding kind for
every optional module (ADR 0030 §7). After this PR, simlock doctor
reports a module that is turned on but cannot run as module-unavailable,
with the module's name. The core doctor, the contract's doctor finding
and the CLI's doctor output name no module.
This task runs first in #591. It and #595 both bump the protocol in the
same lines, and it and #597 edit the same main.test.ts tests.
In short
The report simlock doctor prints on stdout, with an agent-device that is
too old.
Before:
{ "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`)" }] }After:
{ "findings": [{ "kind": "module-unavailable", "module": "agent-device",
"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`)" }] }The line simlock doctor writes to stderr does not change:
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`)
If this goes wrong: a module that cannot run is missing from
simlock doctor, or a client on the old protocol misreads the report.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | The core's DoctorFinding has an agent-device-unavailable member with message and remedy; OptionalModuleFinding is that member. |
src/core/doctor.ts:119-128 |
| F2 | The doctor appends the findings optionalModules() returns, and logs and drops them when it throws. |
src/core/doctor.ts:157, :258, :571-576 |
| F3 | isDrift leaves agent-device-unavailable out of doctor.reconciled's driftFindings. |
src/core/doctor.ts:263-270, :1197-1204 |
| F4 | The contract's doctorFindingSchema has an agent-device-unavailable member. |
src/contract/schemas.ts:686, :746-750 |
| F5 | A contract test parses that member. | src/contract/schemas.test.ts:659-668 |
| F6 | agentDeviceFindings builds the finding from the supervisor's availability, only when it is unavailable. |
src/agent-device/host/availability-findings.ts:4-14 |
| F7 | The CLI writes each agent-device-unavailable finding as agent-device fail <message>; <remedy>, after the advisory, prerequisite and warm-pool lines. It collapses each run of whitespace to one space and cuts each text to 500 characters. |
src/cli/index.ts:1090-1094, :1126-1133 |
| F8 | A CLI test pins F7's line, cut and order. | src/cli/index.test.ts:1424 |
| F9 | Daemon tests assert the old kind through doctor.run. |
src/daemon/main.test.ts:2596-2664 |
| F10 | The slow-lane test waits for an agent-device-unavailable row, and checks that none is present. |
e2e/slow-agent-device.test.ts:203, :526-531 |
| F11 | docs/CLI.md documents the finding and its stderr line. |
docs/CLI.md:2199-2212 |
| F12 | The protocol range is { min: 36, max: 36 }, and three tests assert 36. |
src/contract/protocol.ts:13; src/daemon-protocol/index.test.ts:12; src/gateway/service.test.ts:1237; src/daemon/server.test.ts:219 |
| F13 | The doctor's report goes to the client unchecked, and the client parses it with the contract; a member that fails the schema fails the whole report. | src/daemon/dispatcher.ts:737; src/simlock-client/client.ts:868-875 |
| F14 | A capability name, which is also a module's name, matches ^[a-z][a-z0-9-]{0,31}$. |
src/contract/schemas.ts:1054 |
| F16 | capabilityNameSchema is declared below doctorFindingSchema, and the protocol history table names the old kind for protocol 30. |
src/contract/schemas.ts:686, :1054; src/daemon/server.test.ts:200 |
| F15 | The supervisor's remedy for a too-old agent-device ends with the restart instruction. | src/agent-device/host/agent-device-supervisor.ts:84, :231-232 |
Modules touched
flowchart LR
ad["agent-device findings *"] --> core["core/doctor *"]
core --> contract["contract/schemas *"]
cli["cli/index *"] --> contract
Surface and extension points
- The core's member becomes
{ kind: "module-unavailable", module: string, message: string, remedy: string }.
OptionalModuleFindingis that member. isDriftleaves outmodule-unavailable(F3).agentDeviceFindingsreturns it withmodule: "agent-device".- Until agent-device runs as an optional module on workers #597, this line sets agent-device's
module. Optional modules load through one host and are turned on under capabilities #595's host stamps
moduleon the findings of the modules it loads. - agent-device runs as an optional module on workers #597 moves it: the host stamps
modulefrom the definition's name,
which Optional modules load through one host and are turned on under capabilities #595 checks against F14 at start. A bad name then fails the
start, never the report (F13).
- Until agent-device runs as an optional module on workers #597, this line sets agent-device's
- The CLI's
writeAgentDeviceUnavailablebecomeswriteModuleUnavailable.
It writes<module> fail <message>; <remedy>, with F7's collapse, cut
and order. For agent-device the line is unchanged.
Contract and event changes
doctorFindingSchema's member becomes
{ kind: z.literal("module-unavailable"), module: capabilityNameSchema, message: z.string(), remedy: z.string() }
(F4, F14). capabilityNameSchema moves above doctorFindingSchema in
src/contract/schemas.ts, which uses it at load. This PR bumps the
protocol by one. CHANGELOG.md gets a line
under Unreleased: agent-device-unavailable is now module-unavailable
with module.
doctor.reconciled does not change. The finding never enters its
driftFindings (F3). Searched both EVENTS.md files for the kind: no entry
names it.
Other code on the same state
doctor --fixand the purge act only on drift findings. This finding
stays out of drift (F3).- Optional modules load through one host and are turned on under capabilities #595 and agent-device runs as an optional module on workers #597 build on this shape. Both run after this task.
Rules in play
architecture.mdrule 21: the core doctor, the contract's doctor
finding and the CLI's doctor output name no module.testing.mdrule 2: F10'snot.toContainmust look for a kind it could
see.documentation.md:docs/CLI.md(F11).
Tests
Seam: Doctor with optionalModules, modelled on src/core/doctor.test.ts.
Also doctorFindingSchema in src/contract/schemas.test.ts, simlock doctor in src/cli/index.test.ts, and startDaemon → doctor.run in
src/daemon/main.test.ts (F9).
- a doctor run returns each
module-unavailablefindingoptionalModulesgives, unchanged - a
module-unavailablefinding is not indoctor.reconciled'sdriftFindings doctorReportSchemaparses amodule-unavailablefinding, and refuses one withoutmodule, withoutmessageor withoutremedydoctorReportSchemarefuses amodulethat fails the capability name checkdoctorReportSchemarefuses the oldagent-device-unavailablekindagentDeviceFindingsreturnsmodule: "agent-device"with the supervisor's message and remedy when unavailable, and nothing when starting, running or off- F8's test, for
writeModuleUnavailable:simlock doctorwritesagent-device fail <message>; <remedy>on stderr, whitespace collapsed, at most 500 characters per part, after the advisory, prerequisite and warm-pool lines (as today) simlock doctorwrites<module> fail …for a finding of another module- F9's tests, now asserting
{ kind: "module-unavailable", module: "agent-device" }throughdoctor.run: too-old version, command not found, none when off, none while starting - the protocol version is one above the base branch's
Done when
- Every Tests line passes.
git grep -n agent-device-unavailable -- src e2e ':(glob)docs/*.md'prints only
the refusal test insrc/contract/schemas.test.tsand the protocol 30
row of the history table insrc/daemon/server.test.ts(F16), which
stays as history.- F10's two assertions name
module-unavailable. On a Mac with an iOS
runtime:scripts/slow-e2e.sh e2e/slow-agent-device.test.tspasses.
Out of scope
- Any change to when agent-device is reported unavailable.
- The module host (Optional modules load through one host and are turned on under capabilities #595).
- Accepted ADRs and old drafts under
docs/internal, which keep the old
name as history, and architecture rule 21, which names it as the example
of what a host must not know.
Depends on
Written by an agent.
Activity
V3RON commentedon Oct 15, 2026
Spec updated: findings from the first spec check folded in (order is now #596 → #595 → #597 → #598; split loaders, own registry component, routes under the module's name, concurrent stop steps per ADR 0030's amendment).
Written by an agent.
feat(doctor): optional modules report one module-unavailable finding (#…
fix: protocol history row for 36, doc comment beside its schema, CLI.…
V3RON commentedon Oct 15, 2026
Handoff
Done: PR #599 built tests-first. All 8 spec test files pass, CI is green, and review round 1 passed with 0 blocking findings. The PR is ready, labelled
needs-hardware, and sits in the stack above ADR PR #592.Not done: Done when line 3, the slow lane
scripts/slow-e2e.sh e2e/slow-agent-device.test.ts. It did not run.Findings: This Mac meets the lane's other checks (iOS 26.4 and 27.0, 36 GiB). But
SIMLOCK_E2E_AGENT_DEVICEis not set and.slow-lane.envdoes not exist, so the lane has no built agent-device checkout to use.Blocked on: a hardware run of line 3 on a machine with an agent-device checkout at the commit
vendor/README.mdnames. #595, #597 and #598 depend on this task, so they wait.Written by an agent.
docs: protocol range is 37 in the contract and daemon guides (#596)
feat(doctor): one module-unavailable finding for every optional module (