feat(primer): hold off-slot Codex turns so the fixed-slot primer anchors the reset
The usage primer fires at 08/13/18/23 KST but could not pin the Codex 5h reset because reviewer/arbiter turns on the same shared account ran off-slot and anchored the window first. Add a time-based hold (shouldHoldCodexForPrimerAlignment) that keeps non-primer Codex turns quiet during the 04-08 KST dawn gap and for a few minutes after each slot, so the scheduled primer wins the first-request race. Applied at the reviewer/arbiter dispatch site (the actual off-slot consumer here) and for codex-owner rooms; urgent @-mention turns bypass it. Time-based by design — the usage/reset figures are exactly the unreliable data, so a blunt clock rule is predictable and testable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,45 @@ vi.mock('./session-commands.js', () => ({
|
|||||||
handleSessionCommand: handleSessionCommandMock,
|
handleSessionCommand: handleSessionCommandMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
import {
|
||||||
|
handleQueuedRunGates,
|
||||||
|
msSincePreviousPrimerSlotKST,
|
||||||
|
shouldHoldCodexForPrimerAlignment,
|
||||||
|
} from './message-runtime-gating.js';
|
||||||
|
|
||||||
|
// nowMs such that the shifted KST clock reads the given hour/minute on a day.
|
||||||
|
const KST_OFFSET = 9 * 60 * 60 * 1000;
|
||||||
|
const atKst = (hour: number, minute = 0): number =>
|
||||||
|
Date.UTC(2026, 5, 9, hour, minute) - KST_OFFSET;
|
||||||
|
|
||||||
|
describe('primer-alignment hold timing', () => {
|
||||||
|
it('measures ms since the most recent fixed slot', () => {
|
||||||
|
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 04–08 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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('message-runtime-gating', () => {
|
describe('message-runtime-gating', () => {
|
||||||
const baseArgs = {
|
const baseArgs = {
|
||||||
|
|||||||
@@ -1,8 +1,71 @@
|
|||||||
|
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 { KST_OFFSET_MS, PRIMER_HOURS_KST } from './usage-primer.js';
|
||||||
|
import type { AgentType, NewMessage, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
// ── Codex primer-alignment hold ──
|
||||||
|
// The usage primer fires at fixed KST slots (08/13/18/23) to anchor the shared
|
||||||
|
// Codex 5h usage window. For that primer to actually pin the reset to the slot,
|
||||||
|
// it must be the *first* Codex request after the previous window expires —
|
||||||
|
// otherwise an off-slot reviewer/arbiter/owner Codex turn anchors the window
|
||||||
|
// early and the reset drifts. So we hold non-primer Codex turns during two
|
||||||
|
// time-based windows, letting the scheduled primer win the first-request race:
|
||||||
|
//
|
||||||
|
// 1. Dawn gap 04:00–08: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 we keep
|
||||||
|
// the account quiet until the 08:00 primer.
|
||||||
|
// 2. A few minutes right after each slot, so a reviewer turn firing a beat
|
||||||
|
// 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, and a blunt
|
||||||
|
// clock rule is predictable and testable. Urgent @-mention turns bypass it.
|
||||||
|
const DAWN_GAP_START_HOUR_KST = 4;
|
||||||
|
const DAWN_GAP_END_HOUR_KST = 8;
|
||||||
|
const POST_SLOT_HOLD_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** 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 turns 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;
|
||||||
|
}
|
||||||
|
return msSincePreviousPrimerSlotKST(nowMs) < POST_SLOT_HOLD_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODEX_ALIGNMENT_AUTO_REPLY =
|
||||||
|
'코덱스 사용량 정렬 시간입니다. 고정 슬롯(08/13/18/23 KST) 직후 잠시, 그리고 04~08시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주시거나 긴급하면 @태그해 주세요.';
|
||||||
|
|
||||||
export async function handleQueuedRunGates(args: {
|
export async function handleQueuedRunGates(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -30,5 +93,32 @@ 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 fixed-slot primer.
|
||||||
|
const agentType: AgentType = args.group.agentType ?? 'claude-code';
|
||||||
|
if (agentType === 'codex' && shouldHoldCodexForPrimerAlignment()) {
|
||||||
|
const hasUrgentMention = args.missedMessages.some((m) =>
|
||||||
|
args.triggerPattern.test(m.content),
|
||||||
|
);
|
||||||
|
if (!hasUrgentMention) {
|
||||||
|
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 };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ async function handleNoMissedMessages(
|
|||||||
roleToChannel: runtime.roleToChannel,
|
roleToChannel: runtime.roleToChannel,
|
||||||
labelPairedSenders: args.labelPairedSenders,
|
labelPairedSenders: args.labelPairedSenders,
|
||||||
mode: 'idle',
|
mode: 'idle',
|
||||||
|
triggerPattern: args.triggerPattern,
|
||||||
});
|
});
|
||||||
if (pendingTurnOutcome !== null) {
|
if (pendingTurnOutcome !== null) {
|
||||||
return pendingTurnOutcome;
|
return pendingTurnOutcome;
|
||||||
@@ -393,6 +394,7 @@ function runBotOnlyPendingTurn(
|
|||||||
labelPairedSenders: args.labelPairedSenders,
|
labelPairedSenders: args.labelPairedSenders,
|
||||||
mode: 'bot-only',
|
mode: 'bot-only',
|
||||||
missedMessages,
|
missedMessages,
|
||||||
|
triggerPattern: args.triggerPattern,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
resolveQueuedTurnRole,
|
resolveQueuedTurnRole,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
import { getTaskContextMessages } from './message-runtime-task-context.js';
|
import { getTaskContextMessages } from './message-runtime-task-context.js';
|
||||||
|
import { shouldHoldCodexForPrimerAlignment } from './message-runtime-gating.js';
|
||||||
|
import { getEffectiveChannelLease } from './service-routing.js';
|
||||||
import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js';
|
import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js';
|
||||||
import type {
|
import type {
|
||||||
ExecuteTurnFn,
|
ExecuteTurnFn,
|
||||||
@@ -182,6 +184,7 @@ export async function runPendingPairedTurnIfNeeded(args: {
|
|||||||
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
mode: 'idle' | 'bot-only';
|
mode: 'idle' | 'bot-only';
|
||||||
missedMessages?: NewMessage[];
|
missedMessages?: NewMessage[];
|
||||||
|
triggerPattern?: RegExp;
|
||||||
}): Promise<boolean | null> {
|
}): Promise<boolean | null> {
|
||||||
const { chatJid, task, roleToChannel } = args;
|
const { chatJid, task, roleToChannel } = args;
|
||||||
if (!task) {
|
if (!task) {
|
||||||
@@ -219,6 +222,35 @@ export async function runPendingPairedTurnIfNeeded(args: {
|
|||||||
return null;
|
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, so this just postpones (no work is lost). An urgent
|
||||||
|
// @-mention in the recent human messages bypasses the hold.
|
||||||
|
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;
|
||||||
|
const hasUrgentMention = args.triggerPattern
|
||||||
|
? recentHumanMessages.some((m) => args.triggerPattern!.test(m.content))
|
||||||
|
: false;
|
||||||
|
if (roleAgentType === 'codex' && !hasUrgentMention) {
|
||||||
|
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) {
|
if (pendingTurn.channel) {
|
||||||
const claimed = claimPairedTurnExecution({
|
const claimed = claimPairedTurnExecution({
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
@@ -18,17 +18,19 @@ import { getAllTokens } from './token-rotation.js';
|
|||||||
* provider account is kept warm and any usage-window issues surface in the
|
* provider account is kept warm and any usage-window issues surface in the
|
||||||
* logs at predictable times.
|
* logs at predictable times.
|
||||||
*
|
*
|
||||||
* IMPORTANT — what this does NOT do: it does not pin the reset to a fixed
|
* Reset alignment: the primer alone cannot pin the reset — empirically the
|
||||||
* clock time. Empirically the Codex 5h limit is a *trailing rolling* window:
|
* Codex 5h limit slides forward with continued usage (observed reset moving
|
||||||
* the reported reset slides forward with continued usage (observed reset
|
* 18:00 → 18:33 over ~33 min of activity), so any off-slot Codex call by a
|
||||||
* moving 18:00 → 18:33 over ~33 min of activity). A single timed message can
|
* paired-room reviewer/arbiter on the same account would anchor the window
|
||||||
* therefore not anchor the reset to a fixed time while Codex keeps being used
|
* early. To make the slot the *anchor*, the primer is paired with a hold gate
|
||||||
* (e.g. by a paired-room reviewer on the same account). The slots are a
|
* (`shouldHoldCodexForPrimerAlignment` in message-runtime-gating.ts) that
|
||||||
* best-effort warm-up + a success/failure record, not a reset-alignment lever.
|
* keeps non-primer Codex turns quiet during the dawn gap (04–08 KST) and for a
|
||||||
|
* few minutes after each slot, so the primer wins the first-request race. The
|
||||||
|
* primer is also a best-effort warm-up + success/failure record.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
export const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
||||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||||
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
|
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
|
||||||
const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
||||||
// Fire the Codex primer unconditionally at every slot, mirroring the Claude
|
// Fire the Codex primer unconditionally at every slot, mirroring the Claude
|
||||||
|
|||||||
Reference in New Issue
Block a user