|
|
@@ -60,14 +60,12 @@ export interface CodexSkillSummary {
|
|
|
cwd: string;
|
|
|
}
|
|
|
|
|
|
-/** 一次 spawn 用的模型来源。id 为 null 表示使用 Codex 内置 provider(ollama / lmstudio) */
|
|
|
+/** 一次 spawn 用的模型来源:一律是自定义 provider,Codex 的内置 id 是保留字、不可覆盖 */
|
|
|
export interface CodexProviderSpec {
|
|
|
- id: string | null;
|
|
|
+ id: string;
|
|
|
name?: string | null;
|
|
|
- baseUrl?: string | null;
|
|
|
+ baseUrl: string;
|
|
|
model: string;
|
|
|
- /** 内置 provider id,例如 'ollama';给了就不再写 model_providers 段 */
|
|
|
- builtinProvider?: string | null;
|
|
|
/** apiKey 只注入子进程环境变量,不落任何盘 */
|
|
|
apiKey?: string | null;
|
|
|
envKey?: string;
|
|
|
@@ -182,6 +180,9 @@ interface TurnWaiter {
|
|
|
}
|
|
|
|
|
|
const TURN_TIMEOUT_MS = 20 * 60 * 1_000;
|
|
|
+/** 退出事件可能比 stderr 落地慢半拍,等一下再拼错误信息 */
|
|
|
+const EXIT_GRACE_MS = 300;
|
|
|
+const STDERR_TAIL_LINES = 40;
|
|
|
export const DEFAULT_API_KEY_ENV = 'ZSJZ_CODEX_API_KEY';
|
|
|
const PROVIDER_EXTRA_ALLOWLIST = new Set([
|
|
|
'request_max_retries',
|
|
|
@@ -224,9 +225,17 @@ export class CodexRuntime extends EventEmitter {
|
|
|
|
|
|
/** 换 provider 必须重启子进程:api_key 走的是子进程环境变量,无法热更新 */
|
|
|
async applyProvider(provider: CodexProviderSpec | null): Promise<RuntimeStatus> {
|
|
|
+ const previous = this.#provider;
|
|
|
this.#provider = provider;
|
|
|
if (this.#child) await this.stop();
|
|
|
- return this.start();
|
|
|
+ try {
|
|
|
+ return await this.start();
|
|
|
+ } catch (error) {
|
|
|
+ // 失败要回滚:坏 spec 留在内存里,之后每次 start 都会重放同一组参数,
|
|
|
+ // 一条选错的模型能把运行时锁死到重启应用为止。
|
|
|
+ this.#provider = previous;
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
async start(): Promise<RuntimeStatus> {
|
|
|
@@ -524,6 +533,7 @@ export class CodexRuntime extends EventEmitter {
|
|
|
this.emit('status', this.status);
|
|
|
let child: ChildProcessWithoutNullStreams | null = null;
|
|
|
let peer: JsonRpcPeer | null = null;
|
|
|
+ let watcher: ExitWatcher | null = null;
|
|
|
try {
|
|
|
const binaryPath = await locateCodexBinary();
|
|
|
this.#assertGeneration(generation);
|
|
|
@@ -539,25 +549,27 @@ export class CodexRuntime extends EventEmitter {
|
|
|
this.#child = child;
|
|
|
peer = new JsonRpcPeer(child.stdout, child.stdin);
|
|
|
this.#peer = peer;
|
|
|
+ const spawnedChild = child;
|
|
|
+ const spawnedPeer = peer;
|
|
|
peer.on('notification', (notification) => this.#handleNotification(notification));
|
|
|
peer.on('serverRequest', (request: JsonRpcServerRequest) => this.emit('serverRequest', request));
|
|
|
peer.on('protocolError', (error) => this.emit('diagnostic', asError(error).message));
|
|
|
- peer.once('closed', (error) => {
|
|
|
- if (this.#peer !== peer || this.#child !== child) return;
|
|
|
- if (child?.exitCode === null) child.kill('SIGTERM');
|
|
|
- this.#handleExit(child!, asError(error));
|
|
|
- });
|
|
|
- peer.start();
|
|
|
+ watcher = createExitWatcher(
|
|
|
+ spawnedChild,
|
|
|
+ (line) => this.emit('diagnostic', sanitizeDiagnostic(line)),
|
|
|
+ (error) => {
|
|
|
+ if (this.#peer !== spawnedPeer || this.#child !== spawnedChild) return;
|
|
|
+ this.#handleExit(spawnedChild, error);
|
|
|
+ },
|
|
|
+ );
|
|
|
child.stderr.setEncoding('utf8');
|
|
|
child.stderr.on('data', (chunk: string) => {
|
|
|
- for (const line of chunk.split(/\r?\n/u).filter(Boolean)) {
|
|
|
- this.emit('diagnostic', sanitizeDiagnostic(line));
|
|
|
- }
|
|
|
- });
|
|
|
- child.once('error', (error) => this.#handleExit(child!, error));
|
|
|
- child.once('exit', (code, signal) => {
|
|
|
- this.#handleExit(child!, new Error(`Codex App Server 退出(${code ?? signal ?? 'unknown'})`));
|
|
|
+ for (const line of chunk.split(/\r?\n/u).filter(Boolean)) watcher?.noteStderr(line);
|
|
|
});
|
|
|
+ child.once('error', (error) => watcher?.shutdown(error.message));
|
|
|
+ child.once('exit', () => watcher?.shutdown('Codex App Server 已退出'));
|
|
|
+ peer.once('closed', (error) => watcher?.shutdown(asError(error).message));
|
|
|
+ peer.start();
|
|
|
|
|
|
const initialized = await peer.request<InitializeResponse>('initialize', {
|
|
|
clientInfo: this.#clientInfo,
|
|
|
@@ -571,12 +583,14 @@ export class CodexRuntime extends EventEmitter {
|
|
|
binaryPath,
|
|
|
version,
|
|
|
codexHome: initialized.codexHome,
|
|
|
- providerId: provider?.id ?? provider?.builtinProvider ?? null,
|
|
|
+ providerId: provider?.id ?? null,
|
|
|
};
|
|
|
return await this.refresh();
|
|
|
} catch (error) {
|
|
|
- const message = asError(error).message;
|
|
|
- peer?.close(asError(error));
|
|
|
+ // 子进程启动即退出的话真话只在 stderr 里:等它落定,别把「协议流已关闭」丢给用户猜
|
|
|
+ const fallback = asError(error).message;
|
|
|
+ const message = watcher ? (await watcher.describe(fallback)).message : fallback;
|
|
|
+ peer?.close(new Error(message));
|
|
|
if (this.#peer === peer) this.#peer = null;
|
|
|
if (this.#child === child) this.#child = null;
|
|
|
if (child && child.exitCode === null) child.kill('SIGTERM');
|
|
|
@@ -584,6 +598,8 @@ export class CodexRuntime extends EventEmitter {
|
|
|
this.#runtime = { ...this.#runtime, state: 'error', error: sanitizeDiagnostic(message) };
|
|
|
this.emit('status', this.status);
|
|
|
}
|
|
|
+ // 换掉原文而不是换掉错误对象:调用方拿到的类型与 stack 不变,只是话变清楚了
|
|
|
+ if (error instanceof Error) error.message = message;
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
@@ -676,18 +692,8 @@ export function buildArgs(provider: CodexProviderSpec | null): string[] {
|
|
|
const args = ['app-server', '--listen', 'stdio://'];
|
|
|
if (!provider) return args;
|
|
|
|
|
|
- const providerKey = provider.builtinProvider ?? provider.id;
|
|
|
- if (!providerKey) return args;
|
|
|
- args.push('-c', `model_provider=${tomlString(providerKey)}`);
|
|
|
+ args.push('-c', `model_provider=${tomlString(provider.id)}`);
|
|
|
args.push('-c', `model=${tomlString(provider.model)}`);
|
|
|
- if (provider.builtinProvider) {
|
|
|
- // 内置 provider 只允许覆盖 base_url,其余字段由 Codex 自己决定;
|
|
|
- // 不覆盖就会打到 localhost:11434,远端 Ollama / LM Studio 连不上
|
|
|
- if (provider.baseUrl) {
|
|
|
- args.push('-c', `model_providers.${provider.builtinProvider}.base_url=${tomlString(provider.baseUrl)}`);
|
|
|
- }
|
|
|
- return args;
|
|
|
- }
|
|
|
|
|
|
const prefix = `model_providers.${provider.id}`;
|
|
|
if (provider.name) args.push('-c', `${prefix}.name=${tomlString(provider.name)}`);
|
|
|
@@ -719,7 +725,7 @@ function buildEnv(codexHome: string, provider: CodexProviderSpec | null): NodeJS
|
|
|
LOG_FORMAT: 'json',
|
|
|
};
|
|
|
// 密钥只存在于子进程环境变量,config.toml 里只写 env_key 名字
|
|
|
- if (provider?.apiKey && !provider.builtinProvider) {
|
|
|
+ if (provider?.apiKey) {
|
|
|
env[provider.envKey ?? DEFAULT_API_KEY_ENV] = provider.apiKey;
|
|
|
}
|
|
|
return env;
|
|
|
@@ -812,3 +818,69 @@ export function sanitizeDiagnostic(value: string): string {
|
|
|
.replace(/Bearer\s+[A-Za-z0-9._~-]+/giu, 'Bearer [redacted]')
|
|
|
.slice(0, 4_000);
|
|
|
}
|
|
|
+
|
|
|
+function delay(ms: number): Promise<void> {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const timer = setTimeout(resolve, ms);
|
|
|
+ timer.unref();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Codex 的致命错误由 anyhow 打成纯文本(`Error: ...`),RUST_LOG 的常规日志是 JSON 行;
|
|
|
+ * 纯文本里还混着 `WARNING: ...` 这类噪音,所以优先取 Error 行,取不到才退化成第一条纯文本行。
|
|
|
+ */
|
|
|
+function pickFatalLine(tail: string[]): string | null {
|
|
|
+ const plain = tail
|
|
|
+ .map((line) => line.trim())
|
|
|
+ .filter((line) => line && !line.startsWith('{'));
|
|
|
+ if (!plain.length) return null;
|
|
|
+ const fatal = plain.find((line) => /^(error|panic)\b/iu.test(line) || line.includes('panicked at'));
|
|
|
+ return sanitizeDiagnostic((fatal ?? plain[0]).slice(0, 500));
|
|
|
+}
|
|
|
+
|
|
|
+interface ExitWatcher {
|
|
|
+ /** 收 stderr:既实时推诊断,也留尾部若干行用于拼退出原因 */
|
|
|
+ noteStderr(line: string): void;
|
|
|
+ /** 统一退出出口(流关闭 / 进程退出 / spawn 失败),只生效一次 */
|
|
|
+ shutdown(fallback: string): void;
|
|
|
+ /** 等 stderr 收完,把没有信息量的 fallback 换成带 exit code 与原因原文的错误 */
|
|
|
+ describe(fallback: string): Promise<Error>;
|
|
|
+}
|
|
|
+
|
|
|
+function createExitWatcher(
|
|
|
+ child: ChildProcessWithoutNullStreams,
|
|
|
+ onDiagnostic: (line: string) => void,
|
|
|
+ onExit: (error: Error) => void,
|
|
|
+): ExitWatcher {
|
|
|
+ const tail: string[] = [];
|
|
|
+ const closed = new Promise<void>((resolve) => child.once('close', () => resolve()));
|
|
|
+ let shuttingDown = false;
|
|
|
+
|
|
|
+ const describe = async (fallback: string): Promise<Error> => {
|
|
|
+ // 等不到 close 也得往下走:一次卡住的退出不能把启动的 Promise 挂在那里
|
|
|
+ await Promise.race([closed, delay(EXIT_GRACE_MS)]);
|
|
|
+ const code = child.exitCode ?? child.signalCode;
|
|
|
+ const reason = code === null || code === undefined ? fallback : `Codex App Server 退出(${code})`;
|
|
|
+ const detail = pickFatalLine(tail);
|
|
|
+ return new Error(detail ? `${reason}:${detail}` : reason);
|
|
|
+ };
|
|
|
+
|
|
|
+ return {
|
|
|
+ noteStderr(line: string): void {
|
|
|
+ tail.push(line);
|
|
|
+ if (tail.length > STDERR_TAIL_LINES) tail.shift();
|
|
|
+ onDiagnostic(line);
|
|
|
+ },
|
|
|
+ describe,
|
|
|
+ shutdown(fallback: string): void {
|
|
|
+ if (shuttingDown) return;
|
|
|
+ shuttingDown = true;
|
|
|
+ void describe(fallback).then((error) => {
|
|
|
+ // 流都关了进程还活着(卡死的子进程)只能自己收
|
|
|
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
|
|
+ onExit(error);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|