From b3a927b241155c07864cb2de478c8830c1bfd5ab Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 15:09:46 +0900 Subject: [PATCH] revert: drop reviewer-holding primer-alignment approach per user direction The user asked to NOT defer reviewer/arbiter Codex calls, but instead just fire a simple primer task at each fixed slot independent of the reviewer. That is already the live behavior (efc8c00: primer fires at 08/13/18/23 KST + the Claude binary-path fix). Revert the hold/gating work (e324a1b..ef2b26e) so it can't be deployed; it is preserved on branch backup/primer-hold-work in case the 08:00 overnight-exhaustion gap later warrants a minimal quiet window. 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 | 70 ++------------------- src/codex-warmup.ts | 14 ----- src/message-runtime-gating.ts | 33 +--------- src/message-runtime-queue.ts | 34 ---------- src/usage-primer.test.ts | 2 +- src/usage-primer.ts | 53 ++++++++++------ 8 files changed, 43 insertions(+), 318 deletions(-) delete mode 100644 src/codex-primer-alignment.test.ts delete mode 100644 src/codex-primer-alignment.ts diff --git a/src/codex-primer-alignment.test.ts b/src/codex-primer-alignment.test.ts deleted file mode 100644 index b10e665..0000000 --- a/src/codex-primer-alignment.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 64cdf76..0000000 --- a/src/codex-primer-alignment.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 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 0841c57..a52c97d 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, isPrimer: true }, + { nowMs: now, statePath }, ); 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, isPrimer: true }, + { nowMs: now, statePath }, ); expect(childProcess.spawn).toHaveBeenCalledTimes(1); @@ -275,7 +275,7 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath, isPrimer: true }, + { nowMs: now, statePath }, ); expect(result).toEqual({ @@ -338,7 +338,7 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath, ignoreZeroUsageWindow: true, isPrimer: true }, + { nowMs: now, statePath, ignoreZeroUsageWindow: true }, ); expect(result).toEqual({ status: 'warmed', accountIndex: 0 }); @@ -388,17 +388,12 @@ describe('Codex warm-up scheduler', () => { maxConsecutiveFailures: 1, }; - const failed = await runCodexWarmupCycle(config, { - nowMs: now, - statePath, - isPrimer: true, - }); + const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath }); expect(failed.status).toBe('failed'); const backedOff = await runCodexWarmupCycle(config, { nowMs: now + 60_000, statePath, - isPrimer: true, }); expect(backedOff).toEqual({ @@ -451,63 +446,10 @@ describe('Codex warm-up scheduler', () => { failureCooldownMs: 21_600_000, maxConsecutiveFailures: 2, }, - { nowMs: now, statePath, isPrimer: true }, + { nowMs: now, statePath }, ); 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 ccdac8e..1e336b8 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -5,7 +5,6 @@ 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 { @@ -37,13 +36,6 @@ 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 = @@ -325,12 +317,6 @@ 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.ts b/src/message-runtime-gating.ts index c045f05..2a0c1c9 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -1,13 +1,8 @@ -import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; -import { logger } from './logger.js'; import { handleSessionCommand, type SessionCommandDeps, } from './session-commands.js'; -import type { AgentType, NewMessage, RegisteredGroup } from './types.js'; - -const CODEX_ALIGNMENT_AUTO_REPLY = - '코덱스 사용량 정렬 시간입니다. 고정 슬롯(08/13/18/23 KST) 직후·직전 잠시, 그리고 04~08시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주세요.'; +import type { NewMessage, RegisteredGroup } from './types.js'; export async function handleQueuedRunGates(args: { chatJid: string; @@ -35,31 +30,5 @@ 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. 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', - ); - 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-queue.ts b/src/message-runtime-queue.ts index 2089a34..a2ac4d7 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -22,8 +22,6 @@ import { resolveQueuedTurnRole, } from './message-runtime-rules.js'; import { getTaskContextMessages } from './message-runtime-task-context.js'; -import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; -import { getEffectiveChannelLease } from './service-routing.js'; import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js'; import type { ExecuteTurnFn, @@ -221,38 +219,6 @@ 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, 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') && - shouldHoldCodexForPrimerAlignment() - ) { - const lease = getEffectiveChannelLease(chatJid); - const roleAgentType = - pendingRole === 'reviewer' - ? lease.reviewer_agent_type - : lease.arbiter_agent_type; - if (roleAgentType === 'codex') { - 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.test.ts b/src/usage-primer.test.ts index 8643c25..18e649b 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, isPrimer: true }, + { ignoreZeroUsageWindow: true }, ); }); }); diff --git a/src/usage-primer.ts b/src/usage-primer.ts index f9f7f52..b2e80c4 100644 --- a/src/usage-primer.ts +++ b/src/usage-primer.ts @@ -2,7 +2,6 @@ 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, @@ -19,18 +18,17 @@ import { getAllTokens } from './token-rotation.js'; * provider account is kept warm and any usage-window issues surface in the * logs at predictable times. * - * 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 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. + * 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. */ +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; // Fire the Codex primer unconditionally at every slot, mirroring the Claude @@ -165,14 +163,13 @@ export async function runCodexPrimerCycle(): Promise { maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT, maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT, }, - { ignoreZeroUsageWindow: true, isPrimer: true }, + { ignoreZeroUsageWindow: true }, ); - // `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. + // 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. logger.info({ result }, 'Codex primer cycle finished'); } catch (err) { logger.warn({ err }, 'Codex primer cycle threw'); @@ -184,6 +181,26 @@ 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 {