diff --git a/src/codex-primer-alignment.test.ts b/src/codex-primer-alignment.test.ts index 58a5152..59dc90e 100644 --- a/src/codex-primer-alignment.test.ts +++ b/src/codex-primer-alignment.test.ts @@ -1,33 +1,48 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; +import { + ALIGNMENT_NOTICE_KEY, + beginPrimerAnchor, + endPrimerAnchor, + isAlignmentSlot, + isPrimerAnchoring, + shouldHoldCodexForPrimerAlignment, +} from './codex-primer-alignment.js'; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; -/** Build an epoch-ms value that reads as the given KST wall-clock hour/min. */ -function atKst(hour: number, minute = 0): number { - // shouldHold reads `new Date(ms + KST_OFFSET_MS).getUTCHours()`, so picking - // a UTC instant equal to the desired KST clock and subtracting the offset - // reproduces that KST hour exactly. - const kstShifted = Date.UTC(2026, 5, 10, hour, minute, 0); +/** Build an epoch-ms value that reads as the given KST wall-clock time. */ +function atKst(hour: number, minute = 0, second = 0): number { + // shouldHold reads KST via `new Date(ms + KST_OFFSET_MS)`, so picking a UTC + // instant equal to the desired KST clock and subtracting the offset + // reproduces that KST time exactly. + const kstShifted = Date.UTC(2026, 5, 10, hour, minute, second); return kstShifted - KST_OFFSET_MS; } -describe('shouldHoldCodexForPrimerAlignment', () => { +afterEach(() => { + // The anchor flag is module-global; never leak it across tests. + endPrimerAnchor(); +}); + +describe('shouldHoldCodexForPrimerAlignment — dawn window', () => { it('holds across the full 03:00–08:00 KST dawn window', () => { expect(shouldHoldCodexForPrimerAlignment(atKst(3, 0))).toBe(true); expect(shouldHoldCodexForPrimerAlignment(atKst(5, 30))).toBe(true); - expect(shouldHoldCodexForPrimerAlignment(atKst(7, 59))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(7, 59, 59))).toBe(true); }); - it('does not hold at/after the 08:00 primer slot', () => { - // 08:00 is the primer slot itself — it must run, not be held. - expect(shouldHoldCodexForPrimerAlignment(atKst(8, 0))).toBe(false); - expect(shouldHoldCodexForPrimerAlignment(atKst(8, 1))).toBe(false); + it('keeps holding through the post-08:00 grace, then releases', () => { + // Grace bridges the gap between the 08:00 boundary lapsing and the primer + // setting its anchor flag, so a consumer poll at 08:00:00 cannot slip in. + expect(shouldHoldCodexForPrimerAlignment(atKst(8, 0, 0))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(8, 2, 59))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(8, 3, 1))).toBe(false); + expect(shouldHoldCodexForPrimerAlignment(atKst(9, 0))).toBe(false); }); it('does not hold before the dawn window', () => { - expect(shouldHoldCodexForPrimerAlignment(atKst(2, 59))).toBe(false); + expect(shouldHoldCodexForPrimerAlignment(atKst(2, 59, 59))).toBe(false); expect(shouldHoldCodexForPrimerAlignment(atKst(0, 0))).toBe(false); }); @@ -37,3 +52,39 @@ describe('shouldHoldCodexForPrimerAlignment', () => { } }); }); + +describe('shouldHoldCodexForPrimerAlignment — anchor lock', () => { + it('holds while the primer is anchoring, even outside the dawn window', () => { + // Closes the 08:00 race: once the primer begins, all other Codex consumers + // are held until it finishes — regardless of the clock. + const noon = atKst(13, 0); + expect(shouldHoldCodexForPrimerAlignment(noon)).toBe(false); + beginPrimerAnchor(); + expect(isPrimerAnchoring()).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(noon)).toBe(true); + endPrimerAnchor(); + expect(isPrimerAnchoring()).toBe(false); + expect(shouldHoldCodexForPrimerAlignment(noon)).toBe(false); + }); +}); + +describe('isAlignmentSlot', () => { + it('is true only at the 08:00 KST hour', () => { + expect(isAlignmentSlot(atKst(8, 0))).toBe(true); + expect(isAlignmentSlot(atKst(8, 59))).toBe(true); + expect(isAlignmentSlot(atKst(7, 59))).toBe(false); + expect(isAlignmentSlot(atKst(13, 0))).toBe(false); + }); +}); + +describe('ALIGNMENT_NOTICE_KEY', () => { + it('is stable within a KST day and changes across days', () => { + expect(ALIGNMENT_NOTICE_KEY(atKst(3, 0))).toBe( + ALIGNMENT_NOTICE_KEY(atKst(7, 59)), + ); + const dayTwo = atKst(3, 0) + 24 * 60 * 60 * 1000; + expect(ALIGNMENT_NOTICE_KEY(dayTwo)).not.toBe( + ALIGNMENT_NOTICE_KEY(atKst(3, 0)), + ); + }); +}); diff --git a/src/codex-primer-alignment.ts b/src/codex-primer-alignment.ts index 5621166..6acc27c 100644 --- a/src/codex-primer-alignment.ts +++ b/src/codex-primer-alignment.ts @@ -1,42 +1,105 @@ /** - * Codex usage-primer alignment — narrow pre-08:00 quiet window. + * Codex usage-primer alignment — narrow pre-08:00 quiet window + anchor lock. * * The Codex 5h usage limit is a FIXED window: it opens on the first Codex * request after the previous window expires and resets exactly 5h later - * (verified empirically — usedPercent climbs while resetsAt stays put). To pin - * the daily reset to 13:00 KST, the 08:00 KST primer must be the *first* Codex + * (verified live — usedPercent climbs while resetsAt stays put). To pin the + * daily reset to 13:00 KST, the 08:00 KST primer must be the *first* Codex * request of a fresh window. Codex is otherwise used almost continuously - * (reviewers/arbiters in every paired room, codex-owner rooms, warm-up), so - * 08:00 normally lands mid-window and the reset drifts (e.g. observed 15:47). + * (reviewers/arbiters every paired room, codex-owner rooms, warm-up), so 08:00 + * normally lands mid-window and the reset drifts (observed 15:47). * - * Fix: hold all non-primer Codex execution during the 5 hours before the 08:00 - * slot (03:00–08:00 KST). One Codex window is at most 5h, so whatever opened - * before 03:00 has expired by 08:00 — leaving the 08:00 primer to anchor a clean - * window → 13:00 reset. This is the narrow, single-slot version chosen by the - * user: ONLY the pre-08:00 dawn window holds; the other slots (13/18/23) and all - * daytime hours pass through untouched. + * Two mechanisms, both time-based / in-process (no I/O, no reliance on the + * unreliable live usage figures): * - * Time-based and I/O-free so every Codex path (gating, queue, warm-up) can share - * it without import cycles or depending on the unreliable live usage figures. + * 1. Dawn quiet window [03:00, 08:00) KST. One Codex window is at most 5h, so + * anything opened before 03:00 has expired by 08:00 — held requests can't + * keep a stray window alive into the slot. + * + * 2. Anchor lock. The 08:00 primer sets an in-process flag for the duration of + * its own Codex call (beginPrimerAnchor/endPrimerAnchor). Held consumers + * stay held while the flag is set, so the primer wins the first-request race + * even though the dawn window's clock boundary lapses at exactly 08:00. A + * short post-slot grace bridges the few ms between the 08:00 boundary lapse + * and the primer firing, so a consumer poll in that gap can't slip in first. + * + * Narrow by design (the broad all-slots version was previously reverted): only + * the 08:00 alignment slot holds; the other slots (13/18/23) and all daytime + * hours pass through untouched. */ const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; -// Quiet window before the 08:00 KST primer slot. 5h = one full Codex window, so -// any window opened before 03:00 is guaranteed expired by 08:00. -export const DAWN_QUIET_START_HOUR_KST = 3; -export const DAWN_QUIET_END_HOUR_KST = 8; +// Dawn quiet window before the 08:00 KST primer slot. 5h = one full Codex +// window, so any window opened before 03:00 is guaranteed expired by 08:00. +export const DAWN_QUIET_START_MS = 3 * HOUR_MS; +export const DAWN_QUIET_END_MS = 8 * HOUR_MS; + +// Grace after the 08:00 boundary during which consumers stay held even though +// the dawn window technically lapsed — covers the gap between the boundary and +// the primer setting its anchor flag (incl. event-loop lag at the slot). +export const PRIMER_SLOT_GRACE_MS = 3 * 60 * 1000; + +/** The KST clock hour the primer aligns to (08:00). */ +export const ALIGNMENT_SLOT_HOUR_KST = 8; + +let primerAnchorActive = false; + +/** Mark the 08:00 alignment primer as in-flight so consumers stay held. */ +export function beginPrimerAnchor(): void { + primerAnchorActive = true; +} + +/** Clear the anchor flag once the alignment primer's Codex call has finished. */ +export function endPrimerAnchor(): void { + primerAnchorActive = false; +} + +/** Whether the 08:00 alignment primer is currently anchoring the window. */ +export function isPrimerAnchoring(): boolean { + return primerAnchorActive; +} + +/** Milliseconds since KST midnight for the given instant. */ +function kstMsOfDay(nowMs: number): number { + const shifted = new Date(nowMs + KST_OFFSET_MS); + return ( + ((shifted.getUTCHours() * 60 + shifted.getUTCMinutes()) * 60 + + shifted.getUTCSeconds()) * + 1000 + + shifted.getUTCMilliseconds() + ); +} + +/** Whether the given instant is the 08:00 KST alignment slot (hour granularity). */ +export function isAlignmentSlot(nowMs: number = Date.now()): boolean { + return ( + new Date(nowMs + KST_OFFSET_MS).getUTCHours() === ALIGNMENT_SLOT_HOUR_KST + ); +} + +/** + * A stable key identifying the current dawn hold window (its KST calendar date), + * used to dedupe the "please wait" notice to at most once per room per window. + */ +export function ALIGNMENT_NOTICE_KEY(nowMs: number = Date.now()): string { + return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10); +} /** * Whether non-primer Codex execution should be held right now so the 08:00 - * fixed-slot primer is the window-anchoring first request. Holds only during the - * 03:00–08:00 KST dawn window; every other hour returns false. + * fixed-slot primer is the window-anchoring first request. Holds while the + * alignment primer is anchoring, and during [03:00, 08:00 + grace) KST; every + * other hour returns false. */ export function shouldHoldCodexForPrimerAlignment( nowMs: number = Date.now(), ): boolean { - const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours(); + if (primerAnchorActive) return true; + const msOfDay = kstMsOfDay(nowMs); return ( - kstHour >= DAWN_QUIET_START_HOUR_KST && kstHour < DAWN_QUIET_END_HOUR_KST + msOfDay >= DAWN_QUIET_START_MS && + msOfDay < DAWN_QUIET_END_MS + PRIMER_SLOT_GRACE_MS ); } diff --git a/src/message-runtime-gating.test.ts b/src/message-runtime-gating.test.ts index 3768541..95f71ca 100644 --- a/src/message-runtime-gating.test.ts +++ b/src/message-runtime-gating.test.ts @@ -1,19 +1,33 @@ -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 { handleSessionCommandMock, getEffectiveChannelLeaseMock } = vi.hoisted( + () => ({ + handleSessionCommandMock: vi.fn(), + getEffectiveChannelLeaseMock: vi.fn(), + }), +); vi.mock('./session-commands.js', () => ({ handleSessionCommand: handleSessionCommandMock, })); +vi.mock('./service-routing.js', () => ({ + getEffectiveChannelLease: getEffectiveChannelLeaseMock, +})); + +import { + beginPrimerAnchor, + endPrimerAnchor, +} from './codex-primer-alignment.js'; import { handleQueuedRunGates } from './message-runtime-gating.js'; -describe('message-runtime-gating', () => { - const baseArgs = { - chatJid: 'room-1', +function makeArgs(overrides: { + chatJid?: string; + sendMessage?: () => Promise; +}) { + return { + chatJid: overrides.chatJid ?? 'room-1', group: { folder: 'room-folder', name: 'room', @@ -26,11 +40,25 @@ describe('message-runtime-gating', () => { triggerPattern: /^코덱스/, timezone: 'Asia/Seoul', hasImplicitContinuationWindow: () => false, - sessionCommandDeps: {} as never, + sessionCommandDeps: { + sendMessage: overrides.sendMessage ?? (async () => {}), + } as never, }; +} +describe('message-runtime-gating', () => { beforeEach(() => { handleSessionCommandMock.mockReset(); + getEffectiveChannelLeaseMock.mockReset(); + // Default: claude owner, no failover → no alignment hold. + getEffectiveChannelLeaseMock.mockReturnValue({ + owner_agent_type: 'claude-code', + owner_failover_active: false, + }); + }); + + afterEach(() => { + endPrimerAnchor(); }); it('returns session-command results directly when a command is handled', async () => { @@ -39,16 +67,54 @@ describe('message-runtime-gating', () => { success: false, }); - const result = await handleQueuedRunGates(baseArgs); + const result = await handleQueuedRunGates(makeArgs({})); expect(result).toEqual({ handled: true, success: false }); }); - it('falls through when no session command is handled', async () => { + it('falls through when no command and the room owner is not on Codex', async () => { handleSessionCommandMock.mockResolvedValue({ handled: false }); + beginPrimerAnchor(); // force the alignment window on, isolate provider check - const result = await handleQueuedRunGates(baseArgs); + const result = await handleQueuedRunGates(makeArgs({})); expect(result).toEqual({ handled: false }); }); + + it('holds a codex-owner turn during the alignment window and notifies once', async () => { + handleSessionCommandMock.mockResolvedValue({ handled: false }); + getEffectiveChannelLeaseMock.mockReturnValue({ + owner_agent_type: 'codex', + owner_failover_active: false, + }); + beginPrimerAnchor(); + const sendMessage = vi.fn(async () => {}); + + const first = await handleQueuedRunGates( + makeArgs({ chatJid: 'room-codex', sendMessage }), + ); + const second = await handleQueuedRunGates( + makeArgs({ chatJid: 'room-codex', sendMessage }), + ); + + expect(first).toEqual({ handled: true, success: true }); + expect(second).toEqual({ handled: true, success: true }); + // Notice deduped: sent once across repeated holds in the same window. + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + it('holds a claude-owner room when global failover routes the owner to Codex', async () => { + handleSessionCommandMock.mockResolvedValue({ handled: false }); + getEffectiveChannelLeaseMock.mockReturnValue({ + owner_agent_type: 'claude-code', + owner_failover_active: true, + }); + beginPrimerAnchor(); + + const result = await handleQueuedRunGates( + makeArgs({ chatJid: 'room-failover' }), + ); + + expect(result).toEqual({ handled: true, success: true }); + }); }); diff --git a/src/message-runtime-gating.ts b/src/message-runtime-gating.ts index 29555fd..5a2f56f 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -1,14 +1,29 @@ -import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; +import { + ALIGNMENT_NOTICE_KEY, + shouldHoldCodexForPrimerAlignment, +} from './codex-primer-alignment.js'; import { logger } from './logger.js'; +import { getEffectiveChannelLease } from './service-routing.js'; import { handleSessionCommand, type SessionCommandDeps, } from './session-commands.js'; -import type { AgentType, NewMessage, RegisteredGroup } from './types.js'; +import type { NewMessage, RegisteredGroup } from './types.js'; const CODEX_ALIGNMENT_AUTO_REPLY = '코덱스 5시간 한도 리셋을 매일 오후 1시에 고정하기 위해, 새벽 03~08시 구간은 정렬용으로 잠시 대기합니다. 08시 이후 다시 보내주세요.'; +// Per-room dedupe so a held owner room gets the wait notice at most once per +// dawn hold window, instead of on every re-poll while it stays held. +const lastAlignmentNoticeKeyByChat = new Map(); + +function shouldSendAlignmentNotice(chatJid: string): boolean { + const key = ALIGNMENT_NOTICE_KEY(); + if (lastAlignmentNoticeKeyByChat.get(chatJid) === key) return false; + lastAlignmentNoticeKeyByChat.set(chatJid, key); + return true; +} + export async function handleQueuedRunGates(args: { chatJid: string; group: RegisteredGroup; @@ -37,12 +52,17 @@ export async function handleQueuedRunGates(args: { // ── Codex primer-alignment hold (codex-owner rooms) ── // Reviewer/arbiter Codex turns are held at their own dispatch site; here we - // cover rooms whose *owner* runs on Codex, so a fresh human turn doesn't - // anchor the shared window before the 08:00 fixed-slot primer. Keyed on the - // room's configured owner agent type. Only fires inside the 03:00–08:00 KST - // dawn window, so daytime turns are never delayed. - const ownerAgentType: AgentType = args.group.agentType ?? 'claude-code'; - if (ownerAgentType === 'codex' && shouldHoldCodexForPrimerAlignment()) { + // cover rooms whose *owner* effectively runs on Codex, so a fresh human turn + // doesn't anchor the shared window before the 08:00 fixed-slot primer. Keyed + // on the EFFECTIVE owner backend — configured codex owner OR a global-failover + // override to Codex — not just the static group agent type. Only fires inside + // the dawn hold window, so daytime turns are never delayed. Returning handled + // without advancing the message cursor lets the turn resume on the next poll + // exactly once after the window clears. + const lease = getEffectiveChannelLease(args.chatJid); + const ownerRunsCodex = + lease.owner_agent_type === 'codex' || lease.owner_failover_active === true; + if (ownerRunsCodex && shouldHoldCodexForPrimerAlignment()) { logger.info( { chatJid: args.chatJid, @@ -51,10 +71,12 @@ export async function handleQueuedRunGates(args: { }, 'Holding fresh Codex owner turn — keeping 08:00 primer slot as first request', ); - try { - await args.sessionCommandDeps.sendMessage(CODEX_ALIGNMENT_AUTO_REPLY); - } catch (err) { - logger.warn({ err }, 'Failed to send Codex alignment notice'); + if (shouldSendAlignmentNotice(args.chatJid)) { + try { + await args.sessionCommandDeps.sendMessage(CODEX_ALIGNMENT_AUTO_REPLY); + } catch (err) { + logger.warn({ err }, 'Failed to send Codex alignment notice'); + } } return { handled: true, success: true }; } diff --git a/src/usage-primer.ts b/src/usage-primer.ts index cee20c0..aa34431 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -2,6 +2,11 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { + beginPrimerAnchor, + endPrimerAnchor, + isAlignmentSlot, +} from './codex-primer-alignment.js'; import { CODEX_WARMUP_CONFIG } from './config.js'; import { refreshActiveCodexUsage } from './codex-usage-collector.js'; import { runCodexWarmupCycle } from './codex-warmup.js'; @@ -149,6 +154,12 @@ async function runClaudePrimerCycle(): Promise { } export async function runCodexPrimerCycle(): Promise { + // At the 08:00 alignment slot, hold all other Codex consumers (via the shared + // shouldHoldCodexForPrimerAlignment flag) for the duration of this primer's + // Codex call, so the primer wins the first-request race that opens the fresh + // 5h window. Other slots run without the anchor lock. + const anchoring = isAlignmentSlot(); + if (anchoring) beginPrimerAnchor(); try { // Fire immediately at the slot. There is NO pre-call usage refresh on // purpose: forceAttempt fires regardless of usage, so reading usage first @@ -170,13 +181,12 @@ export async function runCodexPrimerCycle(): Promise { ); logger.info({ result }, 'Codex primer cycle finished'); // Observability only — runs AFTER the call so it never delays it: refresh - // so the newly-anchored reset window shows up in usage data/logs. A slot - // may still land while the trailing 5h window is exhausted (records - // `failed`); we do not chase the reported reset time, since a trailing - // window keeps sliding with later usage. + // so the newly-anchored reset window shows up in usage data/logs. await refreshActiveCodexUsage(); } catch (err) { logger.warn({ err }, 'Codex primer cycle threw'); + } finally { + if (anchoring) endPrimerAnchor(); } }