From 1a85ebf7eb74eb78de734032cc23a8a10bab6810 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 10 Jun 2026 11:40:17 +0900 Subject: [PATCH] feat(primer): narrow pre-08:00 Codex hold to anchor 5h reset at 13:00 KST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex 5h usage limit is a FIXED window (verified live: usedPercent climbs while resetsAt stays put), so it can be anchored — but only if the 08:00 KST primer is the first Codex request of a fresh window. Codex is otherwise used near-continuously (reviewers/arbiters every paired room, codex-owner rooms, warm-up), so 08:00 lands mid-window and the reset drifts (observed 15:47). Restore the alignment hold from backup/primer-hold-work, but narrowed to a single dawn window (03:00-08:00 KST) per user direction — the broad all-slots version was previously reverted. One Codex window is at most 5h, so anything opened before 03:00 has expired by 08:00, leaving the primer to anchor cleanly => 13:00 reset. Other slots (13/18/23) and all daytime hours are untouched. - codex-primer-alignment.ts: narrow shouldHoldCodexForPrimerAlignment (03-08 KST) - codex-warmup.ts: skip non-primer (non-forceAttempt) warm-ups during the hold - message-runtime-queue.ts: defer Codex reviewer/arbiter turns during the hold - message-runtime-gating.ts: defer fresh Codex-owner room turns during the hold Co-Authored-By: Claude Opus 4.7 --- src/codex-primer-alignment.test.ts | 39 +++++++++++++++++++++++++++ src/codex-primer-alignment.ts | 42 ++++++++++++++++++++++++++++++ src/codex-warmup.ts | 8 ++++++ src/message-runtime-gating.ts | 31 +++++++++++++++++++++- src/message-runtime-queue.ts | 34 ++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) 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..58a5152 --- /dev/null +++ b/src/codex-primer-alignment.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { 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); + return kstShifted - KST_OFFSET_MS; +} + +describe('shouldHoldCodexForPrimerAlignment', () => { + 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); + }); + + 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('does not hold before the dawn window', () => { + expect(shouldHoldCodexForPrimerAlignment(atKst(2, 59))).toBe(false); + expect(shouldHoldCodexForPrimerAlignment(atKst(0, 0))).toBe(false); + }); + + it('leaves the other primer slots and daytime hours untouched', () => { + for (const h of [9, 12, 13, 15, 18, 20, 23]) { + expect(shouldHoldCodexForPrimerAlignment(atKst(h, 0))).toBe(false); + } + }); +}); diff --git a/src/codex-primer-alignment.ts b/src/codex-primer-alignment.ts new file mode 100644 index 0000000..5621166 --- /dev/null +++ b/src/codex-primer-alignment.ts @@ -0,0 +1,42 @@ +/** + * Codex usage-primer alignment — narrow pre-08:00 quiet window. + * + * 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 + * 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). + * + * 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. + * + * 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. + */ + +const KST_OFFSET_MS = 9 * 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; + +/** + * 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. + */ +export function shouldHoldCodexForPrimerAlignment( + nowMs: number = Date.now(), +): boolean { + const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours(); + return ( + kstHour >= DAWN_QUIET_START_HOUR_KST && kstHour < DAWN_QUIET_END_HOUR_KST + ); +} diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index 8c0f491..9e09388 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'; import { DATA_DIR } from './config.js'; import type { AppConfig } from './config/schema.js'; +import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; import { getAllCodexAccounts, getCodexAuthPath, @@ -341,6 +342,13 @@ export async function runCodexWarmupCycle( return { status: 'skipped', reason: 'runtime_busy' }; const nowMs = runtime.nowMs ?? Date.now(); + // Hold every non-primer warm-up during the pre-08:00 dawn window so it can't + // anchor the shared Codex 5h window ahead of the scheduled primer (which + // passes forceAttempt). Skipping is safe — an opportunistic warm-up is + // best-effort. The fixed-slot primer itself (forceAttempt) is never held. + if (!runtime.forceAttempt && 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 2a0c1c9..29555fd 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -1,8 +1,13 @@ +import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; +import { logger } from './logger.js'; import { handleSessionCommand, type SessionCommandDeps, } from './session-commands.js'; -import type { NewMessage, RegisteredGroup } from './types.js'; +import type { AgentType, NewMessage, RegisteredGroup } from './types.js'; + +const CODEX_ALIGNMENT_AUTO_REPLY = + '코덱스 5시간 한도 리셋을 매일 오후 1시에 고정하기 위해, 새벽 03~08시 구간은 정렬용으로 잠시 대기합니다. 08시 이후 다시 보내주세요.'; export async function handleQueuedRunGates(args: { chatJid: string; @@ -30,5 +35,29 @@ 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 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()) { + logger.info( + { + chatJid: args.chatJid, + groupName: args.group.name, + runId: args.runId, + }, + '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'); + } + return { handled: true, success: true }; + } + return { handled: false }; } diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index a2ac4d7..d89f150 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -12,6 +12,8 @@ import { } from './message-runtime-flow.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { resolveStoredVisibleVerdict } from './paired-verdict.js'; +import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js'; +import { getEffectiveChannelLease } from './service-routing.js'; import { advanceLastAgentCursor, finalizeQueuedRunCursor, @@ -62,6 +64,23 @@ function timestampMs(value: string | null | undefined): number | null { return Number.isFinite(parsed) ? parsed : null; } +/** + * If the pending paired turn is a Codex reviewer/arbiter turn that must wait for + * the pre-08:00 primer-alignment window, return its role; otherwise null. + */ +function codexRoleHeldForPrimerAlignment( + chatJid: string, + taskStatus: string | null | undefined, +): 'reviewer' | 'arbiter' | null { + const role = resolveActiveRole(taskStatus); + if (role !== 'reviewer' && role !== 'arbiter') return null; + if (!shouldHoldCodexForPrimerAlignment()) return null; + const lease = getEffectiveChannelLease(chatJid); + const agentType = + role === 'reviewer' ? lease.reviewer_agent_type : lease.arbiter_agent_type; + return agentType === 'codex' ? role : null; +} + function isHumanMessageFreshForTask( task: PairedTask | null | undefined, message: NewMessage, @@ -219,6 +238,21 @@ export async function runPendingPairedTurnIfNeeded(args: { return null; } + // ── Codex primer-alignment hold (reviewer/arbiter turns) ── + // Reviewer/arbiter turns are the off-slot Codex usage that anchors the shared + // 5h window early and drifts the reset. Inside the pre-08:00 dawn window we + // defer them 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). + const heldRole = codexRoleHeldForPrimerAlignment(chatJid, task.status); + if (heldRole) { + args.log.info( + { chatJid, taskId: task.id, taskStatus: task.status, role: heldRole }, + 'Holding Codex reviewer/arbiter turn — keeping 08:00 primer slot as first request', + ); + return true; + } + if (pendingTurn.channel) { const claimed = claimPairedTurnExecution({ chatJid,