fix(primer): force a Codex command at every fixed slot (bypass warm-up skip gates)

The scheduled primer reused the opportunistic warm-up gates, so the 18:00 slot
(exactly 5h after 13:00, but firing a few hundred ms later) fell just under
minIntervalMs and was silently skipped as no_eligible_accounts — the primer did
not actually fire at every slot. Add a forceAttempt path: the fixed-slot primer
bypasses stagger, post-failure cooldown, min-interval, and per-account
usage/rate-limit filters, attempting a real Codex command on the active account
at every 08/13/18/23 slot and recording success/failure.

Reviewer/arbiter holding stays removed (per user). This does not pin the reset
clock — a trailing window still slides with later usage — but it guarantees the
"simple task at every fixed slot" the user asked for.

Verified: typecheck, build, bundle-smoke; new regression tests (13:00 success
then sub-5h 18:00 still fires; control without forceAttempt is skipped;
forceAttempt bypasses cooldown/stagger) + usage-primer/codex-warmup suites pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-09 15:17:59 +09:00
parent b3a927b241
commit 4ddaa88f8a
4 changed files with 145 additions and 4 deletions

View File

@@ -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 });
});
});

View File

@@ -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 };

View File

@@ -71,7 +71,7 @@ describe('usage-primer', () => {
maxUsagePct: 100,
maxD7UsagePct: 100,
}),
{ ignoreZeroUsageWindow: true },
{ ignoreZeroUsageWindow: true, forceAttempt: true },
);
});
});

View File

@@ -163,7 +163,11 @@ export async function runCodexPrimerCycle(): Promise<void> {
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