From 2313d6cf3ea472e377332a223ba7604666f214ec Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 13:31:05 +0900 Subject: [PATCH] fix(primer): repair Claude binary path and re-anchor Codex window after reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two evidence-based fixes for the usage-window primer that aligns the 5h reset to fixed KST slots (08/13/18/23): 1. Claude primer always failed with binary_missing: it only looked under runners/agent-runner/node_modules, but the SDK platform binary is hoisted to the repo-root node_modules. Resolve both locations. 2. When a Codex slot lands while the 5h window is still active/exhausted, the warm-up call fails with a rate-limit and the window only re-anchors at its natural reset — leaving Codex un-primed until the next 5h slot. Parse the account's known reset time and schedule a one-shot retry right after it so the next window anchors close to the slot. Also corrected a stale comment claiming a 04:00–08:00 gap is enforced by message-runtime-gating (it is not). Co-Authored-By: Claude Opus 4.7 --- src/usage-primer.test.ts | 38 ++++++++++++++- src/usage-primer.ts | 102 +++++++++++++++++++++++++++++++++------ 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/src/usage-primer.test.ts b/src/usage-primer.test.ts index 18e649b..effb9b7 100644 --- a/src/usage-primer.test.ts +++ b/src/usage-primer.test.ts @@ -12,8 +12,13 @@ const refreshActiveCodexUsage = vi.fn(async () => { }); const runCodexWarmupCycle = vi.fn(async () => { callOrder.push('warmup'); - return { status: 'warmed', accountIndex: 0 }; + return { status: 'warmed', accountIndex: 0 } as { + status: string; + accountIndex?: number; + reason?: string; + }; }); +const getAllCodexAccounts = vi.fn<() => Array<{ resetAt?: string }>>(() => []); vi.mock('./config.js', () => ({ CODEX_WARMUP_CONFIG: { @@ -53,6 +58,10 @@ vi.mock('./codex-warmup.js', () => ({ runCodexWarmupCycle, })); +vi.mock('./codex-token-rotation.js', () => ({ + getAllCodexAccounts, +})); + describe('usage-primer', () => { it('refreshes Codex usage before primer selection and fires regardless of usage level', async () => { callOrder.length = 0; @@ -74,4 +83,31 @@ describe('usage-primer', () => { { ignoreZeroUsageWindow: true }, ); }); + + it('reschedules a retry at the window reset when the slot is rate-limited', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-09T00:00:00.000Z')); + runCodexWarmupCycle.mockClear(); + // First slot: the 5h window is still active/exhausted → command fails. + runCodexWarmupCycle.mockResolvedValueOnce({ + status: 'failed', + accountIndex: 0, + reason: 'exit_1', + }); + // The account's window resets 60s from now. + getAllCodexAccounts.mockReturnValue([ + { resetAt: '2026-06-09T00:01:00.000Z' }, + ]); + + const { runCodexPrimerCycle } = await import('./usage-primer.js'); + await runCodexPrimerCycle(); + expect(runCodexWarmupCycle).toHaveBeenCalledTimes(1); + + // Retry is scheduled at reset (+60s) plus a 30s buffer = +90s. + await vi.advanceTimersByTimeAsync(91_000); + expect(runCodexWarmupCycle).toHaveBeenCalledTimes(2); + + getAllCodexAccounts.mockReturnValue([]); + vi.useRealTimers(); + }); }); diff --git a/src/usage-primer.ts b/src/usage-primer.ts index fad904a..4a3aeed 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -8,18 +8,24 @@ import { refreshAllCodexAccountUsage, } from './codex-usage-collector.js'; import { runCodexWarmupCycle } from './codex-warmup.js'; +import { getAllCodexAccounts } from './codex-token-rotation.js'; import { logger } from './logger.js'; import { getAllTokens } from './token-rotation.js'; /** * Usage-window alignment primer. * - * Anthropic/OpenAI enforce 5h rolling usage windows that start on first - * inference call. We anchor windows to fixed KST times by firing a tiny - * primer call at 08, 13, 18, 23 KST (4 windows × 5h = 20h, leaving a - * 04:00–08:00 gap blocked by message-runtime-gating). + * Anthropic/OpenAI enforce 5h rolling usage windows that start on the first + * inference call after the previous window expires and reset ~5h later. We + * anchor windows to fixed KST times by firing a tiny primer call at 08, 13, + * 18, 23 KST (4 windows × 5h = 20h, leaving a 04:00–08:00 quiet gap with no + * scheduled primer so 08:00 can re-anchor the day). * - * Daily reset point ends up being 08:00 KST (start of first slot after gap). + * Daily reset point ends up being 08:00 KST (start of first slot after gap), + * provided no off-slot Codex usage (e.g. a paired-room reviewer) re-anchors + * the window inside the gap. When a slot lands while the window is still + * active/exhausted, runCodexPrimerCycle schedules a one-shot retry right after + * the reported reset so the next window still anchors close to the slot. */ const PRIMER_HOURS_KST = [8, 13, 18, 23]; @@ -36,16 +42,21 @@ const CODEX_PRIMER_MAX_USAGE_PCT = 100; const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100; function resolveClaudeBinary(): string { - const candidates = [ - path.resolve( - process.cwd(), - 'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude', - ), - path.resolve( - process.cwd(), - 'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude', - ), + const subPaths = [ + '@anthropic-ai/claude-agent-sdk-linux-x64/claude', + '@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude', ]; + // The SDK's platform package is usually hoisted to the repo-root node_modules + // (the bot runs `bun src/index.ts` from the repo root), but can also live + // under the agent-runner workspace when deps are not hoisted. Check both so + // the Claude primer doesn't silently fail with binary_missing. + const roots = [ + path.resolve(process.cwd(), 'node_modules'), + path.resolve(process.cwd(), 'runners/agent-runner/node_modules'), + ]; + const candidates = roots.flatMap((root) => + subPaths.map((sub) => path.join(root, sub)), + ); for (const p of candidates) { if (fs.existsSync(p)) return p; } @@ -141,7 +152,53 @@ async function runClaudePrimerCycle(): Promise { } } -export async function runCodexPrimerCycle(): Promise { +// When a primer slot lands while the 5h window is still active (exhausted / +// rate-limited), the warm-up call fails and the window only re-anchors at its +// natural reset. Rather than leaving Codex un-primed until the next 5h slot, +// we schedule a single retry right after the reported window reset so the next +// window is anchored as close to the reset boundary as possible. +const CODEX_RETRY_BUFFER_MS = 30_000; +const CODEX_RETRY_MAX_DELAY_MS = 5.5 * 60 * 60 * 1000; +let codexResetRetryTimer: ReturnType | null = null; + +function soonestCodexResetMs(nowMs: number): number | null { + let soonest: number | null = null; + for (const account of getAllCodexAccounts()) { + const resetMs = account.resetAt ? Date.parse(account.resetAt) : NaN; + if (Number.isFinite(resetMs) && resetMs > nowMs) { + if (soonest === null || resetMs < soonest) soonest = resetMs; + } + } + return soonest; +} + +function scheduleCodexResetRetry(): void { + if (codexResetRetryTimer) return; // at most one outstanding retry + const nowMs = Date.now(); + const resetMs = soonestCodexResetMs(nowMs); + if (resetMs === null) { + logger.info('Codex primer retry not scheduled: no known window reset time'); + return; + } + const delay = resetMs - nowMs + CODEX_RETRY_BUFFER_MS; + if (delay <= 0 || delay > CODEX_RETRY_MAX_DELAY_MS) { + return; + } + logger.info( + { fireAt: new Date(nowMs + delay).toISOString() }, + 'Scheduling Codex primer retry at window reset', + ); + codexResetRetryTimer = setTimeout(() => { + codexResetRetryTimer = null; + void runCodexPrimerCycle({ allowRetry: false }); + }, delay); + codexResetRetryTimer.unref?.(); +} + +export async function runCodexPrimerCycle( + options: { allowRetry?: boolean } = {}, +): Promise { + const { allowRetry = true } = options; try { await refreshAllCodexAccountUsage(); await refreshActiveCodexUsage(); @@ -156,6 +213,17 @@ export async function runCodexPrimerCycle(): Promise { { ignoreZeroUsageWindow: true }, ); logger.info({ result }, 'Codex primer cycle finished'); + // The window was still active at this slot (exhausted or all accounts + // rate-limited). Re-anchor right after the window resets instead of + // waiting for the next 5h slot. + const windowBlocked = + result.status === 'failed' || + (result.status === 'skipped' && + (result.reason === 'no_eligible_accounts' || + result.reason === 'disabled_cooldown')); + if (allowRetry && windowBlocked) { + scheduleCodexResetRetry(); + } } catch (err) { logger.warn({ err }, 'Codex primer cycle threw'); } @@ -209,4 +277,8 @@ export function stopUsagePrimer(): void { clearTimeout(primerTimer); primerTimer = null; } + if (codexResetRetryTimer) { + clearTimeout(codexResetRetryTimer); + codexResetRetryTimer = null; + } }