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,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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user