Skip to content

Add construct doctor - #1071

Merged
edwin-zvs merged 1 commit into
mainfrom
doctor-subcommand
Aug 1, 2026
Merged

Add construct doctor#1071
edwin-zvs merged 1 commit into
mainfrom
doctor-subcommand

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Adds construct doctor — a read-only diagnostic for the install itself.

Construct sets up with one command and then depends on a fair amount of ambient
machine state: four directories, a config file, a daemon, a socket, several
third-party CLIs found through PATH, and several third-party OAuth logins it
reads but does not own. Today, when any of that is wrong, you meet the failure at
the point of use, one symptom at a time. This gives you the whole picture in one
command, and gives bug reports something to paste.

The constraint that shapes it

Doctor is run precisely when something is already broken. So:

  • It never mutates the machine. No daemon autostart, no mkdir, no config
    rewrite, no login refresh, no probe files. Writability is an access() check.
    Update availability is read from the existing on-disk cache — deliberately not
    cached_latest_version, which spawns a refresh against api.github.com.
    Doctor is also added to command_allows_upgrade_prompt's exclusion list; the
    upgrade prompt does a network round trip and can block on stdin.
  • It works with the daemon down. That's the primary case, not a degraded one.
    Checks that genuinely need a daemon are still emitted, as info marked
    skipped, never omitted — so --json consumers always get every finding id.

Those two together ruled out an IPC method: it would be unreachable exactly when
you need it. Instead the check logic lives in a new pub mod doctor in the daemon
crate (it needs config, availability, router::oauth, legacy_migration_notice),
exposing only plain-data types, and the CLI injects what only it can observe —
whether a daemon answered, and what it said.

The harness probe ladder moves out of SessionManager into
availability::probe_harness so the daemon and doctor ask the identical
question. That's what lets doctor name the single most confusing real failure:
"claude works in my terminal but construct says it's missing" — a PATH
difference between your shell and the daemon's environment.

Severity means something

error = construct cannot work here, and it is the only thing that sets exit 1.
The error-capable set is small on purpose: unusable state directories, an
unparseable config, and a socket that accepts connections but doesn't answer.

A stopped daemon is a warning, not an error — a CLI-only user with no TUI open
has no daemon and is perfectly healthy. So is an expired third-party login. If
those exited non-zero, doctor would fail on most machines and the exit code
would mean nothing.

Every non-ok finding carries the exact command that addresses it. There is no
--fix: the remedies span other vendors' tools (codex login, kimi), your
filesystem permissions, and your editor — Construct shouldn't silently act inside
systems it doesn't own.

Output

Daemon up, on my machine — note it found three real things (an expired kimi
login, build skew from a dirty worktree, and a 349 MiB daemon log):

construct doctor

environment
  [ok]     version                  0.16.7 (build 0.16.7+250938c-dirty)
  [ok]     binary                   /…/target/debug/construct
  [warn]   PATH                     `construct` is not on PATH; only the binary you invoked directly will work
                                    fix: export PATH="/…/target/debug:$PATH"
  [ok]     update check             up to date (latest 0.16.7, cached 15s ago)
  [ok]     path overrides           none set

paths
  [ok]     config                   /Users/moon/.config/construct — writable
  [ok]     state                    /Users/moon/.local/state/construct — writable
  [ok]     data                     /Users/moon/.local/share/construct — writable
  [ok]     runtime                  /Users/moon/.local/state/construct — writable
  [ok]     config file              /Users/moon/.config/construct/config.toml
  [info]   sessions                 332 on disk at /Users/moon/.local/share/construct/sessions
  [ok]     legacy layout            no pre-rename `agentd` directories

config
  [ok]     config                   10 adapters, 0 smith model profile(s)
  [info]   operator                 enabled (smith)
  [info]   router                   enabled=true publish_models=true

daemon
  [ok]     socket                   /Users/moon/.local/state/construct/construct.sock (live)
  [warn]   build skew               client 0.16.7+250938c-dirty vs daemon 0.16.7+250938c
                                    fix: construct daemon restart
  [info]   daemon log               /Users/moon/.local/state/construct/daemon.log (349.8 MiB)
                                    fix: tail -f '/Users/moon/.local/state/construct/daemon.log'
  [ok]     router port              listening on 127.0.0.1:8917
  [ok]     web UI port              listening on 127.0.0.1:5746

harnesses
  [ok]     available                10/10 available (daemon)
  [ok]     agy                      ready
  …
  [ok]     smith                    ready (OpenAI API key)
  [ok]     environment              the daemon and this shell agree about what is installed

logins
  [ok]     claude-oauth             logged in
  [ok]     codex-oauth              logged in
  [ok]     grok-oauth               logged in
  [warn]   kimi-oauth               kimi-oauth login has expired; run `kimi` once to renew it (the router never refreshes another tool's credential)
                                    fix: kimi
  [ok]     smith auth               7 credential method(s) available
                                    Anthropic API key      [--]  ANTHROPIC_API_KEY not set
                                    OpenAI API key         [ok]  OPENAI_API_KEY is set
                                    …
                                    Codex subscription     [ok]  ~/.codex/auth.json found  <- current

ambient features
  [ok]     Session auto-naming      titles generated via smith (ready (OpenAI API key))
  [ok]     Next-prompt suggestions  smith and shell sessions generate via smith (…)
  [ok]     Operator session         runs on smith (ready (OpenAI API key))
  [ok]     degradation              no ambient feature has skipped work

34 ok, 4 info, 3 warn, 0 error — construct looks healthy.
exit=0

Daemon down, isolated home — still exit 0, and no socket was created. The port
warnings are correct here: my real daemon holds 8917/5746, which is exactly the
condition that check is for.

daemon
  [warn]   socket                   no daemon running (/tmp/doctor-manual/run/construct.sock)
                                    fix: construct daemon start
  [info]   build skew               skipped — daemon not running
  [info]   daemon log               none yet
  [warn]   router port              port 8917 is already in use while no construct daemon is running
                                    fix: lsof -nP -iTCP:8917 -sTCP:LISTEN
  [warn]   web UI port              port 5746 is already in use while no construct daemon is running
                                    fix: lsof -nP -iTCP:5746 -sTCP:LISTEN

harnesses
  [ok]     available                10/10 available (local probe — daemon not running)
  …
  [info]   environment              skipped — daemon not running

26 ok, 10 info, 5 warn, 0 error — construct looks healthy.
exit=0

### socket created? ###
total 0
drwxr-xr-x@ 2 moon  wheel   64 Jul 31 22:55 .
drwxr-xr-x@ 6 moon  wheel  192 Jul 31 22:55 ..

Malformed config.toml — the only non-daemon path that exits 1. The parser's own
caret diagram is carried through, and the rest of the report still renders against
built-in defaults rather than aborting at the first bad line:

config
  [error]  config                   parse /tmp/doctor-manual/config/config.toml: TOML parse error at line 1, column 10
                                      |
                                    1 | [adapters
                                      |          ^
                                    invalid table header
                                    expected `.`, `]`
                                    later checks use built-in defaults; your config.toml was not applied
                                    fix: ${EDITOR:-vi} '/tmp/doctor-manual/config/config.toml'

exit=1

Tests

  • Unit (inline, both crates): pure classifiers — classify_dir,
    classify_path_shadowing (including canonical-dedupe of a symlinked duplicate),
    classify_port's full truth table, login_finding asserting the blocker's
    reason is passed through verbatim, Report::summarize, the config-parse fallback
    (asserts every BUILTIN_ADAPTERS name survives), and rendering (column clamping,
    note/fix indentation, both summary variants).
  • e2e (crates/e2e/tests/doctor.rs, 6 tests) — the first e2e tests that run a
    CLI subcommand as a subprocess and read stdout, because the properties worth
    protecting are properties of the command. The load-bearing one asserts that
    after a daemon-down run the socket still does not exist; that catches the
    worst possible regression here.

cargo test -p construct-daemon -p construct-cli → 1247 + 467 passing.
cargo test -p construct-e2e --test doctor → 6 passing. Clippy clean on the new
files.

Deliberately not asserted in e2e: which harnesses are available (CI has no
claude/codex), the port findings (a developer's own daemon may hold them), and
the exact wording of any detail string.

Notes for the reviewer

  • cargo fmt --all reformats ~50 unrelated files on this branch — main isn't
    rustfmt-clean under the current toolchain. I formatted only the files this PR
    touches, so the diff stays reviewable.
  • One honest caveat, documented in the spec: the reused probe_smith isn't
    strictly zero-network — it TCP-probes $OLLAMA_HOST (200 ms) and shells out to
    security on macOS. Fidelity to what the daemon actually does is worth more here
    than a strict no-syscall guarantee.
  • Relevant binary: this touches crates/cli and crates/daemon, both of which
    link into the single construct binary →
    .claude/worktrees/doctor-subcommand/target/debug/construct doctor

🤖 Generated with Claude Code

A read-only diagnostic for the install: paths and writability, config
parse, legacy `agentd` layout, daemon liveness and build skew, harness
availability, third-party OAuth logins, smith credentials, ambient
feature status, router/web-UI ports, and PATH shadowing.

The design constraint that shapes everything: doctor is run precisely
when something is already broken, so it must work with the daemon down
and must never mutate the machine it inspects. It does not autostart a
daemon, create directories, rewrite config, refresh a login, or leave
probe files behind — writability is an access() check, and update
availability is read from the existing on-disk cache without the network
refresh `cached_latest_version` would trigger. `Doctor` is excluded from
the interactive upgrade prompt for the same reason.

Because the checks need the daemon's own probes but must run without a
daemon, the logic lives in a new `pub mod doctor` in the daemon crate
exposing only plain-data types, with the CLI injecting what only it can
observe (whether a daemon answered, and what it said). The harness probe
ladder moves out of `SessionManager` into `availability::probe_harness`
so both callers ask the identical question — which is what lets doctor
name the most confusing real failure, "claude works in my terminal but
construct says it's missing."

Severity is a claim about the machine: `error` means construct cannot
work here and is the only thing that sets exit 1 (unusable state dirs,
an unparseable config, a socket that accepts connections but does not
answer). A stopped daemon and an expired third-party login are warnings
— most healthy machines have both. Every non-ok finding carries the
exact command that addresses it; there is no --fix. `--json` emits every
finding id unconditionally, with unrunnable checks marked skipped rather
than omitted.

Spec 0168.
@edwin-zvs
edwin-zvs merged commit 4c1459b into main Aug 1, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the doctor-subcommand branch August 1, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant