feat(primer): broaden Codex alignment hold to all real consumers; pre+post slot window
Address review gaps in the primer-alignment hold so the fixed-slot primer reliably wins the first-request race for the shared Codex window: - Extract the time-based hold into a dependency-free leaf module (codex-primer-alignment.ts) so every Codex path can share it without import cycles. - Add a pre-slot hold window (2 min) in addition to the post-slot (5 min) and the 04-08 KST dawn gap, closing the "request a beat before the slot" gap. - Hold the dashboard's periodic Codex warm-up too: runCodexWarmupCycle now skips during the hold unless called with isPrimer (the scheduled primer sets it), so the warm-up can't anchor the window ahead of the primer. - Remove the urgent @-mention bypass on the reviewer/arbiter and owner holds — the reset is anchored unconditionally as requested. - Reviewer/arbiter hold resumes exactly once after the window via the existing per-revision claim (poll re-detect; no double run). Note: the unified lease boundary (syncHostCodexSessionFiles) is synchronous and throws a terminal "Codex unavailable", so it can't host a clean defer+resume; gating stays at the async turn-scheduling layer that can re-queue. Honest caveat unchanged: making the primer first is necessary but not sufficient to pin a trailing reset. Verified: typecheck, build, bundle-smoke; full suite 1482 passing with only the 9 pre-existing env-config baseline failures (service-routing/paired-context/ migrate-room owner=claude vs codex-main); new alignment + warmup-hold tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
56
src/codex-primer-alignment.test.ts
Normal file
56
src/codex-primer-alignment.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
KST_OFFSET_MS,
|
||||||
|
msSincePreviousPrimerSlotKST,
|
||||||
|
msUntilNextPrimerSlotKST,
|
||||||
|
shouldHoldCodexForPrimerAlignment,
|
||||||
|
} from './codex-primer-alignment.js';
|
||||||
|
|
||||||
|
// nowMs such that the shifted KST clock reads the given hour/minute on a day.
|
||||||
|
const atKst = (hour: number, minute = 0): number =>
|
||||||
|
Date.UTC(2026, 5, 9, hour, minute) - KST_OFFSET_MS;
|
||||||
|
|
||||||
|
describe('codex-primer-alignment timing', () => {
|
||||||
|
it('measures ms until the next fixed slot (with day wrap)', () => {
|
||||||
|
expect(msUntilNextPrimerSlotKST(atKst(8, 0))).toBe(5 * 60 * 60 * 1000);
|
||||||
|
expect(msUntilNextPrimerSlotKST(atKst(12, 50))).toBe(10 * 60 * 1000);
|
||||||
|
// After the day's last slot → next slot is tomorrow's 08:00.
|
||||||
|
expect(msUntilNextPrimerSlotKST(atKst(23, 0))).toBe(9 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures ms since the most recent fixed slot (with day wrap)', () => {
|
||||||
|
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 in the minutes just before each slot (pre-slot window)', () => {
|
||||||
|
// ≤2 min before the slot is held so a just-before call cannot anchor first.
|
||||||
|
expect(shouldHoldCodexForPrimerAlignment(atKst(12, 59))).toBe(true);
|
||||||
|
expect(shouldHoldCodexForPrimerAlignment(atKst(17, 59))).toBe(true);
|
||||||
|
// 3 min before is outside the 2-min pre-window.
|
||||||
|
expect(shouldHoldCodexForPrimerAlignment(atKst(12, 57))).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/codex-primer-alignment.ts
Normal file
99
src/codex-primer-alignment.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Codex usage-primer alignment timing.
|
||||||
|
*
|
||||||
|
* The usage primer fires at fixed KST slots (08/13/18/23) to anchor the shared
|
||||||
|
* Codex 5h usage window. For the primer to actually pin the reset to a slot it
|
||||||
|
* must be the *first* Codex request after the previous window expires —
|
||||||
|
* otherwise an off-slot reviewer/arbiter/owner Codex turn (or the dashboard
|
||||||
|
* warm-up) anchors the window early and the reset drifts.
|
||||||
|
*
|
||||||
|
* So non-primer Codex execution is held during three time-based windows, so the
|
||||||
|
* scheduled primer wins 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 the
|
||||||
|
* account is kept quiet until the 08:00 primer.
|
||||||
|
* 2. A couple of minutes *before* each slot, so a call started a beat before
|
||||||
|
* the slot (against an already-reset window) cannot anchor first.
|
||||||
|
* 3. A few minutes *after* each slot, so a call firing right at/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, so a blunt clock
|
||||||
|
* rule is predictable and testable. This is a leaf module (no heavy imports) so
|
||||||
|
* every Codex path — gating, queue, warm-up, primer — can share it without
|
||||||
|
* import cycles.
|
||||||
|
*
|
||||||
|
* Honest limitation: making the primer the first request is necessary but not
|
||||||
|
* sufficient to pin the reset. If the provider window is a *trailing* one (reset
|
||||||
|
* slides forward with continued usage), post-slot usage can still move the reset
|
||||||
|
* even with a clean anchor. This holds the line as hard as a client can.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
||||||
|
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DAWN_GAP_START_HOUR_KST = 4;
|
||||||
|
const DAWN_GAP_END_HOUR_KST = 8;
|
||||||
|
const PRE_SLOT_HOLD_MS = 2 * 60 * 1000;
|
||||||
|
const POST_SLOT_HOLD_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** ms until the next fixed primer slot (handles the day wrap). */
|
||||||
|
export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number {
|
||||||
|
const kstNowShifted = nowMs + KST_OFFSET_MS;
|
||||||
|
const kstHour = new Date(kstNowShifted).getUTCHours();
|
||||||
|
|
||||||
|
let nextHour = PRIMER_HOURS_KST.find((h) => h > kstHour);
|
||||||
|
let dayOffset = 0;
|
||||||
|
if (nextHour === undefined) {
|
||||||
|
nextHour = PRIMER_HOURS_KST[0];
|
||||||
|
dayOffset = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = new Date(kstNowShifted);
|
||||||
|
target.setUTCHours(nextHour, 0, 0, 0);
|
||||||
|
if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1);
|
||||||
|
return target.getTime() - kstNowShifted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 execution 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;
|
||||||
|
}
|
||||||
|
if (msUntilNextPrimerSlotKST(nowMs) < PRE_SLOT_HOLD_MS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return msSincePreviousPrimerSlotKST(nowMs) < POST_SLOT_HOLD_MS;
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
failureCooldownMs: 21_600_000,
|
failureCooldownMs: 21_600_000,
|
||||||
maxConsecutiveFailures: 2,
|
maxConsecutiveFailures: 2,
|
||||||
},
|
},
|
||||||
{ nowMs: now, statePath },
|
{ nowMs: now, statePath, isPrimer: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ status: 'warmed', accountIndex: 2 });
|
expect(result).toEqual({ status: 'warmed', accountIndex: 2 });
|
||||||
@@ -215,7 +215,7 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
failureCooldownMs: 21_600_000,
|
failureCooldownMs: 21_600_000,
|
||||||
maxConsecutiveFailures: 2,
|
maxConsecutiveFailures: 2,
|
||||||
},
|
},
|
||||||
{ nowMs: now, statePath },
|
{ nowMs: now, statePath, isPrimer: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
|
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
|
||||||
@@ -275,7 +275,7 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
failureCooldownMs: 21_600_000,
|
failureCooldownMs: 21_600_000,
|
||||||
maxConsecutiveFailures: 2,
|
maxConsecutiveFailures: 2,
|
||||||
},
|
},
|
||||||
{ nowMs: now, statePath },
|
{ nowMs: now, statePath, isPrimer: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -338,7 +338,7 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
failureCooldownMs: 21_600_000,
|
failureCooldownMs: 21_600_000,
|
||||||
maxConsecutiveFailures: 2,
|
maxConsecutiveFailures: 2,
|
||||||
},
|
},
|
||||||
{ nowMs: now, statePath, ignoreZeroUsageWindow: true },
|
{ nowMs: now, statePath, ignoreZeroUsageWindow: true, isPrimer: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ status: 'warmed', accountIndex: 0 });
|
expect(result).toEqual({ status: 'warmed', accountIndex: 0 });
|
||||||
@@ -388,12 +388,13 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
maxConsecutiveFailures: 1,
|
maxConsecutiveFailures: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath });
|
const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath, isPrimer: true });
|
||||||
expect(failed.status).toBe('failed');
|
expect(failed.status).toBe('failed');
|
||||||
|
|
||||||
const backedOff = await runCodexWarmupCycle(config, {
|
const backedOff = await runCodexWarmupCycle(config, {
|
||||||
nowMs: now + 60_000,
|
nowMs: now + 60_000,
|
||||||
statePath,
|
statePath,
|
||||||
|
isPrimer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(backedOff).toEqual({
|
expect(backedOff).toEqual({
|
||||||
@@ -446,10 +447,63 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
failureCooldownMs: 21_600_000,
|
failureCooldownMs: 21_600_000,
|
||||||
maxConsecutiveFailures: 2,
|
maxConsecutiveFailures: 2,
|
||||||
},
|
},
|
||||||
{ nowMs: now, statePath },
|
{ nowMs: now, statePath, isPrimer: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ status: 'skipped', reason: 'stagger_wait' });
|
expect(result).toEqual({ status: 'skipped', reason: 'stagger_wait' });
|
||||||
expect(childProcess.spawn).not.toHaveBeenCalled();
|
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Codex warm-up primer-alignment hold', () => {
|
||||||
|
// KST 05:00 (dawn gap) — utcHour 20 → (20+9)%24 = 5.
|
||||||
|
const dawnNow = Date.UTC(2026, 3, 24, 20, 0, 0);
|
||||||
|
const baseConfig = {
|
||||||
|
enabled: true as const,
|
||||||
|
prompt: '.',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
intervalMs: 300_000,
|
||||||
|
minIntervalMs: 18_300_000,
|
||||||
|
staggerMs: 1_800_000,
|
||||||
|
maxUsagePct: 0,
|
||||||
|
maxD7UsagePct: 0,
|
||||||
|
commandTimeoutMs: 120_000,
|
||||||
|
failureCooldownMs: 21_600_000,
|
||||||
|
maxConsecutiveFailures: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a non-primer warm-up during a slot window', async () => {
|
||||||
|
const childProcess = await import('child_process');
|
||||||
|
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||||
|
|
||||||
|
const result = await runCodexWarmupCycle(baseConfig, { nowMs: dawnNow });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'skipped',
|
||||||
|
reason: 'primer_alignment_hold',
|
||||||
|
});
|
||||||
|
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exempts the primer call (isPrimer) from the alignment hold', async () => {
|
||||||
|
const rotation = await import('./codex-token-rotation.js');
|
||||||
|
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||||
|
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = await runCodexWarmupCycle(baseConfig, {
|
||||||
|
nowMs: dawnNow,
|
||||||
|
isPrimer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proceeds past the hold; with no accounts it skips for a different reason.
|
||||||
|
expect(result.status).toBe('skipped');
|
||||||
|
if (result.status === 'skipped') {
|
||||||
|
expect(result.reason).not.toBe('primer_alignment_hold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||||
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 {
|
import {
|
||||||
@@ -36,6 +37,13 @@ interface CodexWarmupRuntimeOptions {
|
|||||||
statePath?: string;
|
statePath?: string;
|
||||||
shouldSkip?: () => boolean;
|
shouldSkip?: () => boolean;
|
||||||
ignoreZeroUsageWindow?: boolean;
|
ignoreZeroUsageWindow?: boolean;
|
||||||
|
/**
|
||||||
|
* Set by the scheduled usage primer so its call is exempt from the
|
||||||
|
* primer-alignment hold. Any other warm-up caller (e.g. the dashboard's
|
||||||
|
* periodic warm-up) is held during the slot windows so it can't anchor the
|
||||||
|
* shared Codex window ahead of the primer.
|
||||||
|
*/
|
||||||
|
isPrimer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CodexWarmupCycleResult =
|
export type CodexWarmupCycleResult =
|
||||||
@@ -317,6 +325,12 @@ 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 slot windows so it can't anchor
|
||||||
|
// the shared Codex 5h window ahead of the scheduled primer (which passes
|
||||||
|
// isPrimer). Skipping is safe — a warm-up is best-effort.
|
||||||
|
if (!runtime.isPrimer && 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);
|
||||||
|
|||||||
@@ -9,45 +9,7 @@ vi.mock('./session-commands.js', () => ({
|
|||||||
handleSessionCommand: handleSessionCommandMock,
|
handleSessionCommand: handleSessionCommandMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
||||||
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,73 +1,13 @@
|
|||||||
|
import { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
type SessionCommandDeps,
|
type SessionCommandDeps,
|
||||||
} from './session-commands.js';
|
} from './session-commands.js';
|
||||||
import { KST_OFFSET_MS, PRIMER_HOURS_KST } from './usage-primer.js';
|
|
||||||
import type { AgentType, NewMessage, RegisteredGroup } from './types.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 =
|
const CODEX_ALIGNMENT_AUTO_REPLY =
|
||||||
'코덱스 사용량 정렬 시간입니다. 고정 슬롯(08/13/18/23 KST) 직후 잠시, 그리고 04~08시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주시거나 긴급하면 @태그해 주세요.';
|
'코덱스 사용량 정렬 시간입니다. 고정 슬롯(08/13/18/23 KST) 직후·직전 잠시, 그리고 04~08시 구간엔 정렬을 위해 대기합니다. 잠시 후 다시 보내주세요.';
|
||||||
|
|
||||||
export async function handleQueuedRunGates(args: {
|
export async function handleQueuedRunGates(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -98,13 +38,13 @@ export async function handleQueuedRunGates(args: {
|
|||||||
// ── Codex primer-alignment hold (codex-owner rooms) ──
|
// ── Codex primer-alignment hold (codex-owner rooms) ──
|
||||||
// Reviewer/arbiter Codex turns are held at their own dispatch site; here we
|
// 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
|
// cover rooms whose *owner* runs on Codex, so a fresh human turn doesn't
|
||||||
// anchor the shared window before the fixed-slot primer.
|
// anchor the shared window before the fixed-slot primer. Keyed on the room's
|
||||||
const agentType: AgentType = args.group.agentType ?? 'claude-code';
|
// configured owner agent type (a failover-only override to Codex is a rare
|
||||||
if (agentType === 'codex' && shouldHoldCodexForPrimerAlignment()) {
|
// edge we accept rather than do a DB read on every gate). No urgent bypass:
|
||||||
const hasUrgentMention = args.missedMessages.some((m) =>
|
// the user asked to anchor the reset unconditionally, so the held turn waits
|
||||||
args.triggerPattern.test(m.content),
|
// for the slot like any other.
|
||||||
);
|
const ownerAgentType: AgentType = args.group.agentType ?? 'claude-code';
|
||||||
if (!hasUrgentMention) {
|
if (ownerAgentType === 'codex' && shouldHoldCodexForPrimerAlignment()) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
chatJid: args.chatJid,
|
chatJid: args.chatJid,
|
||||||
@@ -120,7 +60,6 @@ export async function handleQueuedRunGates(args: {
|
|||||||
}
|
}
|
||||||
return { handled: true, success: true };
|
return { handled: true, success: true };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ 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;
|
||||||
@@ -394,7 +393,6 @@ function runBotOnlyPendingTurn(
|
|||||||
labelPairedSenders: args.labelPairedSenders,
|
labelPairedSenders: args.labelPairedSenders,
|
||||||
mode: 'bot-only',
|
mode: 'bot-only',
|
||||||
missedMessages,
|
missedMessages,
|
||||||
triggerPattern: args.triggerPattern,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ 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 { shouldHoldCodexForPrimerAlignment } from './codex-primer-alignment.js';
|
||||||
import { getEffectiveChannelLease } from './service-routing.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 {
|
||||||
@@ -184,7 +184,6 @@ 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) {
|
||||||
@@ -227,8 +226,9 @@ export async function runPendingPairedTurnIfNeeded(args: {
|
|||||||
// shared 5h window early and drifts the reset. When the active role runs on
|
// 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
|
// 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
|
// 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
|
// on the next poll, and the per-revision claim guarantees it resumes exactly
|
||||||
// @-mention in the recent human messages bypasses the hold.
|
// once after the window clears (no work lost, no double run). No urgent
|
||||||
|
// bypass: the reset is anchored unconditionally.
|
||||||
const pendingRole = resolveActiveRole(task.status);
|
const pendingRole = resolveActiveRole(task.status);
|
||||||
if (
|
if (
|
||||||
(pendingRole === 'reviewer' || pendingRole === 'arbiter') &&
|
(pendingRole === 'reviewer' || pendingRole === 'arbiter') &&
|
||||||
@@ -239,10 +239,7 @@ export async function runPendingPairedTurnIfNeeded(args: {
|
|||||||
pendingRole === 'reviewer'
|
pendingRole === 'reviewer'
|
||||||
? lease.reviewer_agent_type
|
? lease.reviewer_agent_type
|
||||||
: lease.arbiter_agent_type;
|
: lease.arbiter_agent_type;
|
||||||
const hasUrgentMention = args.triggerPattern
|
if (roleAgentType === 'codex') {
|
||||||
? recentHumanMessages.some((m) => args.triggerPattern!.test(m.content))
|
|
||||||
: false;
|
|
||||||
if (roleAgentType === 'codex' && !hasUrgentMention) {
|
|
||||||
args.log.info(
|
args.log.info(
|
||||||
{
|
{
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('usage-primer', () => {
|
|||||||
maxUsagePct: 100,
|
maxUsagePct: 100,
|
||||||
maxD7UsagePct: 100,
|
maxD7UsagePct: 100,
|
||||||
}),
|
}),
|
||||||
{ ignoreZeroUsageWindow: true },
|
{ ignoreZeroUsageWindow: true, isPrimer: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { msUntilNextPrimerSlotKST } from './codex-primer-alignment.js';
|
||||||
import { CODEX_WARMUP_CONFIG } from './config.js';
|
import { CODEX_WARMUP_CONFIG } from './config.js';
|
||||||
import {
|
import {
|
||||||
refreshActiveCodexUsage,
|
refreshActiveCodexUsage,
|
||||||
@@ -23,14 +24,13 @@ import { getAllTokens } from './token-rotation.js';
|
|||||||
* 18:00 → 18:33 over ~33 min of activity), so any off-slot Codex call by a
|
* 18:00 → 18:33 over ~33 min of activity), so any off-slot Codex call by a
|
||||||
* paired-room reviewer/arbiter on the same account would anchor the window
|
* paired-room reviewer/arbiter on the same account would anchor the window
|
||||||
* early. To make the slot the *anchor*, the primer is paired with a hold gate
|
* early. To make the slot the *anchor*, the primer is paired with a hold gate
|
||||||
* (`shouldHoldCodexForPrimerAlignment` in message-runtime-gating.ts) that
|
* (`shouldHoldCodexForPrimerAlignment` in codex-primer-alignment.ts) that keeps
|
||||||
* keeps non-primer Codex turns quiet during the dawn gap (04–08 KST) and for a
|
* non-primer Codex turns/warm-ups quiet during the dawn gap (04–08 KST) and the
|
||||||
* few minutes after each slot, so the primer wins the first-request race. The
|
* minutes around each slot, so the primer wins the first-request race. The
|
||||||
* primer is also a best-effort warm-up + success/failure record.
|
* primer itself passes `isPrimer` so it is never held. The primer is also a
|
||||||
|
* best-effort warm-up + success/failure record.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
|
||||||
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
|
||||||
@@ -165,13 +165,14 @@ export async function runCodexPrimerCycle(): Promise<void> {
|
|||||||
maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT,
|
maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT,
|
||||||
maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT,
|
maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT,
|
||||||
},
|
},
|
||||||
{ ignoreZeroUsageWindow: true },
|
{ ignoreZeroUsageWindow: true, isPrimer: true },
|
||||||
);
|
);
|
||||||
// Best-effort: a slot may land while the trailing 5h window is exhausted,
|
// `isPrimer` exempts this call from the alignment hold (the hold blocks
|
||||||
// in which case this records `failed`. We deliberately do NOT try to
|
// every *other* Codex path during the slot windows) so the primer is the
|
||||||
// re-anchor to the reported reset time: the 5h limit is a trailing rolling
|
// first Codex request and anchors the window at the slot. A slot may still
|
||||||
// window whose reset slides forward with continued Codex usage, so no
|
// land while the trailing 5h window is exhausted, in which case this records
|
||||||
// single timed call can pin the reset to a fixed clock time.
|
// `failed`; we do not chase the reported reset time, since a trailing window
|
||||||
|
// can keep sliding with later usage.
|
||||||
logger.info({ result }, 'Codex primer cycle finished');
|
logger.info({ result }, 'Codex primer cycle finished');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, 'Codex primer cycle threw');
|
logger.warn({ err }, 'Codex primer cycle threw');
|
||||||
@@ -183,26 +184,6 @@ async function runPrimerCycle(): Promise<void> {
|
|||||||
await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]);
|
await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number {
|
|
||||||
// KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*.
|
|
||||||
const kstNowShifted = nowMs + KST_OFFSET_MS;
|
|
||||||
const kstDate = new Date(kstNowShifted);
|
|
||||||
const kstH = kstDate.getUTCHours();
|
|
||||||
|
|
||||||
let nextH = PRIMER_HOURS_KST.find((h) => h > kstH);
|
|
||||||
let dayOffset = 0;
|
|
||||||
if (nextH === undefined) {
|
|
||||||
nextH = PRIMER_HOURS_KST[0];
|
|
||||||
dayOffset = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = new Date(kstNowShifted);
|
|
||||||
target.setUTCHours(nextH, 0, 0, 0);
|
|
||||||
if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1);
|
|
||||||
|
|
||||||
return target.getTime() - kstNowShifted;
|
|
||||||
}
|
|
||||||
|
|
||||||
let primerTimer: ReturnType<typeof setTimeout> | null = null;
|
let primerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
export function startUsagePrimer(): void {
|
export function startUsagePrimer(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user