feat(primer): narrow pre-08:00 Codex hold to anchor 5h reset at 13:00 KST

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 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-10 11:40:17 +09:00
parent 728a8c2ec9
commit 1a85ebf7eb
5 changed files with 153 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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