diff --git a/src/message-runtime-gating.test.ts b/src/message-runtime-gating.test.ts index f6acd03..63418bb 100644 --- a/src/message-runtime-gating.test.ts +++ b/src/message-runtime-gating.test.ts @@ -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'; -const { handleSessionCommandMock } = vi.hoisted(() => ({ - handleSessionCommandMock: vi.fn(), +const { getAllCodexAccountsMock, handleSessionCommandMock } = vi.hoisted( + () => ({ + getAllCodexAccountsMock: vi.fn(), + handleSessionCommandMock: vi.fn(), + }), +); + +vi.mock('./codex-token-rotation.js', () => ({ + getAllCodexAccounts: getAllCodexAccountsMock, })); vi.mock('./session-commands.js', () => ({ @@ -12,6 +19,7 @@ vi.mock('./session-commands.js', () => ({ import { exhaustionChecks, handleQueuedRunGates, + shouldHoldFreshCodexUntilPrimer, } from './message-runtime-gating.js'; describe('message-runtime-gating', () => { @@ -38,10 +46,16 @@ describe('message-runtime-gating', () => { beforeEach(() => { handleSessionCommandMock.mockReset(); sendMessage.mockReset(); + getAllCodexAccountsMock.mockReset(); + getAllCodexAccountsMock.mockReturnValue([]); exhaustionChecks.claude = () => ({ 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 () => { handleSessionCommandMock.mockResolvedValue({ handled: true, @@ -126,4 +140,57 @@ describe('message-runtime-gating', () => { expect(result).toEqual({ handled: false }); 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'), + ); + }); }); diff --git a/src/message-runtime-gating.ts b/src/message-runtime-gating.ts index f023a6d..dfe5ba3 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -6,6 +6,7 @@ import { CODEX_EXHAUSTION_THRESHOLD_PCT, getCodexUsageExhaustion, } from './codex-usage-collector.js'; +import { getAllCodexAccounts } from './codex-token-rotation.js'; import { logger } from './logger.js'; import { handleSessionCommand, @@ -66,16 +67,65 @@ function exhaustionMessageFor( // is processed anyway — user explicitly tagged the bot, accept the drift. const GAP_START_HOUR_KST = 4; const GAP_END_HOUR_KST = 8; +const PRIMER_HOURS_KST = [8, 13, 18, 23]; 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 { const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours(); 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 = '사용량 정렬 시간(04~08 KST)입니다. 08시 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.'; +const CODEX_ALIGNMENT_AUTO_REPLY = (nowMs: number) => + `Codex 사용량 정렬 대기 중입니다. 다음 프라이머 시각(${formatPrimerHourKST( + nowMs, + )}) 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.`; + export async function handleQueuedRunGates(args: { chatJid: string; 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 ── // 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 // runs are not affected; this only stops *new* turns from starting. - const agentType: AgentType = args.group.agentType ?? 'claude-code'; const info = agentType === 'codex' ? exhaustionChecks.codex() diff --git a/src/usage-primer.test.ts b/src/usage-primer.test.ts new file mode 100644 index 0000000..7ccb686 --- /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 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 }, + ); + }); +}); diff --git a/src/usage-primer.ts b/src/usage-primer.ts index eed2e63..97e7a0c 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -3,6 +3,10 @@ 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'; @@ -22,7 +26,10 @@ 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; +// 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; function resolveClaudeBinary(): string { @@ -131,8 +138,10 @@ async function runClaudePrimerCycle(): Promise { } } -async function runCodexPrimerCycle(): Promise { +export async function runCodexPrimerCycle(): Promise { try { + await refreshAllCodexAccountUsage(); + await refreshActiveCodexUsage(); const result = await runCodexWarmupCycle( { ...CODEX_WARMUP_CONFIG,