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:
Codex
2026-06-10 11:59:03 +09:00
parent 1a85ebf7eb
commit aec70bc388
5 changed files with 275 additions and 63 deletions

View File

@@ -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 });
});
});