fix(cli-resolvers): negative-result caching, SIGKILL on probes, restored VITEST hermeticity, wired not-found diagnostics

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>
This commit is contained in:
Codeman maintainer
2026-08-21 02:37:58 +02:00
parent bb4ba79791
commit 61251c0b94
10 changed files with 447 additions and 120 deletions
+102 -9
View File
@@ -1,3 +1,35 @@
/**
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
*
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi): the server process PATH first, then the
* CLI's common install directories in order, then — last, because it is the
* only step that spawns anything — an interactive login shell, which is what
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
* service with a minimal PATH (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`).
*
* Caching is asymmetric, same shape as `resolveClaudeCliVersion` in
* claude-cli-resolver.ts: a successful resolution is cached for the process
* lifetime, a MISS is negative-cached and retried only after a doubling backoff
* (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI
* status endpoints in system-routes.ts, the availability gates in
* session-routes.ts, and tmux-manager's spawn path), and the login-shell probe
* is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
* to 5s on every request, forever.
*
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
* short-circuits — IO primitives that were not injected become inert stubs, so
* a suite can never scan the machine's PATH or spawn login shells (the same
* rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in
* `getClaudeCliVersion`). Tests opt back in through the injection hooks
* (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for
* fixtures that need the real filesystem predicate against their own temp
* files, via `allowRealIoUnderVitest`.
*
* @module utils/cli-executable-resolver
*/
import { execFileSync } from 'node:child_process';
import { accessSync, constants, statSync } from 'node:fs';
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
@@ -10,6 +42,26 @@ const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
/** First retry window after a full-chain resolution miss. */
const RESOLVE_RETRY_BASE_MS = 60_000;
/**
* Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on
* the claude version probe: that one is cosmetic, while this gates the Run
* flow, and "installing a CLI while the server is running is picked up without
* a restart" should stay true within minutes.
*/
const RESOLVE_RETRY_MAX_MS = 5 * 60_000;
/**
* How long to wait before re-running the resolution chain after `failures`
* consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors
* `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests.
*/
export function cliResolveRetryDelayMs(failures: number): number {
if (failures <= 0) return 0;
return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS);
}
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
export interface CliResolutionDiagnostics {
@@ -50,6 +102,7 @@ export interface CliResolverCommandOptions {
encoding: 'utf8';
timeout: number;
stdio: ['ignore', 'pipe', 'ignore'];
killSignal: 'SIGKILL';
}
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
@@ -60,6 +113,13 @@ export interface ProductionCliResolverHostOptions {
shellArgs?: string[];
runCommand?: CliResolverCommandRunner;
isExecutableFile?: (path: string) => boolean;
/**
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
* For tests that exercise `isExecutableRegularFile` against their own temp
* fixtures. Such a test must still inject `runCommand` if it can reach the
* login-shell step, or it would spawn a real interactive shell.
*/
allowRealIoUnderVitest?: boolean;
}
function isExecutableRegularFile(path: string): boolean {
@@ -97,15 +157,26 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
const shellPath = options.shellPath ?? resolveLocalShell();
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
const processPath = options.processPath ?? process.env.PATH ?? '';
const isExecutableFile = options.isExecutableFile ?? isExecutableRegularFile;
// Hermeticity gate (see @fileoverview): under vitest, any IO primitive the
// caller did not inject is replaced by an inert stub. The suites must never
// depend on — or execute — whatever happens to be installed on the machine
// running them, and route tests hitting the per-CLI status endpoints would
// otherwise scan the real PATH and spawn real login shells on CI.
const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true;
const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile);
const runCommand: CliResolverCommandRunner =
options.runCommand ?? ((file, args, commandOptions) => execFileSync(file, args, commandOptions));
options.runCommand ?? (inert ? () => '' : (file, args, commandOptions) => execFileSync(file, args, commandOptions));
const run = (file: string, args: string[]): string => {
try {
return runCommand(file, args, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill
// signal and then keeps waiting for the child to exit. Interactive bash
// ignores SIGTERM (the default), so a login shell stuck in a blocking
// .bash_profile would survive the timeout and block the server forever.
killSignal: 'SIGKILL',
});
} catch {
return '';
@@ -138,6 +209,8 @@ export function createCliExecutableResolver<T = undefined>(
binary: string;
searchDirs: string[];
validateCandidate?: (path: string) => CandidateValidation<T>;
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
now?: () => number;
},
host: CliResolverHost = createProductionCliResolverHost()
): CliExecutableResolver<T> {
@@ -145,7 +218,13 @@ export function createCliExecutableResolver<T = undefined>(
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
}
const now = options.now ?? Date.now;
/** Successful resolution, cached for the process lifetime. */
let cached: CliResolution<T> | null = null;
/** Consecutive full-chain misses (drives the retry backoff). */
let failures = 0;
/** Timestamp of the most recent miss. */
let lastFailureAt = 0;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
@@ -161,17 +240,31 @@ export function createCliExecutableResolver<T = undefined>(
return {
resolve() {
if (cached) return cached;
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
// backoff elapses. Without this, every status poll and Run click against
// a missing CLI froze the event loop for the full probe, forever.
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (cached) return cached;
for (const dir of options.searchDirs) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) return cached;
if (!cached) {
for (const dir of options.searchDirs) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) break;
}
}
if (!cached) {
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
}
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
return cached;
if (cached) {
failures = 0;
lastFailureAt = 0;
return cached;
}
failures += 1;
lastFailureAt = now();
return null;
},
diagnostics: () => ({
binary: options.binary,