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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('usage-primer', () => {
|
||||
maxUsagePct: 100,
|
||||
maxD7UsagePct: 100,
|
||||
}),
|
||||
{ ignoreZeroUsageWindow: true },
|
||||
{ ignoreZeroUsageWindow: true, forceAttempt: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user