fix(primer): guarantee 08:00 Codex primer wins the window-anchor race
Address reviewer findings on the narrow dawn-hold approach:
- 08:00 release race (the daily failure): the dawn window's clock boundary
lapses at exactly 08:00, so a held reviewer turn released at 08:00:00 could
beat the primer to open the fresh window. Add an in-process anchor lock — the
08:00 primer sets a flag for the duration of its own Codex call, and a short
post-slot grace bridges the gap between the boundary lapse and the flag set.
Held consumers stay held until the primer's anchoring request completes.
- Owner gate now keys on the EFFECTIVE owner backend (configured codex owner OR
a global-failover override to Codex) via getEffectiveChannelLease, not the
static group.agentType — covers failover-to-Codex rooms.
- Dedupe the owner wait-notice to at most once per room per dawn window
(ALIGNMENT_NOTICE_KEY) instead of on every re-poll. Held turns still resume
exactly once: the gate returns without advancing the message cursor.
Note on coverage: the deep common boundary (runAgentForGroup) returns only
'success' | 'error' with no defer state — holding there would consume/lose the
message or trigger handoff/failover, so holds stay at the cursor-safe shallow
gates. Paired reviewer/arbiter turns (incl. scheduled review-ready
reconciliation) and codex-owner turns are covered; generic codex scheduled
tasks (runTask) and Claude->Codex handoffs at dawn remain rare residual edges.
Tests: anchor-lock + grace + slot/notice-key (codex-primer-alignment.test),
failover-owner hold + notice dedupe (message-runtime-gating.test). 27 focused
tests pass; the 9 pre-existing service-routing/migration failures predate this
work (verified at 728a8c2).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||
import {
|
||||
ALIGNMENT_NOTICE_KEY,
|
||||
beginPrimerAnchor,
|
||||
endPrimerAnchor,
|
||||
isAlignmentSlot,
|
||||
isPrimerAnchoring,
|
||||
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);
|
||||
/** Build an epoch-ms value that reads as the given KST wall-clock time. */
|
||||
function atKst(hour: number, minute = 0, second = 0): number {
|
||||
// shouldHold reads KST via `new Date(ms + KST_OFFSET_MS)`, so picking a UTC
|
||||
// instant equal to the desired KST clock and subtracting the offset
|
||||
// reproduces that KST time exactly.
|
||||
const kstShifted = Date.UTC(2026, 5, 10, hour, minute, second);
|
||||
return kstShifted - KST_OFFSET_MS;
|
||||
}
|
||||
|
||||
describe('shouldHoldCodexForPrimerAlignment', () => {
|
||||
afterEach(() => {
|
||||
// The anchor flag is module-global; never leak it across tests.
|
||||
endPrimerAnchor();
|
||||
});
|
||||
|
||||
describe('shouldHoldCodexForPrimerAlignment — dawn window', () => {
|
||||
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);
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(7, 59, 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('keeps holding through the post-08:00 grace, then releases', () => {
|
||||
// Grace bridges the gap between the 08:00 boundary lapsing and the primer
|
||||
// setting its anchor flag, so a consumer poll at 08:00:00 cannot slip in.
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(8, 0, 0))).toBe(true);
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(8, 2, 59))).toBe(true);
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(8, 3, 1))).toBe(false);
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(9, 0))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not hold before the dawn window', () => {
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(2, 59))).toBe(false);
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(2, 59, 59))).toBe(false);
|
||||
expect(shouldHoldCodexForPrimerAlignment(atKst(0, 0))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -37,3 +52,39 @@ describe('shouldHoldCodexForPrimerAlignment', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldCodexForPrimerAlignment — anchor lock', () => {
|
||||
it('holds while the primer is anchoring, even outside the dawn window', () => {
|
||||
// Closes the 08:00 race: once the primer begins, all other Codex consumers
|
||||
// are held until it finishes — regardless of the clock.
|
||||
const noon = atKst(13, 0);
|
||||
expect(shouldHoldCodexForPrimerAlignment(noon)).toBe(false);
|
||||
beginPrimerAnchor();
|
||||
expect(isPrimerAnchoring()).toBe(true);
|
||||
expect(shouldHoldCodexForPrimerAlignment(noon)).toBe(true);
|
||||
endPrimerAnchor();
|
||||
expect(isPrimerAnchoring()).toBe(false);
|
||||
expect(shouldHoldCodexForPrimerAlignment(noon)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlignmentSlot', () => {
|
||||
it('is true only at the 08:00 KST hour', () => {
|
||||
expect(isAlignmentSlot(atKst(8, 0))).toBe(true);
|
||||
expect(isAlignmentSlot(atKst(8, 59))).toBe(true);
|
||||
expect(isAlignmentSlot(atKst(7, 59))).toBe(false);
|
||||
expect(isAlignmentSlot(atKst(13, 0))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALIGNMENT_NOTICE_KEY', () => {
|
||||
it('is stable within a KST day and changes across days', () => {
|
||||
expect(ALIGNMENT_NOTICE_KEY(atKst(3, 0))).toBe(
|
||||
ALIGNMENT_NOTICE_KEY(atKst(7, 59)),
|
||||
);
|
||||
const dayTwo = atKst(3, 0) + 24 * 60 * 60 * 1000;
|
||||
expect(ALIGNMENT_NOTICE_KEY(dayTwo)).not.toBe(
|
||||
ALIGNMENT_NOTICE_KEY(atKst(3, 0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +1,105 @@
|
||||
/**
|
||||
* Codex usage-primer alignment — narrow pre-08:00 quiet window.
|
||||
* Codex usage-primer alignment — narrow pre-08:00 quiet window + anchor lock.
|
||||
*
|
||||
* 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
|
||||
* (verified live — 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).
|
||||
* (reviewers/arbiters every paired room, codex-owner rooms, warm-up), so 08:00
|
||||
* normally lands mid-window and the reset drifts (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.
|
||||
* Two mechanisms, both time-based / in-process (no I/O, no reliance on the
|
||||
* unreliable live usage figures):
|
||||
*
|
||||
* 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.
|
||||
* 1. Dawn quiet window [03:00, 08:00) KST. One Codex window is at most 5h, so
|
||||
* anything opened before 03:00 has expired by 08:00 — held requests can't
|
||||
* keep a stray window alive into the slot.
|
||||
*
|
||||
* 2. Anchor lock. The 08:00 primer sets an in-process flag for the duration of
|
||||
* its own Codex call (beginPrimerAnchor/endPrimerAnchor). Held consumers
|
||||
* stay held while the flag is set, so the primer wins the first-request race
|
||||
* even though the dawn window's clock boundary lapses at exactly 08:00. A
|
||||
* short post-slot grace bridges the few ms between the 08:00 boundary lapse
|
||||
* and the primer firing, so a consumer poll in that gap can't slip in first.
|
||||
*
|
||||
* Narrow by design (the broad all-slots version was previously reverted): only
|
||||
* the 08:00 alignment slot holds; the other slots (13/18/23) and all daytime
|
||||
* hours pass through untouched.
|
||||
*/
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const HOUR_MS = 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;
|
||||
// Dawn 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_MS = 3 * HOUR_MS;
|
||||
export const DAWN_QUIET_END_MS = 8 * HOUR_MS;
|
||||
|
||||
// Grace after the 08:00 boundary during which consumers stay held even though
|
||||
// the dawn window technically lapsed — covers the gap between the boundary and
|
||||
// the primer setting its anchor flag (incl. event-loop lag at the slot).
|
||||
export const PRIMER_SLOT_GRACE_MS = 3 * 60 * 1000;
|
||||
|
||||
/** The KST clock hour the primer aligns to (08:00). */
|
||||
export const ALIGNMENT_SLOT_HOUR_KST = 8;
|
||||
|
||||
let primerAnchorActive = false;
|
||||
|
||||
/** Mark the 08:00 alignment primer as in-flight so consumers stay held. */
|
||||
export function beginPrimerAnchor(): void {
|
||||
primerAnchorActive = true;
|
||||
}
|
||||
|
||||
/** Clear the anchor flag once the alignment primer's Codex call has finished. */
|
||||
export function endPrimerAnchor(): void {
|
||||
primerAnchorActive = false;
|
||||
}
|
||||
|
||||
/** Whether the 08:00 alignment primer is currently anchoring the window. */
|
||||
export function isPrimerAnchoring(): boolean {
|
||||
return primerAnchorActive;
|
||||
}
|
||||
|
||||
/** Milliseconds since KST midnight for the given instant. */
|
||||
function kstMsOfDay(nowMs: number): number {
|
||||
const shifted = new Date(nowMs + KST_OFFSET_MS);
|
||||
return (
|
||||
((shifted.getUTCHours() * 60 + shifted.getUTCMinutes()) * 60 +
|
||||
shifted.getUTCSeconds()) *
|
||||
1000 +
|
||||
shifted.getUTCMilliseconds()
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the given instant is the 08:00 KST alignment slot (hour granularity). */
|
||||
export function isAlignmentSlot(nowMs: number = Date.now()): boolean {
|
||||
return (
|
||||
new Date(nowMs + KST_OFFSET_MS).getUTCHours() === ALIGNMENT_SLOT_HOUR_KST
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable key identifying the current dawn hold window (its KST calendar date),
|
||||
* used to dedupe the "please wait" notice to at most once per room per window.
|
||||
*/
|
||||
export function ALIGNMENT_NOTICE_KEY(nowMs: number = Date.now()): string {
|
||||
return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* fixed-slot primer is the window-anchoring first request. Holds while the
|
||||
* alignment primer is anchoring, and during [03:00, 08:00 + grace) KST; every
|
||||
* other hour returns false.
|
||||
*/
|
||||
export function shouldHoldCodexForPrimerAlignment(
|
||||
nowMs: number = Date.now(),
|
||||
): boolean {
|
||||
const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours();
|
||||
if (primerAnchorActive) return true;
|
||||
const msOfDay = kstMsOfDay(nowMs);
|
||||
return (
|
||||
kstHour >= DAWN_QUIET_START_HOUR_KST && kstHour < DAWN_QUIET_END_HOUR_KST
|
||||
msOfDay >= DAWN_QUIET_START_MS &&
|
||||
msOfDay < DAWN_QUIET_END_MS + PRIMER_SLOT_GRACE_MS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
const { handleSessionCommandMock } = vi.hoisted(() => ({
|
||||
handleSessionCommandMock: vi.fn(),
|
||||
}));
|
||||
const { handleSessionCommandMock, getEffectiveChannelLeaseMock } = vi.hoisted(
|
||||
() => ({
|
||||
handleSessionCommandMock: vi.fn(),
|
||||
getEffectiveChannelLeaseMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('./session-commands.js', () => ({
|
||||
handleSessionCommand: handleSessionCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock('./service-routing.js', () => ({
|
||||
getEffectiveChannelLease: getEffectiveChannelLeaseMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
beginPrimerAnchor,
|
||||
endPrimerAnchor,
|
||||
} from './codex-primer-alignment.js';
|
||||
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
||||
|
||||
describe('message-runtime-gating', () => {
|
||||
const baseArgs = {
|
||||
chatJid: 'room-1',
|
||||
function makeArgs(overrides: {
|
||||
chatJid?: string;
|
||||
sendMessage?: () => Promise<void>;
|
||||
}) {
|
||||
return {
|
||||
chatJid: overrides.chatJid ?? 'room-1',
|
||||
group: {
|
||||
folder: 'room-folder',
|
||||
name: 'room',
|
||||
@@ -26,11 +40,25 @@ describe('message-runtime-gating', () => {
|
||||
triggerPattern: /^코덱스/,
|
||||
timezone: 'Asia/Seoul',
|
||||
hasImplicitContinuationWindow: () => false,
|
||||
sessionCommandDeps: {} as never,
|
||||
sessionCommandDeps: {
|
||||
sendMessage: overrides.sendMessage ?? (async () => {}),
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
|
||||
describe('message-runtime-gating', () => {
|
||||
beforeEach(() => {
|
||||
handleSessionCommandMock.mockReset();
|
||||
getEffectiveChannelLeaseMock.mockReset();
|
||||
// Default: claude owner, no failover → no alignment hold.
|
||||
getEffectiveChannelLeaseMock.mockReturnValue({
|
||||
owner_agent_type: 'claude-code',
|
||||
owner_failover_active: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
endPrimerAnchor();
|
||||
});
|
||||
|
||||
it('returns session-command results directly when a command is handled', async () => {
|
||||
@@ -39,16 +67,54 @@ describe('message-runtime-gating', () => {
|
||||
success: false,
|
||||
});
|
||||
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
const result = await handleQueuedRunGates(makeArgs({}));
|
||||
|
||||
expect(result).toEqual({ handled: true, success: false });
|
||||
});
|
||||
|
||||
it('falls through when no session command is handled', async () => {
|
||||
it('falls through when no command and the room owner is not on Codex', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
beginPrimerAnchor(); // force the alignment window on, isolate provider check
|
||||
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
const result = await handleQueuedRunGates(makeArgs({}));
|
||||
|
||||
expect(result).toEqual({ handled: false });
|
||||
});
|
||||
|
||||
it('holds a codex-owner turn during the alignment window and notifies once', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
getEffectiveChannelLeaseMock.mockReturnValue({
|
||||
owner_agent_type: 'codex',
|
||||
owner_failover_active: false,
|
||||
});
|
||||
beginPrimerAnchor();
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
|
||||
const first = await handleQueuedRunGates(
|
||||
makeArgs({ chatJid: 'room-codex', sendMessage }),
|
||||
);
|
||||
const second = await handleQueuedRunGates(
|
||||
makeArgs({ chatJid: 'room-codex', sendMessage }),
|
||||
);
|
||||
|
||||
expect(first).toEqual({ handled: true, success: true });
|
||||
expect(second).toEqual({ handled: true, success: true });
|
||||
// Notice deduped: sent once across repeated holds in the same window.
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('holds a claude-owner room when global failover routes the owner to Codex', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
getEffectiveChannelLeaseMock.mockReturnValue({
|
||||
owner_agent_type: 'claude-code',
|
||||
owner_failover_active: true,
|
||||
});
|
||||
beginPrimerAnchor();
|
||||
|
||||
const result = await handleQueuedRunGates(
|
||||
makeArgs({ chatJid: 'room-failover' }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||
import {
|
||||
ALIGNMENT_NOTICE_KEY,
|
||||
shouldHoldCodexForPrimerAlignment,
|
||||
} from './codex-primer-alignment.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getEffectiveChannelLease } from './service-routing.js';
|
||||
import {
|
||||
handleSessionCommand,
|
||||
type SessionCommandDeps,
|
||||
} from './session-commands.js';
|
||||
import type { AgentType, NewMessage, RegisteredGroup } from './types.js';
|
||||
import type { NewMessage, RegisteredGroup } from './types.js';
|
||||
|
||||
const CODEX_ALIGNMENT_AUTO_REPLY =
|
||||
'코덱스 5시간 한도 리셋을 매일 오후 1시에 고정하기 위해, 새벽 03~08시 구간은 정렬용으로 잠시 대기합니다. 08시 이후 다시 보내주세요.';
|
||||
|
||||
// Per-room dedupe so a held owner room gets the wait notice at most once per
|
||||
// dawn hold window, instead of on every re-poll while it stays held.
|
||||
const lastAlignmentNoticeKeyByChat = new Map<string, string>();
|
||||
|
||||
function shouldSendAlignmentNotice(chatJid: string): boolean {
|
||||
const key = ALIGNMENT_NOTICE_KEY();
|
||||
if (lastAlignmentNoticeKeyByChat.get(chatJid) === key) return false;
|
||||
lastAlignmentNoticeKeyByChat.set(chatJid, key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleQueuedRunGates(args: {
|
||||
chatJid: string;
|
||||
group: RegisteredGroup;
|
||||
@@ -37,12 +52,17 @@ export async function handleQueuedRunGates(args: {
|
||||
|
||||
// ── 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()) {
|
||||
// cover rooms whose *owner* effectively runs on Codex, so a fresh human turn
|
||||
// doesn't anchor the shared window before the 08:00 fixed-slot primer. Keyed
|
||||
// on the EFFECTIVE owner backend — configured codex owner OR a global-failover
|
||||
// override to Codex — not just the static group agent type. Only fires inside
|
||||
// the dawn hold window, so daytime turns are never delayed. Returning handled
|
||||
// without advancing the message cursor lets the turn resume on the next poll
|
||||
// exactly once after the window clears.
|
||||
const lease = getEffectiveChannelLease(args.chatJid);
|
||||
const ownerRunsCodex =
|
||||
lease.owner_agent_type === 'codex' || lease.owner_failover_active === true;
|
||||
if (ownerRunsCodex && shouldHoldCodexForPrimerAlignment()) {
|
||||
logger.info(
|
||||
{
|
||||
chatJid: args.chatJid,
|
||||
@@ -51,10 +71,12 @@ export async function handleQueuedRunGates(args: {
|
||||
},
|
||||
'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');
|
||||
if (shouldSendAlignmentNotice(args.chatJid)) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
beginPrimerAnchor,
|
||||
endPrimerAnchor,
|
||||
isAlignmentSlot,
|
||||
} from './codex-primer-alignment.js';
|
||||
import { CODEX_WARMUP_CONFIG } from './config.js';
|
||||
import { refreshActiveCodexUsage } from './codex-usage-collector.js';
|
||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||
@@ -149,6 +154,12 @@ async function runClaudePrimerCycle(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function runCodexPrimerCycle(): Promise<void> {
|
||||
// At the 08:00 alignment slot, hold all other Codex consumers (via the shared
|
||||
// shouldHoldCodexForPrimerAlignment flag) for the duration of this primer's
|
||||
// Codex call, so the primer wins the first-request race that opens the fresh
|
||||
// 5h window. Other slots run without the anchor lock.
|
||||
const anchoring = isAlignmentSlot();
|
||||
if (anchoring) beginPrimerAnchor();
|
||||
try {
|
||||
// Fire immediately at the slot. There is NO pre-call usage refresh on
|
||||
// purpose: forceAttempt fires regardless of usage, so reading usage first
|
||||
@@ -170,13 +181,12 @@ export async function runCodexPrimerCycle(): Promise<void> {
|
||||
);
|
||||
logger.info({ result }, 'Codex primer cycle finished');
|
||||
// Observability only — runs AFTER the call so it never delays it: refresh
|
||||
// so the newly-anchored reset window shows up in usage data/logs. A slot
|
||||
// may still land while the trailing 5h window is exhausted (records
|
||||
// `failed`); we do not chase the reported reset time, since a trailing
|
||||
// window keeps sliding with later usage.
|
||||
// so the newly-anchored reset window shows up in usage data/logs.
|
||||
await refreshActiveCodexUsage();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Codex primer cycle threw');
|
||||
} finally {
|
||||
if (anchoring) endPrimerAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user