From 85085e278227092fd41be5eac3b41e2d4e9b9e8e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 18:29:51 +0900 Subject: [PATCH] fix(codex): align primer warmup with fixed slots --- src/codex-warmup.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++ src/codex-warmup.ts | 23 ++++++++++---- src/usage-primer.ts | 14 ++++++++- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index b0c4b0c..13e4a90 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -225,6 +225,71 @@ describe('Codex warm-up scheduler', () => { expect(childProcess.spawn).not.toHaveBeenCalled(); }); + it('lets the fixed-slot primer warm an account again when 5h usage is fresh but weekly usage is nonzero', async () => { + 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-24T14:00:00Z').getTime(); + + fs.writeFileSync( + statePath, + JSON.stringify({ + lastWarmupAt: '2026-04-24T09:00:00.000Z', + consecutiveFailures: 0, + accounts: { + '0': { + lastWarmupAt: '2026-04-24T09:00:00.000Z', + zeroUsageWarmupUntil: '2026-05-01T09:00:00.000Z', + }, + }, + }), + ); + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 0, + accountId: 'same-account-next-slot', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 37, + resetAt: '2026-04-24T19:00:00.000Z', + resetD7At: '2026-05-01T09:00:00.000Z', + }, + ]); + vi.mocked(rotation.getCodexAuthPath).mockImplementation( + (accountIndex = 0) => authPathFor(tempHome, accountIndex), + ); + vi.mocked(childProcess.spawn).mockImplementation( + () => createFakeCodexProcess(0) as never, + ); + + const result = await runCodexWarmupCycle( + { + enabled: true, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_000_000, + staggerMs: 0, + maxUsagePct: 0, + maxD7UsagePct: 100, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, + { nowMs: now, statePath, ignoreZeroUsageWindow: true }, + ); + + expect(result).toEqual({ status: 'warmed', accountIndex: 0 }); + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + expect(state.accounts['0'].lastWarmupAt).toBe('2026-04-24T14:00:00.000Z'); + expect(state.accounts['0'].zeroUsageWarmupUntil).toBe( + '2026-04-24T14:00:00.000Z', + ); + }); + it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', 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 a70b58a..5c2ba11 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -33,6 +33,7 @@ interface CodexWarmupRuntimeOptions { nowMs?: number; statePath?: string; shouldSkip?: () => boolean; + ignoreZeroUsageWindow?: boolean; } export type CodexWarmupCycleResult = @@ -105,6 +106,7 @@ function selectWarmupCandidate( config: CodexWarmupConfig, state: CodexWarmupState, nowMs: number, + options: { ignoreZeroUsageWindow?: boolean } = {}, ): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } { const disabledUntilMs = parseTimestamp(state.disabledUntil); if (disabledUntilMs != null && disabledUntilMs > nowMs) { @@ -134,7 +136,11 @@ function selectWarmupCandidate( const zeroUsageWarmupUntilMs = parseTimestamp( accountState?.zeroUsageWarmupUntil, ); - if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) { + if ( + !options.ignoreZeroUsageWindow && + zeroUsageWarmupUntilMs != null && + zeroUsageWarmupUntilMs > nowMs + ) { continue; } @@ -144,10 +150,13 @@ function selectWarmupCandidate( } const resetD7Ms = parseTimestamp(account.resetD7At); - const zeroUsageWarmupUntilMsForState = - resetD7Ms != null && resetD7Ms > nowMs - ? resetD7Ms - : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + let zeroUsageWarmupUntilMsForState = nowMs; + if (!options.ignoreZeroUsageWindow) { + zeroUsageWarmupUntilMsForState = + resetD7Ms != null && resetD7Ms > nowMs + ? resetD7Ms + : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + } return { accountIndex: account.index, @@ -254,7 +263,9 @@ export async function runCodexWarmupCycle( const nowIso = new Date(nowMs).toISOString(); const statePath = runtime.statePath ?? DEFAULT_STATE_FILE; const state = readWarmupState(statePath); - const selected = selectWarmupCandidate(config, state, nowMs); + const selected = selectWarmupCandidate(config, state, nowMs, { + ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow, + }); if ('reason' in selected) { return { status: 'skipped', reason: selected.reason }; } diff --git a/src/usage-primer.ts b/src/usage-primer.ts index 490ab5c..eed2e63 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -21,6 +21,9 @@ import { getAllTokens } from './token-rotation.js'; const PRIMER_HOURS_KST = [8, 13, 18, 23]; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; const CLAUDE_PRIMER_TIMEOUT_MS = 60_000; +const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000; +const CODEX_PRIMER_MAX_USAGE_PCT = 0; +const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100; function resolveClaudeBinary(): string { const candidates = [ @@ -130,7 +133,16 @@ async function runClaudePrimerCycle(): Promise { async function runCodexPrimerCycle(): Promise { try { - const result = await runCodexWarmupCycle(CODEX_WARMUP_CONFIG); + const result = await runCodexWarmupCycle( + { + ...CODEX_WARMUP_CONFIG, + enabled: true, + minIntervalMs: CODEX_PRIMER_MIN_INTERVAL_MS, + maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT, + maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT, + }, + { ignoreZeroUsageWindow: true }, + ); logger.info({ result }, 'Codex primer cycle finished'); } catch (err) { logger.warn({ err }, 'Codex primer cycle threw');