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 { DATA_DIR } from './config.js';
|
||||||
import type { AppConfig } from './config/schema.js';
|
import type { AppConfig } from './config/schema.js';
|
||||||
|
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||||
import {
|
import {
|
||||||
getAllCodexAccounts,
|
getAllCodexAccounts,
|
||||||
getCodexAuthPath,
|
getCodexAuthPath,
|
||||||
@@ -341,6 +342,13 @@ export async function runCodexWarmupCycle(
|
|||||||
return { status: 'skipped', reason: 'runtime_busy' };
|
return { status: 'skipped', reason: 'runtime_busy' };
|
||||||
|
|
||||||
const nowMs = runtime.nowMs ?? Date.now();
|
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 nowIso = new Date(nowMs).toISOString();
|
||||||
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
|
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
|
||||||
const state = readWarmupState(statePath);
|
const state = readWarmupState(statePath);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
type SessionCommandDeps,
|
type SessionCommandDeps,
|
||||||
} from './session-commands.js';
|
} 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: {
|
export async function handleQueuedRunGates(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -30,5 +35,29 @@ export async function handleQueuedRunGates(args: {
|
|||||||
return cmdResult;
|
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 };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from './message-runtime-flow.js';
|
} from './message-runtime-flow.js';
|
||||||
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import { resolveStoredVisibleVerdict } from './paired-verdict.js';
|
import { resolveStoredVisibleVerdict } from './paired-verdict.js';
|
||||||
|
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||||
|
import { getEffectiveChannelLease } from './service-routing.js';
|
||||||
import {
|
import {
|
||||||
advanceLastAgentCursor,
|
advanceLastAgentCursor,
|
||||||
finalizeQueuedRunCursor,
|
finalizeQueuedRunCursor,
|
||||||
@@ -62,6 +64,23 @@ function timestampMs(value: string | null | undefined): number | null {
|
|||||||
return Number.isFinite(parsed) ? parsed : 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(
|
function isHumanMessageFreshForTask(
|
||||||
task: PairedTask | null | undefined,
|
task: PairedTask | null | undefined,
|
||||||
message: NewMessage,
|
message: NewMessage,
|
||||||
@@ -219,6 +238,21 @@ export async function runPendingPairedTurnIfNeeded(args: {
|
|||||||
return null;
|
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) {
|
if (pendingTurn.channel) {
|
||||||
const claimed = claimPairedTurnExecution({
|
const claimed = claimPairedTurnExecution({
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
Reference in New Issue
Block a user