refactor: cut over room mode from paired legacy

This commit is contained in:
Eyejoker
2026-03-31 14:47:59 +09:00
parent f04e5d06b3
commit 509e4c24a3
12 changed files with 245 additions and 68 deletions

View File

@@ -17,10 +17,6 @@ vi.mock('./config.js', () => ({
isReviewService: vi.fn(() => false),
}));
vi.mock('./db.js', () => ({
isPairedRoomJid: vi.fn(() => false),
}));
vi.mock('./env.js', () => ({
readEnvFile: mockReadEnvFile,
getEnv: vi.fn((key: string) => undefined),
@@ -40,6 +36,7 @@ vi.mock('./platform-prompts.js', () => ({
}));
vi.mock('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false),
getEffectiveChannelLease: vi.fn(() => ({
chat_jid: 'dc:test',
owner_service_id: 'claude',
@@ -82,7 +79,6 @@ vi.mock('os', async () => {
import { prepareGroupEnvironment } from './agent-runner-environment.js';
import * as config from './config.js';
import * as db from './db.js';
import * as serviceRouting from './service-routing.js';
import type { RegisteredGroup } from './types.js';
@@ -202,7 +198,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
it('uses the failover owner prompt pack for codex-review when it owns an explicit failover lease', () => {
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'dc:test',
owner_service_id: 'codex-review',
@@ -262,7 +258,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
});
it('adds only the shared owner prompt fragments to Claude session prompts', () => {
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
mockReadEnvFile.mockReturnValue({});
const promptsDir = path.join(tempRoot, 'prompts');
@@ -307,7 +303,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
it('returns to the normal owner prompt stack after failover is cleared', () => {
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'dc:test',
owner_service_id: 'claude',

View File

@@ -11,7 +11,6 @@ import {
isReviewService,
} from './config.js';
import { logger } from './logger.js';
import { isPairedRoomJid } from './db.js';
import { readEnvFile } from './env.js';
import { getActiveCodexAuthPath } from './codex-token-rotation.js';
import { getCurrentToken } from './token-rotation.js';
@@ -27,7 +26,7 @@ import {
readPairedRoomPrompt,
readPlatformPrompt,
} from './platform-prompts.js';
import { getEffectiveChannelLease } from './service-routing.js';
import { getEffectiveChannelLease, hasReviewerLease } from './service-routing.js';
import type { AgentType, RegisteredGroup } from './types.js';
// writeCodexApiKeyAuth removed — Codex uses OAuth only.
@@ -418,7 +417,7 @@ export function prepareGroupEnvironment(
const globalDir = path.join(GROUPS_DIR, 'global');
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
const isPairedRoom = isPairedRoomJid(chatJid);
const isPairedRoom = hasReviewerLease(chatJid);
const effectiveLease = getEffectiveChannelLease(chatJid);
const useCodexReviewFailoverPromptPack =
isReviewService(SERVICE_ID) &&
@@ -551,7 +550,7 @@ export function prepareContainerSessionEnvironment(args: {
// Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
const claudePairedRoomPrompt = isPairedRoomJid(chatJid)
const claudePairedRoomPrompt = hasReviewerLease(chatJid)
? role === 'arbiter'
? readArbiterPrompt(projectRoot)
: readPairedRoomPrompt('claude-code', projectRoot)

View File

@@ -67,6 +67,7 @@ vi.mock('./service-routing.js', () => ({
reason: null,
explicit: false,
})),
hasReviewerLease: vi.fn(() => true),
}));
// Create a controllable fake ChildProcess

View File

@@ -29,10 +29,10 @@ vi.mock('../logger.js', () => ({
},
}));
const isPairedRoomJidMock = vi.hoisted(() => vi.fn(() => false));
const hasReviewerLeaseMock = vi.hoisted(() => vi.fn(() => false));
vi.mock('../db.js', () => ({
isPairedRoomJid: isPairedRoomJidMock,
vi.mock('../service-routing.js', () => ({
hasReviewerLease: hasReviewerLeaseMock,
}));
// --- discord.js mock ---
@@ -206,7 +206,7 @@ async function triggerMessage(message: any) {
describe('DiscordChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
isPairedRoomJidMock.mockReturnValue(false);
hasReviewerLeaseMock.mockReturnValue(false);
});
afterEach(() => {
@@ -343,7 +343,7 @@ describe('DiscordChannel', () => {
});
it('delivers other bot messages in paired rooms', async () => {
isPairedRoomJidMock.mockReturnValue(true);
hasReviewerLeaseMock.mockReturnValue(true);
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);

View File

@@ -17,10 +17,10 @@ import {
DATA_DIR,
TRIGGER_PATTERN,
} from '../config.js';
import { isPairedRoomJid } from '../db.js';
import { getEnv } from '../env.js';
import { logger } from '../logger.js';
import { formatOutbound } from '../router.js';
import { hasReviewerLease } from '../service-routing.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
@@ -214,7 +214,7 @@ export class DiscordChannel implements Channel {
const chatJid = `dc:${channelId}`;
const isOwnBotMessage = message.author.id === this.client?.user?.id;
if (isOwnBotMessage) return;
if (message.author.bot && !isPairedRoomJid(chatJid)) return;
if (message.author.bot && !hasReviewerLease(chatJid)) return;
let content = message.content;
const timestamp = message.createdAt.toISOString();

View File

@@ -1,9 +1,12 @@
import { Database } from 'bun:sqlite';
import fs from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
_initTestDatabaseFromFile,
claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor,
createPairedTask,
@@ -791,6 +794,8 @@ describe('paired room registration', () => {
'claude-code',
'codex',
]);
expect(getExplicitRoomMode('dc:123')).toBeUndefined();
expect(getEffectiveRoomMode('dc:123')).toBe('tribunal');
expect(isPairedRoomJid('dc:123')).toBe(true);
});
@@ -807,7 +812,7 @@ describe('paired room registration', () => {
expect(isPairedRoomJid('dc:solo')).toBe(false);
});
it('falls back to inferred room mode when no explicit room mode exists', () => {
it('keeps inferred room mode available when no explicit override exists', () => {
setRegisteredGroup('dc:legacy-paired', {
name: 'Legacy Paired Claude',
folder: 'legacy-paired-claude',
@@ -829,6 +834,74 @@ describe('paired room registration', () => {
expect(isPairedRoomJid('dc:legacy-paired')).toBe(true);
});
it('backfills inferred room modes for legacy SQL rows missing room_settings', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-mode-');
const dbPath = path.join(tempDir, 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE registered_groups (
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
CREATE TABLE room_settings (
chat_jid TEXT PRIMARY KEY,
room_mode TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal'))
);
`);
const insertGroup = legacyDb.prepare(
`INSERT INTO registered_groups (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`,
);
insertGroup.run(
'dc:legacy-sql',
'Legacy SQL Claude',
'legacy-sql-claude',
'@Andy',
'2024-01-01T00:00:00.000Z',
'claude-code',
);
insertGroup.run(
'dc:legacy-sql',
'Legacy SQL Codex',
'legacy-sql-codex',
'@Codex',
'2024-01-01T00:00:00.000Z',
'codex',
);
legacyDb.close();
_initTestDatabaseFromFile(dbPath);
expect(getExplicitRoomMode('dc:legacy-sql')).toBeUndefined();
expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal');
expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal');
expect(isPairedRoomJid('dc:legacy-sql')).toBe(true);
});
it('lets explicit single override dual registration for paired-room checks', () => {
setRegisteredGroup('dc:explicit-single', {
name: 'Explicit Single Claude',

135
src/db.ts
View File

@@ -38,6 +38,12 @@ import {
import { readJsonFile } from './utils.js';
let db: Database;
type RoomModeSource = 'explicit' | 'inferred';
interface StoredRoomModeRow {
roomMode: RoomMode;
source: RoomModeSource;
}
export interface WorkItem {
id: number;
@@ -303,6 +309,7 @@ function createSchema(database: Database): void {
CREATE TABLE IF NOT EXISTS room_settings (
chat_jid TEXT PRIMARY KEY,
room_mode TEXT NOT NULL,
mode_source TEXT NOT NULL DEFAULT 'explicit',
updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal'))
);
@@ -387,6 +394,20 @@ function createSchema(database: Database): void {
/* column already exists */
}
try {
database.exec(
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
);
} catch {
/* column already exists */
}
database.exec(
`UPDATE room_settings
SET mode_source = 'explicit'
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
);
database.exec(`
UPDATE scheduled_tasks
SET agent_type = COALESCE(
@@ -785,6 +806,8 @@ function createSchema(database: Database): void {
} catch {
/* columns already exist */
}
backfillStoredRoomModes(database);
}
export function initDatabase(): void {
@@ -1832,6 +1855,7 @@ export function getRegisteredGroup(
}
export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
const existingRoomMode = getStoredRoomModeRow(jid);
if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
@@ -1850,6 +1874,10 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
group.agentType || 'claude-code',
group.workDir || null,
);
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
syncInferredRoomModeForJid(jid);
}
}
export function updateRegisteredGroupName(jid: string, name: string): void {
@@ -1916,10 +1944,11 @@ export function getAllRegisteredGroups(
return result;
}
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return [];
const rows = db
function collectRegisteredAgentTypes(
database: Database,
jid: string,
): AgentType[] {
const rows = database
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
.all(jid) as Array<{ agent_type: string | null }>;
@@ -1933,6 +1962,11 @@ export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
return [...types];
}
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return [];
return collectRegisteredAgentTypes(db, jid);
}
export function inferRoomModeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
): RoomMode {
@@ -1946,37 +1980,75 @@ export function inferRoomModeForJid(jid: string): RoomMode {
);
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
function normalizeRoomModeSource(
source: string | null | undefined,
): RoomModeSource | undefined {
return source === 'explicit' || source === 'inferred' ? source : undefined;
}
function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
if (!db) return undefined;
const row = db
.prepare(
`SELECT room_mode
`SELECT room_mode, mode_source
FROM room_settings
WHERE chat_jid = ?`,
)
.get(chatJid) as { room_mode: string | null } | undefined;
return row?.room_mode === 'single' || row?.room_mode === 'tribunal'
? row.room_mode
: undefined;
.get(chatJid) as
| { room_mode: string | null; mode_source: string | null }
| undefined;
const roomMode =
row?.room_mode === 'single' || row?.room_mode === 'tribunal'
? row.room_mode
: undefined;
const source = normalizeRoomModeSource(row?.mode_source);
return roomMode && source ? { roomMode, source } : undefined;
}
export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
function upsertStoredRoomMode(
chatJid: string,
roomMode: RoomMode,
source: RoomModeSource,
): void {
db.prepare(
`INSERT OR REPLACE INTO room_settings (
chat_jid,
room_mode,
mode_source,
updated_at
) VALUES (?, ?, ?)`,
).run(chatJid, roomMode, new Date().toISOString());
) VALUES (?, ?, ?, ?)`,
).run(chatJid, roomMode, source, new Date().toISOString());
}
function syncInferredRoomModeForJid(chatJid: string): void {
upsertStoredRoomMode(chatJid, inferRoomModeForJid(chatJid), 'inferred');
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
const row = getStoredRoomModeRow(chatJid);
return row?.source === 'explicit' ? row.roomMode : undefined;
}
export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
upsertStoredRoomMode(chatJid, roomMode, 'explicit');
}
export function clearExplicitRoomMode(chatJid: string): void {
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
const agentTypes = getRegisteredAgentTypesForJid(chatJid);
if (agentTypes.length === 0) {
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
return;
}
upsertStoredRoomMode(
chatJid,
inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred',
);
}
export function getEffectiveRoomMode(chatJid: string): RoomMode {
return getExplicitRoomMode(chatJid) ?? inferRoomModeForJid(chatJid);
return getStoredRoomModeRow(chatJid)?.roomMode ?? 'single';
}
function canRunTribunalFromRegisteredAgentTypes(
@@ -2002,6 +2074,39 @@ export function isPairedRoomJid(jid: string): boolean {
return getEffectiveRuntimeRoomMode(jid) === 'tribunal';
}
function backfillStoredRoomModes(database: Database): void {
const rows = database
.prepare(
`SELECT DISTINCT rg.jid
FROM registered_groups rg
LEFT JOIN room_settings rs ON rs.chat_jid = rg.jid
WHERE rs.chat_jid IS NULL`,
)
.all() as Array<{ jid: string }>;
if (rows.length === 0) return;
const insertRoomMode = database.prepare(
`INSERT OR REPLACE INTO room_settings (
chat_jid,
room_mode,
mode_source,
updated_at
) VALUES (?, ?, 'inferred', ?)`,
);
const tx = database.transaction((missingRows: Array<{ jid: string }>) => {
const updatedAt = new Date().toISOString();
for (const row of missingRows) {
const roomMode = inferRoomModeFromRegisteredAgentTypes(
collectRegisteredAgentTypes(database, row.jid),
);
insertRoomMode.run(row.jid, roomMode, updatedAt);
}
});
tx(rows);
}
// --- Paired task/project/workspace state ---
export function upsertPairedProject(project: PairedProject): void {

View File

@@ -1,9 +1,10 @@
import { getLastHumanMessageTimestamp, isPairedRoomJid } from './db.js';
import { getLastHumanMessageTimestamp } from './db.js';
import { filterProcessableMessages } from './bot-message-filter.js';
import { normalizeStoredSeqCursor } from './message-cursor.js';
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import { isTaskStatusControlMessage } from './task-watch-status.js';
import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from './config.js';
import { hasReviewerLease } from './service-routing.js';
import {
resolveAgentTypeForRole,
resolveRoleAgentPlan,
@@ -59,7 +60,7 @@ export function resolveCursorKey(
chatJid: string,
taskStatus?: string | null,
): string {
if (!isPairedRoomJid(chatJid)) return chatJid;
if (!hasReviewerLease(chatJid)) return chatJid;
const role = resolveActiveRole(taskStatus);
return role === 'owner' ? chatJid : `${chatJid}:${role}`;
}
@@ -128,7 +129,7 @@ export function shouldSkipBotOnlyCollaboration(
chatJid: string,
messages: NewMessage[],
): boolean {
if (isPairedRoomJid(chatJid)) return false;
if (hasReviewerLease(chatJid)) return false;
const allFromBots = messages.every(
(message) => message.is_from_me || !!message.is_bot_message,
);
@@ -164,7 +165,7 @@ export function hasAllowedTrigger(opts: {
// Paired rooms: bot-to-bot ping-pong doesn't need trigger patterns.
// Peer bot messages (from reviewer/owner) are always allowed.
if (isPairedRoomJid(chatJid)) {
if (hasReviewerLease(chatJid)) {
return true;
}
@@ -185,7 +186,7 @@ export function getProcessableMessages(
) {
return filterProcessableMessages(
messages,
isPairedRoomJid(chatJid),
hasReviewerLease(chatJid),
channel?.isOwnMessage?.bind(channel),
).filter((message) => !isTaskStatusControlMessage(message.content));
}
@@ -195,7 +196,7 @@ export function filterLoopingPairedBotMessages(
messages: Parameters<typeof filterProcessableMessages>[0],
failureText: string,
) {
if (!isPairedRoomJid(chatJid)) return messages;
if (!hasReviewerLease(chatJid)) return messages;
return messages.filter(
(message) =>

View File

@@ -137,12 +137,12 @@ vi.mock('./db.js', () => {
})),
markWorkItemDelivered: vi.fn(),
markWorkItemDeliveryRetry: vi.fn(),
isPairedRoomJid: vi.fn(() => false),
getLastBotFinalMessage: vi.fn(() => []),
};
});
vi.mock('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false),
getEffectiveChannelLease: vi.fn((chatJid: string) => ({
chat_jid: chatJid,
owner_service_id: 'claude',
@@ -212,7 +212,7 @@ describe('createMessageRuntime', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(db.getLastBotFinalMessage).mockReturnValue([]);
vi.mocked(db.isPairedRoomJid).mockReturnValue(false);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
vi.mocked(db.getRecentChatMessages).mockReturnValue([]);
vi.mocked(config.isClaudeService).mockReturnValue(true);
vi.mocked(config.isReviewService).mockReturnValue(false);
@@ -225,7 +225,7 @@ describe('createMessageRuntime', () => {
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -280,7 +280,7 @@ describe('createMessageRuntime', () => {
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -355,7 +355,7 @@ describe('createMessageRuntime', () => {
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -423,7 +423,7 @@ describe('createMessageRuntime', () => {
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
@@ -553,7 +553,7 @@ describe('createMessageRuntime', () => {
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: chatJid,
@@ -653,7 +653,7 @@ describe('createMessageRuntime', () => {
const longReviewerOutput = `검토 요약 ${'a'.repeat(2105)}`;
const truncatedReviewerOutput = longReviewerOutput.slice(0, 2000);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-merge-ready',
chat_jid: chatJid,
@@ -756,7 +756,7 @@ describe('createMessageRuntime', () => {
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-arbiter',
chat_jid: chatJid,
@@ -880,7 +880,7 @@ describe('createMessageRuntime', () => {
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
};
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid,
owner_service_id: 'claude',
@@ -990,7 +990,7 @@ describe('createMessageRuntime', () => {
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
};
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid,
owner_service_id: 'claude',

View File

@@ -14,7 +14,6 @@ import {
getLastBotFinalMessage,
getLastHumanMessageContent,
getRecentChatMessages,
isPairedRoomJid,
getLatestOpenPairedTaskForChat,
getPairedTurnOutputs,
updatePairedTask,
@@ -65,7 +64,7 @@ import {
} from './types.js';
import { logger } from './logger.js';
import { resolveGroupIpcPath } from './group-folder.js';
import { getEffectiveChannelLease } from './service-routing.js';
import { getEffectiveChannelLease, hasReviewerLease } from './service-routing.js';
/**
* Check if a message is a duplicate of the last bot final message in a paired room.
@@ -75,8 +74,7 @@ export function isDuplicateOfLastBotFinal(
chatJid: string,
text: string,
): boolean {
// Only check in paired rooms (both claude and codex registered)
if (!isPairedRoomJid(chatJid)) {
if (!hasReviewerLease(chatJid)) {
return false;
}
@@ -130,7 +128,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid: string,
messages: NewMessage[],
): NewMessage[] => {
if (!isPairedRoomJid(chatJid)) return messages;
if (!hasReviewerLease(chatJid)) return messages;
const lease = getEffectiveChannelLease(chatJid);
const sharedOwnerReviewerService =
lease.reviewer_service_id !== null &&
@@ -297,7 +295,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid: string,
messages: NewMessage[],
): boolean =>
isPairedRoomJid(chatJid) &&
hasReviewerLease(chatJid) &&
messages.every(
(message) => message.is_from_me === true || !!message.is_bot_message,
);
@@ -649,7 +647,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
// For paired rooms, determine the reviewer channel for correct bot routing.
// This is used whenever the reviewer needs to send output.
const reviewerChannelName =
isPairedRoomJid(chatJid) && REVIEWER_AGENT_TYPE === 'claude-code'
hasReviewerLease(chatJid) && REVIEWER_AGENT_TYPE === 'claude-code'
? 'discord'
: 'discord-review';
const foundReviewerChannel = findChannelByName(
@@ -748,7 +746,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return deliverySucceeded;
};
if (isPairedRoomJid(chatJid)) {
if (hasReviewerLease(chatJid)) {
logger.info(
{
chatJid,
@@ -771,7 +769,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
(group.agentType || 'claude-code') as 'claude-code' | 'codex',
);
if (openWorkItem) {
const pendingTask = isPairedRoomJid(chatJid)
const pendingTask = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const deliveryChannel = resolveChannel(pendingTask?.status);
@@ -803,7 +801,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (missedMessages.length === 0) {
// Check if a paired review is pending — run reviewer even without new messages
const pendingReviewTask = isPairedRoomJid(chatJid)
const pendingReviewTask = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const pendingTurn = pendingReviewTask
@@ -883,7 +881,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
);
},
resetPairedTask: () => {
if (isPairedRoomJid(chatJid)) {
if (hasReviewerLease(chatJid)) {
const task = getLatestOpenPairedTaskForChat(chatJid);
if (task) {
updatePairedTask(task.id, {
@@ -918,7 +916,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
// Determine role BEFORE advancing cursor — paired rooms use
// separate cursors for owner and reviewer so neither misses
// the other's messages.
const pendingTaskForChannel = isPairedRoomJid(chatJid)
const pendingTaskForChannel = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const taskStatus = pendingTaskForChannel?.status;
@@ -1130,7 +1128,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
// Use role-aware cursor for paired rooms so the reviewer
// always sees the owner's last messages and vice versa.
const loopPendingTask = isPairedRoomJid(chatJid)
const loopPendingTask = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const loopCursorKey = resolveCursorKey(

View File

@@ -171,6 +171,10 @@ export function isReviewerServiceForChat(
);
}
export function hasReviewerLease(chatJid: string): boolean {
return getEffectiveChannelLease(chatJid).reviewer_service_id !== null;
}
export function isArbiterServiceForChat(
chatJid: string,
serviceId: string = SERVICE_ID,

View File

@@ -15,7 +15,6 @@ import {
deleteTask,
getDueTasks,
getTaskById,
isPairedRoomJid,
logTaskRun,
updateTask,
updateTaskAfterRun,
@@ -62,6 +61,7 @@ import {
shouldUseTaskScopedSession,
} from './task-watch-status.js';
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
import { hasReviewerLease } from './service-routing.js';
import {
checkGitHubActionsRun,
computeGitHubWatcherDelayMs,
@@ -403,7 +403,7 @@ async function runTask(
// In paired rooms, post cron output via reviewer bot so the
// owner treats it as a peer request and acts on it.
const send =
isPairedRoomJid(task.chat_jid) && deps.sendMessageViaReviewerBot
hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot
? deps.sendMessageViaReviewerBot
: deps.sendMessage;
await send(task.chat_jid, outputText);
@@ -757,7 +757,7 @@ async function runGithubCiTask(
await statusTracker.update('completed');
if (check.completionMessage) {
const send =
isPairedRoomJid(task.chat_jid) && deps.sendMessageViaReviewerBot
hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot
? deps.sendMessageViaReviewerBot
: deps.sendMessage;
await send(task.chat_jid, check.completionMessage);