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 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-09 15:09:46 +09:00
parent ef2b26e5da
commit b3a927b241
8 changed files with 43 additions and 318 deletions

View File

@@ -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 0408 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);
});
});

View File

@@ -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:0008: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;
}

View File

@@ -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');
}
});
});

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -71,7 +71,7 @@ describe('usage-primer', () => {
maxUsagePct: 100,
maxD7UsagePct: 100,
}),
{ ignoreZeroUsageWindow: true, isPrimer: true },
{ ignoreZeroUsageWindow: true },
);
});
});

View File

@@ -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 (0408 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<void> {
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<void> {
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<typeof setTimeout> | null = null;
export function startUsagePrimer(): void {