From ee8b18145e4c86465f085acfbcdfb3102c44b707 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 24 Apr 2026 19:04:22 +0900 Subject: [PATCH] fix: avoid repeated zero-usage Codex warm-ups --- src/codex-warmup.test.ts | 62 ++++++++++++++++++++++++++++++++++++++++ src/codex-warmup.ts | 25 ++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index 7c8332b..b0c4b0c 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -106,6 +106,8 @@ describe('Codex warm-up scheduler', () => { isRateLimited: false, cachedUsagePct: 0, cachedUsageD7Pct: 0, + resetAt: '2026-04-24T14:00:00.000Z', + resetD7At: '2026-05-01T09:00:00.000Z', }, ]); vi.mocked(rotation.getCodexAuthPath).mockImplementation( @@ -160,9 +162,69 @@ describe('Codex warm-up scheduler', () => { const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); expect(state.lastWarmupAt).toBe('2026-04-24T09:00:00.000Z'); expect(state.accounts['2'].lastWarmupAt).toBe('2026-04-24T09:00:00.000Z'); + expect(state.accounts['2'].zeroUsageWarmupUntil).toBe( + '2026-05-01T09:00:00.000Z', + ); expect(state.consecutiveFailures).toBe(0); }); + 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'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + const now = new Date('2026-04-24T15: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: 'fresh-account-still-rounded-zero', + planType: 'pro', + isActive: false, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + resetAt: '2026-04-24T14:00:00.000Z', + resetD7At: '2026-05-01T09:00:00.000Z', + }, + ]); + + const result = await runCodexWarmupCycle( + { + enabled: true, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_300_000, + staggerMs: 0, + maxUsagePct: 0, + maxD7UsagePct: 0, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, + { nowMs: now, statePath }, + ); + + expect(result).toEqual({ + status: 'skipped', + reason: 'no_eligible_accounts', + }); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + 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 cacc63d..a70b58a 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -17,6 +17,7 @@ interface CodexWarmupAccountState { lastWarmupAt?: string; lastAttemptAt?: string; lastErrorAt?: string; + zeroUsageWarmupUntil?: string; failures?: number; } @@ -41,6 +42,7 @@ export type CodexWarmupCycleResult = | { status: 'failed'; accountIndex: number; reason: string }; const DEFAULT_STATE_FILE = path.join(DATA_DIR, 'codex-warmup-state.json'); +const DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; function parseTimestamp(value?: string): number | null { if (!value) return null; @@ -103,7 +105,7 @@ function selectWarmupCandidate( config: CodexWarmupConfig, state: CodexWarmupState, nowMs: number, -): { accountIndex: number } | { reason: string } { +): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } { const disabledUntilMs = parseTimestamp(state.disabledUntil); if (disabledUntilMs != null && disabledUntilMs > nowMs) { return { reason: 'disabled_cooldown' }; @@ -129,12 +131,30 @@ function selectWarmupCandidate( if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue; const accountState = state.accounts?.[String(account.index)]; + const zeroUsageWarmupUntilMs = parseTimestamp( + accountState?.zeroUsageWarmupUntil, + ); + if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) { + continue; + } + const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt); if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) { continue; } - return { accountIndex: account.index }; + const resetD7Ms = parseTimestamp(account.resetD7At); + const zeroUsageWarmupUntilMsForState = + resetD7Ms != null && resetD7Ms > nowMs + ? resetD7Ms + : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + + return { + accountIndex: account.index, + zeroUsageWarmupUntil: new Date( + zeroUsageWarmupUntilMsForState, + ).toISOString(), + }; } return { reason: 'no_eligible_accounts' }; @@ -259,6 +279,7 @@ export async function runCodexWarmupCycle( state.consecutiveFailures = 0; delete state.disabledUntil; accountState.lastWarmupAt = nowIso; + accountState.zeroUsageWarmupUntil = selected.zeroUsageWarmupUntil; accountState.failures = 0; writeWarmupState(statePath, state); logger.info(