From 35a59218d5cf4adcd05a65076bb8a790b5ab29b6 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 22:27:17 +0900 Subject: [PATCH 1/3] refactor: internalize setRegisteredGroup export --- src/db.test.ts | 32 +++++++++++++-------------- src/db.ts | 18 ++++++++++++++-- src/duplicate-suppression.test.ts | 36 +++++++++++++++---------------- src/ipc-auth.test.ts | 10 ++++----- src/service-routing.test.ts | 24 ++++++++++----------- 5 files changed, 67 insertions(+), 53 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index 330314b..de7487e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _setRegisteredGroupForTests, assignRoom, claimServiceHandoff, completeServiceHandoffAndAdvanceTargetCursor, @@ -43,7 +44,6 @@ import { markWorkItemDelivered, markWorkItemDeliveryRetry, setSession, - setRegisteredGroup, setRouterStateForService, setExplicitRoomMode, storeChatMetadata, @@ -720,7 +720,7 @@ describe('message query LIMIT', () => { describe('registered group isMain', () => { it('persists isMain=true through set/get round-trip', () => { - setRegisteredGroup('dc:main', { + _setRegisteredGroupForTests('dc:main', { name: 'Main Chat', folder: 'discord_main', trigger: '@Andy', @@ -736,7 +736,7 @@ describe('registered group isMain', () => { }); it('omits isMain for non-main groups', () => { - setRegisteredGroup('group@g.us', { + _setRegisteredGroupForTests('group@g.us', { name: 'Family Chat', folder: 'discord_family-chat', trigger: '@Andy', @@ -750,14 +750,14 @@ describe('registered group isMain', () => { }); it('filters duplicate jid registrations by agent type', () => { - setRegisteredGroup('dc:shared', { + _setRegisteredGroupForTests('dc:shared', { name: 'Shared Room', folder: 'shared-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:shared', { + _setRegisteredGroupForTests('dc:shared', { name: 'Shared Room', folder: 'shared-room', trigger: '@Codex', @@ -828,14 +828,14 @@ describe('room assignment writes', () => { describe('paired room registration', () => { it('detects when both Claude and Codex are registered on the same jid', () => { - setRegisteredGroup('dc:123', { + _setRegisteredGroupForTests('dc:123', { name: 'Paired Room', folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:123', { + _setRegisteredGroupForTests('dc:123', { name: 'Paired Room', folder: 'paired-room', trigger: '@Codex', @@ -853,7 +853,7 @@ describe('paired room registration', () => { }); it('does not mark solo rooms as paired', () => { - setRegisteredGroup('dc:solo', { + _setRegisteredGroupForTests('dc:solo', { name: 'Solo Claude Room', folder: 'solo-claude', trigger: '@Andy', @@ -866,14 +866,14 @@ describe('paired room registration', () => { }); it('keeps inferred room mode available when no explicit override exists', () => { - setRegisteredGroup('dc:legacy-paired', { + _setRegisteredGroupForTests('dc:legacy-paired', { name: 'Legacy Paired', folder: 'legacy-paired', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:legacy-paired', { + _setRegisteredGroupForTests('dc:legacy-paired', { name: 'Legacy Paired', folder: 'legacy-paired', trigger: '@Codex', @@ -966,14 +966,14 @@ describe('paired room registration', () => { }); it('keeps room-level metadata synced on setRegisteredGroup helper writes', () => { - setRegisteredGroup('dc:room-settings', { + _setRegisteredGroupForTests('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', { + _setRegisteredGroupForTests('dc:room-settings', { name: 'Room Settings Test', folder: 'room-settings-test', trigger: '@Codex', @@ -1082,14 +1082,14 @@ describe('paired room registration', () => { }); it('lets explicit single override dual registration for paired-room checks', () => { - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Codex', @@ -1105,7 +1105,7 @@ describe('paired room registration', () => { }); it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => { - setRegisteredGroup('dc:explicit-tribunal', { + _setRegisteredGroupForTests('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', folder: 'explicit-tribunal-claude', trigger: '@Andy', @@ -1131,7 +1131,7 @@ describe('paired room registration', () => { }); it('keeps explicit tribunal non-runnable when the configured reviewer service is unavailable', () => { - setRegisteredGroup('dc:explicit-tribunal-codex', { + _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', trigger: '@Codex', diff --git a/src/db.ts b/src/db.ts index d34428b..32ecf88 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1987,7 +1987,10 @@ export function getRegisteredGroup( return getLegacyRegisteredGroup(db, jid, agentType); } -export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { +function writeLegacyRegisteredGroupAndSyncRoomSettings( + jid: string, + group: RegisteredGroup, +): void { const existingRoomMode = getStoredRoomModeRow(jid); if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); @@ -2017,6 +2020,17 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { tx(); } +/** + * @internal Test/migration helper for seeding legacy capability rows. + * Runtime code must use assignRoom() so room_settings remains the explicit SSOT. + */ +export function _setRegisteredGroupForTests( + jid: string, + group: RegisteredGroup, +): void { + writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); +} + export function assignRoom( chatJid: string, input: AssignRoomInput, @@ -3337,7 +3351,7 @@ function migrateJsonState(): void { if (groups) { for (const [jid, group] of Object.entries(groups)) { try { - setRegisteredGroup(jid, group); + writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); } catch (err) { logger.warn( { jid, folder: group.folder, err }, diff --git a/src/duplicate-suppression.test.ts b/src/duplicate-suppression.test.ts index af938af..8552a9d 100644 --- a/src/duplicate-suppression.test.ts +++ b/src/duplicate-suppression.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { + _setRegisteredGroupForTests, _initTestDatabase, storeMessage, storeChatMetadata, createProducedWorkItem, getOpenWorkItem, - setRegisteredGroup, getLastBotFinalMessage, markWorkItemDelivered, } from './db.js'; @@ -56,7 +56,7 @@ describe('hasReviewerLease', () => { it('returns true when both claude-code and codex are registered', () => { const jid = 'dc:paired-room'; - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -64,7 +64,7 @@ describe('hasReviewerLease', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -78,7 +78,7 @@ describe('hasReviewerLease', () => { it('returns false when only claude-code is registered', () => { const jid = 'dc:single-claude'; - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Single Claude', folder: 'single-claude', trigger: '@claude', @@ -92,7 +92,7 @@ describe('hasReviewerLease', () => { it('returns false when only codex is registered', () => { const jid = 'dc:single-codex'; - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Single Codex', folder: 'single-codex', trigger: '@codex', @@ -180,7 +180,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -188,7 +188,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -225,7 +225,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register only one bot - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Single Bot', folder: 'single-bot', trigger: '@claude', @@ -257,7 +257,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -265,7 +265,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -297,7 +297,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -305,7 +305,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -336,7 +336,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -344,7 +344,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -384,7 +384,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -392,7 +392,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', @@ -440,7 +440,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { setupChat(jid); // Register as paired room - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@claude', @@ -448,7 +448,7 @@ describe('isDuplicateOfLastBotFinal (runtime function)', () => { agentType: 'claude-code', }); - setRegisteredGroup(jid, { + _setRegisteredGroupForTests(jid, { name: 'Paired Room', folder: 'paired-room', trigger: '@codex', diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index cba6575..3b39ec3 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ASSISTANT_NAME } from './config.js'; import { + _setRegisteredGroupForTests, _initTestDatabase, assignRoom, createTask, @@ -10,7 +11,6 @@ import { getRegisteredGroup, getStoredRoomSettings, getTaskById, - setRegisteredGroup, } from './db.js'; import { processTaskIpc, IpcDeps } from './ipc.js'; import { DEFAULT_WATCH_CI_MAX_DURATION_MS } from './task-watch-status.js'; @@ -52,9 +52,9 @@ beforeEach(() => { }; // Populate DB as well - setRegisteredGroup('main@g.us', MAIN_GROUP); - setRegisteredGroup('other@g.us', OTHER_GROUP); - setRegisteredGroup('third@g.us', THIRD_GROUP); + _setRegisteredGroupForTests('main@g.us', MAIN_GROUP); + _setRegisteredGroupForTests('other@g.us', OTHER_GROUP); + _setRegisteredGroupForTests('third@g.us', THIRD_GROUP); deps = { sendMessage: async () => {}, @@ -119,7 +119,7 @@ describe('schedule_task authorization', () => { ...OTHER_GROUP, agentType: 'codex', }; - setRegisteredGroup('other@g.us', groups['other@g.us']); + _setRegisteredGroupForTests('other@g.us', groups['other@g.us']); await processTaskIpc( { diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index 0e6e766..63f3c1e 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { + _setRegisteredGroupForTests, _setStoredRoomOwnerAgentTypeForTests, _initTestDatabase, setExplicitRoomMode, - setRegisteredGroup, } from './db.js'; import { activateCodexFailover, @@ -22,14 +22,14 @@ beforeEach(() => { describe('service-routing global failover', () => { it('uses codex-review as owner during global failover', () => { - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Codex', @@ -57,14 +57,14 @@ describe('service-routing global failover', () => { }); it('restores default lease after global failover is cleared', () => { - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:paired', { + _setRegisteredGroupForTests('dc:paired', { name: 'Paired Room', folder: 'paired-room', trigger: '@Codex', @@ -85,14 +85,14 @@ describe('service-routing global failover', () => { }); it('uses explicit single room mode to suppress reviewer lease on dual registration', () => { - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); - setRegisteredGroup('dc:explicit-single', { + _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', folder: 'explicit-single', trigger: '@Codex', @@ -110,14 +110,14 @@ describe('service-routing global failover', () => { }); it('uses stored owner agent type when room_settings selects a different owner', () => { - setRegisteredGroup('dc:stored-owner-claude', { + _setRegisteredGroupForTests('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', { + _setRegisteredGroupForTests('dc:stored-owner-claude', { name: 'Stored Owner Claude', folder: 'stored-owner-claude', trigger: '@Codex', @@ -139,7 +139,7 @@ describe('service-routing global failover', () => { }); it('falls back to the available service when stored owner agent type is unavailable', () => { - setRegisteredGroup('dc:stored-owner-fallback', { + _setRegisteredGroupForTests('dc:stored-owner-fallback', { name: 'Stored Owner Fallback', folder: 'stored-owner-fallback', trigger: '@Andy', @@ -157,7 +157,7 @@ describe('service-routing global failover', () => { }); it('creates a same-service reviewer lease when explicit tribunal is runnable on a solo registration', () => { - setRegisteredGroup('dc:explicit-tribunal', { + _setRegisteredGroupForTests('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', folder: 'explicit-tribunal-claude', trigger: '@Andy', @@ -175,7 +175,7 @@ describe('service-routing global failover', () => { }); it('keeps reviewer lease disabled when explicit tribunal cannot deliver the configured reviewer', () => { - setRegisteredGroup('dc:explicit-tribunal-codex', { + _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', trigger: '@Codex', From c590fdaaa32537241c93659a80f2f1f9798f44d8 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 22:27:36 +0900 Subject: [PATCH 2/3] refactor: prefer room_settings over registered_groups in runtime reads --- src/db.test.ts | 4 ++-- src/db.ts | 7 ++++++- src/service-routing.test.ts | 8 ++++---- src/service-routing.ts | 13 +++++-------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index de7487e..1b34f2e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1130,7 +1130,7 @@ describe('paired room registration', () => { expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe('single'); }); - it('keeps explicit tribunal non-runnable when the configured reviewer service is unavailable', () => { + it('trusts stored tribunal mode even when legacy capability rows are incomplete', () => { _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', @@ -1143,7 +1143,7 @@ describe('paired room registration', () => { expect(getEffectiveRoomMode('dc:explicit-tribunal-codex')).toBe('tribunal'); expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal-codex')).toBe( - 'single', + 'tribunal', ); }); }); diff --git a/src/db.ts b/src/db.ts index 32ecf88..1f113f4 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2770,7 +2770,12 @@ function canRunTribunalFromRegisteredAgentTypes( } export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode { - return getEffectiveRoomMode(chatJid) === 'tribunal' && + const stored = getStoredRoomSettings(chatJid); + if (stored) { + return stored.roomMode; + } + + return inferStoredRoomModeForJid(chatJid) === 'tribunal' && canRunTribunalFromRegisteredAgentTypes( getRegisteredAgentTypesForJid(chatJid), ) diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index 63f3c1e..a2ce005 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -138,7 +138,7 @@ describe('service-routing global failover', () => { }); }); - it('falls back to the available service when stored owner agent type is unavailable', () => { + it('trusts stored owner agent type over incomplete legacy capability rows', () => { _setRegisteredGroupForTests('dc:stored-owner-fallback', { name: 'Stored Owner Fallback', folder: 'stored-owner-fallback', @@ -150,7 +150,7 @@ describe('service-routing global failover', () => { expect(getEffectiveChannelLease('dc:stored-owner-fallback')).toMatchObject({ chat_jid: 'dc:stored-owner-fallback', - owner_service_id: 'claude', + owner_service_id: 'codex-main', reviewer_service_id: null, explicit: false, }); @@ -174,7 +174,7 @@ describe('service-routing global failover', () => { }); }); - it('keeps reviewer lease disabled when explicit tribunal cannot deliver the configured reviewer', () => { + it('builds reviewer lease from stored tribunal mode even when legacy rows are incomplete', () => { _setRegisteredGroupForTests('dc:explicit-tribunal-codex', { name: 'Explicit Tribunal Codex', folder: 'explicit-tribunal-codex', @@ -189,7 +189,7 @@ describe('service-routing global failover', () => { ).toMatchObject({ chat_jid: 'dc:explicit-tribunal-codex', owner_service_id: 'codex-main', - reviewer_service_id: null, + reviewer_service_id: 'claude', explicit: false, }); }); diff --git a/src/service-routing.ts b/src/service-routing.ts index 9194228..f20bbd4 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -72,17 +72,14 @@ function inferFallbackOwnerAgentType( } function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined { + const storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType; + if (storedOwnerAgentType) { + return storedOwnerAgentType; + } + 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); } From 85cbce555a28b10a80958896dade20b5c3520b6d Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 31 Mar 2026 22:27:43 +0900 Subject: [PATCH 3/3] docs: update README with room assignment model --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index c8471c9..de13fa6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,25 @@ A single unified service (`ejclaw`) manages three Discord bots in one process: All agent types and models are independently configurable per role via `.env`. +## Room Assignment Model + +Per-room routing now uses an explicit assignment model: + +- `room_settings` is the room-level source of truth (SSOT) +- Each room stores: + - `room_mode`: `single` or `tribunal` + - `owner_agent_type`: `codex` or `claude-code` +- Public room assignment uses `assign_room` +- Legacy `register_group` public interfaces were removed +- `registered_groups` remains as a materialized capability/read-model layer and legacy fallback, not the authoritative room configuration + +Operationally: + +- `single` → one owner bot +- `tribunal` → per-room owner + globally configured reviewer + optional arbiter + +This means tribunal is no longer inferred from “two bots registered on one room”; it is an explicit room setting. + ## Tribunal 3-Agent System ```