fix(wiki): address local CLI provider review findings

- Add subprocess timeout: LocalCLIConfig gains requestTimeoutMs,
  runLocalCLI sets a kill timer that rejects with an actionable error
  matching the HTTP timeout message format. --timeout is no longer
  silently ignored for claude/codex providers.
- Add windowsHide: true to spawn() to prevent console window flash on
  Windows, matching cursor-client.ts behavior.
- Skip GITNEXUS_MODEL env var for local providers so a user's OpenAI
  model name doesn't cross-contaminate claude/codex CLI invocations.
  Precedence for local providers: --model → savedLocalModel → ''.
- Guard against empty stdout: reject with actionable error when CLI
  exits 0 but produces no output, preventing silent empty wiki pages.
This commit is contained in:
Test 2026-05-25 12:02:25 +01:00
parent 0f9bf35a1d
commit 0245e60f30
3 changed files with 30 additions and 2 deletions

View file

@ -222,6 +222,7 @@ export class WikiGenerator {
const localConfig = resolveLocalCLIConfig({
model: this.llmConfig.model,
workingDirectory: this.repoPath,
requestTimeoutMs: this.llmConfig.requestTimeoutMs,
});
return this.llmConfig.provider === 'claude'
? callClaudeLLM(prompt, localConfig, systemPrompt, options)

View file

@ -79,7 +79,7 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
'https://openrouter.ai/api/v1',
model:
overrides?.model ||
process.env.GITNEXUS_MODEL ||
(localProvider ? undefined : process.env.GITNEXUS_MODEL) ||
savedLocalModel ||
(localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'),
maxTokens: overrides?.maxTokens ?? 16_384,

View file

@ -19,6 +19,7 @@ export type LocalAgentProvider = 'claude' | 'codex';
export interface LocalCLIConfig {
model?: string;
workingDirectory?: string;
requestTimeoutMs?: number;
}
const COMMANDS: Record<LocalAgentProvider, string> = {
@ -62,6 +63,7 @@ export function resolveLocalCLIConfig(overrides?: Partial<LocalCLIConfig>): Loca
return {
model: overrides?.model,
workingDirectory: overrides?.workingDirectory,
requestTimeoutMs: overrides?.requestTimeoutMs,
};
}
@ -156,6 +158,7 @@ function runLocalCLI(
const child = spawn(commandInfo.command, finalArgs, {
cwd: config.workingDirectory || process.cwd(),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
env: {
...process.env,
CI: '1',
@ -168,19 +171,38 @@ function runLocalCLI(
let stderr = '';
let stdinError: Error | undefined;
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
if (killTimer !== undefined) clearTimeout(killTimer);
reject(error);
};
const resolveOnce = (response: LLMResponse) => {
if (settled) return;
settled = true;
if (killTimer !== undefined) clearTimeout(killTimer);
resolve(response);
};
if (config.requestTimeoutMs !== undefined && config.requestTimeoutMs > 0) {
killTimer = setTimeout(() => {
child.kill();
const duration =
config.requestTimeoutMs! >= 60_000
? `${Math.round(config.requestTimeoutMs! / 60_000)}m`
: `${Math.round(config.requestTimeoutMs! / 1_000)}s`;
rejectOnce(
new Error(
`${provider} CLI timed out after ${duration}. ` +
'Increase --timeout or omit it to disable the request timeout.',
),
);
}, config.requestTimeoutMs);
}
child.stdout.on('data', (chunk: Buffer) => {
const chunkStr = chunk.toString();
stdout += chunkStr;
@ -212,7 +234,12 @@ function runLocalCLI(
rejectOnce(new Error(`${provider} CLI stdin error: ${stdinError.message}`));
return;
}
resolveOnce({ content: stdout.trim() });
const output = stdout.trim();
if (!output) {
rejectOnce(new Error(`${provider} CLI returned empty output`));
return;
}
resolveOnce({ content: output });
});
child.on('error', (err) => {