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:
39
src/codex-primer-alignment.test.ts
Normal file
39
src/codex-primer-alignment.test.ts
Normal 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: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);
|
||||
}
|
||||
});
|
||||
});
|
||||
42
src/codex-primer-alignment.ts
Normal file
42
src/codex-primer-alignment.ts
Normal 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: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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user