Post-merge follow-ups for PR #329 (shared CLI executable resolution):
- Negative-cache resolution misses with a doubling backoff (1min -> 5min
cap, cliResolveRetryDelayMs, mirroring claudeVersionRetryDelayMs): the
shared resolver cached success only, so a missing CLI re-ran the whole
chain - ending in a synchronous interactive login-shell spawn bounded by
the 5s EXEC_TIMEOUT_MS - on every /api/<cli>/status request and Run
attempt, stalling the event loop each time, forever. Success still caches
for the process lifetime, so an installed CLI is picked up within minutes
without a restart. Tests drive the backoff via an injectable clock
(createCliExecutableResolver `now` option, threaded through the
createPiResolverForTest / createAntigravityResolverForTest wrappers).
- Pass killSignal: 'SIGKILL' on the resolver's login-shell spawn and on the
pi/claude --version probes: execFileSync's timeout only SENDS the kill
signal and then keeps waiting for the child to exit, and interactive bash
ignores SIGTERM, so a login shell stuck in a blocking .bash_profile
survived the timeout and blocked the server permanently.
- Restore test hermeticity (PR #329 deleted pi's VITEST guards, and one
test pinned the deletion): under vitest the production resolver host now
replaces un-injected IO primitives with inert stubs - no real PATH
scanning, no login-shell spawns - and probePiVersion never executes a
`pi` candidate again (`pi` is a generic binary name, so route tests
hitting /api/pi/status executed whatever binary the machine carried).
Tests opt in through the runCommand/isExecutableFile injection hooks or
allowRealIoUnderVitest for real-filesystem fixtures. The deletion-pinning
test is replaced by behavioral pins, including a real-executable fixture
in the new test/pi-cli-resolver.test.ts that fails loudly if the pi gate
is ever removed again.
- Wire the six get*NotFoundMessage() exports (previously dead) into their
intended call sites: the createSession throws in tmux-manager and the
availability gates on POST /api/sessions and POST /api/quick-start in
session-routes, replacing a third hardcoded copy of the text. A not-found
error now names where resolution looked (server PATH, login shell,
checked directories). npm run knip no longer reports any unused export
from the resolver modules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A CLI installed by nvm, Homebrew or a user-level npm prefix lives on a PATH that
only a login shell sets up. Codeman running under systemd or launchd does not get
that PATH — launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` — so every
resolver reported the CLI as unavailable on installs where it is plainly there
and works from a terminal.
Each of the six resolvers had its own hand-rolled copy of the same PATH walk, so
the fix is factored into one shared `createCliExecutableResolver()` with an
explicit lookup order: the server process PATH, then common install directories in
order, then an interactive login shell as the last resort. Only the last step
spawns anything, and only when the cheap lookups have already missed.
Also adds `formatCliNotFoundMessage()`, so a failure explains where it looked
instead of just asserting the CLI is missing. Its diagnostics are bounded and
control characters are flattened, so a not-found message cannot dump arbitrary
environment data.
Success is cached and failure is retried, so installing a CLI while the server is
running is picked up without a restart.
Net -103 lines across the six resolvers. Behaviour is unchanged wherever the CLI
was already on the process PATH: that remains the first thing checked.
Tests: 20 cases in test/cli-executable-resolver.test.ts covering the precedence
order, login-shell-only resolution, the caching rule, unsafe-name rejection, and
the bounded diagnostics.