mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user