diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index d98b814..4b1a653 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -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', diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 144df19..d1da400 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -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) diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index f1bf1b5..dba711c 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -67,6 +67,7 @@ vi.mock('./service-routing.js', () => ({ reason: null, explicit: false, })), + hasReviewerLease: vi.fn(() => true), })); // Create a controllable fake ChildProcess diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index bb67da1..bb7390c 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -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); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 3294055..d72a009 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -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(); diff --git a/src/db.test.ts b/src/db.test.ts index 51b1161..10d880f 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -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', diff --git a/src/db.ts b/src/db.ts index 99f4e38..1cfd03a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 { diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 9a32bd2..601e6b7 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -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[0], failureText: string, ) { - if (!isPairedRoomJid(chatJid)) return messages; + if (!hasReviewerLease(chatJid)) return messages; return messages.filter( (message) => diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index a67f702..a390e36 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -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 = {}; - 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 = {}; - 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', diff --git a/src/message-runtime.ts b/src/message-runtime.ts index c61897b..c6238d2 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -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( diff --git a/src/service-routing.ts b/src/service-routing.ts index 8f5f6fc..d1b8790 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -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, diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index a7f2ed5..dacfb84 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -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);