| type | reference | ||
|---|---|---|---|
| topic | cli | ||
| audience |
|
||
| applies_to |
|
Report measured health, never declared health (#4364). Read-only — this command never deletes, writes, or repairs anything; every line it prints comes from actually reading a live effect (a file's real age/size on disk), not from restating a config value back.
This is PR-3a of a staged arc — see .reyn/ directory layout
and the design constraints below for what's in and out of scope today.
reyn doctor [--project-root <path>]
--project-root defaults to the current directory; it must contain a .reyn/ tree
(the same resolution reyn chat and other project-scoped commands use, walking up
from the given path to find reyn.yaml).
- Measure, don't assert. "A hook is registered" is
reyn config show's job; "a hook's argv actually launched" is a separate measurement, landed since as C-1 (#4596 — see the hook launch-probe section below). The disk-usage checks follow the same rule: every number comes fromos.stat/Path.rglob, never from re-reading a config object. - Report-only, never mutate.
doctorreaches into wider internals than any other CLI surface (sandbox / MCP / hook, in later slices). Every local-CLI precedent surveyed for this design (npm doctor,claude doctor, oh-my-opencodedoctor) reports-only or requires explicit confirmation before acting; none auto-repairs from a CLI invocation.reyn doctornever calls a delete/purge function — only read-only queries. - Disclose what wasn't measured, and how "measurable" was decided. A doctor that only ever prints what it happens to check reads, on a clean run, identical to a doctor that checked everything — a shape this repo hit repeatedly the night this design was settled (a declaration outrunning what was actually verified). The first line of output is therefore always a coverage disclosure: total config leaves / how many have a measurable effective surface / how many don't, with the measurability criterion printed alongside the count — never a bare "N checks passed."
$ reyn doctor
155 config leaves total, 2 have a measurable effective surface (checked below), 153 uncovered.
Measurable means: a leaf counts as measurable here iff this module reads its LIVE EFFECT
(a file's actual age/size on disk), not merely the config value itself -- re-reading the
config object back is not a measurement
Disk usage — declared retention vs. actual (.reyn/events/):
declared: cleanup_period_days=30 (0=disabled), max_disk_usage_percent=10.0 (0=disabled)
actual: 1 file(s), 22 bytes, oldest = 43 day(s) old
⚠ 1 file(s) currently exceed the declared policy but have not been purged yet (purge
fires on the next write/rotation, not continuously) — run 'reyn events purge' to apply
the policy now if you don't want to wait.
Disk usage — no declared retention policy (visibility only):
media/: 0 file(s), 0 bytes
tool-results/: 0 file(s), 0 bytes
history.jsonl: 0 file(s), 0 bytes, 0 turn(s)
total (media+tool-results+history): 0 bytes
(filesystem free space: 243,848,552,448 bytes)
Hook launch probe (argv[0] only, no configured args — a launch
probe, not a run; D-2: doctor never executes a hook for real):
no exec/exec_capture hooks configured
External-event producer/consumer pairing (a producer with 0
subscribing hooks is a real gap; a point with no producer is not
reported — see 'not checked' below for why some points aren't):
✗ file_changed: producer present (1 declared fs_watch path(s)) but 0 subscribing hooks — this point's notifications have nowhere to go
? mcp_resource_updated: no event history yet
? webhook_received: no event history yetCompares audit_events.cleanup_period_days / audit_events.max_disk_usage_percent
(the automatic-purge policy, #4479) against
the real on-disk file count, byte total, and oldest file's age. The select_purge_targets
query this reuses (reyn.core.events.event_purge) is the SAME read-only selection logic
reyn events purge and the automatic trigger both use — no re-derived logic here.
A non-empty "exceed the declared policy" finding means files exist RIGHT NOW that the policy's own axes would purge, but haven't been yet — the automatic trigger only fires on write/rotation, not continuously, so a quiet period can leave a real backlog this command surfaces without waiting for the next write.
None of these three has a retention policy yet (#4478 /
#4476 Phase 2 unimplemented) — the visibility
itself is the finding: an unowned, unbounded resource made visible rather than silently
absent from every report. Reuses MediaStore.storage_stats() and
aggregate_history_stats(), the same functions reyn storage stats reports.
A differential probe, not an exec check: the backend's own known-good control binary
is launched under each exec/exec_capture hook's own sandbox policy first, and only
the difference between that and the hook's argv[0] is reported (ok / target_failed
/ sandbox_failed, or a ? line where the resolved backend cannot probe at all — Noop,
or Docker, whose image contents reyn cannot assume). It launches only argv[0], never
the hook's configured args, and its output is labeled a launch probe rather than a run
(owner ruling: running a hook's real configured args as a side effect of reyn doctor
would make the diagnostic itself a footgun). A program that requires arguments
therefore reports here without being broken — disclosed in the output rather than
papered over by passing the args.
For each of the 4 external ingress points (hooks.schema_registry._EXTERNAL_POINTS:
file_changed / cron_fired / mcp_resource_updated / webhook_received), checks
whether a PRODUCER exists and, only where one does, whether any hook is registered to
consume it (reyn.yaml's top-level hooks: + .reyn/config/hooks.yaml + every
.reyn/agents/<name>/hooks.yaml, the SAME 3-layer combine Session._build_hook_registry
uses at runtime — a malformed layer is dropped, its siblings kept, so one bad file
cannot hide a real gap).
The check pairs producer↔consumer, not subscription↔consumer — an architect design
correction mid-arc: an MCP resource subscription is only meaningful on a HELD
(persistent) connection, so reyn doctor (a separate, one-shot process) cannot observe
one directly. Producer evidence differs per point:
file_changed— a declaredfs_watch.pathsentry.cron_fired— an enabledcron.jobs[]entry (a disabled job is not a producer).mcp_resource_updated/webhook_received— past evidence in.reyn/events(both kinds ARE audit-events,event_schema.py—webhook_receivedgained its own kind in #4618, joining this check in #4620), scanned newest-first, bounded to the most recent 20 dated files (a kind lookup has no index —.reyn/eventsis append-only — so this bound keeps a "did it ever happen" query cheap even with retention disabled; the question only needs "at least once," so an early exit can never turn a real positive into a false negative). This check is windowed, unlike the other two (#4614): "not seen in the newest 20 files" is NOT proof of "no producer" — a real producer whose last arrival predates the window is indistinguishable from one that never fired. Reporting nothing here (the pre-#4614 behavior) silently hid exactly the state C-2 exists to catch, so both points ALWAYS print a line —✓/✗when seen within the window, or? <point>: not seen in the newest N event file(s) scanned — ... NOT proof no producer existsotherwise — never folded into the "no producer → no finding" rule below. #4624 exception: when.reyn/eventshas NO dated files at all (a fresh install, or retention already purged everything), N is 0 and "a producer whose last arrival predates the window" cannot be true — there is no window to predate. The #4614 caveat would be a TRUE but EMPTY statement there, and an empty caveat printed on every fresh install trains the reader to skip caveats (architect's finding, #4622 co-vet), so this one case prints the plain fact instead:? <point>: no event history yet, no window talk.webhook_receivedhas no config surface of its own (unlikefile_changed/cron_fired), so it can ONLY ever be evidence-based here — there is no complete-read alternative for it.
A point with a COMPLETE producer read (file_changed/cron_fired) and no producer
prints no finding at all — reporting "0 subscribing hooks" for
a point that will never fire would be noise, not signal.
Sandbox posture — declared vs. RESOLVED (absence of a declaration
does not mean unrestricted; see the resolved backend below):
declared: sandbox.backend='auto', sandbox.on_unsupported='warn'
declared: no sandbox.policy — NOT the same as unrestricted, see resolved backend below
resolved: 'seatbelt' (production's own resolution — a backend that cannot enforce is already treated as absent at this step, #2983, so this name IS the enforcement witness)
The motivating real case (architect's own note, #4364): an operator read "no
sandbox.policy declared" as "unrestricted," but the resolved backend was actually
enforcing all along (SeatbeltBackend, write_paths=[]) — declaration and
resolution silently disagreed, and only the resolved side is true.
reyn doctor reports declared sandbox.backend / sandbox.on_unsupported /
sandbox.policy (from config, verbatim — only the write-scope keys,
allow_write_paths/deny_write_paths, are echoed) next to the backend
production's OWN resolution (reyn.security.sandbox.launcher.resolve_backend
— the SAME call C-1's hook probe already makes) actually hands back. This is
deliberately not a second, doctor-invented probe: get_default_backend()
already self-tests a real deny before returning a backend (#2983) and applies
sandbox.on_unsupported to any backend that can't enforce, so "which backend
resolved" already carries the enforcement witness — doctor reports that
verdict, never re-derives it. If the declared backend was explicitly forced
(not 'auto') and the resolution fell back to a different one, the line says
DOWNGRADED from declared '<name>' rather than silently agreeing with the
fallback. If sandbox.on_unsupported: error makes resolution itself
fail-closed, doctor reports resolved: refuses to run (...) rather than
crashing or swallowing the error.
reyn doctor has no op context (no LLM tool call it's diagnosing), so it does
NOT build a resolve_sandbox_policy() call — that needs a caller-supplied
write_paths floor ("this op needs this directory") doctor cannot know and
must not invent a stand-in for. Only the declared sandbox.policy dict's own
write-scope keys are shown, never a merged/resolved policy.
MCP servers — last negotiated version/capabilities (audit-log
evidence, not a live probe; D-2: doctor never connects):
✓ filesystem: last negotiated '2025-11-25', capabilities=['resources', 'tools']
? brave: no event history yet
The motivating real case (architect's own note, #4364): a protocol-version mismatch
between reyn (2.0) and a connected server (1.27.1) had already fallen back silently
to an older shared version — nothing was BROKEN, so nothing raised, and the only way
to learn it happened was digging through the audit log after the fact. reyn doctor
now surfaces the same fact in one line.
For each MCP server declared under mcp.servers, this reuses the SAME windowed
evidence-based scan C-2's mcp_resource_updated/webhook_received checks already
use (_mcp_initialized_evidence, sharing _MCP_EVENT_SCAN_MAX_FILES) — the newest
mcp_initialized audit-event record per server (emitted once per real (re)connect,
mcp/connection_service.py) carries negotiated_version + capabilities verbatim.
✓ when found within the window; ? <server>: no event history yet when
.reyn/events has no dated files at all (#4624's fresh-install shape — the #4614
windowed caveat would be a true but empty statement there); ? <server>: not seen in the newest N event file(s) scanned — ... NOT proof the server was never reached
otherwise, same #4614 wording as the other windowed checks.
C-3(a) (an actual live tools/list connect + response check) was ruled
unnecessary: the evidence this check reports already exists from the connections
reyn itself made — a SEPARATE live reachability probe from doctor's own one-shot
process would duplicate work, and a held MCP connection is a session concept doctor
cannot observe directly anyway (the same architect correction C-2's own
producer↔consumer design rests on). D-2 holds: doctor never connects to a server
itself, only reads what a real connection already recorded.
Model reachability — 0-token GET {api_base}/v1/models (never a
real completion call; D-2: doctor never spends inference cost):
✓ http://127.0.0.1:8998: reachable (HTTP 200)
✓ light ('gpt-4o'): accepted by the proxy's model list
✗ standard ('gpt-5.6-luna'): NOT in the proxy's model list — check the name form (bare vs 'provider/name')
The motivating real case (architect's own note, #4364): a configured model name
(openai/gpt-5.6-luna) that the LiteLLM proxy expected bare (gpt-5.6-luna) — no
error until the first real chat turn.
The original ask was replaced, not implemented as first proposed — a real litellm
completion probe would make reyn doctor itself charge the operator for inference,
exactly what the cross-cutting cost/budget band exists to keep OS-internal diagnostics
from doing. The 0-token GET {api_base}/v1/models answers the SAME two questions
(is api_base reachable, is the configured model name's form accepted) in one
request, at zero inference cost — reachability from the HTTP response itself (any
response, including 401/403, proves reachability; a connection error does not), model
acceptance from checking each declared llm.models entry's BARE name (stripped of the
provider/ routing prefix reyn's own config uses) against the response's own model
list, when the response is a 200 carrying one.
Only llm.api_base (a LiteLLM proxy) is checked — a provider with no declared
api_base routes straight to its own hosted endpoint, which this module has no
per-provider URL table for and was not architect's motivating case (a local proxy).
? not checked — no llm.api_base declared when absent (D-3, same "cannot confirm"
shape webhook_received had before it gained a surface).
API keys are never read (litellm-boundary convention, owner's standing
instruction) — the request carries no Authorization header at all; a provider that
requires one to list models still proves reachability by responding (401/403 is a
real HTTP response, not a failure this check reports as unreachable).
Uses reyn._network.build_sync_http_client (the repo's own single httpx-client
constructor, #3075) — never a free-hand httpx.Client(...), so this call site is
covered by the same standard-proxy-env/CA completeness gate every other reyn-owned
HTTP client is.
C-6's "listen port declared-vs-effective" example does NOT apply to reyn's
own config surface (measured before writing any code, per lead-coder's
instruction, #4364): reyn web's --host/--port are bare CLI arguments
(interfaces/cli/commands/web.py) with no corresponding ReynConfig field
anywhere — verified by walking the full schema
(reyn.config.config_schema.walk_config_schema) for any key naming a port;
there is none. A declared-vs-effective PAIR needs a DECLARATION to pair
against, and reyn's own port is set once, per-invocation, by the
operator's own CLI argument — there is nothing upstream of that argument
for doctor (a separate, later, short-lived process with no view into a
sibling reyn web process's argv) to compare it with. C-6's motivating
incident (a different project's settings.port silently going dead
across a dependency bump) illustrated the GENERAL "declared ≠ effective"
shape, never a claim that reyn itself declares a listen port. C-6's
GENERAL form is already implemented — C-5 above is architect's own named
special case of it, not a separate slice still owed. A regression guard
(tests/interfaces/test_4364_c6_no_declared_listen_port.py) fails the
moment a port-shaped config field appears, naming this section to revisit.
reyn storage— the measurement functions this command's no-declared-policy section reusesreyn events—reyn events purge, the manual counterpart to the automatic policy this command's events section checks compliance against.reyn/directory layout — the audit-bucket classification formedia//tool-results//events/