Merge branch 'codex/room-settings-ssot'

This commit is contained in:
Eyejoker
2026-03-31 20:54:51 +09:00
4 changed files with 620 additions and 118 deletions

View File

@@ -36,6 +36,7 @@ import {
getPairedWorkspace,
getRouterStateForService,
getSession,
getStoredRoomSettings,
getTaskById,
listPairedWorkspacesForTask,
markWorkItemDelivered,
@@ -46,6 +47,7 @@ import {
setExplicitRoomMode,
storeChatMetadata,
storeMessage,
updateRegisteredGroupName,
updatePairedTask,
upsertPairedProject,
upsertPairedWorkspace,
@@ -748,16 +750,16 @@ describe('registered group isMain', () => {
it('filters duplicate jid registrations by agent type', () => {
setRegisteredGroup('dc:shared', {
name: 'Shared Room Claude',
name: 'Shared Room',
folder: 'shared-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:shared', {
name: 'Shared Room Codex',
name: 'Shared Room',
folder: 'shared-room',
trigger: '@Andy',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
@@ -766,24 +768,24 @@ describe('registered group isMain', () => {
const codexGroups = getAllRegisteredGroups('codex');
expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code');
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room Claude');
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room');
expect(codexGroups['dc:shared']?.agentType).toBe('codex');
expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex');
expect(codexGroups['dc:shared']?.name).toBe('Shared Room');
});
});
describe('paired room registration', () => {
it('detects when both Claude and Codex are registered on the same jid', () => {
setRegisteredGroup('dc:123', {
name: 'Paired Room Claude',
folder: 'paired-claude',
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:123', {
name: 'Paired Room Codex',
folder: 'paired-codex',
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
@@ -813,15 +815,15 @@ describe('paired room registration', () => {
it('keeps inferred room mode available when no explicit override exists', () => {
setRegisteredGroup('dc:legacy-paired', {
name: 'Legacy Paired Claude',
folder: 'legacy-paired-claude',
name: 'Legacy Paired',
folder: 'legacy-paired',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:legacy-paired', {
name: 'Legacy Paired Codex',
folder: 'legacy-paired-codex',
name: 'Legacy Paired',
folder: 'legacy-paired',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
@@ -876,16 +878,16 @@ describe('paired room registration', () => {
);
insertGroup.run(
'dc:legacy-sql',
'Legacy SQL Claude',
'legacy-sql-claude',
'@Andy',
'Legacy SQL Room',
'legacy-sql-room',
'@Claude',
'2024-01-01T00:00:00.000Z',
'claude-code',
);
insertGroup.run(
'dc:legacy-sql',
'Legacy SQL Codex',
'legacy-sql-codex',
'Legacy SQL Room',
'legacy-sql-room',
'@Codex',
'2024-01-01T00:00:00.000Z',
'codex',
@@ -897,19 +899,147 @@ describe('paired room registration', () => {
expect(getExplicitRoomMode('dc:legacy-sql')).toBeUndefined();
expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal');
expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal');
expect(getStoredRoomSettings('dc:legacy-sql')).toMatchObject({
chatJid: 'dc:legacy-sql',
roomMode: 'tribunal',
modeSource: 'inferred',
name: 'Legacy SQL Room',
folder: 'legacy-sql-room',
trigger: '@Codex',
requiresTrigger: true,
isMain: false,
ownerAgentType: 'codex',
});
});
it('keeps room-level metadata synced on legacy register_group writes', () => {
setRegisteredGroup('dc:room-settings', {
name: 'Room Settings Test',
folder: 'room-settings-test',
trigger: '@Claude',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:room-settings', {
name: 'Room Settings Test',
folder: 'room-settings-test',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
expect(getStoredRoomSettings('dc:room-settings')).toMatchObject({
chatJid: 'dc:room-settings',
roomMode: 'tribunal',
modeSource: 'inferred',
name: 'Room Settings Test',
folder: 'room-settings-test',
trigger: '@Codex',
ownerAgentType: 'codex',
});
setExplicitRoomMode('dc:room-settings', 'single');
expect(getStoredRoomSettings('dc:room-settings')).toMatchObject({
chatJid: 'dc:room-settings',
roomMode: 'single',
modeSource: 'explicit',
name: 'Room Settings Test',
folder: 'room-settings-test',
trigger: '@Codex',
ownerAgentType: 'codex',
});
updateRegisteredGroupName('dc:room-settings', 'Room Settings Renamed');
expect(getStoredRoomSettings('dc:room-settings')).toMatchObject({
chatJid: 'dc:room-settings',
roomMode: 'single',
modeSource: 'explicit',
name: 'Room Settings Renamed',
folder: 'room-settings-test',
trigger: '@Codex',
ownerAgentType: 'codex',
});
});
it('fails room_settings backfill when room-level metadata conflicts across agent rows', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-settings-conflict-');
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:conflict',
'Conflict Room',
'conflict-folder-1',
'@Claude',
'2024-01-01T00:00:00.000Z',
'claude-code',
);
insertGroup.run(
'dc:conflict',
'Conflict Room',
'conflict-folder-2',
'@Codex',
'2024-01-01T00:00:00.000Z',
'codex',
);
legacyDb.close();
expect(() => _initTestDatabaseFromFile(dbPath)).toThrow(
/Conflicting room-level registered_groups metadata/,
);
});
it('lets explicit single override dual registration for paired-room checks', () => {
setRegisteredGroup('dc:explicit-single', {
name: 'Explicit Single Claude',
folder: 'explicit-single-claude',
name: 'Explicit Single',
folder: 'explicit-single',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:explicit-single', {
name: 'Explicit Single Codex',
folder: 'explicit-single-codex',
name: 'Explicit Single',
folder: 'explicit-single',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',

414
src/db.ts
View File

@@ -9,6 +9,7 @@ import {
CODEX_REVIEW_SERVICE_ID,
DATA_DIR,
normalizeServiceId,
OWNER_AGENT_TYPE,
REVIEWER_AGENT_TYPE,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
@@ -45,6 +46,29 @@ interface StoredRoomModeRow {
source: RoomModeSource;
}
export interface StoredRoomSettings {
chatJid: string;
roomMode: RoomMode;
modeSource: RoomModeSource;
name?: string;
folder?: string;
trigger?: string;
requiresTrigger?: boolean;
isMain?: boolean;
ownerAgentType?: AgentType;
workDir?: string;
}
interface RoomRegistrationSnapshot {
name: string;
folder: string;
triggerPattern: string;
requiresTrigger: boolean;
isMain: boolean;
ownerAgentType: AgentType;
workDir: string | null;
}
export interface WorkItem {
id: number;
group_folder: string;
@@ -310,8 +334,16 @@ function createSchema(database: Database): void {
chat_jid TEXT PRIMARY KEY,
room_mode TEXT NOT NULL,
mode_source TEXT NOT NULL DEFAULT 'explicit',
name TEXT,
folder TEXT,
trigger_pattern TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
owner_agent_type TEXT,
work_dir TEXT,
updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal'))
CHECK (room_mode IN ('single', 'tribunal')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL)
);
CREATE TABLE IF NOT EXISTS service_handoffs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -402,6 +434,52 @@ function createSchema(database: Database): void {
/* column already exists */
}
try {
database.exec(`ALTER TABLE room_settings ADD COLUMN name TEXT`);
} catch {
/* column already exists */
}
try {
database.exec(`ALTER TABLE room_settings ADD COLUMN folder TEXT`);
} catch {
/* column already exists */
}
try {
database.exec(`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`);
} catch {
/* column already exists */
}
try {
database.exec(
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
);
} catch {
/* column already exists */
}
try {
database.exec(
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
);
} catch {
/* column already exists */
}
try {
database.exec(`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`);
} catch {
/* column already exists */
}
try {
database.exec(`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`);
} catch {
/* column already exists */
}
database.exec(
`UPDATE room_settings
SET mode_source = 'explicit'
@@ -807,7 +885,7 @@ function createSchema(database: Database): void {
/* columns already exist */
}
backfillStoredRoomModes(database);
backfillStoredRoomSettings(database);
}
export function initDatabase(): void {
@@ -835,6 +913,22 @@ export function _initTestDatabaseFromFile(dbPath: string): void {
createSchema(db);
}
/** @internal - for tests only. */
export function _setStoredRoomOwnerAgentTypeForTests(
chatJid: string,
ownerAgentType: AgentType | null,
): void {
if (!db) {
throw new Error('Database not initialized');
}
db.prepare(
`UPDATE room_settings
SET owner_agent_type = ?,
updated_at = ?
WHERE chat_jid = ?`,
).run(ownerAgentType, new Date().toISOString(), chatJid);
}
/**
* Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive content.
@@ -1859,25 +1953,29 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
db.prepare(
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
jid,
group.name,
group.folder,
group.trigger,
group.added_at,
group.agentConfig ? JSON.stringify(group.agentConfig) : null,
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
group.isMain ? 1 : 0,
group.agentType || 'claude-code',
group.workDir || null,
);
const tx = db.transaction(() => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
jid,
group.name,
group.folder,
group.trigger,
group.added_at,
group.agentConfig ? JSON.stringify(group.agentConfig) : null,
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
group.isMain ? 1 : 0,
group.agentType || 'claude-code',
group.workDir || null,
);
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
syncInferredRoomModeForJid(jid);
}
syncStoredRoomRegistrationSnapshotForJid(jid);
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
syncInferredRoomModeForJid(jid);
}
});
tx();
}
export function updateRegisteredGroupName(jid: string, name: string): void {
@@ -1885,6 +1983,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void {
name,
jid,
);
syncStoredRoomRegistrationSnapshotForJid(jid);
}
export function getAllRegisteredGroups(
@@ -1974,6 +2073,15 @@ export function inferRoomModeFromRegisteredAgentTypes(
return types.has('claude-code') && types.has('codex') ? 'tribunal' : 'single';
}
function inferOwnerAgentTypeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
): AgentType {
const types = new Set(agentTypes);
if (types.has(OWNER_AGENT_TYPE)) return OWNER_AGENT_TYPE;
if (types.has('codex')) return 'codex';
return 'claude-code';
}
/**
* Internal registration/backfill helper.
* This infers the stored room mode from current registrations and must not be
@@ -1991,24 +2099,228 @@ function normalizeRoomModeSource(
return source === 'explicit' || source === 'inferred' ? source : undefined;
}
function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
if (!db) return undefined;
function normalizeStoredAgentType(
agentType: string | null | undefined,
): AgentType | undefined {
return agentType === 'claude-code' || agentType === 'codex'
? agentType
: undefined;
}
const row = db
function getStoredRoomSettingsRowFromDatabase(
database: Database,
chatJid: string,
): StoredRoomSettings | undefined {
const row = database
.prepare(
`SELECT room_mode, mode_source
`SELECT room_mode, mode_source, name, folder, trigger_pattern,
requires_trigger, is_main, owner_agent_type, work_dir
FROM room_settings
WHERE chat_jid = ?`,
)
.get(chatJid) as
| { room_mode: string | null; mode_source: string | null }
| {
room_mode: string | null;
mode_source: string | null;
name: string | null;
folder: string | null;
trigger_pattern: string | null;
requires_trigger: number | null;
is_main: number | null;
owner_agent_type: string | null;
work_dir: 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;
if (!row || !roomMode || !source) return undefined;
return {
chatJid,
roomMode,
modeSource: source,
name: row.name ?? undefined,
folder: row.folder ?? undefined,
trigger: row.trigger_pattern ?? undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === null ? undefined : row.is_main === 1,
ownerAgentType: normalizeStoredAgentType(row.owner_agent_type),
workDir: row.work_dir ?? undefined,
};
}
export function getStoredRoomSettings(
chatJid: string,
): StoredRoomSettings | undefined {
if (!db) return undefined;
return getStoredRoomSettingsRowFromDatabase(db, chatJid);
}
function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
const row = getStoredRoomSettings(chatJid);
return row
? {
roomMode: row.roomMode,
source: row.modeSource,
}
: undefined;
}
function collectRoomRegistrationSnapshot(
database: Database,
jid: string,
): RoomRegistrationSnapshot | undefined {
const rows = database
.prepare(
`SELECT name, folder, trigger_pattern, requires_trigger, is_main, agent_type, work_dir
FROM registered_groups
WHERE jid = ?
ORDER BY agent_type`,
)
.all(jid) as Array<{
name: string;
folder: string;
trigger_pattern: string;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}>;
if (rows.length === 0) return undefined;
const first = rows[0];
const conflicts = new Set<string>();
for (const row of rows.slice(1)) {
if (row.name !== first.name) conflicts.add('name');
if (row.folder !== first.folder) conflicts.add('folder');
if ((row.requires_trigger ?? 1) !== (first.requires_trigger ?? 1)) {
conflicts.add('requires_trigger');
}
if ((row.is_main ?? 0) !== (first.is_main ?? 0)) {
conflicts.add('is_main');
}
if ((row.work_dir ?? null) !== (first.work_dir ?? null)) {
conflicts.add('work_dir');
}
}
if (conflicts.size > 0) {
throw new Error(
`Conflicting room-level registered_groups metadata for ${jid}: ${[
...conflicts,
].join(', ')}`,
);
}
const agentTypes = collectRegisteredAgentTypes(database, jid);
const ownerAgentType = inferOwnerAgentTypeFromRegisteredAgentTypes(agentTypes);
const ownerRow =
rows.find(
(row) => normalizeStoredAgentType(row.agent_type) === ownerAgentType,
) ?? rows[0];
return {
name: first.name,
folder: first.folder,
triggerPattern: ownerRow.trigger_pattern,
requiresTrigger: (first.requires_trigger ?? 1) === 1,
isMain: (first.is_main ?? 0) === 1,
ownerAgentType,
workDir: first.work_dir ?? null,
};
}
function insertStoredRoomSettings(
database: Database,
chatJid: string,
roomMode: RoomMode,
source: RoomModeSource,
snapshot: RoomRegistrationSnapshot,
): void {
database
.prepare(
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
name,
folder,
trigger_pattern,
requires_trigger,
is_main,
owner_agent_type,
work_dir,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
chatJid,
roomMode,
source,
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType,
snapshot.workDir,
new Date().toISOString(),
);
}
function updateStoredRoomMetadata(
database: Database,
chatJid: string,
snapshot: RoomRegistrationSnapshot,
): void {
database
.prepare(
`UPDATE room_settings
SET name = ?,
folder = ?,
trigger_pattern = ?,
requires_trigger = ?,
is_main = ?,
owner_agent_type = ?,
work_dir = ?,
updated_at = ?
WHERE chat_jid = ?`,
)
.run(
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType,
snapshot.workDir,
new Date().toISOString(),
chatJid,
);
}
function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
const snapshot = collectRoomRegistrationSnapshot(db, chatJid);
if (!snapshot) return;
const existing = getStoredRoomModeRow(chatJid);
if (!existing) {
insertStoredRoomSettings(
db,
chatJid,
inferRoomModeFromRegisteredAgentTypes(getRegisteredAgentTypesForJid(chatJid)),
'inferred',
snapshot,
);
return;
}
updateStoredRoomMetadata(db, chatJid, snapshot);
}
function upsertStoredRoomMode(
@@ -2017,12 +2329,16 @@ function upsertStoredRoomMode(
source: RoomModeSource,
): void {
db.prepare(
`INSERT OR REPLACE INTO room_settings (
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
updated_at
) VALUES (?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?)
ON CONFLICT(chat_jid) DO UPDATE SET
room_mode = excluded.room_mode,
mode_source = excluded.mode_source,
updated_at = excluded.updated_at`,
).run(chatJid, roomMode, source, new Date().toISOString());
}
@@ -2075,36 +2391,34 @@ export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode {
: 'single';
}
function backfillStoredRoomModes(database: Database): void {
function backfillStoredRoomSettings(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`,
)
.prepare(`SELECT DISTINCT jid FROM registered_groups`)
.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((registeredRows: Array<{ jid: string }>) => {
for (const row of registeredRows) {
const snapshot = collectRoomRegistrationSnapshot(database, row.jid);
if (!snapshot) continue;
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),
const existing = getStoredRoomSettingsRowFromDatabase(database, row.jid);
if (existing) {
updateStoredRoomMetadata(database, row.jid, snapshot);
continue;
}
insertStoredRoomSettings(
database,
row.jid,
inferRoomModeFromRegisteredAgentTypes(
collectRegisteredAgentTypes(database, row.jid),
),
'inferred',
snapshot,
);
insertRoomMode.run(row.jid, roomMode, updatedAt);
}
});
tx(rows);
}

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_setStoredRoomOwnerAgentTypeForTests,
_initTestDatabase,
setExplicitRoomMode,
setRegisteredGroup,
@@ -22,15 +23,15 @@ beforeEach(() => {
describe('service-routing global failover', () => {
it('uses codex-review as owner during global failover', () => {
setRegisteredGroup('dc:paired', {
name: 'Paired Room Claude',
folder: 'paired-claude',
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:paired', {
name: 'Paired Room Codex',
folder: 'paired-codex',
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
@@ -57,15 +58,15 @@ describe('service-routing global failover', () => {
it('restores default lease after global failover is cleared', () => {
setRegisteredGroup('dc:paired', {
name: 'Paired Room Claude',
folder: 'paired-claude',
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:paired', {
name: 'Paired Room Codex',
folder: 'paired-codex',
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
@@ -85,15 +86,15 @@ describe('service-routing global failover', () => {
it('uses explicit single room mode to suppress reviewer lease on dual registration', () => {
setRegisteredGroup('dc:explicit-single', {
name: 'Explicit Single Claude',
folder: 'explicit-single-claude',
name: 'Explicit Single',
folder: 'explicit-single',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:explicit-single', {
name: 'Explicit Single Codex',
folder: 'explicit-single-codex',
name: 'Explicit Single',
folder: 'explicit-single',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
@@ -108,6 +109,53 @@ describe('service-routing global failover', () => {
});
});
it('uses stored owner agent type when room_settings selects a different owner', () => {
setRegisteredGroup('dc:stored-owner-claude', {
name: 'Stored Owner Claude',
folder: 'stored-owner-claude',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:stored-owner-claude', {
name: 'Stored Owner Claude',
folder: 'stored-owner-claude',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
setExplicitRoomMode('dc:stored-owner-claude', 'single');
_setStoredRoomOwnerAgentTypeForTests(
'dc:stored-owner-claude',
'claude-code',
);
expect(getEffectiveChannelLease('dc:stored-owner-claude')).toMatchObject({
chat_jid: 'dc:stored-owner-claude',
owner_service_id: 'claude',
reviewer_service_id: null,
explicit: false,
});
});
it('falls back to the available service when stored owner agent type is unavailable', () => {
setRegisteredGroup('dc:stored-owner-fallback', {
name: 'Stored Owner Fallback',
folder: 'stored-owner-fallback',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
_setStoredRoomOwnerAgentTypeForTests('dc:stored-owner-fallback', 'codex');
expect(getEffectiveChannelLease('dc:stored-owner-fallback')).toMatchObject({
chat_jid: 'dc:stored-owner-fallback',
owner_service_id: 'claude',
reviewer_service_id: null,
explicit: false,
});
});
it('creates a same-service reviewer lease when explicit tribunal is runnable on a solo registration', () => {
setRegisteredGroup('dc:explicit-tribunal', {
name: 'Explicit Tribunal Claude',
@@ -145,4 +193,13 @@ describe('service-routing global failover', () => {
explicit: false,
});
});
it('keeps the legacy claude fallback for chats without room settings', () => {
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
chat_jid: 'dc:unregistered',
owner_service_id: 'claude',
reviewer_service_id: null,
explicit: false,
});
});
});

View File

@@ -14,10 +14,12 @@ import {
getAllChannelOwnerLeases,
getEffectiveRuntimeRoomMode,
getRegisteredAgentTypesForJid,
getStoredRoomSettings,
setChannelOwnerLease,
type ChannelOwnerLeaseRow,
} from './db.js';
import { logger } from './logger.js';
import type { AgentType } from './types.js';
export interface EffectiveChannelLease {
chat_jid: string;
@@ -55,21 +57,44 @@ function normalizeLeaseRow(
};
}
function getDefaultLease(chatJid: string): EffectiveChannelLease {
function getServiceIdForAgentType(agentType: AgentType): string {
return agentType === 'codex' ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID;
}
function inferFallbackOwnerAgentType(
hasClaude: boolean,
hasCodex: boolean,
): AgentType | undefined {
if (hasClaude && hasCodex) return OWNER_AGENT_TYPE;
if (hasCodex) return 'codex';
if (hasClaude) return 'claude-code';
return undefined;
}
function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
const types = getRegisteredAgentTypesForJid(chatJid);
const hasClaude = types.includes('claude-code');
const hasCodex = types.includes('codex');
const storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType;
if (storedOwnerAgentType === 'claude-code' && hasClaude) {
return 'claude-code';
}
if (storedOwnerAgentType === 'codex' && hasCodex) {
return 'codex';
}
return inferFallbackOwnerAgentType(hasClaude, hasCodex);
}
function getDefaultLease(chatJid: string): EffectiveChannelLease {
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid);
const ownerServiceId = ownerAgentType
? getServiceIdForAgentType(ownerAgentType)
: CLAUDE_SERVICE_ID;
if (roomMode === 'tribunal') {
const ownerServiceId =
hasClaude && hasCodex
? OWNER_AGENT_TYPE === 'codex'
? CODEX_MAIN_SERVICE_ID
: CLAUDE_SERVICE_ID
: hasCodex
? CODEX_MAIN_SERVICE_ID
: CLAUDE_SERVICE_ID;
return {
chat_jid: chatJid,
owner_service_id: ownerServiceId,
@@ -81,33 +106,9 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
};
}
if (hasCodex) {
return {
chat_jid: chatJid,
owner_service_id: CODEX_MAIN_SERVICE_ID,
reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
};
}
if (hasClaude) {
return {
chat_jid: chatJid,
owner_service_id: CLAUDE_SERVICE_ID,
reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
};
}
return {
chat_jid: chatJid,
owner_service_id: CLAUDE_SERVICE_ID,
owner_service_id: ownerServiceId,
reviewer_service_id: null,
arbiter_service_id: null,
activated_at: null,