201 lines
5.6 KiB
TypeScript
201 lines
5.6 KiB
TypeScript
import { spawn } from 'child_process';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
|
||
import { CODEX_WARMUP_CONFIG } from './config.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;
|
||
const CODEX_PRIMER_MAX_USAGE_PCT = 0;
|
||
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',
|
||
);
|
||
}
|
||
}
|
||
|
||
async function runCodexPrimerCycle(): Promise<void> {
|
||
try {
|
||
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;
|
||
}
|
||
}
|