refactor: cut over room mode from paired legacy
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -67,6 +67,7 @@ vi.mock('./service-routing.js', () => ({
|
||||
reason: null,
|
||||
explicit: false,
|
||||
})),
|
||||
hasReviewerLease: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Create a controllable fake ChildProcess
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
135
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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user