From 0ca24debfd95720fd70635459b8af5baea0a1f21 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 15:01:21 +0900 Subject: [PATCH] feat(primer): broaden Codex alignment hold to all real consumers; pre+post slot window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review gaps in the primer-alignment hold so the fixed-slot primer reliably wins the first-request race for the shared Codex window: - Extract the time-based hold into a dependency-free leaf module (codex-primer-alignment.ts) so every Codex path can share it without import cycles. - Add a pre-slot hold window (2 min) in addition to the post-slot (5 min) and the 04-08 KST dawn gap, closing the "request a beat before the slot" gap. - Hold the dashboard's periodic Codex warm-up too: runCodexWarmupCycle now skips during the hold unless called with isPrimer (the scheduled primer sets it), so the warm-up can't anchor the window ahead of the primer. - Remove the urgent @-mention bypass on the reviewer/arbiter and owner holds — the reset is anchored unconditionally as requested. - Reviewer/arbiter hold resumes exactly once after the window via the existing per-revision claim (poll re-detect; no double run). Note: the unified lease boundary (syncHostCodexSessionFiles) is synchronous and throws a terminal "Codex unavailable", so it can't host a clean defer+resume; gating stays at the async turn-scheduling layer that can re-queue. Honest caveat unchanged: making the primer first is necessary but not sufficient to pin a trailing reset. Verified: typecheck, build, bundle-smoke; full suite 1482 passing with only the 9 pre-existing env-config baseline failures (service-routing/paired-context/ migrate-room owner=claude vs codex-main); new alignment + warmup-hold tests pass. Co-Authored-By: Claude Opus 4.7 --- src/codex-primer-alignment.test.ts | 56 +++++++++++++ src/codex-primer-alignment.ts | 99 +++++++++++++++++++++++ src/codex-warmup.test.ts | 66 +++++++++++++-- src/codex-warmup.ts | 14 ++++ src/message-runtime-gating.test.ts | 40 +-------- src/message-runtime-gating.ts | 103 +++++------------------- src/message-runtime-group-processing.ts | 2 - src/message-runtime-queue.ts | 13 ++- src/usage-primer.test.ts | 2 +- src/usage-primer.ts | 45 +++-------- 10 files changed, 270 insertions(+), 170 deletions(-) create mode 100644 src/codex-primer-alignment.test.ts create mode 100644 src/codex-primer-alignment.ts diff --git a/src/codex-primer-alignment.test.ts b/src/codex-primer-alignment.test.ts new file mode 100644 index 0000000..b10e665 --- /dev/null +++ b/src/codex-primer-alignment.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + KST_OFFSET_MS, + msSincePreviousPrimerSlotKST, + msUntilNextPrimerSlotKST, + shouldHoldCodexForPrimerAlignment, +} from './codex-primer-alignment.js'; + +// nowMs such that the shifted KST clock reads the given hour/minute on a day. +const atKst = (hour: number, minute = 0): number => + Date.UTC(2026, 5, 9, hour, minute) - KST_OFFSET_MS; + +describe('codex-primer-alignment timing', () => { + it('measures ms until the next fixed slot (with day wrap)', () => { + expect(msUntilNextPrimerSlotKST(atKst(8, 0))).toBe(5 * 60 * 60 * 1000); + expect(msUntilNextPrimerSlotKST(atKst(12, 50))).toBe(10 * 60 * 1000); + // After the day's last slot → next slot is tomorrow's 08:00. + expect(msUntilNextPrimerSlotKST(atKst(23, 0))).toBe(9 * 60 * 60 * 1000); + }); + + it('measures ms since the most recent fixed slot (with day wrap)', () => { + 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 in the minutes just before each slot (pre-slot window)', () => { + // ≤2 min before the slot is held so a just-before call cannot anchor first. + expect(shouldHoldCodexForPrimerAlignment(atKst(12, 59))).toBe(true); + expect(shouldHoldCodexForPrimerAlignment(atKst(17, 59))).toBe(true); + // 3 min before is outside the 2-min pre-window. + expect(shouldHoldCodexForPrimerAlignment(atKst(12, 57))).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); + }); +}); diff --git a/src/codex-primer-alignment.ts b/src/codex-primer-alignment.ts new file mode 100644 index 0000000..64cdf76 --- /dev/null +++ b/src/codex-primer-alignment.ts @@ -0,0 +1,99 @@ +/** + * Codex usage-primer alignment timing. + * + * The usage primer fires at fixed KST slots (08/13/18/23) to anchor the shared + * Codex 5h usage window. For the primer to actually pin the reset to a slot it + * must be the *first* Codex request after the previous window expires — + * otherwise an off-slot reviewer/arbiter/owner Codex turn (or the dashboard + * warm-up) anchors the window early and the reset drifts. + * + * So non-primer Codex execution is held during three time-based windows, so the + * scheduled primer wins 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 the + * account is kept quiet until the 08:00 primer. + * 2. A couple of minutes *before* each slot, so a call started a beat before + * the slot (against an already-reset window) cannot anchor first. + * 3. A few minutes *after* each slot, so a call firing right at/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, so a blunt clock + * rule is predictable and testable. This is a leaf module (no heavy imports) so + * every Codex path — gating, queue, warm-up, primer — can share it without + * import cycles. + * + * Honest limitation: making the primer the first request is necessary but not + * sufficient to pin the reset. If the provider window is a *trailing* one (reset + * slides forward with continued usage), post-slot usage can still move the reset + * even with a clean anchor. This holds the line as hard as a client can. + */ + +export const PRIMER_HOURS_KST = [8, 13, 18, 23]; +export const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +const DAWN_GAP_START_HOUR_KST = 4; +const DAWN_GAP_END_HOUR_KST = 8; +const PRE_SLOT_HOLD_MS = 2 * 60 * 1000; +const POST_SLOT_HOLD_MS = 5 * 60 * 1000; + +/** ms until the next fixed primer slot (handles the day wrap). */ +export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number { + const kstNowShifted = nowMs + KST_OFFSET_MS; + const kstHour = new Date(kstNowShifted).getUTCHours(); + + let nextHour = PRIMER_HOURS_KST.find((h) => h > kstHour); + let dayOffset = 0; + if (nextHour === undefined) { + nextHour = PRIMER_HOURS_KST[0]; + dayOffset = 1; + } + + const target = new Date(kstNowShifted); + target.setUTCHours(nextHour, 0, 0, 0); + if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1); + return target.getTime() - kstNowShifted; +} + +/** 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 execution 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; + } + if (msUntilNextPrimerSlotKST(nowMs) < PRE_SLOT_HOLD_MS) { + return true; + } + return msSincePreviousPrimerSlotKST(nowMs) < POST_SLOT_HOLD_MS; +} diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index a52c97d..ec7e3a0 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -137,7 +137,7 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath }, + { nowMs: now, statePath, isPrimer: true }, ); expect(result).toEqual({ status: 'warmed', accountIndex: 2 }); @@ -215,7 +215,7 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath }, + { nowMs: now, statePath, isPrimer: true }, ); expect(childProcess.spawn).toHaveBeenCalledTimes(1); @@ -275,7 +275,7 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath }, + { nowMs: now, statePath, isPrimer: true }, ); expect(result).toEqual({ @@ -338,7 +338,7 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath, ignoreZeroUsageWindow: true }, + { nowMs: now, statePath, ignoreZeroUsageWindow: true, isPrimer: true }, ); expect(result).toEqual({ status: 'warmed', accountIndex: 0 }); @@ -388,12 +388,13 @@ describe('Codex warm-up scheduler', () => { maxConsecutiveFailures: 1, }; - const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath }); + const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath, isPrimer: true }); expect(failed.status).toBe('failed'); const backedOff = await runCodexWarmupCycle(config, { nowMs: now + 60_000, statePath, + isPrimer: true, }); expect(backedOff).toEqual({ @@ -446,10 +447,63 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath }, + { nowMs: now, statePath, isPrimer: true }, ); expect(result).toEqual({ status: 'skipped', reason: 'stagger_wait' }); expect(childProcess.spawn).not.toHaveBeenCalled(); }); }); + +describe('Codex warm-up primer-alignment hold', () => { + // KST 05:00 (dawn gap) — utcHour 20 → (20+9)%24 = 5. + const dawnNow = Date.UTC(2026, 3, 24, 20, 0, 0); + const baseConfig = { + enabled: true as const, + prompt: '.', + 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, + }; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('holds a non-primer warm-up during a slot window', async () => { + const childProcess = await import('child_process'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + + const result = await runCodexWarmupCycle(baseConfig, { nowMs: dawnNow }); + + expect(result).toEqual({ + status: 'skipped', + reason: 'primer_alignment_hold', + }); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it('exempts the primer call (isPrimer) from the alignment hold', async () => { + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([]); + + const result = await runCodexWarmupCycle(baseConfig, { + nowMs: dawnNow, + isPrimer: true, + }); + + // Proceeds past the hold; with no accounts it skips for a different reason. + expect(result.status).toBe('skipped'); + if (result.status === 'skipped') { + expect(result.reason).not.toBe('primer_alignment_hold'); + } + }); +}); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index 1e336b8..ccdac8e 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -5,6 +5,7 @@ import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; +import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; import { DATA_DIR } from './config.js'; import type { AppConfig } from './config/schema.js'; import { @@ -36,6 +37,13 @@ interface CodexWarmupRuntimeOptions { statePath?: string; shouldSkip?: () => boolean; ignoreZeroUsageWindow?: boolean; + /** + * Set by the scheduled usage primer so its call is exempt from the + * primer-alignment hold. Any other warm-up caller (e.g. the dashboard's + * periodic warm-up) is held during the slot windows so it can't anchor the + * shared Codex window ahead of the primer. + */ + isPrimer?: boolean; } export type CodexWarmupCycleResult = @@ -317,6 +325,12 @@ export async function runCodexWarmupCycle( return { status: 'skipped', reason: 'runtime_busy' }; const nowMs = runtime.nowMs ?? Date.now(); + // Hold every non-primer warm-up during the slot windows so it can't anchor + // the shared Codex 5h window ahead of the scheduled primer (which passes + // isPrimer). Skipping is safe — a warm-up is best-effort. + if (!runtime.isPrimer && shouldHoldCodexForPrimerAlignment(nowMs)) { + return { status: 'skipped', reason: 'primer_alignment_hold' }; + } const nowIso = new Date(nowMs).toISOString(); const statePath = runtime.statePath ?? DEFAULT_STATE_FILE; const state = readWarmupState(statePath); diff --git a/src/message-runtime-gating.test.ts b/src/message-runtime-gating.test.ts index 26825bc..3768541 100644 --- a/src/message-runtime-gating.test.ts +++ b/src/message-runtime-gating.test.ts @@ -9,45 +9,7 @@ vi.mock('./session-commands.js', () => ({ handleSessionCommand: handleSessionCommandMock, })); -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); - }); -}); +import { handleQueuedRunGates } from './message-runtime-gating.js'; describe('message-runtime-gating', () => { const baseArgs = { diff --git a/src/message-runtime-gating.ts b/src/message-runtime-gating.ts index 665a186..c045f05 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -1,73 +1,13 @@ +import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; import { logger } from './logger.js'; import { handleSessionCommand, type SessionCommandDeps, } from './session-commands.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시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주시거나 긴급하면 @태그해 주세요.'; + '코덱스 사용량 정렬 시간입니다. 고정 슬롯(08/13/18/23 KST) 직후·직전 잠시, 그리고 04~08시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주세요.'; export async function handleQueuedRunGates(args: { chatJid: string; @@ -98,28 +38,27 @@ 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 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), + // anchor the shared window before the fixed-slot primer. Keyed on the room's + // configured owner agent type (a failover-only override to Codex is a rare + // edge we accept rather than do a DB read on every gate). No urgent bypass: + // the user asked to anchor the reset unconditionally, so the held turn waits + // for the slot like any other. + const ownerAgentType: AgentType = args.group.agentType ?? 'claude-code'; + if (ownerAgentType === 'codex' && shouldHoldCodexForPrimerAlignment()) { + logger.info( + { + chatJid: args.chatJid, + groupName: args.group.name, + runId: args.runId, + }, + 'Holding fresh Codex owner turn — keeping primer slot as first request', ); - 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 }; + 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 4afc061..c8a8294 100644 --- a/src/message-runtime-group-processing.ts +++ b/src/message-runtime-group-processing.ts @@ -352,7 +352,6 @@ async function handleNoMissedMessages( roleToChannel: runtime.roleToChannel, labelPairedSenders: args.labelPairedSenders, mode: 'idle', - triggerPattern: args.triggerPattern, }); if (pendingTurnOutcome !== null) { return pendingTurnOutcome; @@ -394,7 +393,6 @@ 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 0cbdc66..2089a34 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -22,7 +22,7 @@ import { resolveQueuedTurnRole, } from './message-runtime-rules.js'; import { getTaskContextMessages } from './message-runtime-task-context.js'; -import { shouldHoldCodexForPrimerAlignment } from './message-runtime-gating.js'; +import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; import { getEffectiveChannelLease } from './service-routing.js'; import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js'; import type { @@ -184,7 +184,6 @@ 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) { @@ -227,8 +226,9 @@ export async function runPendingPairedTurnIfNeeded(args: { // 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. + // on the next poll, and the per-revision claim guarantees it resumes exactly + // once after the window clears (no work lost, no double run). No urgent + // bypass: the reset is anchored unconditionally. const pendingRole = resolveActiveRole(task.status); if ( (pendingRole === 'reviewer' || pendingRole === 'arbiter') && @@ -239,10 +239,7 @@ export async function runPendingPairedTurnIfNeeded(args: { 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) { + if (roleAgentType === 'codex') { args.log.info( { chatJid, diff --git a/src/usage-primer.test.ts b/src/usage-primer.test.ts index 18e649b..8643c25 100644 --- a/src/usage-primer.test.ts +++ b/src/usage-primer.test.ts @@ -71,7 +71,7 @@ describe('usage-primer', () => { maxUsagePct: 100, maxD7UsagePct: 100, }), - { ignoreZeroUsageWindow: true }, + { ignoreZeroUsageWindow: true, isPrimer: true }, ); }); }); diff --git a/src/usage-primer.ts b/src/usage-primer.ts index 68f1979..f9f7f52 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { msUntilNextPrimerSlotKST } from './codex-primer-alignment.js'; import { CODEX_WARMUP_CONFIG } from './config.js'; import { refreshActiveCodexUsage, @@ -23,14 +24,13 @@ import { getAllTokens } from './token-rotation.js'; * 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. + * (`shouldHoldCodexForPrimerAlignment` in codex-primer-alignment.ts) that keeps + * non-primer Codex turns/warm-ups quiet during the dawn gap (04–08 KST) and the + * minutes around each slot, so the primer wins the first-request race. The + * primer itself passes `isPrimer` so it is never held. The primer is also a + * best-effort warm-up + success/failure record. */ -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 @@ -165,13 +165,14 @@ export async function runCodexPrimerCycle(): Promise { maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT, maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT, }, - { ignoreZeroUsageWindow: true }, + { ignoreZeroUsageWindow: true, isPrimer: true }, ); - // Best-effort: a slot may land while the trailing 5h window is exhausted, - // in which case this records `failed`. We deliberately do NOT try to - // re-anchor to the reported reset time: the 5h limit is a trailing rolling - // window whose reset slides forward with continued Codex usage, so no - // single timed call can pin the reset to a fixed clock time. + // `isPrimer` exempts this call from the alignment hold (the hold blocks + // every *other* Codex path during the slot windows) so the primer is the + // first Codex request and anchors the window at the slot. A slot may still + // land while the trailing 5h window is exhausted, in which case this records + // `failed`; we do not chase the reported reset time, since a trailing window + // can keep sliding with later usage. logger.info({ result }, 'Codex primer cycle finished'); } catch (err) { logger.warn({ err }, 'Codex primer cycle threw'); @@ -183,26 +184,6 @@ async function runPrimerCycle(): Promise { await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]); } -export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number { - // KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*. - 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; -} - let primerTimer: ReturnType | null = null; export function startUsagePrimer(): void {