From 7eb97dd921dbb2ca40f3d73eb2a4224f251a811d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 15:54:14 +0900 Subject: [PATCH] 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 --- src/codex-warmup.test.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/codex-warmup.ts | 63 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index 13e4a90..a52c97d 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -168,6 +168,66 @@ describe('Codex warm-up scheduler', () => { 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 () => { const childProcess = await import('child_process'); const rotation = await import('./codex-token-rotation.js'); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index 5c2ba11..1e336b8 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -1,7 +1,9 @@ import { ChildProcess, spawn } from 'child_process'; import fs from 'fs'; +import { createRequire } from 'module'; import os from 'os'; import path from 'path'; +import { fileURLToPath } from 'url'; import { DATA_DIR } from './config.js'; import type { AppConfig } from './config/schema.js'; @@ -87,9 +89,63 @@ function getPreferredCodexPathEntries(): string[] { 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'); - return fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; + if (fs.existsSync(npmGlobalBin)) { + return { command: npmGlobalBin, prefixArgs: [] }; + } + return { command: 'codex', prefixArgs: [] }; } function ensureAccountState( @@ -219,7 +275,8 @@ function runCodexWarmupCommand( ); try { - proc = spawn(resolveCodexBinary(), args, { + const launcher = resolveCodexLauncher(); + proc = spawn(launcher.command, [...launcher.prefixArgs, ...args], { stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnv, });