fix(paired): remove dawn Codex primer-alignment hold; add owner routing indicator
Reviewer/arbiter/owner Codex turns are no longer deferred during the [03:00-08:00 KST] dawn window — they dispatch immediately at all hours. Deletes codex-primer-alignment.ts and every hold call site (gating owner hold + alignment notice, reviewer/arbiter queue hold, warm-up skip, primer anchor-lock). The 08:00 primer still fires; it just no longer holds other consumers. Tradeoff: the 13:00 KST 5h-reset anchoring is no longer enforced. Also adds a user-visible next-step indicator after owner turns that do not end the task (review_ready -> reviewer requested, arbiter_requested -> arbiter called) so a same-looking status line is no longer ambiguous; no extra line on completion to avoid duplicating the owner's final message. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,90 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, 59))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
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, 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 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 every paired room, codex-owner rooms, warm-up), so 08:00
|
|
||||||
* normally lands mid-window and the reset drifts (observed 15:47).
|
|
||||||
*
|
|
||||||
* Two mechanisms, both time-based / in-process (no I/O, no reliance 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;
|
|
||||||
|
|
||||||
// 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 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 {
|
|
||||||
if (primerAnchorActive) return true;
|
|
||||||
const msOfDay = kstMsOfDay(nowMs);
|
|
||||||
return (
|
|
||||||
msOfDay >= DAWN_QUIET_START_MS &&
|
|
||||||
msOfDay < DAWN_QUIET_END_MS + PRIMER_SLOT_GRACE_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ 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,
|
||||||
@@ -342,13 +341,6 @@ 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);
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ vi.mock('./message-runtime-follow-up.js', () => ({
|
|||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import * as pairedExecutionContextModule from './paired-execution-context.js';
|
import * as pairedExecutionContextModule from './paired-execution-context.js';
|
||||||
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
import {
|
||||||
|
createPairedExecutionLifecycle,
|
||||||
|
resolveOwnerNextStepNotice,
|
||||||
|
} from './message-agent-executor-paired.js';
|
||||||
|
import type { PairedTask } from './types.js';
|
||||||
|
|
||||||
const log = {
|
const log = {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
@@ -325,3 +329,42 @@ describe('createPairedExecutionLifecycle completion handling', () => {
|
|||||||
expect(enqueueMessageCheck).not.toHaveBeenCalled();
|
expect(enqueueMessageCheck).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveOwnerNextStepNotice routing indicator', () => {
|
||||||
|
const task = (over: Partial<PairedTask>): PairedTask =>
|
||||||
|
({
|
||||||
|
status: 'active',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
completion_reason: null,
|
||||||
|
...over,
|
||||||
|
}) as PairedTask;
|
||||||
|
|
||||||
|
it('announces reviewer request while waiting', () => {
|
||||||
|
expect(resolveOwnerNextStepNotice(task({ status: 'review_ready' }))).toBe(
|
||||||
|
'🔁 리뷰어 검토 요청됨 — 리뷰어 응답을 기다리는 중입니다.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces arbiter call when arbiter is requested', () => {
|
||||||
|
expect(resolveOwnerNextStepNotice(task({ status: 'arbiter_requested' }))).toBe(
|
||||||
|
'⚖️ 의견 차이로 중재자 호출됨 — 중재 판정을 기다리는 중입니다.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent when the task finishes (no redundant done line)', () => {
|
||||||
|
expect(
|
||||||
|
resolveOwnerNextStepNotice(
|
||||||
|
task({ status: 'completed', completion_reason: 'done' }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveOwnerNextStepNotice(
|
||||||
|
task({ status: 'completed', completion_reason: 'escalated' }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent for in-progress owner work', () => {
|
||||||
|
expect(resolveOwnerNextStepNotice(task({ status: 'active' }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,25 +64,63 @@ function completeStoredExecution(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routing indicator shown to the user after an owner turn when the turn does
|
||||||
|
* NOT end the task — i.e. when more is still pending — so a same-looking status
|
||||||
|
* line (e.g. TASK_DONE) no longer reads as inconsistent: the user can always
|
||||||
|
* tell whether the reviewer was requested or the arbiter was called, vs. the
|
||||||
|
* task simply finishing (no line — the owner's final message is the ending).
|
||||||
|
* Purely additive — it reflects the routing the state machine already chose; it
|
||||||
|
* does not change any routing, and it never duplicates the completion message.
|
||||||
|
*/
|
||||||
|
export function resolveOwnerNextStepNotice(
|
||||||
|
task: PairedTaskRecord,
|
||||||
|
): string | null {
|
||||||
|
switch (task.status) {
|
||||||
|
case 'review_ready':
|
||||||
|
case 'in_review':
|
||||||
|
return '🔁 리뷰어 검토 요청됨 — 리뷰어 응답을 기다리는 중입니다.';
|
||||||
|
case 'arbiter_requested':
|
||||||
|
case 'in_arbitration':
|
||||||
|
return '⚖️ 의견 차이로 중재자 호출됨 — 중재 판정을 기다리는 중입니다.';
|
||||||
|
default:
|
||||||
|
// 'completed' included: the owner's final message is itself the ending,
|
||||||
|
// so no extra done/escalation line is appended here (escalation has its
|
||||||
|
// own dedicated notice above).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyPairedCompletionIfNeeded(args: {
|
async function notifyPairedCompletionIfNeeded(args: {
|
||||||
task: PairedTaskRecord | null | undefined;
|
task: PairedTaskRecord | null | undefined;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
|
completedRole: PairedRoomRole;
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (args.task?.status !== 'completed' || !args.task.completion_reason) return;
|
const task = args.task;
|
||||||
const sender = getLastHumanMessageSender(args.chatJid);
|
if (!task) return;
|
||||||
const mention = sender ? `<@${sender}>` : '';
|
|
||||||
const notifications: Record<string, string> = {
|
const emit = (text: string): Promise<void> | undefined =>
|
||||||
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
|
args.onOutput?.({
|
||||||
};
|
status: 'success',
|
||||||
const message = notifications[args.task.completion_reason];
|
result: text,
|
||||||
if (!message) return;
|
output: { visibility: 'public', text },
|
||||||
await args.onOutput?.({
|
phase: 'final',
|
||||||
status: 'success',
|
});
|
||||||
result: message,
|
|
||||||
output: { visibility: 'public', text: message },
|
// Escalation notice fires for any role's completion that escalated to the user.
|
||||||
phase: 'final',
|
if (task.status === 'completed' && task.completion_reason === 'escalated') {
|
||||||
});
|
const sender = getLastHumanMessageSender(args.chatJid);
|
||||||
|
const mention = sender ? `<@${sender}> ` : '';
|
||||||
|
await emit(`${mention}⚠️ 자동 해결 불가 — 확인이 필요합니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner turn: surface the next routing step so the ending is unambiguous.
|
||||||
|
if (args.completedRole !== 'owner') return;
|
||||||
|
const notice = resolveOwnerNextStepNotice(task);
|
||||||
|
if (!notice) return;
|
||||||
|
await emit(notice);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairedExecutionLifecycle {
|
export interface PairedExecutionLifecycle {
|
||||||
@@ -581,7 +619,8 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle {
|
|||||||
private async notifyCompletionAndQueueFollowUp(
|
private async notifyCompletionAndQueueFollowUp(
|
||||||
state: FinalizeState,
|
state: FinalizeState,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { chatJid, onOutput, pairedExecutionContext } = this.args;
|
const { chatJid, completedRole, onOutput, pairedExecutionContext } =
|
||||||
|
this.args;
|
||||||
if (!pairedExecutionContext || state.interruptedByHumanMessage) {
|
if (!pairedExecutionContext || state.interruptedByHumanMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -589,6 +628,7 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle {
|
|||||||
await notifyPairedCompletionIfNeeded({
|
await notifyPairedCompletionIfNeeded({
|
||||||
task: finishedTask,
|
task: finishedTask,
|
||||||
chatJid,
|
chatJid,
|
||||||
|
completedRole,
|
||||||
onOutput,
|
onOutput,
|
||||||
});
|
});
|
||||||
this.queueFollowUpIfNeeded(state, finishedTask);
|
this.queueFollowUpIfNeeded(state, finishedTask);
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
const { handleSessionCommandMock, getEffectiveChannelLeaseMock } = vi.hoisted(
|
const { handleSessionCommandMock } = vi.hoisted(() => ({
|
||||||
() => ({
|
handleSessionCommandMock: vi.fn(),
|
||||||
handleSessionCommandMock: vi.fn(),
|
}));
|
||||||
getEffectiveChannelLeaseMock: vi.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock('./session-commands.js', () => ({
|
vi.mock('./session-commands.js', () => ({
|
||||||
handleSessionCommand: handleSessionCommandMock,
|
handleSessionCommand: handleSessionCommandMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./service-routing.js', () => ({
|
|
||||||
getEffectiveChannelLease: getEffectiveChannelLeaseMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
beginPrimerAnchor,
|
|
||||||
endPrimerAnchor,
|
|
||||||
} from './codex-primer-alignment.js';
|
|
||||||
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
||||||
|
|
||||||
function makeArgs(overrides: {
|
function makeArgs(overrides: {
|
||||||
@@ -49,16 +38,6 @@ function makeArgs(overrides: {
|
|||||||
describe('message-runtime-gating', () => {
|
describe('message-runtime-gating', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
handleSessionCommandMock.mockReset();
|
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 () => {
|
it('returns session-command results directly when a command is handled', async () => {
|
||||||
@@ -72,49 +51,11 @@ describe('message-runtime-gating', () => {
|
|||||||
expect(result).toEqual({ handled: true, success: false });
|
expect(result).toEqual({ handled: true, success: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls through when no command and the room owner is not on Codex', async () => {
|
it('falls through when no session command matched', async () => {
|
||||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||||
beginPrimerAnchor(); // force the alignment window on, isolate provider check
|
|
||||||
|
|
||||||
const result = await handleQueuedRunGates(makeArgs({}));
|
const result = await handleQueuedRunGates(makeArgs({}));
|
||||||
|
|
||||||
expect(result).toEqual({ handled: false });
|
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,29 +1,9 @@
|
|||||||
import {
|
|
||||||
ALIGNMENT_NOTICE_KEY,
|
|
||||||
shouldHoldCodexForPrimerAlignment,
|
|
||||||
} from './codex-primer-alignment.js';
|
|
||||||
import { logger } from './logger.js';
|
|
||||||
import { getEffectiveChannelLease } from './service-routing.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 { 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: {
|
export async function handleQueuedRunGates(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
@@ -50,36 +30,5 @@ 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* 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,
|
|
||||||
groupName: args.group.name,
|
|
||||||
runId: args.runId,
|
|
||||||
},
|
|
||||||
'Holding fresh Codex owner turn — keeping 08:00 primer slot as first request',
|
|
||||||
);
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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,
|
||||||
@@ -64,23 +62,6 @@ 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,
|
||||||
@@ -238,21 +219,6 @@ 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,
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { spawn } from 'child_process';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
|
||||||
beginPrimerAnchor,
|
|
||||||
endPrimerAnchor,
|
|
||||||
isAlignmentSlot,
|
|
||||||
} from './codex-primer-alignment.js';
|
|
||||||
import { CODEX_WARMUP_CONFIG } from './config.js';
|
import { CODEX_WARMUP_CONFIG } from './config.js';
|
||||||
import { refreshActiveCodexUsage } from './codex-usage-collector.js';
|
import { refreshActiveCodexUsage } from './codex-usage-collector.js';
|
||||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||||
@@ -154,12 +149,6 @@ async function runClaudePrimerCycle(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runCodexPrimerCycle(): 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 {
|
try {
|
||||||
// Fire immediately at the slot. There is NO pre-call usage refresh on
|
// Fire immediately at the slot. There is NO pre-call usage refresh on
|
||||||
// purpose: forceAttempt fires regardless of usage, so reading usage first
|
// purpose: forceAttempt fires regardless of usage, so reading usage first
|
||||||
@@ -181,12 +170,10 @@ export async function runCodexPrimerCycle(): Promise<void> {
|
|||||||
);
|
);
|
||||||
logger.info({ result }, 'Codex primer cycle finished');
|
logger.info({ result }, 'Codex primer cycle finished');
|
||||||
// Observability only — runs AFTER the call so it never delays it: refresh
|
// Observability only — runs AFTER the call so it never delays it: refresh
|
||||||
// so the newly-anchored reset window shows up in usage data/logs.
|
// so the new reset window shows up in usage data/logs.
|
||||||
await refreshActiveCodexUsage();
|
await refreshActiveCodexUsage();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, 'Codex primer cycle threw');
|
logger.warn({ err }, 'Codex primer cycle threw');
|
||||||
} finally {
|
|
||||||
if (anchoring) endPrimerAnchor();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user