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();
|
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;
|
statePath?: string;
|
||||||
shouldSkip?: () => boolean;
|
shouldSkip?: () => boolean;
|
||||||
ignoreZeroUsageWindow?: 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 =
|
export type CodexWarmupCycleResult =
|
||||||
@@ -162,15 +171,20 @@ function selectWarmupCandidate(
|
|||||||
config: CodexWarmupConfig,
|
config: CodexWarmupConfig,
|
||||||
state: CodexWarmupState,
|
state: CodexWarmupState,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
options: { ignoreZeroUsageWindow?: boolean } = {},
|
options: { ignoreZeroUsageWindow?: boolean; forceAttempt?: boolean } = {},
|
||||||
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
||||||
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
||||||
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
|
if (
|
||||||
|
!options.forceAttempt &&
|
||||||
|
disabledUntilMs != null &&
|
||||||
|
disabledUntilMs > nowMs
|
||||||
|
) {
|
||||||
return { reason: 'disabled_cooldown' };
|
return { reason: 'disabled_cooldown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastGlobalWarmupMs = parseTimestamp(state.lastWarmupAt);
|
const lastGlobalWarmupMs = parseTimestamp(state.lastWarmupAt);
|
||||||
if (
|
if (
|
||||||
|
!options.forceAttempt &&
|
||||||
lastGlobalWarmupMs != null &&
|
lastGlobalWarmupMs != null &&
|
||||||
config.staggerMs > 0 &&
|
config.staggerMs > 0 &&
|
||||||
nowMs - lastGlobalWarmupMs < config.staggerMs
|
nowMs - lastGlobalWarmupMs < config.staggerMs
|
||||||
@@ -181,6 +195,16 @@ function selectWarmupCandidate(
|
|||||||
const accounts = getAllCodexAccounts();
|
const accounts = getAllCodexAccounts();
|
||||||
if (accounts.length === 0) return { reason: 'no_accounts' };
|
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) {
|
for (const account of accounts) {
|
||||||
if (account.isRateLimited) continue;
|
if (account.isRateLimited) continue;
|
||||||
if (typeof account.cachedUsagePct !== 'number') continue;
|
if (typeof account.cachedUsagePct !== 'number') continue;
|
||||||
@@ -322,6 +346,7 @@ export async function runCodexWarmupCycle(
|
|||||||
const state = readWarmupState(statePath);
|
const state = readWarmupState(statePath);
|
||||||
const selected = selectWarmupCandidate(config, state, nowMs, {
|
const selected = selectWarmupCandidate(config, state, nowMs, {
|
||||||
ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow,
|
ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow,
|
||||||
|
forceAttempt: runtime.forceAttempt,
|
||||||
});
|
});
|
||||||
if ('reason' in selected) {
|
if ('reason' in selected) {
|
||||||
return { status: 'skipped', reason: selected.reason };
|
return { status: 'skipped', reason: selected.reason };
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('usage-primer', () => {
|
|||||||
maxUsagePct: 100,
|
maxUsagePct: 100,
|
||||||
maxD7UsagePct: 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,
|
maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT,
|
||||||
maxD7UsagePct: CODEX_PRIMER_MAX_D7_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,
|
// 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
|
// in which case this records `failed`. We deliberately do NOT try to
|
||||||
|
|||||||
Reference in New Issue
Block a user