fix(primer): resolve bundled codex JS launcher so the periodic primer fires
The Codex usage primer spawned a bare `codex` binary, but under bun/systemd there is no global `codex` on PATH, so every slot failed with ENOENT and the primer never ran. Resolve the vendored @openai/codex JS launcher and run it through the current JS runtime (matching the codex-runner), with global-binary and PATH fallbacks. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,66 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
expect(state.consecutiveFailures).toBe(0);
|
expect(state.consecutiveFailures).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('spawns the bundled codex JS launcher via the JS runtime instead of a bare "codex" PATH lookup', async () => {
|
||||||
|
// Regression: under bun/systemd there is usually no global `codex` on PATH,
|
||||||
|
// so spawning bare `codex` failed with ENOENT and the primer never fired.
|
||||||
|
const childProcess = await import('child_process');
|
||||||
|
const rotation = await import('./codex-token-rotation.js');
|
||||||
|
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||||
|
const now = new Date('2026-04-24T09:00:00Z').getTime();
|
||||||
|
let capturedCmd: string | undefined;
|
||||||
|
let capturedArgs: readonly string[] | undefined;
|
||||||
|
|
||||||
|
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
accountId: 'fresh-account',
|
||||||
|
planType: 'pro',
|
||||||
|
isActive: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
cachedUsagePct: 0,
|
||||||
|
cachedUsageD7Pct: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
|
||||||
|
(accountIndex = 0) => authPathFor(tempHome, accountIndex),
|
||||||
|
);
|
||||||
|
vi.mocked(childProcess.spawn).mockImplementation(((
|
||||||
|
cmd: string,
|
||||||
|
args?: readonly string[],
|
||||||
|
) => {
|
||||||
|
capturedCmd = cmd;
|
||||||
|
capturedArgs = args;
|
||||||
|
return createFakeCodexProcess(0) as never;
|
||||||
|
}) as unknown as typeof childProcess.spawn);
|
||||||
|
|
||||||
|
await runCodexWarmupCycle(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
prompt: 'Reply exactly OK. Do not run tools.',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
intervalMs: 300_000,
|
||||||
|
minIntervalMs: 18_300_000,
|
||||||
|
staggerMs: 1_800_000,
|
||||||
|
maxUsagePct: 0,
|
||||||
|
maxD7UsagePct: 0,
|
||||||
|
commandTimeoutMs: 120_000,
|
||||||
|
failureCooldownMs: 21_600_000,
|
||||||
|
maxConsecutiveFailures: 2,
|
||||||
|
},
|
||||||
|
{ nowMs: now, statePath },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
|
||||||
|
// Command is the JS runtime (bun/node), never a bare "codex" PATH lookup.
|
||||||
|
expect(capturedCmd).toBe(process.execPath);
|
||||||
|
expect(capturedCmd).not.toBe('codex');
|
||||||
|
// First arg is the resolvable codex JS launcher; 'exec' follows after it.
|
||||||
|
expect(capturedArgs?.[0]).toMatch(/codex\.js$/);
|
||||||
|
expect(fs.existsSync(capturedArgs?.[0] as string)).toBe(true);
|
||||||
|
expect(capturedArgs?.[1]).toBe('exec');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not repeat warm-up while the same zero-usage quota window is already marked warmed', async () => {
|
it('does not repeat warm-up while the same zero-usage quota window is already marked warmed', async () => {
|
||||||
const childProcess = await import('child_process');
|
const childProcess = await import('child_process');
|
||||||
const rotation = await import('./codex-token-rotation.js');
|
const rotation = await import('./codex-token-rotation.js');
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ChildProcess, spawn } from 'child_process';
|
import { ChildProcess, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { createRequire } from 'module';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR } from './config.js';
|
||||||
import type { AppConfig } from './config/schema.js';
|
import type { AppConfig } from './config/schema.js';
|
||||||
@@ -87,9 +89,63 @@ function getPreferredCodexPathEntries(): string[] {
|
|||||||
return [...new Set(entries)];
|
return [...new Set(entries)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCodexBinary(): string {
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
interface CodexLauncher {
|
||||||
|
command: string;
|
||||||
|
prefixArgs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve how to spawn the Codex CLI for warm-up.
|
||||||
|
*
|
||||||
|
* The bot runs under bun/systemd where a global `codex` binary usually is NOT
|
||||||
|
* on PATH, so the old bare-`codex` fallback failed with ENOENT and the primer
|
||||||
|
* never fired. Prefer the bundled `@openai/codex` JS launcher (the same path
|
||||||
|
* the codex-runner spawns) and run it through the current JS runtime, falling
|
||||||
|
* back to a globally installed binary only when the package is unavailable.
|
||||||
|
*/
|
||||||
|
function resolveCodexLauncher(): CodexLauncher {
|
||||||
|
const launcherCandidates: string[] = [];
|
||||||
|
try {
|
||||||
|
launcherCandidates.push(
|
||||||
|
path.join(
|
||||||
|
path.dirname(require.resolve('@openai/codex/package.json')),
|
||||||
|
'bin',
|
||||||
|
'codex.js',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* @openai/codex not resolvable from this module — try explicit paths */
|
||||||
|
}
|
||||||
|
// The codex-runner workspace always vendors @openai/codex.
|
||||||
|
const repoRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'..',
|
||||||
|
);
|
||||||
|
launcherCandidates.push(
|
||||||
|
path.join(
|
||||||
|
repoRoot,
|
||||||
|
'runners',
|
||||||
|
'codex-runner',
|
||||||
|
'node_modules',
|
||||||
|
'@openai',
|
||||||
|
'codex',
|
||||||
|
'bin',
|
||||||
|
'codex.js',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const launcher of launcherCandidates) {
|
||||||
|
if (fs.existsSync(launcher)) {
|
||||||
|
return { command: process.execPath, prefixArgs: [launcher] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to a globally installed codex binary, then bare PATH lookup.
|
||||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||||
return fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
if (fs.existsSync(npmGlobalBin)) {
|
||||||
|
return { command: npmGlobalBin, prefixArgs: [] };
|
||||||
|
}
|
||||||
|
return { command: 'codex', prefixArgs: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAccountState(
|
function ensureAccountState(
|
||||||
@@ -219,7 +275,8 @@ function runCodexWarmupCommand(
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
proc = spawn(resolveCodexBinary(), args, {
|
const launcher = resolveCodexLauncher();
|
||||||
|
proc = spawn(launcher.command, [...launcher.prefixArgs, ...args], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: spawnEnv,
|
env: spawnEnv,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user