fix(primer): refresh Codex usage before primer and allow 1% fresh accounts
Codex primer was skipping with no_eligible_accounts because it read a stale usage cache and required exactly 0% usage. Re-query usage right before the primer call and treat freshly-reset 0~1% accounts as eligible so the 5h window can be anchored at the fixed KST slot. Also hold new Codex-owner turns at fresh usage until the next primer slot. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
const { handleSessionCommandMock } = vi.hoisted(() => ({
|
const { getAllCodexAccountsMock, handleSessionCommandMock } = vi.hoisted(
|
||||||
handleSessionCommandMock: vi.fn(),
|
() => ({
|
||||||
|
getAllCodexAccountsMock: vi.fn(),
|
||||||
|
handleSessionCommandMock: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('./codex-token-rotation.js', () => ({
|
||||||
|
getAllCodexAccounts: getAllCodexAccountsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./session-commands.js', () => ({
|
vi.mock('./session-commands.js', () => ({
|
||||||
@@ -12,6 +19,7 @@ vi.mock('./session-commands.js', () => ({
|
|||||||
import {
|
import {
|
||||||
exhaustionChecks,
|
exhaustionChecks,
|
||||||
handleQueuedRunGates,
|
handleQueuedRunGates,
|
||||||
|
shouldHoldFreshCodexUntilPrimer,
|
||||||
} from './message-runtime-gating.js';
|
} from './message-runtime-gating.js';
|
||||||
|
|
||||||
describe('message-runtime-gating', () => {
|
describe('message-runtime-gating', () => {
|
||||||
@@ -38,10 +46,16 @@ describe('message-runtime-gating', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
handleSessionCommandMock.mockReset();
|
handleSessionCommandMock.mockReset();
|
||||||
sendMessage.mockReset();
|
sendMessage.mockReset();
|
||||||
|
getAllCodexAccountsMock.mockReset();
|
||||||
|
getAllCodexAccountsMock.mockReturnValue([]);
|
||||||
exhaustionChecks.claude = () => ({ exhausted: false, nextResetAt: null });
|
exhaustionChecks.claude = () => ({ exhausted: false, nextResetAt: null });
|
||||||
exhaustionChecks.codex = () => ({ exhausted: false, nextResetAt: null });
|
exhaustionChecks.codex = () => ({ exhausted: false, nextResetAt: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns session-command results directly when a command is handled', async () => {
|
it('returns session-command results directly when a command is handled', async () => {
|
||||||
handleSessionCommandMock.mockResolvedValue({
|
handleSessionCommandMock.mockResolvedValue({
|
||||||
handled: true,
|
handled: true,
|
||||||
@@ -126,4 +140,57 @@ describe('message-runtime-gating', () => {
|
|||||||
expect(result).toEqual({ handled: false });
|
expect(result).toEqual({ handled: false });
|
||||||
expect(sendMessage).not.toHaveBeenCalled();
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('holds a fresh Codex account until the next fixed primer slot', async () => {
|
||||||
|
getAllCodexAccountsMock.mockReturnValue([
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
accountId: 'codex-1',
|
||||||
|
planType: 'pro',
|
||||||
|
isActive: true,
|
||||||
|
isRateLimited: false,
|
||||||
|
cachedUsagePct: 1,
|
||||||
|
cachedUsageD7Pct: 22,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldHoldFreshCodexUntilPrimer(
|
||||||
|
new Date('2026-06-01T06:30:00.000Z').getTime(), // 15:30 KST
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldHoldFreshCodexUntilPrimer(
|
||||||
|
new Date('2026-06-01T09:00:30.000Z').getTime(), // 18:00:30 KST
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks fresh Codex turns before the next primer slot and notifies the channel', async () => {
|
||||||
|
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||||
|
getAllCodexAccountsMock.mockReturnValue([
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
accountId: 'codex-1',
|
||||||
|
planType: 'pro',
|
||||||
|
isActive: true,
|
||||||
|
isRateLimited: false,
|
||||||
|
cachedUsagePct: 1,
|
||||||
|
cachedUsageD7Pct: 22,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(
|
||||||
|
new Date('2026-06-01T06:30:00.000Z').getTime(), // 15:30 KST
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await handleQueuedRunGates({
|
||||||
|
...baseArgs,
|
||||||
|
group: { ...baseArgs.group, agentType: 'codex' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('18:00 KST'),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CODEX_EXHAUSTION_THRESHOLD_PCT,
|
CODEX_EXHAUSTION_THRESHOLD_PCT,
|
||||||
getCodexUsageExhaustion,
|
getCodexUsageExhaustion,
|
||||||
} from './codex-usage-collector.js';
|
} from './codex-usage-collector.js';
|
||||||
|
import { getAllCodexAccounts } from './codex-token-rotation.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
@@ -66,16 +67,65 @@ function exhaustionMessageFor(
|
|||||||
// is processed anyway — user explicitly tagged the bot, accept the drift.
|
// is processed anyway — user explicitly tagged the bot, accept the drift.
|
||||||
const GAP_START_HOUR_KST = 4;
|
const GAP_START_HOUR_KST = 4;
|
||||||
const GAP_END_HOUR_KST = 8;
|
const GAP_END_HOUR_KST = 8;
|
||||||
|
const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
||||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||||
|
const CODEX_ALIGNMENT_FRESH_USAGE_PCT = 1;
|
||||||
|
const PRIMER_GRACE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
export function isInUsageAlignmentGap(nowMs: number = Date.now()): boolean {
|
export function isInUsageAlignmentGap(nowMs: number = Date.now()): boolean {
|
||||||
const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours();
|
const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours();
|
||||||
return kstHour >= GAP_START_HOUR_KST && kstHour < GAP_END_HOUR_KST;
|
return kstHour >= GAP_START_HOUR_KST && kstHour < GAP_END_HOUR_KST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function msUntilNextPrimerSlotKST(nowMs: number): number {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrimerHourKST(nowMs: number): string {
|
||||||
|
const nextMs = nowMs + msUntilNextPrimerSlotKST(nowMs);
|
||||||
|
const kst = new Date(nextMs + KST_OFFSET_MS);
|
||||||
|
return `${String(kst.getUTCHours()).padStart(2, '0')}:00 KST`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHoldFreshCodexUntilPrimer(
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
const accounts = getAllCodexAccounts();
|
||||||
|
const active = accounts.find((a) => a.isActive) ?? accounts[0];
|
||||||
|
if (!active || active.isRateLimited) return false;
|
||||||
|
const h5 = active.cachedUsagePct;
|
||||||
|
if (h5 == null || h5 < 0 || h5 > CODEX_ALIGNMENT_FRESH_USAGE_PCT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedFromPreviousSlot =
|
||||||
|
(5 * 60 * 60 * 1000 - msUntilNextPrimerSlotKST(nowMs)) %
|
||||||
|
(5 * 60 * 60 * 1000);
|
||||||
|
return elapsedFromPreviousSlot > PRIMER_GRACE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
const GAP_AUTO_REPLY =
|
const GAP_AUTO_REPLY =
|
||||||
'사용량 정렬 시간(04~08 KST)입니다. 08시 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.';
|
'사용량 정렬 시간(04~08 KST)입니다. 08시 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.';
|
||||||
|
|
||||||
|
const CODEX_ALIGNMENT_AUTO_REPLY = (nowMs: number) =>
|
||||||
|
`Codex 사용량 정렬 대기 중입니다. 다음 프라이머 시각(${formatPrimerHourKST(
|
||||||
|
nowMs,
|
||||||
|
)}) 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.`;
|
||||||
|
|
||||||
export async function handleQueuedRunGates(args: {
|
export async function handleQueuedRunGates(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
@@ -125,11 +175,35 @@ export async function handleQueuedRunGates(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentType: AgentType = args.group.agentType ?? 'claude-code';
|
||||||
|
if (agentType === 'codex' && shouldHoldFreshCodexUntilPrimer()) {
|
||||||
|
const hasUrgentMention = args.missedMessages.some((m) =>
|
||||||
|
args.triggerPattern.test(m.content),
|
||||||
|
);
|
||||||
|
if (!hasUrgentMention) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid: args.chatJid,
|
||||||
|
groupName: args.group.name,
|
||||||
|
runId: args.runId,
|
||||||
|
},
|
||||||
|
'Blocking new Codex turn — waiting for fixed primer slot',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await args.sessionCommandDeps.sendMessage(
|
||||||
|
CODEX_ALIGNMENT_AUTO_REPLY(Date.now()),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'Failed to send Codex alignment notice');
|
||||||
|
}
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Usage-exhaustion gate ──
|
// ── Usage-exhaustion gate ──
|
||||||
// Block new turns when the agent provider that would handle this room has
|
// Block new turns when the agent provider that would handle this room has
|
||||||
// every account at ≥95% on either window (or rate-limited). In-flight
|
// every account at ≥95% on either window (or rate-limited). In-flight
|
||||||
// runs are not affected; this only stops *new* turns from starting.
|
// runs are not affected; this only stops *new* turns from starting.
|
||||||
const agentType: AgentType = args.group.agentType ?? 'claude-code';
|
|
||||||
const info =
|
const info =
|
||||||
agentType === 'codex'
|
agentType === 'codex'
|
||||||
? exhaustionChecks.codex()
|
? exhaustionChecks.codex()
|
||||||
|
|||||||
77
src/usage-primer.test.ts
Normal file
77
src/usage-primer.test.ts
Normal 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 allows 1% fresh usage', 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: 1,
|
||||||
|
maxD7UsagePct: 100,
|
||||||
|
}),
|
||||||
|
{ ignoreZeroUsageWindow: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,10 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { CODEX_WARMUP_CONFIG } from './config.js';
|
import { CODEX_WARMUP_CONFIG } from './config.js';
|
||||||
|
import {
|
||||||
|
refreshActiveCodexUsage,
|
||||||
|
refreshAllCodexAccountUsage,
|
||||||
|
} from './codex-usage-collector.js';
|
||||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { getAllTokens } from './token-rotation.js';
|
import { getAllTokens } from './token-rotation.js';
|
||||||
@@ -22,7 +26,10 @@ const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
|||||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||||
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
|
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
|
||||||
const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
||||||
const CODEX_PRIMER_MAX_USAGE_PCT = 0;
|
// Codex's rate-limit read can report a freshly reset, otherwise idle account
|
||||||
|
// as 1%. Treat that as still eligible so the real primer call can anchor the
|
||||||
|
// 5h inference window at the fixed slot.
|
||||||
|
const CODEX_PRIMER_MAX_USAGE_PCT = 1;
|
||||||
const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100;
|
const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100;
|
||||||
|
|
||||||
function resolveClaudeBinary(): string {
|
function resolveClaudeBinary(): string {
|
||||||
@@ -131,8 +138,10 @@ async function runClaudePrimerCycle(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCodexPrimerCycle(): Promise<void> {
|
export async function runCodexPrimerCycle(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await refreshAllCodexAccountUsage();
|
||||||
|
await refreshActiveCodexUsage();
|
||||||
const result = await runCodexWarmupCycle(
|
const result = await runCodexWarmupCycle(
|
||||||
{
|
{
|
||||||
...CODEX_WARMUP_CONFIG,
|
...CODEX_WARMUP_CONFIG,
|
||||||
|
|||||||
Reference in New Issue
Block a user