fix(primer): repair Claude binary path and re-anchor Codex window after reset
Two evidence-based fixes for the usage-window primer that aligns the 5h reset to fixed KST slots (08/13/18/23): 1. Claude primer always failed with binary_missing: it only looked under runners/agent-runner/node_modules, but the SDK platform binary is hoisted to the repo-root node_modules. Resolve both locations. 2. When a Codex slot lands while the 5h window is still active/exhausted, the warm-up call fails with a rate-limit and the window only re-anchors at its natural reset — leaving Codex un-primed until the next 5h slot. Parse the account's known reset time and schedule a one-shot retry right after it so the next window anchors close to the slot. Also corrected a stale comment claiming a 04:00–08:00 gap is enforced by message-runtime-gating (it is not). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,13 @@ const refreshActiveCodexUsage = vi.fn(async () => {
|
||||
});
|
||||
const runCodexWarmupCycle = vi.fn(async () => {
|
||||
callOrder.push('warmup');
|
||||
return { status: 'warmed', accountIndex: 0 };
|
||||
return { status: 'warmed', accountIndex: 0 } as {
|
||||
status: string;
|
||||
accountIndex?: number;
|
||||
reason?: string;
|
||||
};
|
||||
});
|
||||
const getAllCodexAccounts = vi.fn<() => Array<{ resetAt?: string }>>(() => []);
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
CODEX_WARMUP_CONFIG: {
|
||||
@@ -53,6 +58,10 @@ vi.mock('./codex-warmup.js', () => ({
|
||||
runCodexWarmupCycle,
|
||||
}));
|
||||
|
||||
vi.mock('./codex-token-rotation.js', () => ({
|
||||
getAllCodexAccounts,
|
||||
}));
|
||||
|
||||
describe('usage-primer', () => {
|
||||
it('refreshes Codex usage before primer selection and fires regardless of usage level', async () => {
|
||||
callOrder.length = 0;
|
||||
@@ -74,4 +83,31 @@ describe('usage-primer', () => {
|
||||
{ ignoreZeroUsageWindow: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('reschedules a retry at the window reset when the slot is rate-limited', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-09T00:00:00.000Z'));
|
||||
runCodexWarmupCycle.mockClear();
|
||||
// First slot: the 5h window is still active/exhausted → command fails.
|
||||
runCodexWarmupCycle.mockResolvedValueOnce({
|
||||
status: 'failed',
|
||||
accountIndex: 0,
|
||||
reason: 'exit_1',
|
||||
});
|
||||
// The account's window resets 60s from now.
|
||||
getAllCodexAccounts.mockReturnValue([
|
||||
{ resetAt: '2026-06-09T00:01:00.000Z' },
|
||||
]);
|
||||
|
||||
const { runCodexPrimerCycle } = await import('./usage-primer.js');
|
||||
await runCodexPrimerCycle();
|
||||
expect(runCodexWarmupCycle).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry is scheduled at reset (+60s) plus a 30s buffer = +90s.
|
||||
await vi.advanceTimersByTimeAsync(91_000);
|
||||
expect(runCodexWarmupCycle).toHaveBeenCalledTimes(2);
|
||||
|
||||
getAllCodexAccounts.mockReturnValue([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,18 +8,24 @@ import {
|
||||
refreshAllCodexAccountUsage,
|
||||
} from './codex-usage-collector.js';
|
||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||
import { getAllCodexAccounts } from './codex-token-rotation.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).
|
||||
* Anthropic/OpenAI enforce 5h rolling usage windows that start on the first
|
||||
* inference call after the previous window expires and reset ~5h later. 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 quiet gap with no
|
||||
* scheduled primer so 08:00 can re-anchor the day).
|
||||
*
|
||||
* Daily reset point ends up being 08:00 KST (start of first slot after gap).
|
||||
* Daily reset point ends up being 08:00 KST (start of first slot after gap),
|
||||
* provided no off-slot Codex usage (e.g. a paired-room reviewer) re-anchors
|
||||
* the window inside the gap. When a slot lands while the window is still
|
||||
* active/exhausted, runCodexPrimerCycle schedules a one-shot retry right after
|
||||
* the reported reset so the next window still anchors close to the slot.
|
||||
*/
|
||||
|
||||
const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
||||
@@ -36,16 +42,21 @@ 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',
|
||||
),
|
||||
const subPaths = [
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64/claude',
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude',
|
||||
];
|
||||
// The SDK's platform package is usually hoisted to the repo-root node_modules
|
||||
// (the bot runs `bun src/index.ts` from the repo root), but can also live
|
||||
// under the agent-runner workspace when deps are not hoisted. Check both so
|
||||
// the Claude primer doesn't silently fail with binary_missing.
|
||||
const roots = [
|
||||
path.resolve(process.cwd(), 'node_modules'),
|
||||
path.resolve(process.cwd(), 'runners/agent-runner/node_modules'),
|
||||
];
|
||||
const candidates = roots.flatMap((root) =>
|
||||
subPaths.map((sub) => path.join(root, sub)),
|
||||
);
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
@@ -141,7 +152,53 @@ async function runClaudePrimerCycle(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCodexPrimerCycle(): Promise<void> {
|
||||
// When a primer slot lands while the 5h window is still active (exhausted /
|
||||
// rate-limited), the warm-up call fails and the window only re-anchors at its
|
||||
// natural reset. Rather than leaving Codex un-primed until the next 5h slot,
|
||||
// we schedule a single retry right after the reported window reset so the next
|
||||
// window is anchored as close to the reset boundary as possible.
|
||||
const CODEX_RETRY_BUFFER_MS = 30_000;
|
||||
const CODEX_RETRY_MAX_DELAY_MS = 5.5 * 60 * 60 * 1000;
|
||||
let codexResetRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function soonestCodexResetMs(nowMs: number): number | null {
|
||||
let soonest: number | null = null;
|
||||
for (const account of getAllCodexAccounts()) {
|
||||
const resetMs = account.resetAt ? Date.parse(account.resetAt) : NaN;
|
||||
if (Number.isFinite(resetMs) && resetMs > nowMs) {
|
||||
if (soonest === null || resetMs < soonest) soonest = resetMs;
|
||||
}
|
||||
}
|
||||
return soonest;
|
||||
}
|
||||
|
||||
function scheduleCodexResetRetry(): void {
|
||||
if (codexResetRetryTimer) return; // at most one outstanding retry
|
||||
const nowMs = Date.now();
|
||||
const resetMs = soonestCodexResetMs(nowMs);
|
||||
if (resetMs === null) {
|
||||
logger.info('Codex primer retry not scheduled: no known window reset time');
|
||||
return;
|
||||
}
|
||||
const delay = resetMs - nowMs + CODEX_RETRY_BUFFER_MS;
|
||||
if (delay <= 0 || delay > CODEX_RETRY_MAX_DELAY_MS) {
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
{ fireAt: new Date(nowMs + delay).toISOString() },
|
||||
'Scheduling Codex primer retry at window reset',
|
||||
);
|
||||
codexResetRetryTimer = setTimeout(() => {
|
||||
codexResetRetryTimer = null;
|
||||
void runCodexPrimerCycle({ allowRetry: false });
|
||||
}, delay);
|
||||
codexResetRetryTimer.unref?.();
|
||||
}
|
||||
|
||||
export async function runCodexPrimerCycle(
|
||||
options: { allowRetry?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { allowRetry = true } = options;
|
||||
try {
|
||||
await refreshAllCodexAccountUsage();
|
||||
await refreshActiveCodexUsage();
|
||||
@@ -156,6 +213,17 @@ export async function runCodexPrimerCycle(): Promise<void> {
|
||||
{ ignoreZeroUsageWindow: true },
|
||||
);
|
||||
logger.info({ result }, 'Codex primer cycle finished');
|
||||
// The window was still active at this slot (exhausted or all accounts
|
||||
// rate-limited). Re-anchor right after the window resets instead of
|
||||
// waiting for the next 5h slot.
|
||||
const windowBlocked =
|
||||
result.status === 'failed' ||
|
||||
(result.status === 'skipped' &&
|
||||
(result.reason === 'no_eligible_accounts' ||
|
||||
result.reason === 'disabled_cooldown'));
|
||||
if (allowRetry && windowBlocked) {
|
||||
scheduleCodexResetRetry();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Codex primer cycle threw');
|
||||
}
|
||||
@@ -209,4 +277,8 @@ export function stopUsagePrimer(): void {
|
||||
clearTimeout(primerTimer);
|
||||
primerTimer = null;
|
||||
}
|
||||
if (codexResetRetryTimer) {
|
||||
clearTimeout(codexResetRetryTimer);
|
||||
codexResetRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user