feat(primer): add usage-window alignment primer on GitHub-main base

Port the local primer subsystem onto the upstream base by intent:
- add src/usage-primer.ts (KST-slot primer firing Claude + Codex signals)
- extend codex-warmup with the ignoreZeroUsageWindow runtime option used
  by the primer so a slot is never skipped just because Codex is partway
  through its weekly window
- Codex primer fires unconditionally (maxUsagePct/maxD7UsagePct=100),
  mirroring the Claude primer, refreshing usage before selection
- wire startUsagePrimer() into the runtime bootstrap

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
claude-bot
2026-06-01 17:25:34 +09:00
parent 1ff6b3434f
commit 5b16bb6694
5 changed files with 373 additions and 6 deletions

View File

@@ -225,6 +225,71 @@ describe('Codex warm-up scheduler', () => {
expect(childProcess.spawn).not.toHaveBeenCalled();
});
it('lets the fixed-slot primer warm an account again when 5h usage is fresh but weekly usage is nonzero', 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-24T14: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: 'same-account-next-slot',
planType: 'pro',
isActive: true,
isRateLimited: false,
cachedUsagePct: 0,
cachedUsageD7Pct: 37,
resetAt: '2026-04-24T19:00:00.000Z',
resetD7At: '2026-05-01T09:00:00.000Z',
},
]);
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
(accountIndex = 0) => authPathFor(tempHome, accountIndex),
);
vi.mocked(childProcess.spawn).mockImplementation(
() => createFakeCodexProcess(0) as never,
);
const result = await runCodexWarmupCycle(
{
enabled: true,
prompt: 'Reply exactly OK. Do not run tools.',
model: 'gpt-5.5',
intervalMs: 300_000,
minIntervalMs: 18_000_000,
staggerMs: 0,
maxUsagePct: 0,
maxD7UsagePct: 100,
commandTimeoutMs: 120_000,
failureCooldownMs: 21_600_000,
maxConsecutiveFailures: 2,
},
{ nowMs: now, statePath, ignoreZeroUsageWindow: true },
);
expect(result).toEqual({ status: 'warmed', accountIndex: 0 });
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
expect(state.accounts['0'].lastWarmupAt).toBe('2026-04-24T14:00:00.000Z');
expect(state.accounts['0'].zeroUsageWarmupUntil).toBe(
'2026-04-24T14:00:00.000Z',
);
});
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 rotation = await import('./codex-token-rotation.js');

View File

@@ -33,6 +33,7 @@ interface CodexWarmupRuntimeOptions {
nowMs?: number;
statePath?: string;
shouldSkip?: () => boolean;
ignoreZeroUsageWindow?: boolean;
}
export type CodexWarmupCycleResult =
@@ -105,6 +106,7 @@ function selectWarmupCandidate(
config: CodexWarmupConfig,
state: CodexWarmupState,
nowMs: number,
options: { ignoreZeroUsageWindow?: boolean } = {},
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
const disabledUntilMs = parseTimestamp(state.disabledUntil);
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
@@ -134,7 +136,11 @@ function selectWarmupCandidate(
const zeroUsageWarmupUntilMs = parseTimestamp(
accountState?.zeroUsageWarmupUntil,
);
if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) {
if (
!options.ignoreZeroUsageWindow &&
zeroUsageWarmupUntilMs != null &&
zeroUsageWarmupUntilMs > nowMs
) {
continue;
}
@@ -144,10 +150,13 @@ function selectWarmupCandidate(
}
const resetD7Ms = parseTimestamp(account.resetD7At);
const zeroUsageWarmupUntilMsForState =
let zeroUsageWarmupUntilMsForState = nowMs;
if (!options.ignoreZeroUsageWindow) {
zeroUsageWarmupUntilMsForState =
resetD7Ms != null && resetD7Ms > nowMs
? resetD7Ms
: nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS;
}
return {
accountIndex: account.index,
@@ -254,7 +263,9 @@ export async function runCodexWarmupCycle(
const nowIso = new Date(nowMs).toISOString();
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
const state = readWarmupState(statePath);
const selected = selectWarmupCandidate(config, state, nowMs);
const selected = selectWarmupCandidate(config, state, nowMs, {
ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow,
});
if ('reason' in selected) {
return { status: 'skipped', reason: selected.reason };
}

View File

@@ -54,6 +54,7 @@ import { createMessageRuntime } from './message-runtime.js';
import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
import { startUnifiedDashboard } from './unified-dashboard.js';
import { startWebDashboardServer } from './web-dashboard-server.js';
import { startUsagePrimer } from './usage-primer.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
import { initCodexTokenRotation } from './codex-token-rotation.js';
@@ -575,6 +576,7 @@ async function main(): Promise<void> {
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
nudgeScheduler: nudgeSchedulerLoop,
});
startUsagePrimer();
leaseRecoveryTimer = setInterval(() => {
const failover = getGlobalFailoverInfo();

77
src/usage-primer.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
const callOrder: string[] = [];
const refreshAllCodexAccountUsage = vi.fn(async () => {
callOrder.push('refreshAll');
return { rows: [], fetchedAt: '2026-06-01T00:00:00.000Z' };
});
const refreshActiveCodexUsage = vi.fn(async () => {
callOrder.push('refreshActive');
return { rows: [], fetchedAt: '2026-06-01T00:00:01.000Z' };
});
const runCodexWarmupCycle = vi.fn(async () => {
callOrder.push('warmup');
return { status: 'warmed', accountIndex: 0 };
});
vi.mock('./config.js', () => ({
CODEX_WARMUP_CONFIG: {
enabled: false,
prompt: 'Reply exactly OK. Do not run tools.',
model: 'gpt-5.5',
intervalMs: 300_000,
minIntervalMs: 18_300_000,
staggerMs: 1_800_000,
maxUsagePct: 0,
maxD7UsagePct: 0,
commandTimeoutMs: 120_000,
failureCooldownMs: 21_600_000,
maxConsecutiveFailures: 2,
},
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./token-rotation.js', () => ({
getAllTokens: vi.fn(() => []),
}));
vi.mock('./codex-usage-collector.js', () => ({
refreshAllCodexAccountUsage,
refreshActiveCodexUsage,
}));
vi.mock('./codex-warmup.js', () => ({
runCodexWarmupCycle,
}));
describe('usage-primer', () => {
it('refreshes Codex usage before primer selection and fires regardless of usage level', async () => {
callOrder.length = 0;
refreshAllCodexAccountUsage.mockClear();
refreshActiveCodexUsage.mockClear();
runCodexWarmupCycle.mockClear();
const { runCodexPrimerCycle } = await import('./usage-primer.js');
await runCodexPrimerCycle();
expect(callOrder).toEqual(['refreshAll', 'refreshActive', 'warmup']);
expect(runCodexWarmupCycle).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
minIntervalMs: 18_000_000,
maxUsagePct: 100,
maxD7UsagePct: 100,
}),
{ ignoreZeroUsageWindow: true },
);
});
});

212
src/usage-primer.ts Normal file
View File

@@ -0,0 +1,212 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { CODEX_WARMUP_CONFIG } from './config.js';
import {
refreshActiveCodexUsage,
refreshAllCodexAccountUsage,
} from './codex-usage-collector.js';
import { runCodexWarmupCycle } from './codex-warmup.js';
import { logger } from './logger.js';
import { getAllTokens } from './token-rotation.js';
/**
* Usage-window alignment primer.
*
* Anthropic/OpenAI enforce 5h rolling usage windows that start on first
* inference call. We anchor windows to fixed KST times by firing a tiny
* primer call at 08, 13, 18, 23 KST (4 windows × 5h = 20h, leaving a
* 04:0008:00 gap blocked by message-runtime-gating).
*
* Daily reset point ends up being 08:00 KST (start of first slot after gap).
*/
const PRIMER_HOURS_KST = [8, 13, 18, 23];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000;
// Fire the Codex primer unconditionally at every slot, mirroring the Claude
// primer (which always fires unless the account is rate-limited). The 5h usage
// level no longer gates the call (maxUsagePct=100), so a slot is never skipped
// just because Codex happens to be partway through its window. The d7 gate
// stays at 100 so we still skip an account whose weekly quota is exhausted,
// which is the Codex analogue of Claude's rate-limit skip.
const CODEX_PRIMER_MAX_USAGE_PCT = 100;
const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100;
function resolveClaudeBinary(): string {
const candidates = [
path.resolve(
process.cwd(),
'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude',
),
path.resolve(
process.cwd(),
'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude',
),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return candidates[0];
}
function runClaudePrimerOnce(
token: string,
): Promise<{ ok: boolean; reason: string }> {
const binary = resolveClaudeBinary();
if (!fs.existsSync(binary)) {
return Promise.resolve({ ok: false, reason: 'binary_missing' });
}
const args = [
'-p',
'ok',
'--model',
'haiku',
'--output-format',
'text',
'--max-turns',
'1',
'--dangerously-skip-permissions',
];
return new Promise((resolve) => {
let done = false;
let stderr = '';
const finish = (result: { ok: boolean; reason: string }): void => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
try {
proc.kill();
} catch {
/* ignore */
}
finish({ ok: false, reason: 'timeout' });
}, CLAUDE_PRIMER_TIMEOUT_MS);
const proc = spawn(binary, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CLAUDE_CODE_OAUTH_TOKEN: token,
},
});
proc.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
if (stderr.length > 2000) stderr = stderr.slice(-2000);
});
proc.on('error', (err) => {
logger.warn({ err }, 'Claude primer spawn error');
finish({ ok: false, reason: 'spawn_error' });
});
proc.on('close', (code) => {
if (code === 0) {
finish({ ok: true, reason: 'ok' });
} else {
logger.warn(
{ exitCode: code, stderr: stderr.trim().slice(-500) || undefined },
'Claude primer command failed',
);
finish({ ok: false, reason: `exit_${code ?? 'unknown'}` });
}
});
});
}
async function runClaudePrimerCycle(): Promise<void> {
const tokens = getAllTokens();
const candidates = tokens.filter((t) => !t.isRateLimited);
if (candidates.length === 0) {
logger.info('Claude primer skipped: no eligible tokens');
return;
}
const target = candidates[0];
logger.info(
{ tokenIndex: target.index },
'Starting Claude primer (haiku, prompt=ok)',
);
const result = await runClaudePrimerOnce(target.token);
if (result.ok) {
logger.info({ tokenIndex: target.index }, 'Claude primer completed');
} else {
logger.warn(
{ tokenIndex: target.index, reason: result.reason },
'Claude primer failed',
);
}
}
export async function runCodexPrimerCycle(): Promise<void> {
try {
await refreshAllCodexAccountUsage();
await refreshActiveCodexUsage();
const result = await runCodexWarmupCycle(
{
...CODEX_WARMUP_CONFIG,
enabled: true,
minIntervalMs: CODEX_PRIMER_MIN_INTERVAL_MS,
maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT,
maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT,
},
{ ignoreZeroUsageWindow: true },
);
logger.info({ result }, 'Codex primer cycle finished');
} catch (err) {
logger.warn({ err }, 'Codex primer cycle threw');
}
}
async function runPrimerCycle(): Promise<void> {
logger.info('Running scheduled usage primer cycle');
await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]);
}
export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number {
// KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*.
const kstNowShifted = nowMs + KST_OFFSET_MS;
const kstDate = new Date(kstNowShifted);
const kstH = kstDate.getUTCHours();
let nextH = PRIMER_HOURS_KST.find((h) => h > kstH);
let dayOffset = 0;
if (nextH === undefined) {
nextH = PRIMER_HOURS_KST[0];
dayOffset = 1;
}
const target = new Date(kstNowShifted);
target.setUTCHours(nextH, 0, 0, 0);
if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1);
return target.getTime() - kstNowShifted;
}
let primerTimer: ReturnType<typeof setTimeout> | null = null;
export function startUsagePrimer(): void {
if (primerTimer) return;
const scheduleNext = (): void => {
const delay = msUntilNextPrimerSlotKST();
primerTimer = setTimeout(() => {
void runPrimerCycle().finally(scheduleNext);
}, delay);
const fireAt = new Date(Date.now() + delay).toISOString();
logger.info(
{ delayMinutes: Math.round(delay / 60_000), fireAt },
'Next usage primer scheduled',
);
};
scheduleNext();
}
export function stopUsagePrimer(): void {
if (primerTimer) {
clearTimeout(primerTimer);
primerTimer = null;
}
}