diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index a52c97d..99c3920 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -453,3 +453,115 @@ describe('Codex warm-up scheduler', () => { expect(childProcess.spawn).not.toHaveBeenCalled(); }); }); + +describe('Codex warm-up fixed-slot forceAttempt', () => { + let tempHome: string; + let statePath: string; + // Two consecutive slots 5h apart, but the primer fires a beat after the slot, + // so the gap is just under minIntervalMs (5h). + const t13 = new Date('2026-06-09T04:00:00.486Z').getTime(); + const t18 = new Date('2026-06-09T09:00:00.000Z').getTime(); + const config = { + enabled: true as const, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_000_000, // 5h + staggerMs: 1_800_000, + maxUsagePct: 100, + maxD7UsagePct: 100, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-force-')); + statePath = path.join(tempHome, 'codex-warmup-state.json'); + fs.mkdirSync(path.dirname(authPathFor(tempHome, 0)), { recursive: true }); + fs.writeFileSync(authPathFor(tempHome, 0), '{}'); + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + async function setup() { + const childProcess = await import('child_process'); + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 0, + accountId: 'acct', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 0, + }, + ]); + vi.mocked(rotation.getCodexAuthPath).mockImplementation( + (accountIndex = 0) => authPathFor(tempHome, accountIndex), + ); + vi.mocked(childProcess.spawn).mockImplementation( + (() => createFakeCodexProcess(0) as never) as unknown as typeof childProcess.spawn, + ); + return { childProcess, runCodexWarmupCycle }; + } + + it('fires at the next slot even when it is just under the 5h min-interval', async () => { + const { childProcess, runCodexWarmupCycle } = await setup(); + const r1 = await runCodexWarmupCycle(config, { + nowMs: t13, + statePath, + ignoreZeroUsageWindow: true, + forceAttempt: true, + }); + expect(r1).toEqual({ status: 'warmed', accountIndex: 0 }); + const r2 = await runCodexWarmupCycle(config, { + nowMs: t18, + statePath, + ignoreZeroUsageWindow: true, + forceAttempt: true, + }); + expect(r2).toEqual({ status: 'warmed', accountIndex: 0 }); + expect(childProcess.spawn).toHaveBeenCalledTimes(2); + }); + + it('without forceAttempt, the sub-5h second slot is skipped (regression guard)', async () => { + const { runCodexWarmupCycle } = await setup(); + await runCodexWarmupCycle(config, { + nowMs: t13, + statePath, + ignoreZeroUsageWindow: true, + }); + const r2 = await runCodexWarmupCycle(config, { + nowMs: t18, + statePath, + ignoreZeroUsageWindow: true, + }); + expect(r2).toEqual({ status: 'skipped', reason: 'no_eligible_accounts' }); + }); + + it('forceAttempt bypasses post-failure cooldown and stagger', async () => { + const { runCodexWarmupCycle } = await setup(); + fs.writeFileSync( + statePath, + JSON.stringify({ + disabledUntil: new Date(t18 + 3_600_000).toISOString(), + lastWarmupAt: new Date(t18 - 1_000).toISOString(), + accounts: {}, + }), + ); + const result = await runCodexWarmupCycle(config, { + nowMs: t18, + statePath, + ignoreZeroUsageWindow: true, + forceAttempt: true, + }); + expect(result).toEqual({ status: 'warmed', accountIndex: 0 }); + }); +}); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index 1e336b8..8c0f491 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -36,6 +36,15 @@ interface CodexWarmupRuntimeOptions { statePath?: string; shouldSkip?: () => boolean; ignoreZeroUsageWindow?: boolean; + /** + * Fixed-slot primer mode: bypass the opportunistic warm-up skip gates + * (stagger, post-failure cooldown, min-interval, per-account usage/rate-limit + * filters) so a real Codex command is attempted at every 08/13/18/23 slot and + * its success/failure recorded. Without this, two slots exactly 5h apart fall + * just under minIntervalMs (the primer fires a few hundred ms after the slot) + * and the later slot is silently skipped as `no_eligible_accounts`. + */ + forceAttempt?: boolean; } export type CodexWarmupCycleResult = @@ -162,15 +171,20 @@ function selectWarmupCandidate( config: CodexWarmupConfig, state: CodexWarmupState, nowMs: number, - options: { ignoreZeroUsageWindow?: boolean } = {}, + options: { ignoreZeroUsageWindow?: boolean; forceAttempt?: boolean } = {}, ): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } { const disabledUntilMs = parseTimestamp(state.disabledUntil); - if (disabledUntilMs != null && disabledUntilMs > nowMs) { + if ( + !options.forceAttempt && + disabledUntilMs != null && + disabledUntilMs > nowMs + ) { return { reason: 'disabled_cooldown' }; } const lastGlobalWarmupMs = parseTimestamp(state.lastWarmupAt); if ( + !options.forceAttempt && lastGlobalWarmupMs != null && config.staggerMs > 0 && nowMs - lastGlobalWarmupMs < config.staggerMs @@ -181,6 +195,16 @@ function selectWarmupCandidate( const accounts = getAllCodexAccounts(); if (accounts.length === 0) return { reason: 'no_accounts' }; + // Fixed-slot primer: attempt a real command on the active account every slot, + // bypassing the per-account usage/rate-limit/min-interval filters below. + if (options.forceAttempt) { + const active = accounts.find((a) => a.isActive) ?? accounts[0]; + return { + accountIndex: active.index, + zeroUsageWarmupUntil: new Date(nowMs).toISOString(), + }; + } + for (const account of accounts) { if (account.isRateLimited) continue; if (typeof account.cachedUsagePct !== 'number') continue; @@ -322,6 +346,7 @@ export async function runCodexWarmupCycle( const state = readWarmupState(statePath); const selected = selectWarmupCandidate(config, state, nowMs, { ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow, + forceAttempt: runtime.forceAttempt, }); if ('reason' in selected) { return { status: 'skipped', reason: selected.reason }; diff --git a/src/usage-primer.test.ts b/src/usage-primer.test.ts index 18e649b..feaaa64 100644 --- a/src/usage-primer.test.ts +++ b/src/usage-primer.test.ts @@ -71,7 +71,7 @@ describe('usage-primer', () => { maxUsagePct: 100, maxD7UsagePct: 100, }), - { ignoreZeroUsageWindow: true }, + { ignoreZeroUsageWindow: true, forceAttempt: true }, ); }); }); diff --git a/src/usage-primer.ts b/src/usage-primer.ts index b2e80c4..bb4687e 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -163,7 +163,11 @@ export async function runCodexPrimerCycle(): Promise { maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT, maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT, }, - { ignoreZeroUsageWindow: true }, + // forceAttempt: fire a real Codex command at *every* fixed slot, + // bypassing the opportunistic warm-up skip gates. Without it, two slots + // exactly 5h apart fall just under minIntervalMs (the primer fires a few + // hundred ms after the slot) and the later slot is silently skipped. + { ignoreZeroUsageWindow: true, forceAttempt: true }, ); // Best-effort: a slot may land while the trailing 5h window is exhausted, // in which case this records `failed`. We deliberately do NOT try to