From e324a1baa112f6276fb35aecb43557e6643a5e5f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 14:31:01 +0900 Subject: [PATCH] feat(primer): hold off-slot Codex turns so the fixed-slot primer anchors the reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage primer fires at 08/13/18/23 KST but could not pin the Codex 5h reset because reviewer/arbiter turns on the same shared account ran off-slot and anchored the window first. Add a time-based hold (shouldHoldCodexForPrimerAlignment) that keeps non-primer Codex turns quiet during the 04-08 KST dawn gap and for a few minutes after each slot, so the scheduled primer wins the first-request race. Applied at the reviewer/arbiter dispatch site (the actual off-slot consumer here) and for codex-owner rooms; urgent @-mention turns bypass it. Time-based by design — the usage/reset figures are exactly the unreliable data, so a blunt clock rule is predictable and testable. Co-Authored-By: Claude Opus 4.7 --- src/message-runtime-gating.test.ts | 40 ++++++++++- src/message-runtime-gating.ts | 92 ++++++++++++++++++++++++- src/message-runtime-group-processing.ts | 2 + src/message-runtime-queue.ts | 32 +++++++++ src/usage-primer.ts | 20 +++--- 5 files changed, 175 insertions(+), 11 deletions(-) diff --git a/src/message-runtime-gating.test.ts b/src/message-runtime-gating.test.ts index 3768541..26825bc 100644 --- a/src/message-runtime-gating.test.ts +++ b/src/message-runtime-gating.test.ts @@ -9,7 +9,45 @@ vi.mock('./session-commands.js', () => ({ handleSessionCommand: handleSessionCommandMock, })); -import { handleQueuedRunGates } from './message-runtime-gating.js'; +import { + handleQueuedRunGates, + msSincePreviousPrimerSlotKST, + shouldHoldCodexForPrimerAlignment, +} from './message-runtime-gating.js'; + +// nowMs such that the shifted KST clock reads the given hour/minute on a day. +const KST_OFFSET = 9 * 60 * 60 * 1000; +const atKst = (hour: number, minute = 0): number => + Date.UTC(2026, 5, 9, hour, minute) - KST_OFFSET; + +describe('primer-alignment hold timing', () => { + it('measures ms since the most recent fixed slot', () => { + expect(msSincePreviousPrimerSlotKST(atKst(8, 0))).toBe(0); + expect(msSincePreviousPrimerSlotKST(atKst(13, 30))).toBe(30 * 60 * 1000); + // Before the day's first slot → previous slot was yesterday's 23:00. + expect(msSincePreviousPrimerSlotKST(atKst(2, 0))).toBe(3 * 60 * 60 * 1000); + }); + + it('holds during the 04–08 KST dawn gap', () => { + expect(shouldHoldCodexForPrimerAlignment(atKst(4, 0))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(5, 0))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(7, 59))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(3, 59))).toBe(false); + }); + + it('holds briefly right after each fixed slot, then releases', () => { + expect(shouldHoldCodexForPrimerAlignment(atKst(8, 0))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(13, 2))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(13, 6))).toBe(false); + expect(shouldHoldCodexForPrimerAlignment(atKst(18, 4))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(23, 30))).toBe(false); + }); + + it('does not hold mid-window away from slots and the dawn gap', () => { + expect(shouldHoldCodexForPrimerAlignment(atKst(15, 0))).toBe(false); + expect(shouldHoldCodexForPrimerAlignment(atKst(2, 0))).toBe(false); + }); +}); describe('message-runtime-gating', () => { const baseArgs = { diff --git a/src/message-runtime-gating.ts b/src/message-runtime-gating.ts index 2a0c1c9..62ddf78 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -1,8 +1,71 @@ +import { logger } from './logger.js'; import { handleSessionCommand, type SessionCommandDeps, } from './session-commands.js'; -import type { NewMessage, RegisteredGroup } from './types.js'; +import { KST_OFFSET_MS, PRIMER_HOURS_KST } from './usage-primer.js'; +import type { AgentType, NewMessage, RegisteredGroup } from './types.js'; + +// ── Codex primer-alignment hold ── +// The usage primer fires at fixed KST slots (08/13/18/23) to anchor the shared +// Codex 5h usage window. For that primer to actually pin the reset to the slot, +// it must be the *first* Codex request after the previous window expires — +// otherwise an off-slot reviewer/arbiter/owner Codex turn anchors the window +// early and the reset drifts. So we hold non-primer Codex turns during two +// time-based windows, letting the scheduled primer win the first-request race: +// +// 1. Dawn gap 04:00–08:00 KST. The 23:00 window resets ~04:00, leaving a 4h +// hole before the 08:00 slot (the 23→08 span is 9h, longer than one 5h +// window). Any Codex call in this hole anchors a stray window, so we keep +// the account quiet until the 08:00 primer. +// 2. A few minutes right after each slot, so a reviewer turn firing a beat +// after the slot cannot beat the primer to anchor the fresh window. +// +// This is intentionally time-based (not usage-% based): the Codex usage/reset +// figures are exactly the data that has proven unreliable here, and a blunt +// clock rule is predictable and testable. Urgent @-mention turns bypass it. +const DAWN_GAP_START_HOUR_KST = 4; +const DAWN_GAP_END_HOUR_KST = 8; +const POST_SLOT_HOLD_MS = 5 * 60 * 1000; + +/** ms elapsed since the most recent fixed primer slot (handles the day wrap). */ +export function msSincePreviousPrimerSlotKST(nowMs: number = Date.now()): number { + const kstNowShifted = nowMs + KST_OFFSET_MS; + const kstHour = new Date(kstNowShifted).getUTCHours(); + + let prevHour: number | undefined; + for (const h of PRIMER_HOURS_KST) { + if (h <= kstHour) prevHour = h; + } + let dayOffset = 0; + if (prevHour === undefined) { + // Before the day's first slot — previous slot was yesterday's last one. + prevHour = PRIMER_HOURS_KST[PRIMER_HOURS_KST.length - 1]; + dayOffset = -1; + } + + const slot = new Date(kstNowShifted); + slot.setUTCHours(prevHour, 0, 0, 0); + if (dayOffset === -1) slot.setUTCDate(slot.getUTCDate() - 1); + return kstNowShifted - slot.getTime(); +} + +/** + * Whether non-primer Codex turns should be held right now so the fixed-slot + * primer can be the window-anchoring first request. Time-based; no I/O. + */ +export function shouldHoldCodexForPrimerAlignment( + nowMs: number = Date.now(), +): boolean { + const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours(); + if (kstHour >= DAWN_GAP_START_HOUR_KST && kstHour < DAWN_GAP_END_HOUR_KST) { + return true; + } + return msSincePreviousPrimerSlotKST(nowMs) < POST_SLOT_HOLD_MS; +} + +const CODEX_ALIGNMENT_AUTO_REPLY = + '코덱스 사용량 정렬 시간입니다. 고정 슬롯(08/13/18/23 KST) 직후 잠시, 그리고 04~08시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주시거나 긴급하면 @태그해 주세요.'; export async function handleQueuedRunGates(args: { chatJid: string; @@ -30,5 +93,32 @@ export async function handleQueuedRunGates(args: { return cmdResult; } + // ── 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 fixed-slot primer. + const agentType: AgentType = args.group.agentType ?? 'claude-code'; + if (agentType === 'codex' && shouldHoldCodexForPrimerAlignment()) { + 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, + }, + 'Holding fresh Codex owner turn — keeping 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'); + } + return { handled: true, success: true }; + } + } + return { handled: false }; } diff --git a/src/message-runtime-group-processing.ts b/src/message-runtime-group-processing.ts index c8a8294..4afc061 100644 --- a/src/message-runtime-group-processing.ts +++ b/src/message-runtime-group-processing.ts @@ -352,6 +352,7 @@ async function handleNoMissedMessages( roleToChannel: runtime.roleToChannel, labelPairedSenders: args.labelPairedSenders, mode: 'idle', + triggerPattern: args.triggerPattern, }); if (pendingTurnOutcome !== null) { return pendingTurnOutcome; @@ -393,6 +394,7 @@ function runBotOnlyPendingTurn( labelPairedSenders: args.labelPairedSenders, mode: 'bot-only', missedMessages, + triggerPattern: args.triggerPattern, }); } diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index a2ac4d7..36ea913 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -22,6 +22,8 @@ import { resolveQueuedTurnRole, } from './message-runtime-rules.js'; import { getTaskContextMessages } from './message-runtime-task-context.js'; +import { shouldHoldCodexForPrimerAlignment } from './message-runtime-gating.js'; +import { getEffectiveChannelLease } from './service-routing.js'; import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js'; import type { ExecuteTurnFn, @@ -182,6 +184,7 @@ export async function runPendingPairedTurnIfNeeded(args: { labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[]; mode: 'idle' | 'bot-only'; missedMessages?: NewMessage[]; + triggerPattern?: RegExp; }): Promise { const { chatJid, task, roleToChannel } = args; if (!task) { @@ -219,6 +222,35 @@ export async function runPendingPairedTurnIfNeeded(args: { return null; } + // ── Codex primer-alignment hold (reviewer/arbiter turns) ── + // Reviewer and arbiter turns are the off-slot Codex usage that anchors the + // shared 5h window early and drifts the reset. When the active role runs on + // Codex and we're inside a primer-alignment hold window, defer the turn so + // the fixed-slot primer stays the first request. The open task is re-detected + // on the next poll, so this just postpones (no work is lost). An urgent + // @-mention in the recent human messages bypasses the hold. + const pendingRole = resolveActiveRole(task.status); + if ( + (pendingRole === 'reviewer' || pendingRole === 'arbiter') && + shouldHoldCodexForPrimerAlignment() + ) { + const lease = getEffectiveChannelLease(chatJid); + const roleAgentType = + pendingRole === 'reviewer' + ? lease.reviewer_agent_type + : lease.arbiter_agent_type; + const hasUrgentMention = args.triggerPattern + ? recentHumanMessages.some((m) => args.triggerPattern!.test(m.content)) + : false; + if (roleAgentType === 'codex' && !hasUrgentMention) { + args.log.info( + { chatJid, taskId: task.id, taskStatus: task.status, role: pendingRole }, + 'Holding Codex reviewer/arbiter turn — keeping primer slot as first request', + ); + return true; + } + } + if (pendingTurn.channel) { const claimed = claimPairedTurnExecution({ chatJid, diff --git a/src/usage-primer.ts b/src/usage-primer.ts index b2e80c4..68f1979 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -18,17 +18,19 @@ import { getAllTokens } from './token-rotation.js'; * provider account is kept warm and any usage-window issues surface in the * logs at predictable times. * - * IMPORTANT — what this does NOT do: it does not pin the reset to a fixed - * clock time. Empirically the Codex 5h limit is a *trailing rolling* window: - * the reported reset slides forward with continued usage (observed reset - * moving 18:00 → 18:33 over ~33 min of activity). A single timed message can - * therefore not anchor the reset to a fixed time while Codex keeps being used - * (e.g. by a paired-room reviewer on the same account). The slots are a - * best-effort warm-up + a success/failure record, not a reset-alignment lever. + * Reset alignment: the primer alone cannot pin the reset — empirically the + * Codex 5h limit slides forward with continued usage (observed reset moving + * 18:00 → 18:33 over ~33 min of activity), so any off-slot Codex call by a + * paired-room reviewer/arbiter on the same account would anchor the window + * early. To make the slot the *anchor*, the primer is paired with a hold gate + * (`shouldHoldCodexForPrimerAlignment` in message-runtime-gating.ts) that + * keeps non-primer Codex turns quiet during the dawn gap (04–08 KST) and for a + * few minutes after each slot, so the primer wins the first-request race. The + * primer is also a best-effort warm-up + success/failure record. */ -const PRIMER_HOURS_KST = [8, 13, 18, 23]; -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +export const PRIMER_HOURS_KST = [8, 13, 18, 23]; +export 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; // Fire the Codex primer unconditionally at every slot, mirroring the Claude