refactor: land approved runtime cleanup and room binding cutover

This commit is contained in:
ejclaw
2026-04-10 21:04:23 +09:00
parent ce3854745d
commit 4e05f60400
45 changed files with 636 additions and 637 deletions

View File

@@ -599,6 +599,17 @@ src/
- `getAllRoomBindings()` 도입
- startup sync 제거
하위 컷 메모:
- 하위 컷 1. `registered_groups` 기반 owner/room-mode/snapshot 추론 제거
- 대상: `src/service-routing.ts`, `src/db.ts`, `src/db/room-registration.ts`
- 범위: stored canonical 값이 없을 때 `registered_groups` legacy row로 owner service, room mode, snapshot 의미를 추론하는 read-path fallback 제거
- 동반 테스트: `src/service-routing.test.ts`, `src/db.test.ts`
- 하위 컷 2. 남은 `registeredGroups` runtime consumer 제거
- 대상: `src/index.ts`, `src/message-runtime-loop.ts`, `src/ipc.ts`
- 범위: `getAllRegisteredGroups()` read-path 소비자 제거, `getAllRoomBindings()` 도입, startup sync 제거
- 주의: 여기서 말하는 추론 제거는 PR 4 / Phase 4의 `paired-state.ts` hydrate fallback 제거와 별개다.
### PR 4. Paired task canonicalization
- paired/channel owner/handoff/work item backfill

View File

@@ -63,8 +63,6 @@ describe('register step', () => {
'dc:test-room',
'--name',
'Test Room',
'--trigger',
'@Andy',
'--folder',
'test-room',
]);
@@ -73,8 +71,6 @@ describe('register step', () => {
expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', {
name: 'Test Room',
folder: 'test-room',
trigger: '@Andy',
requiresTrigger: true,
isMain: false,
});
expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', {
@@ -87,8 +83,6 @@ describe('register step', () => {
NAME: 'Test Room',
FOLDER: 'test-room',
CHANNEL: 'discord',
TRIGGER: '@Andy',
REQUIRES_TRIGGER: true,
STATUS: 'success',
LOG: 'logs/setup.log',
});
@@ -107,8 +101,6 @@ describe('register step', () => {
'dc:test-room',
'--name',
'Test Room',
'--trigger',
'@Andy',
'--folder',
'test-room',
'--assistant-name',
@@ -141,19 +133,14 @@ describe('register step', () => {
'dc:main-room',
'--name',
'Main Room',
'--trigger',
'@Andy',
'--folder',
'main-room',
'--is-main',
'--no-trigger-required',
]);
expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', {
name: 'Main Room',
folder: 'main-room',
trigger: '@Andy',
requiresTrigger: false,
isMain: true,
});
expect(mkdirSpy).toHaveBeenCalledTimes(1);

View File

@@ -15,10 +15,8 @@ import { emitStatus } from './status.js';
interface RegisterArgs {
jid: string;
name: string;
trigger: string;
folder: string;
channel: string;
requiresTrigger: boolean;
isMain: boolean;
assistantNameProvided: boolean;
}
@@ -27,10 +25,8 @@ function parseArgs(args: string[]): RegisterArgs {
const result: RegisterArgs = {
jid: '',
name: '',
trigger: '',
folder: '',
channel: 'discord',
requiresTrigger: true,
isMain: false,
assistantNameProvided: false,
};
@@ -43,18 +39,12 @@ function parseArgs(args: string[]): RegisterArgs {
case '--name':
result.name = args[++i] || '';
break;
case '--trigger':
result.trigger = args[++i] || '';
break;
case '--folder':
result.folder = args[++i] || '';
break;
case '--channel':
result.channel = (args[++i] || '').toLowerCase();
break;
case '--no-trigger-required':
result.requiresTrigger = false;
break;
case '--is-main':
result.isMain = true;
break;
@@ -71,7 +61,7 @@ function parseArgs(args: string[]): RegisterArgs {
export async function run(args: string[]): Promise<void> {
const parsed = parseArgs(args);
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
if (!parsed.jid || !parsed.name || !parsed.folder) {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'missing_required_args',
@@ -115,8 +105,6 @@ export async function run(args: string[]): Promise<void> {
assignRoom(parsed.jid, {
name: parsed.name,
folder: parsed.folder,
trigger: parsed.trigger,
requiresTrigger: parsed.requiresTrigger,
isMain: parsed.isMain,
});
logger.info('Assigned room through canonical room service');
@@ -131,8 +119,6 @@ export async function run(args: string[]): Promise<void> {
NAME: parsed.name,
FOLDER: parsed.folder,
CHANNEL: parsed.channel,
TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger,
STATUS: 'success',
LOG: 'logs/setup.log',
});

View File

@@ -3,10 +3,10 @@ import { getAllChats } from './db.js';
import type { RegisteredGroup } from './types.js';
export function listAvailableGroups(
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
): AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
const registeredJids = new Set(Object.keys(roomBindings));
return chats
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)

View File

@@ -133,7 +133,7 @@ function createTestOpts(
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
roomBindings: vi.fn(() => ({
'dc:1234567890123456': {
name: 'Test Server #general',
folder: 'test-server',
@@ -441,7 +441,7 @@ describe('DiscordChannel', () => {
it('uses sender name for DM chats (no guild)', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
roomBindings: vi.fn(() => ({
'dc:1234567890123456': {
name: 'DM',
folder: 'dm',
@@ -491,10 +491,10 @@ describe('DiscordChannel', () => {
});
});
// --- @mention translation ---
// --- bot mention handling ---
describe('@mention translation', () => {
it('translates <@botId> mention to trigger format', async () => {
describe('bot mention handling', () => {
it('passes through <@botId> mentions without rewriting them', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
@@ -509,12 +509,12 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy what time is it?',
content: '<@999888777> what time is it?',
}),
);
});
it('does not translate if message already matches trigger', async () => {
it('leaves mixed text and mentions untouched', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
@@ -531,7 +531,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy hello',
content: '@Andy hello <@999888777>',
}),
);
});
@@ -555,7 +555,7 @@ describe('DiscordChannel', () => {
);
});
it('handles <@!botId> (nickname mention format)', async () => {
it('passes through <@!botId> nickname mentions without rewriting them', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
@@ -570,7 +570,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy check this',
content: '<@!999888777> check this',
}),
);
});

View File

@@ -208,7 +208,7 @@ import {
export interface DiscordChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
roomBindings: () => Record<string, RegisteredGroup>;
}
export class DiscordChannel implements Channel {
@@ -279,29 +279,6 @@ export class DiscordChannel implements Channel {
chatName = senderName;
}
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
// Discord mentions look like <@botUserId> — these won't match
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
// when the bot is @mentioned.
if (this.client?.user) {
const botId = this.client.user.id;
const isBotMentioned =
message.mentions.users.has(botId) ||
content.includes(`<@${botId}>`) ||
content.includes(`<@!${botId}>`);
if (isBotMentioned) {
// Strip the <@botId> mention to avoid visual clutter
content = content
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
.trim();
// Prepend trigger if not already present
if (!TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
}
// Handle attachments — transcribe voice messages, download files
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
if (message.attachments.size > 0) {
@@ -403,7 +380,7 @@ export class DiscordChannel implements Channel {
// Only deliver full message for registered groups. Secondary role bots
// are configured as outbound-only, while the owner bot receives inbound.
const group = this.opts.registeredGroups()[chatJid];
const group = this.opts.roomBindings()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
@@ -627,7 +604,7 @@ export class DiscordChannel implements Channel {
if (!jid.startsWith('dc:')) return false;
if (!this.ownsDiscordJids) return false;
if (!this.agentTypeFilter) return true;
const group = this.opts.registeredGroups()[jid];
const group = this.opts.roomBindings()[jid];
if (!group) return false;
const groupType = group.agentType || 'claude-code';
return groupType === this.agentTypeFilter;

View File

@@ -8,7 +8,7 @@ import {
export interface ChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
roomBindings: () => Record<string, RegisteredGroup>;
}
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;

View File

@@ -13,7 +13,7 @@ export interface DashboardOptions {
channels: Channel[];
getSessions: () => Record<string, string>;
queue: GroupQueue;
registeredGroups: () => Record<string, RegisteredGroup>;
roomBindings: () => Record<string, RegisteredGroup>;
statusChannelId: string;
statusUpdateInterval: number;
usageUpdateInterval: number;
@@ -30,7 +30,7 @@ export function buildStatusContent(opts: DashboardOptions): string {
if (!STATUS_SHOW_ROOMS) return '';
const sessions = opts.getSessions();
const groups = opts.registeredGroups();
const groups = opts.roomBindings();
const statuses = opts.queue.getStatuses(Object.keys(groups));
let totalActive = 0;

View File

@@ -22,7 +22,7 @@ function makeOptions(sessionId?: string): DashboardOptions {
},
],
} as unknown as DashboardOptions['queue'],
registeredGroups: () => ({
roomBindings: () => ({
'dc:123': {
name: 'clone-test',
folder: 'group-folder',

View File

@@ -22,7 +22,7 @@ export async function startStatusDashboard(
usageUpdateInterval: opts.usageUpdateInterval,
channels: opts.channels,
queue: opts.queue,
registeredGroups: opts.registeredGroups,
roomBindings: opts.roomBindings,
});
}

View File

@@ -25,7 +25,7 @@ import {
deleteTask,
getAllChats,
getChannelOwnerLease,
getAllRegisteredGroups,
getAllRoomBindings,
getDueTasks,
getEffectiveRoomMode,
getEffectiveRuntimeRoomMode,
@@ -1870,7 +1870,7 @@ describe('registered group isMain', () => {
isMain: true,
});
const groups = getAllRegisteredGroups();
const groups = getAllRoomBindings();
const group = groups['dc:main'];
expect(group).toBeDefined();
expect(group.isMain).toBe(true);
@@ -1885,7 +1885,7 @@ describe('registered group isMain', () => {
added_at: '2024-01-01T00:00:00.000Z',
});
const groups = getAllRegisteredGroups();
const groups = getAllRoomBindings();
const group = groups['group@g.us'];
expect(group).toBeDefined();
expect(group.isMain).toBeUndefined();
@@ -1907,8 +1907,8 @@ describe('registered group isMain', () => {
agentType: 'codex',
});
const claudeGroups = getAllRegisteredGroups('claude-code');
const codexGroups = getAllRegisteredGroups('codex');
const claudeGroups = getAllRoomBindings('claude-code');
const codexGroups = getAllRoomBindings('codex');
expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code');
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room');
@@ -1946,9 +1946,9 @@ describe('room assignment writes', () => {
folder: 'assigned-room',
});
const allGroups = getAllRegisteredGroups();
const claudeGroups = getAllRegisteredGroups('claude-code');
const codexGroups = getAllRegisteredGroups('codex');
const allGroups = getAllRoomBindings();
const claudeGroups = getAllRoomBindings('claude-code');
const codexGroups = getAllRoomBindings('codex');
expect(allGroups['dc:assigned-room']).toMatchObject({
name: 'Assigned Room',
@@ -1984,19 +1984,17 @@ describe('room assignment writes', () => {
ownerAgentType: 'codex',
});
expect(
getAllRegisteredGroups('claude-code')['dc:projection-room'],
getAllRoomBindings('claude-code')['dc:projection-room'],
).toMatchObject({
name: 'Projection Room Renamed',
folder: 'projection-room',
agentType: 'claude-code',
});
expect(getAllRegisteredGroups('codex')['dc:projection-room']).toMatchObject(
{
name: 'Projection Room Renamed',
folder: 'projection-room',
agentType: 'codex',
},
);
expect(getAllRoomBindings('codex')['dc:projection-room']).toMatchObject({
name: 'Projection Room Renamed',
folder: 'projection-room',
agentType: 'codex',
});
});
it('does not carry an owner override config onto a different owner agent type', () => {
@@ -2017,19 +2015,17 @@ describe('room assignment writes', () => {
folder: 'owner-switch',
});
expect(
getAllRegisteredGroups('claude-code')['dc:owner-switch'],
).toMatchObject({
expect(getAllRoomBindings('claude-code')['dc:owner-switch']).toMatchObject({
agentType: 'claude-code',
});
expect(
getAllRegisteredGroups('claude-code')['dc:owner-switch']?.agentConfig,
getAllRoomBindings('claude-code')['dc:owner-switch']?.agentConfig,
).toBeUndefined();
expect(getAllRegisteredGroups('codex')['dc:owner-switch']).toMatchObject({
expect(getAllRoomBindings('codex')['dc:owner-switch']).toMatchObject({
agentType: 'codex',
});
expect(
getAllRegisteredGroups('codex')['dc:owner-switch']?.agentConfig,
getAllRoomBindings('codex')['dc:owner-switch']?.agentConfig,
).toBeUndefined();
});
@@ -2057,14 +2053,14 @@ describe('room assignment writes', () => {
expect(projectionRows).toEqual([]);
expect(
getAllRegisteredGroups('claude-code')['dc:assign-no-projection'],
getAllRoomBindings('claude-code')['dc:assign-no-projection'],
).toMatchObject({
name: 'Assign No Projection',
folder: 'assign-no-projection',
agentType: 'claude-code',
});
expect(
getAllRegisteredGroups('codex')['dc:assign-no-projection'],
getAllRoomBindings('codex')['dc:assign-no-projection'],
).toMatchObject({
name: 'Assign No Projection',
folder: 'assign-no-projection',
@@ -2072,7 +2068,7 @@ describe('room assignment writes', () => {
});
});
it('recreates inferred room_settings when renaming a legacy projection-only room', () => {
it('does not recreate inferred room_settings when renaming a legacy projection-only room', () => {
_setRegisteredGroupForTests('dc:legacy-rename', {
name: 'Legacy Rename',
folder: 'legacy-rename',
@@ -2093,23 +2089,12 @@ describe('room assignment writes', () => {
updateRegisteredGroupName('dc:legacy-rename', 'Legacy Rename Updated');
expect(getStoredRoomSettings('dc:legacy-rename')).toMatchObject({
chatJid: 'dc:legacy-rename',
roomMode: 'tribunal',
modeSource: 'inferred',
name: 'Legacy Rename Updated',
folder: 'legacy-rename',
});
expect(getStoredRoomSettings('dc:legacy-rename')).toBeUndefined();
expect(getRegisteredGroup('dc:legacy-rename')).toBeUndefined();
expect(
getAllRegisteredGroups('claude-code')['dc:legacy-rename'],
).toMatchObject({
name: 'Legacy Rename Updated',
agentType: 'claude-code',
});
expect(getAllRegisteredGroups('codex')['dc:legacy-rename']).toMatchObject({
name: 'Legacy Rename Updated',
agentType: 'codex',
});
getAllRoomBindings('claude-code')['dc:legacy-rename'],
).toBeUndefined();
expect(getAllRoomBindings('codex')['dc:legacy-rename']).toBeUndefined();
});
it('requires explicit migration before init when only legacy registered_groups rows exist', () => {
@@ -2169,6 +2154,36 @@ describe('room assignment writes', () => {
expect(() => _initTestDatabaseFromFile(dbPath)).toThrow(
/Legacy room migration required before startup/,
);
const rawDbBeforeMigration = new Database(dbPath, { readonly: true });
expect(
rawDbBeforeMigration
.prepare(
`SELECT COUNT(*) as count
FROM room_settings`,
)
.get(),
).toEqual({ count: 0 });
expect(
rawDbBeforeMigration
.prepare(
`SELECT COUNT(*) as count
FROM room_role_overrides`,
)
.get(),
).toEqual({ count: 0 });
expect(
rawDbBeforeMigration
.prepare(
`SELECT jid, agent_type
FROM registered_groups
ORDER BY jid, agent_type`,
)
.all(),
).toEqual([
{ jid: 'dc:legacy-sql', agent_type: 'claude-code' },
{ jid: 'dc:legacy-sql', agent_type: 'codex' },
]);
rawDbBeforeMigration.close();
expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({
migratedRooms: 1,
migratedRoleOverrides: 2,
@@ -2546,9 +2561,7 @@ describe('room assignment writes', () => {
agentType: 'codex',
});
expect(getRegisteredGroup('dc:ssot-room', 'claude-code')).toBeUndefined();
expect(
getAllRegisteredGroups('claude-code')['dc:ssot-room'],
).toBeUndefined();
expect(getAllRoomBindings('claude-code')['dc:ssot-room']).toBeUndefined();
expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']);
});
@@ -2712,45 +2725,55 @@ describe('room assignment writes', () => {
});
describe('paired room registration', () => {
it('detects when both Claude and Codex are registered on the same jid', () => {
_setRegisteredGroupForTests('dc:123', {
it('detects paired capability types from canonical tribunal room settings', () => {
assignRoom('dc:123', {
name: 'Paired Room',
roomMode: 'tribunal',
ownerAgentType: 'codex',
folder: 'paired-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
_setRegisteredGroupForTests('dc:123', {
name: 'Paired Room',
folder: 'paired-room',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([
'claude-code',
'codex',
]);
expect(getExplicitRoomMode('dc:123')).toBeUndefined();
expect(getExplicitRoomMode('dc:123')).toBe('tribunal');
expect(getEffectiveRoomMode('dc:123')).toBe('tribunal');
expect(getEffectiveRuntimeRoomMode('dc:123')).toBe('tribunal');
});
it('does not mark solo rooms as paired', () => {
_setRegisteredGroupForTests('dc:solo', {
it('does not mark canonical single rooms as paired', () => {
assignRoom('dc:solo', {
name: 'Solo Claude Room',
roomMode: 'single',
ownerAgentType: 'claude-code',
folder: 'solo-claude',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']);
expect(getEffectiveRuntimeRoomMode('dc:solo')).toBe('single');
});
it('keeps inferred room mode available when no explicit override exists', () => {
it('keeps canonical inferred room mode available when no explicit override exists', () => {
assignRoom('dc:canonical-inferred-paired', {
name: 'Canonical Inferred Paired',
roomMode: 'tribunal',
ownerAgentType: 'codex',
folder: 'canonical-inferred-paired',
});
clearExplicitRoomMode('dc:canonical-inferred-paired');
expect(getExplicitRoomMode('dc:canonical-inferred-paired')).toBeUndefined();
expect(getEffectiveRoomMode('dc:canonical-inferred-paired')).toBe(
'tribunal',
);
expect(getEffectiveRuntimeRoomMode('dc:canonical-inferred-paired')).toBe(
'tribunal',
);
});
it('ignores legacy capability rows when canonical room settings are missing', () => {
_setRegisteredGroupForTests('dc:legacy-paired', {
name: 'Legacy Paired',
folder: 'legacy-paired',
@@ -2765,10 +2788,12 @@ describe('paired room registration', () => {
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
_deleteStoredRoomSettingsForTests('dc:legacy-paired');
expect(getRegisteredAgentTypesForJid('dc:legacy-paired')).toEqual([]);
expect(getExplicitRoomMode('dc:legacy-paired')).toBeUndefined();
expect(getEffectiveRoomMode('dc:legacy-paired')).toBe('tribunal');
expect(getEffectiveRuntimeRoomMode('dc:legacy-paired')).toBe('tribunal');
expect(getEffectiveRoomMode('dc:legacy-paired')).toBe('single');
expect(getEffectiveRuntimeRoomMode('dc:legacy-paired')).toBe('single');
});
it('keeps room-level metadata synced on setRegisteredGroup helper writes', () => {
@@ -2901,13 +2926,12 @@ describe('paired room registration', () => {
expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe('single');
});
it('trusts stored tribunal mode even when legacy capability rows are incomplete', () => {
_setRegisteredGroupForTests('dc:explicit-tribunal-codex', {
it('trusts stored tribunal mode without projection rows', () => {
assignRoom('dc:explicit-tribunal-codex', {
name: 'Explicit Tribunal Codex',
roomMode: 'single',
ownerAgentType: 'codex',
folder: 'explicit-tribunal-codex',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
setExplicitRoomMode('dc:explicit-tribunal-codex', 'tribunal');
@@ -3067,8 +3091,6 @@ describe('service handoff completion', () => {
roomMode: 'tribunal',
ownerAgentType: 'codex',
folder: 'owner-handoff-shadow',
trigger: '@Owner',
requiresTrigger: true,
});
const handoff = createServiceHandoff({

100
src/db.ts
View File

@@ -3,7 +3,6 @@ import fs from 'fs';
import path from 'path';
import {
ASSISTANT_NAME,
ARBITER_AGENT_TYPE,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
@@ -11,7 +10,6 @@ import {
DATA_DIR,
normalizeServiceId,
OWNER_AGENT_TYPE,
REVIEWER_AGENT_TYPE,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
} from './config.js';
@@ -31,7 +29,6 @@ import {
buildLegacyRoomMigrationPlan,
collectRegisteredAgentTypes,
collectRegisteredAgentTypesForFolder,
collectRoomRegistrationSnapshot,
deleteLegacyRegisteredGroupRowsForJid,
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase,
@@ -223,8 +220,6 @@ export interface AssignRoomInput {
roomMode?: RoomMode;
ownerAgentType?: AgentType;
folder?: string;
trigger?: string;
requiresTrigger?: boolean;
isMain?: boolean;
workDir?: string;
addedAt?: string;
@@ -791,9 +786,9 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings(
triggerPattern:
existingStored?.modeSource === 'explicit' && existingStored.trigger
? existingStored.trigger
: group.trigger,
: (group.trigger ?? ''),
requiresTrigger:
group.requiresTrigger ?? existingStored?.requiresTrigger ?? true,
group.requiresTrigger ?? existingStored?.requiresTrigger ?? false,
isMain: group.isMain ?? existingStored?.isMain ?? false,
ownerAgentType,
workDir: group.workDir ?? existingStored?.workDir ?? null,
@@ -885,8 +880,8 @@ export function assignRoom(
const snapshot: RoomRegistrationSnapshot = {
name: input.name,
folder,
triggerPattern: input.trigger || existing?.trigger || `@${ASSISTANT_NAME}`,
requiresTrigger: input.requiresTrigger ?? existing?.requiresTrigger ?? true,
triggerPattern: existing?.trigger ?? '',
requiresTrigger: existing?.requiresTrigger ?? false,
isMain: input.isMain ?? existing?.isMain ?? false,
ownerAgentType,
workDir: input.workDir ?? existing?.workDir ?? null,
@@ -959,7 +954,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void {
})();
}
export function getAllRegisteredGroups(
export function getAllRoomBindings(
agentTypeFilter?: string,
): Record<string, RegisteredGroup> {
const result: Record<string, RegisteredGroup> = {};
@@ -984,21 +979,7 @@ export function getAllRegisteredGroups(
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return [];
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
if (stored) {
return resolveStoredRoomCapabilityTypes(db, stored);
}
return collectRegisteredAgentTypes(db, jid);
}
/**
* Internal registration/backfill helper.
* This infers the stored room mode from current registrations and must not be
* treated as runtime source-of-truth by callers.
*/
function inferStoredRoomModeForJid(jid: string): RoomMode {
return inferRoomModeFromRegisteredAgentTypes(
getRegisteredAgentTypesForJid(jid),
);
return stored ? resolveStoredRoomCapabilityTypes(db, stored) : [];
}
export function getStoredRoomSettings(
@@ -1018,31 +999,6 @@ function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
: undefined;
}
function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
const existingSettings = getStoredRoomSettingsRowFromDatabase(db, chatJid);
const snapshot = collectRoomRegistrationSnapshot(
db,
chatJid,
existingSettings,
);
if (!snapshot) return;
if (!existingSettings) {
insertStoredRoomSettings(
db,
chatJid,
inferRoomModeFromRegisteredAgentTypes(
getRegisteredAgentTypesForJid(chatJid),
),
'inferred',
snapshot,
);
return;
}
updateStoredRoomMetadata(db, chatJid, snapshot);
}
function assertNoPendingLegacyRoomMigration(): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
const hasLegacyRegisteredGroupsJson = fs.existsSync(
@@ -1096,8 +1052,8 @@ function buildRoomRegistrationSnapshotFromStoredRoom(
return {
name,
folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder),
triggerPattern: stored.trigger || `@${ASSISTANT_NAME}`,
requiresTrigger: stored.requiresTrigger ?? true,
triggerPattern: stored.trigger ?? '',
requiresTrigger: stored.requiresTrigger ?? false,
isMain: stored.isMain ?? false,
ownerAgentType,
workDir: stored.workDir ?? null,
@@ -1123,19 +1079,7 @@ function buildRoomRegistrationPlanForJid(
};
}
const snapshot = collectRoomRegistrationSnapshot(db, chatJid);
if (!snapshot) return undefined;
return {
snapshot: {
...snapshot,
name: overrides?.name ?? snapshot.name,
},
roomMode: inferRoomModeFromRegisteredAgentTypes(
collectRegisteredAgentTypes(db, chatJid),
),
hasStoredRoom: false,
};
return undefined;
}
function upsertStoredRoomMode(
@@ -1157,10 +1101,6 @@ function upsertStoredRoomMode(
).run(chatJid, roomMode, source, new Date().toISOString());
}
function syncInferredRoomModeForJid(chatJid: string): void {
upsertStoredRoomMode(chatJid, inferStoredRoomModeForJid(chatJid), 'inferred');
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
const row = getStoredRoomModeRow(chatJid);
return row?.source === 'explicit' ? row.roomMode : undefined;
@@ -1193,28 +1133,8 @@ export function getEffectiveRoomMode(chatJid: string): RoomMode {
return getStoredRoomModeRow(chatJid)?.roomMode ?? 'single';
}
function canRunTribunalFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
): boolean {
const types = new Set(agentTypes);
if (types.size === 0) return false;
return REVIEWER_AGENT_TYPE === 'claude-code'
? types.has('claude-code')
: types.has('codex');
}
export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode {
const stored = getStoredRoomSettings(chatJid);
if (stored) {
return stored.roomMode;
}
return inferStoredRoomModeForJid(chatJid) === 'tribunal' &&
canRunTribunalFromRegisteredAgentTypes(
getRegisteredAgentTypesForJid(chatJid),
)
? 'tribunal'
: 'single';
return getStoredRoomSettings(chatJid)?.roomMode ?? 'single';
}
// --- Paired task/project/workspace state ---

View File

@@ -1,10 +1,6 @@
import { Database } from 'bun:sqlite';
import {
ASSISTANT_NAME,
OWNER_AGENT_TYPE,
REVIEWER_AGENT_TYPE,
} from '../config.js';
import { OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js';
import { isValidGroupFolder } from '../group-folder.js';
import { logger } from '../logger.js';
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
@@ -86,6 +82,10 @@ export function normalizeStoredAgentType(
: undefined;
}
/**
* Legacy capability-row helper for migration/write flows.
* Runtime read paths must derive capability types from canonical room tables.
*/
export function collectRegisteredAgentTypes(
database: Database,
jid: string,
@@ -154,73 +154,14 @@ export function parseRegisteredGroupRow(
jid: row.jid,
name: row.name,
folder: row.folder,
trigger: row.trigger_pattern,
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined,
agentType: normalizeStoredAgentType(row.agent_type),
workDir: row.work_dir || undefined,
};
}
export function getLegacyRegisteredGroupRows(
database: Database,
agentTypeFilter?: string,
): RegisteredGroupDatabaseRow[] {
return (
agentTypeFilter
? database
.prepare(
`SELECT *
FROM registered_groups
WHERE agent_type = ?
AND NOT EXISTS (
SELECT 1
FROM room_settings
WHERE chat_jid = registered_groups.jid
)`,
)
.all(agentTypeFilter)
: database
.prepare(
`SELECT *
FROM registered_groups
WHERE NOT EXISTS (
SELECT 1
FROM room_settings
WHERE chat_jid = registered_groups.jid
)`,
)
.all()
) as RegisteredGroupDatabaseRow[];
}
export function getLegacyRegisteredGroup(
database: Database,
jid: string,
agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined {
if (getStoredRoomSettingsRowFromDatabase(database, jid)) {
return undefined;
}
const row = (
agentType
? database
.prepare(
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
)
.get(jid, agentType)
: database
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
.get(jid)
) as RegisteredGroupDatabaseRow | undefined;
return parseRegisteredGroupRow(row);
}
export function getPendingLegacyRegisteredGroupJids(
database: Database,
): string[] {
@@ -605,10 +546,8 @@ export function buildRegisteredGroupFromStoredSettings(
jid: stored.chatJid,
name: stored.name || stored.chatJid,
folder: stored.folder,
trigger: stored.trigger || `@${ASSISTANT_NAME}`,
added_at: capabilityMetadata?.createdAt || new Date(0).toISOString(),
agentConfig: capabilityMetadata?.agentConfig,
requiresTrigger: stored.requiresTrigger,
isMain: stored.isMain ? true : undefined,
agentType: resolvedAgentType,
workDir: stored.workDir,
@@ -855,6 +794,10 @@ export function materializeRegisteredGroupsForRoom(
.run(chatJid, ...desiredTypes);
}
/**
* Legacy snapshot helper for migration/write flows.
* Runtime read paths must not rebuild room metadata from registered_groups.
*/
export function collectRoomRegistrationSnapshot(
database: Database,
jid: string,

View File

@@ -2989,7 +2989,7 @@ export function applySchemaMigrations(
WHERE status IN ('produced', 'delivery_retry');
`);
const registeredGroupsSql = (
const roomBindingsSql = (
database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
@@ -2997,8 +2997,8 @@ export function applySchemaMigrations(
.get() as { sql?: string } | undefined
)?.sql;
if (
registeredGroupsSql &&
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
roomBindingsSql &&
!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')
) {
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')

View File

@@ -319,55 +319,3 @@ describe('formatOutbound with tool-call leaks', () => {
expect(formatOutbound(input)).toBe('Key: [REDACTED]');
});
});
// --- Trigger gating with requiresTrigger flag ---
describe('trigger gating (requiresTrigger interaction)', () => {
// Replicates the exact logic from processGroupMessages and startMessageLoop:
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
function shouldRequireTrigger(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
): boolean {
return !isMainGroup && requiresTrigger !== false;
}
function shouldProcess(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
messages: NewMessage[],
): boolean {
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
}
it('main group always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, undefined, msgs)).toBe(true);
});
it('main group processes even with requiresTrigger=true', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, true, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true requires trigger', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, true, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true processes when trigger present', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, false, msgs)).toBe(true);
});
});

View File

@@ -40,7 +40,7 @@ describe('host evidence IPC', () => {
deps = {
sendMessage: async () => {},
registeredGroups: () => ({ 'other@g.us': OTHER_GROUP }),
roomBindings: () => ({ 'other@g.us': OTHER_GROUP }),
assignRoom: () => {},
syncGroups: async () => {},
getAvailableGroups: () => [],

View File

@@ -24,7 +24,7 @@ import { listAvailableGroups } from './available-groups.js';
import {
type AssignRoomInput,
assignRoom,
getAllRegisteredGroups,
getAllRoomBindings,
getAllSessions,
getAllTasks,
getLatestMessageSeqAtOrBefore,
@@ -139,7 +139,7 @@ export async function editFormattedTrackedChannelMessage(
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let roomBindings: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
const channels: Channel[] = [];
@@ -152,7 +152,7 @@ const runtime = createMessageRuntime({
triggerPattern: TRIGGER_PATTERN,
channels,
queue,
getRegisteredGroups: () => registeredGroups,
getRoomBindings: () => roomBindings,
getSessions: () => sessions,
getLastTimestamp: () => lastTimestamp,
setLastTimestamp: (timestamp) => {
@@ -185,10 +185,10 @@ function loadState(): void {
lastAgentTimestamp = {};
}
sessions = getAllSessions();
registeredGroups = getAllRegisteredGroups();
roomBindings = getAllRoomBindings();
logger.info(
{
groupCount: Object.keys(registeredGroups).length,
groupCount: Object.keys(roomBindings).length,
agentType: 'unified',
},
'State loaded',
@@ -231,7 +231,7 @@ function assignRoomForIpc(jid: string, input: AssignRoomInput): void {
}
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
registeredGroups[jid] = storedGroup;
roomBindings[jid] = storedGroup;
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
}
@@ -240,14 +240,21 @@ function assignRoomForIpc(jid: string, input: AssignRoomInput): void {
* Returns groups ordered by most recent activity.
*/
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
return listAvailableGroups(registeredGroups);
return listAvailableGroups(roomBindings);
}
/** @internal - exported for testing */
export function _setRegisteredGroups(
groups: Record<string, RegisteredGroup>,
): void {
registeredGroups = groups;
roomBindings = groups;
}
/** @internal - exported for testing */
export function _setRoomBindings(
groups: Record<string, RegisteredGroup>,
): void {
roomBindings = groups;
}
async function announceRestartRecovery(
@@ -288,10 +295,7 @@ async function announceRestartRecovery(
return explicitContext;
}
const inferred = inferRecentRestartContext(
registeredGroups,
processStartedAtMs,
);
const inferred = inferRecentRestartContext(roomBindings, processStartedAtMs);
if (!inferred) return null;
if (hasRecentRestartAnnouncement(inferred.chatJid, dedupeSince)) {
@@ -334,7 +338,7 @@ async function main(): Promise<void> {
leaseRecoveryTimer = null;
}
const interruptedGroups = queue
.getStatuses(Object.keys(registeredGroups))
.getStatuses(Object.keys(roomBindings))
.filter(
(
status,
@@ -344,14 +348,14 @@ async function main(): Promise<void> {
)
.map((status) => ({
chatJid: status.jid,
groupName: registeredGroups[status.jid]?.name || status.jid,
groupName: roomBindings[status.jid]?.name || status.jid,
status: status.status,
elapsedMs: status.elapsedMs,
pendingMessages: status.pendingMessages,
pendingTasks: status.pendingTasks,
}));
const writtenPaths = writeShutdownRestartContext(
registeredGroups,
roomBindings,
interruptedGroups,
signal,
);
@@ -376,7 +380,7 @@ async function main(): Promise<void> {
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => {
// Sender allowlist drop mode: discard messages from denied senders before storing
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
if (!msg.is_from_me && !msg.is_bot_message && roomBindings[chatJid]) {
const cfg = loadSenderAllowlist();
if (
shouldDropMessage(chatJid, cfg) &&
@@ -400,7 +404,7 @@ async function main(): Promise<void> {
channel?: string,
isGroup?: boolean,
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
roomBindings: () => roomBindings,
};
// Create and connect all registered channels.
@@ -433,7 +437,7 @@ async function main(): Promise<void> {
);
startSchedulerLoop({
registeredGroups: () => registeredGroups,
roomBindings: () => roomBindings,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, processName, ipcDir) =>
@@ -476,7 +480,7 @@ async function main(): Promise<void> {
}
},
nudgeScheduler: nudgeSchedulerLoop,
registeredGroups: () => registeredGroups,
roomBindings: () => roomBindings,
assignRoom: assignRoomForIpc,
syncGroups: async (force: boolean) => {
await Promise.all(
@@ -494,7 +498,7 @@ async function main(): Promise<void> {
const restartContext = await announceRestartRecovery(processStartedAtMs);
for (const candidate of getInterruptedRecoveryCandidates(
restartContext,
registeredGroups,
roomBindings,
)) {
queue.enqueueMessageCheck(
candidate.chatJid,
@@ -520,9 +524,9 @@ async function main(): Promise<void> {
usageUpdateInterval: USAGE_UPDATE_INTERVAL,
channels,
queue,
registeredGroups: () => registeredGroups,
roomBindings: () => roomBindings,
onGroupNameSynced: (jid, name) => {
const group = registeredGroups[jid];
const group = roomBindings[jid];
if (group) {
group.name = name;
}

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ASSISTANT_NAME } from './config.js';
import {
_setRegisteredGroupForTests,
_initTestDatabase,
@@ -60,7 +59,7 @@ beforeEach(() => {
deps = {
sendMessage: async () => {},
nudgeScheduler: vi.fn(),
registeredGroups: () => groups,
roomBindings: () => groups,
assignRoom: (jid, room) => {
const assigned = assignRoom(jid, room);
if (!assigned) return;
@@ -440,14 +439,13 @@ describe('assign_room authorization', () => {
jid: 'new@g.us',
name: 'New Group',
folder: 'new-group',
trigger: '@Andy',
},
'other-group',
false,
deps,
);
// registeredGroups should not have changed
// roomBindings should not have changed
expect(groups['new@g.us']).toBeUndefined();
});
@@ -458,7 +456,6 @@ describe('assign_room authorization', () => {
jid: 'new@g.us',
name: 'New Group',
folder: '../../outside',
trigger: '@Andy',
},
'whatsapp_main',
true,
@@ -494,9 +491,9 @@ describe('IPC message authorization', () => {
sourceGroup: string,
isMain: boolean,
targetChatJid: string,
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
): boolean {
const targetGroup = registeredGroups[targetChatJid];
const targetGroup = roomBindings[targetChatJid];
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
}
@@ -885,7 +882,6 @@ describe('assign_room success', () => {
jid: 'new@g.us',
name: 'New Group',
folder: 'new-group',
trigger: '@Andy',
},
'whatsapp_main',
true,
@@ -896,10 +892,9 @@ describe('assign_room success', () => {
expect(group).toBeDefined();
expect(group!.name).toBe('New Group');
expect(group!.folder).toBe('new-group');
expect(group!.trigger).toBe('@Andy');
});
it('assign_room auto-fills missing folder and trigger for single rooms', async () => {
it('assign_room auto-fills missing folder for single rooms without exposing trigger metadata', async () => {
await processTaskIpc(
{
type: 'assign_room',
@@ -914,7 +909,6 @@ describe('assign_room success', () => {
const group = getRegisteredGroup('partial@g.us');
expect(group).toBeDefined();
expect(group!.folder).toMatch(/^grp_whatsapp_/);
expect(group!.trigger).toBe(`@${ASSISTANT_NAME}`);
expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('single');
});

View File

@@ -35,7 +35,7 @@ export interface IpcDeps {
senderRole?: string,
) => Promise<void>;
nudgeScheduler?: () => void;
registeredGroups: () => Record<string, RegisteredGroup>;
roomBindings: () => Record<string, RegisteredGroup>;
assignRoom: (jid: string, room: AssignRoomInput) => void;
syncGroups: (force: boolean) => Promise<void>;
getAvailableGroups: () => AvailableGroup[];
@@ -65,14 +65,14 @@ export async function forwardAuthorizedIpcMessage(
msg: IpcMessagePayload,
sourceGroup: string,
isMain: boolean,
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
sendMessage: IpcDeps['sendMessage'],
): Promise<IpcMessageForwardResult> {
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
return { outcome: 'ignored', senderRole: msg.senderRole ?? null };
}
const targetGroup = registeredGroups[msg.chatJid];
const targetGroup = roomBindings[msg.chatJid];
const isMainOverride = isMain === true;
if (
!(isMainOverride || (targetGroup && targetGroup.folder === sourceGroup))
@@ -184,11 +184,11 @@ export function startIpcWatcher(deps: IpcDeps): void {
return;
}
const registeredGroups = deps.registeredGroups();
const roomBindings = deps.roomBindings();
// Build folder→isMain lookup from registered groups
const folderIsMain = new Map<string, boolean>();
for (const group of Object.values(registeredGroups)) {
for (const group of Object.values(roomBindings)) {
if (group.isMain) folderIsMain.set(group.folder, true);
}
@@ -240,7 +240,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
msg,
sourceGroup,
isMain,
registeredGroups,
roomBindings,
deps.sendMessage,
);
if (forwardResult.outcome === 'sent') {
@@ -353,8 +353,6 @@ export async function processTaskIpc(
jid?: string;
name?: string;
folder?: string;
trigger?: string;
requiresTrigger?: boolean;
room_mode?: RoomMode;
owner_agent_type?: AgentType;
isMain?: boolean;
@@ -376,7 +374,7 @@ export async function processTaskIpc(
isMain: boolean, // Verified from directory path
deps: IpcDeps,
): Promise<void> {
const registeredGroups = deps.registeredGroups();
const roomBindings = deps.roomBindings();
switch (data.type) {
case 'schedule_task':
@@ -389,8 +387,8 @@ export async function processTaskIpc(
// Resolve the target group from JID
const targetJid = data.targetJid as string;
const targetGroupEntry =
registeredGroups[targetJid] ||
Object.values(registeredGroups).find(
roomBindings[targetJid] ||
Object.values(roomBindings).find(
(group) => group.folder === targetJid,
);
@@ -404,9 +402,9 @@ export async function processTaskIpc(
const targetFolder = targetGroupEntry.folder;
const resolvedTargetJid =
registeredGroups[targetJid] !== undefined
roomBindings[targetJid] !== undefined
? targetJid
: Object.entries(registeredGroups).find(
: Object.entries(roomBindings).find(
([, group]) => group.folder === targetFolder,
)?.[0];
@@ -765,8 +763,6 @@ export async function processTaskIpc(
roomMode: data.room_mode,
ownerAgentType: data.owner_agent_type,
folder: data.folder,
trigger: data.trigger,
requiresTrigger: data.requiresTrigger,
isMain: data.isMain,
workDir: data.workDir,
});

View File

@@ -67,6 +67,10 @@ export function createPairedExecutionLifecycle(args: {
let pairedTurnOutputPersisted = false;
let pairedTurnStateFinalized = false;
let leaseHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
const requiresVisibleVerdict =
pairedExecutionContext?.requiresVisibleVerdict === true;
const missingVisibleVerdictSummary =
'Execution completed without a visible terminal verdict.';
const finalizePairedTurnState = (
status: 'succeeded' | 'failed',
@@ -254,12 +258,31 @@ export function createPairedExecutionLifecycle(args: {
return;
}
const missingVisibleVerdict =
requiresVisibleVerdict &&
(!pairedFinalOutput || pairedFinalOutput.length === 0);
if (missingVisibleVerdict) {
pairedExecutionSummary = missingVisibleVerdictSummary;
log.warn(
{
pairedTaskId: pairedExecutionContext?.task.id ?? null,
role: completedRole,
runId,
},
'Treating paired execution as failed because it ended without a visible terminal verdict',
);
}
const effectiveStatus =
completedRole === 'owner' &&
pairedExecutionStatus === 'succeeded' &&
!pairedSawOutput
? 'failed'
: pairedExecutionStatus;
: missingVisibleVerdict && pairedExecutionStatus === 'succeeded'
? 'failed'
: pairedExecutionStatus;
const sawOutputForFollowUp = missingVisibleVerdict
? false
: pairedSawOutput;
if (pairedExecutionContext && !pairedExecutionCompleted) {
if (effectiveStatus === 'succeeded') {
@@ -283,7 +306,10 @@ export function createPairedExecutionLifecycle(args: {
pairedExecutionCompleted = true;
}
finalizePairedTurnState(effectiveStatus);
finalizePairedTurnState(
effectiveStatus,
effectiveStatus === 'failed' ? pairedExecutionSummary : null,
);
if (!pairedExecutionContext) {
return;
@@ -314,7 +340,7 @@ export function createPairedExecutionLifecycle(args: {
const queueAction = resolvePairedFollowUpQueueAction({
completedRole,
executionStatus: effectiveStatus,
sawOutput: pairedSawOutput,
sawOutput: sawOutputForFollowUp,
taskStatus: finishedTask?.status ?? null,
});
if (queueAction !== 'pending' || !finishedTask) {
@@ -328,8 +354,8 @@ export function createPairedExecutionLifecycle(args: {
source: 'executor-recovery',
completedRole,
executionStatus: effectiveStatus,
sawOutput: pairedSawOutput,
fallbackLastTurnOutputRole: pairedSawOutput ? completedRole : null,
sawOutput: sawOutputForFollowUp,
fallbackLastTurnOutputRole: sawOutputForFollowUp ? completedRole : null,
enqueueMessageCheck,
});
if (followUpResult.kind !== 'paired-follow-up') {

View File

@@ -272,7 +272,7 @@ function makeDeps() {
registerProcess: vi.fn(),
enqueueMessageCheck: vi.fn(),
},
getRegisteredGroups: () => ({}),
getRoomBindings: () => ({}),
getSessions: () => ({}),
persistSession: vi.fn(),
clearSession: vi.fn(),
@@ -981,6 +981,108 @@ describe('runAgentForGroup room memory', () => {
]);
});
it('fails paired reviewer turns that finish without a visible terminal verdict', async () => {
const group = {
...makeGroup(),
folder: 'test-group',
workDir: '/repo',
agentType: 'codex' as const,
};
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'codex-review',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-review-no-verdict',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: '2026-04-10T00:00:00.000Z',
plan_notes: null,
round_trip_count: 0,
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-10T00:00:00.000Z',
updated_at: '2026-04-10T00:00:00.000Z',
},
claimedTaskUpdatedAt: '2026-04-10T00:00:00.000Z',
workspace: null,
envOverrides: {},
requiresVisibleVerdict: true,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '검토 중입니다.',
output: { visibility: 'public', text: '검토 중입니다.' },
} as any);
return {
status: 'success',
result: null,
};
},
);
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-no-verdict',
forcedRole: 'reviewer',
pairedTurnIdentity: {
turnId:
'paired-task-review-no-verdict:2026-04-10T00:00:00.000Z:reviewer-turn',
taskId: 'paired-task-review-no-verdict',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
},
});
expect(result).toBe('error');
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
expect.objectContaining({
taskId: 'paired-task-review-no-verdict',
role: 'reviewer',
status: 'failed',
summary: 'Execution completed without a visible terminal verdict.',
}),
);
expect(db.failPairedTurn).toHaveBeenCalledWith({
turnIdentity: {
turnId:
'paired-task-review-no-verdict:2026-04-10T00:00:00.000Z:reviewer-turn',
taskId: 'paired-task-review-no-verdict',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
},
error: 'Execution completed without a visible terminal verdict.',
});
expect(db.completePairedTurn).not.toHaveBeenCalled();
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
});
it('passes paired workspace env overrides into the runner when execution metadata exists', async () => {
const group = {
...makeGroup(),

View File

@@ -69,7 +69,7 @@ import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
export interface MessageAgentExecutorDeps {
assistantName: string;
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'>;
getRegisteredGroups: () => Record<string, RegisteredGroup>;
getRoomBindings: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
persistSession: (groupFolder: string, sessionId: string) => void;
clearSession: (groupFolder: string) => void;
@@ -175,7 +175,7 @@ export async function runAgentForGroup(
writeGroupsSnapshot(
group.folder,
isMain,
listAvailableGroups(deps.getRegisteredGroups()),
listAvailableGroups(deps.getRoomBindings()),
);
let resetSessionRequested = false;
@@ -194,6 +194,7 @@ export async function runAgentForGroup(
runId,
roomRoleContext,
hasHumanMessage: args.hasHumanMessage,
pairedTurnIdentity: args.pairedTurnIdentity,
});
const preparedTurnTaskUpdatedAt = pairedExecutionContext
? (pairedExecutionContext.claimedTaskUpdatedAt ??

View File

@@ -8,7 +8,6 @@ import {
advanceLastAgentCursor,
filterLoopingPairedBotMessages,
getProcessableMessages,
hasAllowedTrigger,
resolveCursorKey,
shouldSkipBotOnlyCollaboration,
} from './message-runtime-rules.js';
@@ -296,18 +295,6 @@ export async function processLoopGroupMessages(args: {
return;
}
if (
!hasAllowedTrigger({
chatJid,
messages: processableGroupMessages,
group,
triggerPattern: args.triggerPattern,
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
})
) {
return;
}
await processQueuedGroupDispatch({
chatJid,
group,

View File

@@ -1,27 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RegisteredGroup } from './types.js';
const { handleSessionCommandMock, hasAllowedTriggerMock, loggerInfoMock } =
vi.hoisted(() => ({
handleSessionCommandMock: vi.fn(),
hasAllowedTriggerMock: vi.fn(),
loggerInfoMock: vi.fn(),
}));
const { handleSessionCommandMock } = vi.hoisted(() => ({
handleSessionCommandMock: vi.fn(),
}));
vi.mock('./session-commands.js', () => ({
handleSessionCommand: handleSessionCommandMock,
}));
vi.mock('./message-runtime-rules.js', () => ({
hasAllowedTrigger: hasAllowedTriggerMock,
}));
vi.mock('./logger.js', () => ({
logger: {
info: loggerInfoMock,
},
}));
import { handleQueuedRunGates } from './message-runtime-gating.js';
describe('message-runtime-gating', () => {
@@ -44,8 +31,6 @@ describe('message-runtime-gating', () => {
beforeEach(() => {
handleSessionCommandMock.mockReset();
hasAllowedTriggerMock.mockReset();
loggerInfoMock.mockReset();
});
it('returns session-command results directly when a command is handled', async () => {
@@ -57,29 +42,13 @@ describe('message-runtime-gating', () => {
const result = await handleQueuedRunGates(baseArgs);
expect(result).toEqual({ handled: true, success: false });
expect(hasAllowedTriggerMock).not.toHaveBeenCalled();
});
it('treats missing triggers as a handled no-op', async () => {
it('falls through when no session command is handled', async () => {
handleSessionCommandMock.mockResolvedValue({ handled: false });
hasAllowedTriggerMock.mockReturnValue(false);
const result = await handleQueuedRunGates(baseArgs);
expect(result).toEqual({ handled: true, success: true });
expect(loggerInfoMock).toHaveBeenCalledWith(
{ chatJid: 'room-1', group: 'room', runId: 'run-1' },
'Skipping queued run because no allowed trigger was found',
);
});
it('falls through when no session command is handled and the trigger is allowed', async () => {
handleSessionCommandMock.mockResolvedValue({ handled: false });
hasAllowedTriggerMock.mockReturnValue(true);
const result = await handleQueuedRunGates(baseArgs);
expect(result).toEqual({ handled: false });
expect(loggerInfoMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,4 @@
import { logger } from './logger.js';
import { hasAllowedTrigger } from './message-runtime-rules.js';
import {
handleSessionCommand,
type SessionCommandDeps,
@@ -32,21 +31,5 @@ export async function handleQueuedRunGates(args: {
return cmdResult;
}
if (
!hasAllowedTrigger({
chatJid: args.chatJid,
messages: args.missedMessages,
group: args.group,
triggerPattern: args.triggerPattern,
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
})
) {
logger.info(
{ chatJid: args.chatJid, group: args.group.name, runId: args.runId },
'Skipping queued run because no allowed trigger was found',
);
return { handled: true, success: true };
}
return { handled: false };
}

View File

@@ -124,7 +124,7 @@ describe('message-runtime-handoffs', () => {
completed_at: null,
last_error: null,
},
getRegisteredGroups: () => ({
getRoomBindings: () => ({
'group@test': makeGroup(),
}),
channels: [makeChannel('discord-main', true)],
@@ -174,7 +174,7 @@ describe('message-runtime-handoffs', () => {
completed_at: null,
last_error: null,
},
getRegisteredGroups: () => ({
getRoomBindings: () => ({
'group@test': makeGroup(),
}),
channels: [
@@ -249,7 +249,7 @@ describe('message-runtime-handoffs', () => {
completed_at: null,
last_error: null,
},
getRegisteredGroups: () => ({
getRoomBindings: () => ({
'group@test': makeGroup(),
}),
channels: [

View File

@@ -160,7 +160,7 @@ function failClaimedHandoff(args: {
export async function processClaimedHandoff(args: {
handoff: ServiceHandoff;
getRegisteredGroups: () => Record<string, RegisteredGroup>;
getRoomBindings: () => Record<string, RegisteredGroup>;
channels: Channel[];
executeTurn: ExecuteTurnFn;
lastAgentTimestamps: Record<string, string>;
@@ -175,7 +175,7 @@ export async function processClaimedHandoff(args: {
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
}): Promise<void> {
const { handoff } = args;
const group = args.getRegisteredGroups()[handoff.chat_jid];
const group = args.getRoomBindings()[handoff.chat_jid];
if (!group) {
failClaimedHandoff({
handoff,

View File

@@ -27,7 +27,7 @@ export async function processMessageLoopTick(args: {
triggerPattern: RegExp;
timezone: string;
channels: Channel[];
getRegisteredGroups: () => Record<string, RegisteredGroup>;
getRoomBindings: () => Record<string, RegisteredGroup>;
getLastTimestamp: () => string;
setLastTimestamp: (timestamp: string) => void;
lastAgentTimestamps: Record<string, string>;
@@ -54,8 +54,8 @@ export async function processMessageLoopTick(args: {
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
}): Promise<void> {
args.enqueuePendingHandoffs();
const registeredGroups = args.getRegisteredGroups();
const jids = Object.keys(registeredGroups);
const roomBindings = args.getRoomBindings();
const jids = Object.keys(roomBindings);
const { messages, newSeqCursor } = getNewMessagesBySeq(
jids,
args.getLastTimestamp(),
@@ -81,7 +81,7 @@ export async function processMessageLoopTick(args: {
}
for (const [chatJid, groupMessages] of messagesByGroup) {
const group = registeredGroups[chatJid];
const group = roomBindings[chatJid];
if (!group) continue;
const channel = findChannel(args.channels, chatJid);
@@ -125,7 +125,7 @@ export async function processMessageLoopTick(args: {
export function recoverPendingMessages(args: {
assistantName: string;
channels: Channel[];
getRegisteredGroups: () => Record<string, RegisteredGroup>;
getRoomBindings: () => Record<string, RegisteredGroup>;
lastAgentTimestamps: Record<string, string>;
saveState: () => void;
enqueueScopedGroupMessageCheck: (
@@ -133,8 +133,8 @@ export function recoverPendingMessages(args: {
groupFolder: string,
) => void;
}): void {
const registeredGroups = args.getRegisteredGroups();
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const roomBindings = args.getRoomBindings();
for (const [chatJid, group] of Object.entries(roomBindings)) {
const openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
if (openWorkItem) {
logger.info(

View File

@@ -398,7 +398,7 @@ describe('createMessageRuntime', () => {
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -468,7 +468,7 @@ describe('createMessageRuntime', () => {
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -545,7 +545,7 @@ describe('createMessageRuntime', () => {
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -596,7 +596,7 @@ describe('createMessageRuntime', () => {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -666,7 +666,7 @@ describe('createMessageRuntime', () => {
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -742,7 +742,7 @@ describe('createMessageRuntime', () => {
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -832,7 +832,7 @@ describe('createMessageRuntime', () => {
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -915,7 +915,7 @@ describe('createMessageRuntime', () => {
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1009,7 +1009,7 @@ describe('createMessageRuntime', () => {
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1130,7 +1130,7 @@ describe('createMessageRuntime', () => {
notifyIdle: vi.fn(),
enqueueMessageCheck: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1229,7 +1229,7 @@ describe('createMessageRuntime', () => {
senderRole === 'reviewer',
),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1320,7 +1320,7 @@ describe('createMessageRuntime', () => {
noteDirectTerminalDelivery,
hasDirectTerminalDeliveryForRun: vi.fn(() => false),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1397,7 +1397,7 @@ describe('createMessageRuntime', () => {
noteDirectTerminalDelivery,
hasDirectTerminalDeliveryForRun,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1513,7 +1513,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1619,7 +1619,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1848,7 +1848,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -1976,7 +1976,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2076,7 +2076,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2161,7 +2161,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2254,7 +2254,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2349,7 +2349,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2462,7 +2462,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2578,7 +2578,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2670,7 +2670,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2759,7 +2759,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
notifyIdle: vi.fn(),
enqueueMessageCheck: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2823,7 +2823,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2896,7 +2896,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -2966,7 +2966,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3085,7 +3085,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3200,7 +3200,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3219,7 +3219,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
});
it('allows follow-up messages without a trigger after a visible reply in non-main groups', async () => {
it('processes first and follow-up messages without trigger mentions in non-main groups', async () => {
const chatJid = 'group@test';
const group: RegisteredGroup = {
...makeGroup('codex'),
@@ -3236,7 +3236,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '@Andy 첫 요청',
content: '첫 요청도 멘션 없이 보냄',
timestamp: '2026-03-18T09:00:00.000Z',
},
])
@@ -3278,7 +3278,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3289,7 +3289,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
});
const first = await runtime.processGroupMessages(chatJid, {
runId: 'run-triggered-first-turn',
runId: 'run-triggerless-first-turn',
reason: 'messages',
});
const second = await runtime.processGroupMessages(chatJid, {
@@ -3362,7 +3362,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin,
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3440,7 +3440,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin,
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3527,7 +3527,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3627,7 +3627,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3708,7 +3708,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3810,7 +3810,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin,
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -3921,7 +3921,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4025,7 +4025,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4102,7 +4102,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4163,7 +4163,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4251,7 +4251,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4325,7 +4325,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4431,7 +4431,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4552,7 +4552,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4663,7 +4663,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4743,7 +4743,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4819,7 +4819,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin,
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4902,7 +4902,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -4974,7 +4974,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -5034,7 +5034,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
enqueueMessageCheck,
enqueueTask,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
@@ -5094,7 +5094,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
enqueueMessageCheck,
enqueueTask,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),

View File

@@ -111,7 +111,7 @@ export interface MessageRuntimeDeps {
triggerPattern: RegExp;
channels: Channel[];
queue: GroupQueue;
getRegisteredGroups: () => Record<string, RegisteredGroup>;
getRoomBindings: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
getLastTimestamp: () => string;
setLastTimestamp: (timestamp: string) => void;
@@ -217,7 +217,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
{
assistantName: deps.assistantName,
queue: deps.queue,
getRegisteredGroups: deps.getRegisteredGroups,
getRoomBindings: deps.getRoomBindings,
getSessions: deps.getSessions,
persistSession: deps.persistSession,
clearSession: deps.clearSession,
@@ -272,6 +272,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
getEffectiveChannelLease(chatJid),
resolvedDeliveryRole ?? 'owner',
);
const allowProgressReplayWithoutFinal =
args.pairedTurnIdentity?.role !== 'reviewer' &&
args.pairedTurnIdentity?.role !== 'arbiter';
const turnController = new MessageTurnController({
chatJid,
group,
@@ -283,6 +286,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
clearSession: () => deps.clearSession(group.folder),
requestClose: (reason) =>
deps.queue.closeStdin(chatJid, { runId, reason }),
allowProgressReplayWithoutFinal,
deliveryRole: resolvedDeliveryRole,
deliveryServiceId: resolvedDeliveryServiceId,
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
@@ -426,7 +430,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
processClaimedHandoff: async (handoff) => {
await processClaimedServiceHandoff({
handoff,
getRegisteredGroups: deps.getRegisteredGroups,
getRoomBindings: deps.getRoomBindings,
channels: deps.channels,
executeTurn,
lastAgentTimestamps: deps.getLastAgentTimestamps(),
@@ -444,7 +448,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
context: GroupRunContext,
): Promise<boolean> => {
const { runId } = context;
const group = deps.getRegisteredGroups()[chatJid];
const group = deps.getRoomBindings()[chatJid];
if (!group) return true;
const log = createScopedLogger({
chatJid,
@@ -651,18 +655,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
},
formatMessages,
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
canSenderInteract: (msg) => {
const hasTrigger = deps.triggerPattern.test(msg.content.trim());
const requiresTrigger =
!isMainGroup && group.requiresTrigger !== false;
return (
isMainGroup ||
!requiresTrigger ||
(hasTrigger &&
(msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
);
},
canSenderInteract: () => true,
resetPairedTask: () => {
if (hasReviewerLease(chatJid)) {
const task = getLatestOpenPairedTaskForChat(chatJid);
@@ -717,7 +710,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
messageLoopRunning = true;
logger.info(`EJClaw running (trigger: @${deps.assistantName})`);
logger.info('EJClaw running');
while (true) {
try {
@@ -727,7 +720,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
triggerPattern: deps.triggerPattern,
timezone: deps.timezone,
channels: deps.channels,
getRegisteredGroups: deps.getRegisteredGroups,
getRoomBindings: deps.getRoomBindings,
getLastTimestamp: deps.getLastTimestamp,
setLastTimestamp: deps.setLastTimestamp,
lastAgentTimestamps: deps.getLastAgentTimestamps(),
@@ -755,7 +748,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
recoverRuntimePendingMessages({
assistantName: deps.assistantName,
channels: deps.channels,
getRegisteredGroups: deps.getRegisteredGroups,
getRoomBindings: deps.getRoomBindings,
lastAgentTimestamps: deps.getLastAgentTimestamps(),
saveState: deps.saveState,
enqueueScopedGroupMessageCheck,

View File

@@ -221,4 +221,56 @@ describe('MessageTurnController outbound audit logging', () => {
]),
);
});
it('does not replay the last progress message as a final delivery for paired reviewer turns', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-no-fake-final',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
allowProgressReplayWithoutFinal: false,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '확인하겠습니다.',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '근거를 다시 대조 중입니다.',
} as any);
await flushAsync();
const finishResult = await controller.finish('success');
expect(finishResult.visiblePhase).toBe('progress');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(channel.editMessage).toHaveBeenCalledWith(
'dc:test-room',
'progress-1',
expect.stringContaining('확인하겠습니다.'),
);
expect(deliverFinalText).not.toHaveBeenCalled();
expect(getAuditEntries()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'final-delivery-attempt',
}),
]),
);
});
});

View File

@@ -34,6 +34,7 @@ interface MessageTurnControllerOptions {
clearSession: () => void;
requestClose: (reason: string) => void;
deliverFinalText: (text: string) => Promise<boolean>;
allowProgressReplayWithoutFinal?: boolean;
deliveryRole?: PairedRoomRole | null;
deliveryServiceId?: string | null;
pairedTurnIdentity?: PairedTurnIdentity | null;
@@ -338,11 +339,18 @@ export class MessageTurnController {
!this.hadError &&
this.latestProgressTextForFinal
) {
this.log.info(
'Sending a separate final message from the last progress output after agent completion',
);
await this.finalizeProgressMessage();
await this.deliverFinalText(this.latestProgressTextForFinal);
if (this.options.allowProgressReplayWithoutFinal !== false) {
this.log.info(
'Sending a separate final message from the last progress output after agent completion',
);
await this.deliverFinalText(this.latestProgressTextForFinal);
} else {
this.log.info(
'Skipped replaying the last progress output as a final message for this turn',
);
this.latestProgressTextForFinal = null;
}
} else if (
this.visiblePhase === 'progress' &&
!this.terminalObserved() &&

View File

@@ -334,6 +334,72 @@ describe('paired execution context', () => {
});
});
it('preserves the claimed reviewer turn revision for in-review continuations and requires a visible verdict', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:05:00.000Z',
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:05:00.000Z',
});
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
autoRefreshed: false,
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-review-continuation',
roomRoleContext: reviewerContext,
pairedTurnIdentity: {
turnId: 'task-1:2026-03-28T00:00:00.000Z:reviewer-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-03-28T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
},
});
expect(result?.claimedTaskUpdatedAt).toBe('2026-03-28T00:00:00.000Z');
expect(result?.requiresVisibleVerdict).toBe(true);
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'in_review' }),
);
});
it('does not change task state when the reviewer snapshot is not ready', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',

View File

@@ -46,6 +46,7 @@ import {
prepareReviewerWorkspaceForExecution,
provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import type {
AgentType,
PairedRoomRole,
@@ -193,6 +194,7 @@ export function preparePairedExecutionContext(args: {
runId: string;
roomRoleContext?: RoomRoleContext;
hasHumanMessage?: boolean;
pairedTurnIdentity?: PairedTurnIdentity;
}): PreparedPairedExecutionContext | undefined {
const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) {
@@ -210,7 +212,19 @@ export function preparePairedExecutionContext(args: {
}
const latestTask = getPairedTaskById(task.id) ?? task;
const claimedTaskUpdatedAt = latestTask.updated_at;
const continuationTurnIdentity = args.pairedTurnIdentity;
const claimedTaskUpdatedAt =
continuationTurnIdentity &&
continuationTurnIdentity.taskId === latestTask.id &&
continuationTurnIdentity.role === roomRoleContext.role &&
((roomRoleContext.role === 'reviewer' &&
continuationTurnIdentity.intentKind === 'reviewer-turn' &&
latestTask.status === 'in_review') ||
(roomRoleContext.role === 'arbiter' &&
continuationTurnIdentity.intentKind === 'arbiter-turn' &&
latestTask.status === 'in_arbitration'))
? continuationTurnIdentity.taskUpdatedAt
: latestTask.updated_at;
let workspace: PairedWorkspace | null = null;
let blockMessage: string | undefined;
const now = new Date().toISOString();
@@ -354,6 +368,8 @@ export function preparePairedExecutionContext(args: {
claimedTaskUpdatedAt,
workspace,
envOverrides,
requiresVisibleVerdict:
roomRoleContext.role === 'reviewer' || roomRoleContext.role === 'arbiter',
blockMessage,
};
}

View File

@@ -18,7 +18,7 @@ function makeGroup(folder: string): RegisteredGroup {
describe('getInterruptedRecoveryCandidates', () => {
it('returns only registered interrupted groups and deduplicates by chatJid', () => {
const registeredGroups: Record<string, RegisteredGroup> = {
const roomBindings: Record<string, RegisteredGroup> = {
'dc:1': makeGroup('group-one'),
'dc:2': makeGroup('group-two'),
};
@@ -64,7 +64,7 @@ describe('getInterruptedRecoveryCandidates', () => {
],
};
expect(getInterruptedRecoveryCandidates(context, registeredGroups)).toEqual(
expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual(
[
{
chatJid: 'dc:1',

View File

@@ -53,9 +53,9 @@ function formatKoreanTimestamp(timestamp: string | number | Date): string {
}
function getMainGroupJid(
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
): string | null {
const mainEntry = Object.entries(registeredGroups).find(
const mainEntry = Object.entries(roomBindings).find(
([, group]) => group.isMain === true,
);
return mainEntry?.[0] ?? null;
@@ -140,7 +140,7 @@ export function writeRestartContext(
}
export function writeShutdownRestartContext(
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
interruptedGroups: RestartInterruptedGroup[],
signal: string,
serviceIds: string[] = [SERVICE_ID],
@@ -149,7 +149,7 @@ export function writeShutdownRestartContext(
fs.mkdirSync(DATA_DIR, { recursive: true });
const mainChatJid =
getMainGroupJid(registeredGroups) ?? interruptedGroups[0].chatJid;
getMainGroupJid(roomBindings) ?? interruptedGroups[0].chatJid;
const writtenPaths: string[] = [];
for (const serviceId of serviceIds) {
@@ -233,7 +233,7 @@ export function buildRestartAnnouncement(context: RestartContext): string {
export function getInterruptedRecoveryCandidates(
context: RestartContext | null,
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
): RestartRecoveryCandidate[] {
if (!context?.interruptedGroups?.length) return [];
@@ -242,7 +242,7 @@ export function getInterruptedRecoveryCandidates(
for (const interrupted of context.interruptedGroups) {
if (seen.has(interrupted.chatJid)) continue;
const group = registeredGroups[interrupted.chatJid];
const group = roomBindings[interrupted.chatJid];
if (!group) continue;
seen.add(interrupted.chatJid);
candidates.push({
@@ -258,10 +258,10 @@ export function getInterruptedRecoveryCandidates(
}
export function inferRecentRestartContext(
registeredGroups: Record<string, RegisteredGroup>,
roomBindings: Record<string, RegisteredGroup>,
processStartedAtMs: number,
): InferredRestartContext | null {
const chatJid = getMainGroupJid(registeredGroups);
const chatJid = getMainGroupJid(roomBindings);
if (!chatJid) return null;
const latestBuildTime = getLatestDistBuildTime();

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_deleteStoredRoomSettingsForTests,
_setRegisteredGroupForTests,
_setStoredRoomOwnerAgentTypeForTests,
_initTestDatabase,
@@ -235,10 +236,36 @@ describe('service-routing global failover', () => {
});
});
it('keeps the legacy claude fallback for chats without room settings', () => {
it('defaults to the configured owner service for chats without canonical room settings', () => {
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
chat_jid: 'dc:unregistered',
owner_service_id: 'claude',
owner_service_id: 'codex-main',
reviewer_service_id: null,
owner_failover_active: false,
explicit: false,
});
});
it('ignores legacy capability rows when canonical room settings are missing', () => {
_setRegisteredGroupForTests('dc:legacy-only', {
name: 'Legacy Only',
folder: 'legacy-only',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
_setRegisteredGroupForTests('dc:legacy-only', {
name: 'Legacy Only',
folder: 'legacy-only',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
_deleteStoredRoomSettingsForTests('dc:legacy-only');
expect(getEffectiveChannelLease('dc:legacy-only')).toMatchObject({
chat_jid: 'dc:legacy-only',
owner_service_id: 'codex-main',
reviewer_service_id: null,
owner_failover_active: false,
explicit: false,

View File

@@ -12,7 +12,6 @@ import {
clearChannelOwnerLease,
getAllChannelOwnerLeases,
getEffectiveRuntimeRoomMode,
getRegisteredAgentTypesForJid,
getStoredRoomSettings,
type ChannelOwnerLeaseRow,
} from './db.js';
@@ -103,32 +102,14 @@ function normalizeLeaseRow(
};
}
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 storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType;
if (storedOwnerAgentType) {
return storedOwnerAgentType;
}
const types = getRegisteredAgentTypesForJid(chatJid);
const hasClaude = types.includes('claude-code');
const hasCodex = types.includes('codex');
return inferFallbackOwnerAgentType(hasClaude, hasCodex);
return getStoredRoomSettings(chatJid)?.ownerAgentType;
}
function getDefaultLease(chatJid: string): EffectiveChannelLease {
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid) ?? 'claude-code';
const ownerAgentType =
resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE;
const rolePlan = resolveRoleAgentPlan({
paired: roomMode === 'tribunal',
groupAgentType: ownerAgentType,

View File

@@ -15,8 +15,8 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
});
it('detects /compact with trigger prefix', () => {
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
it('does not treat bot-name prefixes as commands anymore', () => {
expect(extractSessionCommand('@Andy /compact', trigger)).toBeNull();
});
it('detects bare /clear', () => {

View File

@@ -14,13 +14,13 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
function normalizeSessionCommandText(
content: string,
triggerPattern: RegExp,
_triggerPattern: RegExp,
): string {
return content.trim().replace(triggerPattern, '').trim();
return content.trim();
}
/**
* Extract a session slash command from a message, stripping the trigger prefix if present.
* Extract a session slash command from a message.
* Returns the slash command (e.g., '/compact') or null if not a session command.
*/
export function extractSessionCommand(

View File

@@ -71,7 +71,7 @@ export function resolveTaskExecutionContext(
const groupDir = resolveGroupFolderPath(task.group_folder);
fs.mkdirSync(groupDir, { recursive: true });
const groups = deps.registeredGroups();
const groups = deps.roomBindings();
const group = Object.values(groups).find(
(registeredGroup) => registeredGroup.folder === task.group_folder,
);

View File

@@ -5,7 +5,7 @@ import type { AgentType, RegisteredGroup } from './types.js';
export interface SchedulerDependencies {
serviceAgentType?: AgentType;
registeredGroups: () => Record<string, RegisteredGroup>;
roomBindings: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
queue: GroupQueue;
onProcess: (

View File

@@ -228,7 +228,7 @@ describe('task scheduler', () => {
);
startSchedulerLoop({
registeredGroups: () => ({}),
roomBindings: () => ({}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
@@ -274,7 +274,7 @@ describe('task scheduler', () => {
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -324,7 +324,7 @@ describe('task scheduler', () => {
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -372,7 +372,7 @@ describe('task scheduler', () => {
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -425,7 +425,7 @@ describe('task scheduler', () => {
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -496,7 +496,7 @@ describe('task scheduler', () => {
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'paired@g.us': {
name: 'Paired',
folder: 'paired-group',
@@ -568,7 +568,7 @@ describe('task scheduler', () => {
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'paired@g.us': {
name: 'Paired',
folder: 'paired-group',
@@ -608,7 +608,7 @@ describe('task scheduler', () => {
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -658,7 +658,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -751,7 +751,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'claude-code',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -850,7 +850,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'claude-code',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -928,7 +928,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'claude-code',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1021,7 +1021,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'claude-code',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1117,7 +1117,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1149,7 +1149,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1232,7 +1232,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1305,7 +1305,7 @@ Managed by host-driven watcher.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1381,7 +1381,7 @@ Managed by host-driven watcher.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1444,7 +1444,7 @@ Managed by host-driven watcher.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1513,7 +1513,7 @@ Managed by host-driven watcher.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1567,7 +1567,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1608,7 +1608,7 @@ Check the run.
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
@@ -1651,7 +1651,7 @@ Check the run.
startSchedulerLoop({
serviceAgentType: 'claude-code',
registeredGroups: () => ({
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',

View File

@@ -148,10 +148,10 @@ export function toVisiblePhase(phase: AgentOutputPhase): VisiblePhase {
export interface RegisteredGroup {
name: string;
folder: string;
trigger: string;
trigger?: string;
added_at: string;
agentConfig?: AgentConfig;
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
requiresTrigger?: boolean; // Compatibility-only legacy field
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
agentType?: AgentType;
workDir?: string; // Working directory for the agent (defaults to group folder)

View File

@@ -66,7 +66,7 @@ export interface UnifiedDashboardOptions {
usageUpdateInterval: number;
channels: Channel[];
queue: GroupQueue;
registeredGroups: () => Record<string, RegisteredGroup>;
roomBindings: () => Record<string, RegisteredGroup>;
onGroupNameSynced?: (jid: string, name: string) => void;
purgeOnStart?: boolean;
}
@@ -175,7 +175,7 @@ async function refreshChannelMeta(
);
if (!channel?.getChannelMeta) return;
const localJids = Object.keys(opts.registeredGroups()).filter((jid) =>
const localJids = Object.keys(opts.roomBindings()).filter((jid) =>
jid.startsWith('dc:'),
);
const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS)
@@ -189,7 +189,7 @@ async function refreshChannelMeta(
for (const [jid, meta] of channelMetaCache) {
if (!meta.name) continue;
const group = opts.registeredGroups()[jid];
const group = opts.roomBindings()[jid];
if (!group || group.name === meta.name) continue;
logger.info(
{ jid, oldName: group.name, newName: meta.name },
@@ -230,7 +230,7 @@ function formatRoomName(
}
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
const groups = opts.registeredGroups();
const groups = opts.roomBindings();
const statuses = opts.queue.getStatuses(Object.keys(groups));
writeStatusSnapshot({