From b0f51b036689ddc0acf64f0c737e212455de267e Mon Sep 17 00:00:00 2001 From: ejclaw Date: Thu, 9 Apr 2026 09:41:19 +0900 Subject: [PATCH 1/3] refactor: complete room registration ssot cleanup --- src/db.test.ts | 352 ++++++++++++++++++++++++++++++++++++ src/db.ts | 170 +++++++++++++---- src/db/room-registration.ts | 50 ++++- 3 files changed, 533 insertions(+), 39 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index fdceaf6..2e4b54e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _deleteStoredRoomSettingsForTests, _setMemoryTimestampsForTests, _setRegisteredGroupForTests, assignRoom, @@ -30,6 +31,7 @@ import { getLatestPairedTaskForChat, getLatestTurnNumber, getLastRespondingAgentType, + getRegisteredGroup, getMessagesSinceSeq, getNewMessagesBySeq, getOpenWorkItem, @@ -1658,6 +1660,326 @@ describe('room assignment writes', () => { }, ); }); + + it('recreates inferred room_settings when renaming a legacy projection-only room', () => { + _setRegisteredGroupForTests('dc:legacy-rename', { + name: 'Legacy Rename', + folder: 'legacy-rename', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + _setRegisteredGroupForTests('dc:legacy-rename', { + name: 'Legacy Rename', + folder: 'legacy-rename', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + _deleteStoredRoomSettingsForTests('dc:legacy-rename'); + expect(getStoredRoomSettings('dc:legacy-rename')).toBeUndefined(); + + 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( + 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', + }); + }); + + it('ignores stale registered_groups capability rows once room_settings exists', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-ssot-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL DEFAULT 'single', + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER, + is_main INTEGER, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT + ); + + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER, + is_main INTEGER, + agent_type TEXT, + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:ssot-room', + 'single', + 'explicit', + 'SSOT Room', + 'ssot-room', + '@Andy', + 1, + 0, + 'codex', + null, + '2026-04-08T00:00:00.000Z', + ); + + legacyDb + .prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:ssot-room', + 'Stale Projection', + 'ssot-room', + '@Codex', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'codex', + null, + ); + + legacyDb + .prepare( + `INSERT OR REPLACE INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:ssot-room', + 'Stale Projection', + 'ssot-room', + '@Claude', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'claude-code', + null, + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getRegisteredGroup('dc:ssot-room')).toMatchObject({ + folder: 'ssot-room', + agentType: 'codex', + }); + expect(getRegisteredGroup('dc:ssot-room', 'claude-code')).toBeUndefined(); + expect(getAllRegisteredGroups('claude-code')['dc:ssot-room']).toBeUndefined(); + expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']); + }); + + it('re-materializes explicit room_settings writes back into the projection rows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-writeback-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL DEFAULT 'single', + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER, + is_main INTEGER, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT + ); + + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER, + is_main INTEGER, + agent_type TEXT, + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:ssot-writeback', + 'single', + 'explicit', + 'Explicit Writeback', + 'ssot-writeback', + '@Codex', + 1, + 0, + 'codex', + null, + '2026-04-08T00:00:00.000Z', + ); + + const insertProjection = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + insertProjection.run( + 'dc:ssot-writeback', + 'Stale Projection', + 'ssot-writeback', + '@Codex', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'codex', + null, + ); + insertProjection.run( + 'dc:ssot-writeback', + 'Stale Projection', + 'ssot-writeback', + '@Claude', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'claude-code', + null, + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + updateRegisteredGroupName('dc:ssot-writeback', 'SSOT Writeback Renamed'); + + expect(getStoredRoomSettings('dc:ssot-writeback')).toMatchObject({ + chatJid: 'dc:ssot-writeback', + roomMode: 'single', + modeSource: 'explicit', + name: 'SSOT Writeback Renamed', + ownerAgentType: 'codex', + }); + + const rawDb = new Database(dbPath); + const projectionRows = rawDb + .prepare( + `SELECT agent_type, name + FROM registered_groups + WHERE jid = ? + ORDER BY agent_type`, + ) + .all('dc:ssot-writeback') as Array<{ + agent_type: string | null; + name: string; + }>; + rawDb.close(); + + expect(projectionRows).toEqual([ + { + agent_type: 'codex', + name: 'SSOT Writeback Renamed', + }, + ]); + + clearExplicitRoomMode('dc:ssot-writeback'); + + expect(getExplicitRoomMode('dc:ssot-writeback')).toBeUndefined(); + expect(getEffectiveRoomMode('dc:ssot-writeback')).toBe('single'); + }); }); describe('paired room registration', () => { @@ -1938,6 +2260,36 @@ describe('paired room registration', () => { expect(getEffectiveRuntimeRoomMode('dc:explicit-single')).toBe('single'); }); + it('restores inferred paired mode when clearing an explicit single override', () => { + _setRegisteredGroupForTests('dc:explicit-single-clear', { + name: 'Explicit Single Clear', + folder: 'explicit-single-clear', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + _setRegisteredGroupForTests('dc:explicit-single-clear', { + name: 'Explicit Single Clear', + folder: 'explicit-single-clear', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + setExplicitRoomMode('dc:explicit-single-clear', 'single'); + + expect(getExplicitRoomMode('dc:explicit-single-clear')).toBe('single'); + expect(getEffectiveRoomMode('dc:explicit-single-clear')).toBe('single'); + + clearExplicitRoomMode('dc:explicit-single-clear'); + + expect(getExplicitRoomMode('dc:explicit-single-clear')).toBeUndefined(); + expect(getEffectiveRoomMode('dc:explicit-single-clear')).toBe('tribunal'); + expect(getEffectiveRuntimeRoomMode('dc:explicit-single-clear')).toBe( + 'tribunal', + ); + }); + it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => { _setRegisteredGroupForTests('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', diff --git a/src/db.ts b/src/db.ts index 646c0a7..8114fd6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -39,6 +39,7 @@ import { normalizeRoomModeSource, normalizeStoredAgentType, parseRegisteredGroupRow, + resolveStoredRoomCapabilityTypes, resolveAssignedRoomFolder, updateStoredRoomMetadata, } from './db/room-registration.js'; @@ -313,6 +314,14 @@ export function _setStoredRoomOwnerAgentTypeForTests( ).run(ownerAgentType, new Date().toISOString(), chatJid); } +/** @internal - for tests only. */ +export function _deleteStoredRoomSettingsForTests(chatJid: string): void { + if (!db) { + throw new Error('Database not initialized'); + } + db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); +} + /** @internal - for tests only. */ export function _setMemoryTimestampsForTests( id: number, @@ -749,12 +758,7 @@ export function getRegisteredGroup( const requestedAgentType = normalizeStoredAgentType(agentType); const stored = getStoredRoomSettingsRowFromDatabase(db, jid); if (stored) { - const group = buildRegisteredGroupFromStoredSettings( - db, - stored, - requestedAgentType, - ); - if (group) return group; + return buildRegisteredGroupFromStoredSettings(db, stored, requestedAgentType); } return getLegacyRegisteredGroup(db, jid, agentType); } @@ -763,31 +767,58 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings( jid: string, group: RegisteredGroup, ): void { + const existingStored = getStoredRoomSettingsRowFromDatabase(db, jid); const existingRoomMode = getStoredRoomModeRow(jid); if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } const tx = db.transaction(() => { - db.prepare( - `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - jid, - group.name, - group.folder, - group.trigger, - group.added_at, - group.agentConfig ? JSON.stringify(group.agentConfig) : null, - group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, - group.isMain ? 1 : 0, - group.agentType || 'claude-code', - group.workDir || null, - ); + const seededAgentType = group.agentType || 'claude-code'; + const agentTypes = new Set(collectRegisteredAgentTypes(db, jid)); + agentTypes.add(seededAgentType); + const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([ + ...agentTypes, + ]); + const roomMode = + existingRoomMode?.source === 'explicit' + ? existingRoomMode.roomMode + : inferredRoomMode; + const ownerAgentType = + existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType + ? existingStored.ownerAgentType + : inferOwnerAgentTypeFromRegisteredAgentTypes([...agentTypes]); + const snapshot: RoomRegistrationSnapshot = { + name: group.name, + folder: group.folder, + triggerPattern: + existingStored?.modeSource === 'explicit' && existingStored.trigger + ? existingStored.trigger + : group.trigger, + requiresTrigger: + group.requiresTrigger ?? existingStored?.requiresTrigger ?? true, + isMain: group.isMain ?? existingStored?.isMain ?? false, + ownerAgentType, + workDir: group.workDir ?? existingStored?.workDir ?? null, + }; - syncStoredRoomRegistrationSnapshotForJid(jid); - if (!existingRoomMode || existingRoomMode.source === 'inferred') { - syncInferredRoomModeForJid(jid); + if (existingStored) { + updateStoredRoomMetadata(db, jid, snapshot); + if (!existingRoomMode || existingRoomMode.source === 'inferred') { + upsertStoredRoomMode(jid, roomMode, 'inferred'); + } + } else { + insertStoredRoomSettings(db, jid, roomMode, 'inferred', snapshot); } + + materializeRegisteredGroupsForRoom( + db, + jid, + snapshot, + roomMode, + ownerAgentType, + seededAgentType === ownerAgentType ? group.agentConfig : undefined, + group.added_at, + ); }); tx(); } @@ -874,18 +905,24 @@ export function assignRoom( } export function updateRegisteredGroupName(jid: string, name: string): void { - db.prepare( - `UPDATE room_settings - SET name = ?, updated_at = ? - WHERE chat_jid = ?`, - ).run(name, new Date().toISOString(), jid); - db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run( - name, - jid, - ); - if (!getStoredRoomSettingsRowFromDatabase(db, jid)) { - syncStoredRoomRegistrationSnapshotForJid(jid); + const plan = buildRoomRegistrationPlanForJid(jid, { name }); + if (!plan) { + return; } + db.transaction(() => { + if (plan.hasStoredRoom) { + updateStoredRoomMetadata(db, jid, plan.snapshot); + } else { + insertStoredRoomSettings(db, jid, plan.roomMode, 'inferred', plan.snapshot); + } + materializeRegisteredGroupsForRoom( + db, + jid, + plan.snapshot, + plan.roomMode, + plan.snapshot.ownerAgentType, + ); + })(); } export function getAllRegisteredGroups( @@ -921,6 +958,10 @@ 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); } @@ -977,6 +1018,63 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void { updateStoredRoomMetadata(db, chatJid, snapshot); } +function buildRoomRegistrationSnapshotFromStoredRoom( + stored: StoredRoomSettings, + overrides?: Partial>, +): RoomRegistrationSnapshot { + const capabilityTypes = resolveStoredRoomCapabilityTypes(db, stored); + const ownerAgentType = + stored.ownerAgentType || + (capabilityTypes.length > 0 + ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) + : OWNER_AGENT_TYPE); + const name = overrides?.name ?? stored.name ?? stored.chatJid; + + return { + name, + folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder), + triggerPattern: stored.trigger || `@${ASSISTANT_NAME}`, + requiresTrigger: stored.requiresTrigger ?? true, + isMain: stored.isMain ?? false, + ownerAgentType, + workDir: stored.workDir ?? null, + }; +} + +function buildRoomRegistrationPlanForJid( + chatJid: string, + overrides?: Partial>, +): + | { + snapshot: RoomRegistrationSnapshot; + roomMode: RoomMode; + hasStoredRoom: boolean; + } + | undefined { + const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid); + if (stored) { + return { + snapshot: buildRoomRegistrationSnapshotFromStoredRoom(stored, overrides), + roomMode: stored.roomMode, + hasStoredRoom: true, + }; + } + + const snapshot = collectRoomRegistrationSnapshot(db, chatJid); + if (!snapshot) return undefined; + + return { + snapshot: { + ...snapshot, + name: overrides?.name ?? snapshot.name, + }, + roomMode: inferRoomModeFromRegisteredAgentTypes( + collectRegisteredAgentTypes(db, chatJid), + ), + hasStoredRoom: false, + }; +} + function upsertStoredRoomMode( chatJid: string, roomMode: RoomMode, @@ -1010,7 +1108,7 @@ export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void { } export function clearExplicitRoomMode(chatJid: string): void { - const agentTypes = getRegisteredAgentTypesForJid(chatJid); + const agentTypes = collectRegisteredAgentTypes(db, chatJid); if (agentTypes.length === 0) { db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); return; diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index d762600..342ac08 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -147,9 +147,28 @@ export function getLegacyRegisteredGroupRows( return ( agentTypeFilter ? database - .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') + .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').all() + : database + .prepare( + `SELECT * + FROM registered_groups + WHERE NOT EXISTS ( + SELECT 1 + FROM room_settings + WHERE chat_jid = registered_groups.jid + )`, + ) + .all() ) as RegisteredGroupDatabaseRow[]; } @@ -158,6 +177,10 @@ export function getLegacyRegisteredGroup( jid: string, agentType?: string, ): (RegisteredGroup & { jid: string }) | undefined { + if (getStoredRoomSettingsRowFromDatabase(database, jid)) { + return undefined; + } + const row = ( agentType ? database @@ -276,7 +299,7 @@ export function buildRegisteredGroupFromStoredSettings( stored: StoredRoomSettings, requestedAgentType?: AgentType, ): (RegisteredGroup & { jid: string }) | undefined { - const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid); + const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); const resolvedAgentType = requestedAgentType ? capabilityTypes.includes(requestedAgentType) ? requestedAgentType @@ -315,6 +338,27 @@ export function buildRegisteredGroupFromStoredSettings( }; } +export function resolveStoredRoomCapabilityTypes( + database: Database, + stored: StoredRoomSettings, +): AgentType[] { + const projectedTypes = collectRegisteredAgentTypes(database, stored.chatJid); + if (stored.modeSource !== 'explicit') { + return projectedTypes; + } + + const ownerAgentType = + stored.ownerAgentType || + (projectedTypes.length > 0 + ? inferOwnerAgentTypeFromRegisteredAgentTypes(projectedTypes) + : OWNER_AGENT_TYPE); + const types = new Set([ownerAgentType]); + if (stored.roomMode === 'tribunal') { + types.add(REVIEWER_AGENT_TYPE); + } + return [...types]; +} + function detectChannelPrefixForFolder(chatJid: string): string { if (chatJid.startsWith('dc:')) return 'discord'; if (chatJid.startsWith('tg:')) return 'telegram'; From 1788ddd9951bf89814b263c3e4fc921c9cf0fdec Mon Sep 17 00:00:00 2001 From: ejclaw Date: Thu, 9 Apr 2026 09:41:31 +0900 Subject: [PATCH 2/3] fix: harden paired follow-up requeue handling --- src/message-runtime-flow.test.ts | 3 +- src/message-runtime-flow.ts | 4 +- src/message-runtime.test.ts | 119 ++++++++++++++++++++++++- src/paired-follow-up-scheduler.test.ts | 41 ++++++++- src/paired-follow-up-scheduler.ts | 5 +- 5 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/message-runtime-flow.test.ts b/src/message-runtime-flow.test.ts index 280f51b..bea70a6 100644 --- a/src/message-runtime-flow.test.ts +++ b/src/message-runtime-flow.test.ts @@ -102,7 +102,8 @@ describe('executeBotOnlyPairedFollowUpAction', () => { expect(first).toBe(true); expect(second).toBe(true); - expect(closeStdin).toHaveBeenCalledTimes(2); + expect(closeStdin).toHaveBeenCalledTimes(1); + expect(closeStdin).toHaveBeenCalledWith(); expect(enqueue).toHaveBeenCalledTimes(1); expect(log.info).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index ca311e2..4833822 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -400,8 +400,10 @@ export async function executeBotOnlyPairedFollowUpAction(args: { return true; } - closeStdin(); const scheduled = schedulePairedFollowUp(action.task, action.intentKind); + if (scheduled) { + closeStdin(); + } log.info( { chatJid, diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 109bf99..b381bef 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -219,7 +219,10 @@ import { } from './message-runtime-flow.js'; import * as config from './config.js'; import { logger } from './logger.js'; -import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js'; +import { + resetPairedFollowUpScheduleState, + schedulePairedFollowUpOnce, +} from './paired-follow-up-scheduler.js'; import * as serviceRouting from './service-routing.js'; import type { Channel, RegisteredGroup } from './types.js'; @@ -2205,6 +2208,120 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead ); }); + it('re-enqueues owner after arbiter delivery even when a stale owner follow-up key exists for the prior task revision', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const ownerChannel = makeChannel(chatJid); + const reviewerChannel = makeChannel(chatJid, 'discord-review', false); + const arbiterChannel = makeChannel(chatJid, 'discord-arbiter', false); + const enqueueMessageCheck = vi.fn(); + const staleOwnerFollowUpEnqueue = vi.fn(); + const pairedTask = { + id: 'task-arbiter-delivery-owner-follow-up-stale-key', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + arbiter_service_id: 'claude-arbiter', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 1, + status: 'arbiter_requested', + arbiter_verdict: null, + arbiter_requested_at: '2026-03-30T00:00:10.000Z', + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:10.000Z', + } as any; + + schedulePairedFollowUpOnce({ + chatJid, + runId: 'run-stale-owner-follow-up', + task: { + id: pairedTask.id, + status: 'active', + round_trip_count: 1, + updated_at: '2026-03-30T00:00:05.000Z', + }, + intentKind: 'owner-follow-up', + enqueue: staleOwnerFollowUpEnqueue, + }); + + expect(staleOwnerFollowUpEnqueue).toHaveBeenCalledTimes(1); + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockImplementation( + () => pairedTask, + ); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + pairedTask.status = 'active'; + pairedTask.arbiter_verdict = 'revise'; + pairedTask.updated_at = '2026-03-30T00:00:20.000Z'; + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'DONE_WITH_CONCERNS\narbiter says revise', + newSessionId: 'session-arbiter-delivery-owner-follow-up-stale-key', + }); + return { + status: 'success', + result: 'DONE_WITH_CONCERNS\narbiter says revise', + newSessionId: 'session-arbiter-delivery-owner-follow-up-stale-key', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel, reviewerChannel, arbiterChannel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + enqueueMessageCheck, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-arbiter-delivery-owner-follow-up-stale-key', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(arbiterChannel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'DONE_WITH_CONCERNS\narbiter says revise', + ); + expect(ownerChannel.sendMessage).not.toHaveBeenCalled(); + expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid, + runId: 'run-arbiter-delivery-owner-follow-up-stale-key', + completedRole: 'arbiter', + taskId: 'task-arbiter-delivery-owner-follow-up-stale-key', + taskStatus: 'active', + scheduled: true, + }), + 'Queued paired follow-up after successful reviewer/arbiter delivery', + ); + }); + it('does not enqueue the same reviewer follow-up twice across different runs while task state is unchanged', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/paired-follow-up-scheduler.test.ts b/src/paired-follow-up-scheduler.test.ts index 0469b4c..e20f6df 100644 --- a/src/paired-follow-up-scheduler.test.ts +++ b/src/paired-follow-up-scheduler.test.ts @@ -19,6 +19,7 @@ describe('paired follow-up scheduler', () => { id: 'task-1', status: 'review_ready', round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', } as const; const first = schedulePairedFollowUpOnce({ @@ -47,6 +48,7 @@ describe('paired follow-up scheduler', () => { id: 'task-1', status: 'review_ready', round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', } as const; const first = schedulePairedFollowUpOnce({ @@ -78,6 +80,7 @@ describe('paired follow-up scheduler', () => { id: 'task-1', status: 'review_ready', round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', } as const, intentKind: 'reviewer-turn', enqueue, @@ -89,6 +92,7 @@ describe('paired follow-up scheduler', () => { id: 'task-1', status: 'review_ready', round_trip_count: 2, + updated_at: '2026-03-30T00:00:01.000Z', } as const, intentKind: 'reviewer-turn', enqueue, @@ -105,9 +109,43 @@ describe('paired follow-up scheduler', () => { taskId: 'task-1', taskStatus: 'review_ready', roundTripCount: 3, + taskUpdatedAt: '2026-03-30T00:00:00.000Z', intentKind: 'reviewer-turn', }), - ).toBe('task-1:review_ready:3:reviewer-turn'); + ).toBe('task-1:review_ready:3:2026-03-30T00:00:00.000Z:reviewer-turn'); + }); + + it('keeps different task revisions schedulable even when status and round trip are unchanged', () => { + const enqueue = vi.fn(); + + const first = schedulePairedFollowUpOnce({ + chatJid: 'group@test', + runId: 'run-1', + task: { + id: 'task-1', + status: 'active', + round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', + } as const, + intentKind: 'owner-follow-up', + enqueue, + }); + const second = schedulePairedFollowUpOnce({ + chatJid: 'group@test', + runId: 'run-2', + task: { + id: 'task-1', + status: 'active', + round_trip_count: 1, + updated_at: '2026-03-30T00:00:10.000Z', + } as const, + intentKind: 'owner-follow-up', + enqueue, + }); + + expect(first).toBe(true); + expect(second).toBe(true); + expect(enqueue).toHaveBeenCalledTimes(2); }); it('allows the same follow-up again after the TTL expires', () => { @@ -119,6 +157,7 @@ describe('paired follow-up scheduler', () => { id: 'task-1', status: 'review_ready', round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', } as const; const first = schedulePairedFollowUpOnce({ diff --git a/src/paired-follow-up-scheduler.ts b/src/paired-follow-up-scheduler.ts index dfae7fa..9a91f4b 100644 --- a/src/paired-follow-up-scheduler.ts +++ b/src/paired-follow-up-scheduler.ts @@ -8,7 +8,7 @@ export type ScheduledPairedFollowUpIntentKind = type ScheduledPairedFollowUpTask = Pick< PairedTask, - 'id' | 'status' | 'round_trip_count' + 'id' | 'status' | 'round_trip_count' | 'updated_at' >; export const SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS = 10 * 60 * 1000; @@ -26,12 +26,14 @@ export function buildPairedFollowUpKey(args: { taskId: string; taskStatus: PairedTaskStatus | null; roundTripCount: number; + taskUpdatedAt: string | null | undefined; intentKind: ScheduledPairedFollowUpIntentKind; }): string { return [ args.taskId, args.taskStatus ?? 'unknown', String(args.roundTripCount), + args.taskUpdatedAt ?? 'unknown', args.intentKind, ].join(':'); } @@ -52,6 +54,7 @@ export function schedulePairedFollowUpOnce(args: { taskId: args.task.id, taskStatus: args.task.status, roundTripCount: args.task.round_trip_count, + taskUpdatedAt: args.task.updated_at, intentKind: args.intentKind, }), ].join(':'); From e7a7b538b2eaa129cf52082b74a0609153e9acfd Mon Sep 17 00:00:00 2001 From: ejclaw Date: Thu, 9 Apr 2026 09:41:53 +0900 Subject: [PATCH 3/3] style: apply prettier formatting --- src/db.test.ts | 4 +++- src/db.ts | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index 2e4b54e..1f2dff7 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1831,7 +1831,9 @@ describe('room assignment writes', () => { agentType: 'codex', }); expect(getRegisteredGroup('dc:ssot-room', 'claude-code')).toBeUndefined(); - expect(getAllRegisteredGroups('claude-code')['dc:ssot-room']).toBeUndefined(); + expect( + getAllRegisteredGroups('claude-code')['dc:ssot-room'], + ).toBeUndefined(); expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']); }); diff --git a/src/db.ts b/src/db.ts index 8114fd6..fd89aa1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -758,7 +758,11 @@ export function getRegisteredGroup( const requestedAgentType = normalizeStoredAgentType(agentType); const stored = getStoredRoomSettingsRowFromDatabase(db, jid); if (stored) { - return buildRegisteredGroupFromStoredSettings(db, stored, requestedAgentType); + return buildRegisteredGroupFromStoredSettings( + db, + stored, + requestedAgentType, + ); } return getLegacyRegisteredGroup(db, jid, agentType); } @@ -913,7 +917,13 @@ export function updateRegisteredGroupName(jid: string, name: string): void { if (plan.hasStoredRoom) { updateStoredRoomMetadata(db, jid, plan.snapshot); } else { - insertStoredRoomSettings(db, jid, plan.roomMode, 'inferred', plan.snapshot); + insertStoredRoomSettings( + db, + jid, + plan.roomMode, + 'inferred', + plan.snapshot, + ); } materializeRegisteredGroupsForRoom( db,