fix: avoid repeated zero-usage Codex warm-ups
This commit is contained in:
@@ -106,6 +106,8 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
isRateLimited: false,
|
isRateLimited: false,
|
||||||
cachedUsagePct: 0,
|
cachedUsagePct: 0,
|
||||||
cachedUsageD7Pct: 0,
|
cachedUsageD7Pct: 0,
|
||||||
|
resetAt: '2026-04-24T14:00:00.000Z',
|
||||||
|
resetD7At: '2026-05-01T09:00:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
|
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
|
||||||
@@ -160,9 +162,69 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
expect(state.lastWarmupAt).toBe('2026-04-24T09:00:00.000Z');
|
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'].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);
|
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 () => {
|
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 childProcess = await import('child_process');
|
||||||
const rotation = await import('./codex-token-rotation.js');
|
const rotation = await import('./codex-token-rotation.js');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface CodexWarmupAccountState {
|
|||||||
lastWarmupAt?: string;
|
lastWarmupAt?: string;
|
||||||
lastAttemptAt?: string;
|
lastAttemptAt?: string;
|
||||||
lastErrorAt?: string;
|
lastErrorAt?: string;
|
||||||
|
zeroUsageWarmupUntil?: string;
|
||||||
failures?: number;
|
failures?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export type CodexWarmupCycleResult =
|
|||||||
| { status: 'failed'; accountIndex: number; reason: string };
|
| { status: 'failed'; accountIndex: number; reason: string };
|
||||||
|
|
||||||
const DEFAULT_STATE_FILE = path.join(DATA_DIR, 'codex-warmup-state.json');
|
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 {
|
function parseTimestamp(value?: string): number | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -103,7 +105,7 @@ function selectWarmupCandidate(
|
|||||||
config: CodexWarmupConfig,
|
config: CodexWarmupConfig,
|
||||||
state: CodexWarmupState,
|
state: CodexWarmupState,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
): { accountIndex: number } | { reason: string } {
|
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
||||||
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
||||||
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
|
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
|
||||||
return { reason: 'disabled_cooldown' };
|
return { reason: 'disabled_cooldown' };
|
||||||
@@ -129,12 +131,30 @@ function selectWarmupCandidate(
|
|||||||
if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue;
|
if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue;
|
||||||
|
|
||||||
const accountState = state.accounts?.[String(account.index)];
|
const accountState = state.accounts?.[String(account.index)];
|
||||||
|
const zeroUsageWarmupUntilMs = parseTimestamp(
|
||||||
|
accountState?.zeroUsageWarmupUntil,
|
||||||
|
);
|
||||||
|
if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt);
|
const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt);
|
||||||
if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) {
|
if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) {
|
||||||
continue;
|
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' };
|
return { reason: 'no_eligible_accounts' };
|
||||||
@@ -259,6 +279,7 @@ export async function runCodexWarmupCycle(
|
|||||||
state.consecutiveFailures = 0;
|
state.consecutiveFailures = 0;
|
||||||
delete state.disabledUntil;
|
delete state.disabledUntil;
|
||||||
accountState.lastWarmupAt = nowIso;
|
accountState.lastWarmupAt = nowIso;
|
||||||
|
accountState.zeroUsageWarmupUntil = selected.zeroUsageWarmupUntil;
|
||||||
accountState.failures = 0;
|
accountState.failures = 0;
|
||||||
writeWarmupState(statePath, state);
|
writeWarmupState(statePath, state);
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user