From 5b16bb66944a4cd684400cfc5bd06acfe9f1f224 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:25:34 +0900 Subject: [PATCH] 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 --- src/codex-warmup.test.ts | 65 ++++++++++++ src/codex-warmup.ts | 23 +++-- src/index.ts | 2 + src/usage-primer.test.ts | 77 ++++++++++++++ src/usage-primer.ts | 212 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 src/usage-primer.test.ts create mode 100644 src/usage-primer.ts diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index b0c4b0c..13e4a90 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -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'); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index a70b58a..5c2ba11 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -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 = - resetD7Ms != null && resetD7Ms > nowMs - ? resetD7Ms - : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + 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 }; } diff --git a/src/index.ts b/src/index.ts index af887ff..47282a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)), nudgeScheduler: nudgeSchedulerLoop, }); + startUsagePrimer(); leaseRecoveryTimer = setInterval(() => { const failover = getGlobalFailoverInfo(); diff --git a/src/usage-primer.test.ts b/src/usage-primer.test.ts new file mode 100644 index 0000000..18e649b --- /dev/null +++ b/src/usage-primer.test.ts @@ -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 }, + ); + }); +}); diff --git a/src/usage-primer.ts b/src/usage-primer.ts new file mode 100644 index 0000000..fad904a --- /dev/null +++ b/src/usage-primer.ts @@ -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:00–08: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 { + 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 { + 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 { + 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 | 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; + } +}