Merge branch 'codex/owner/ejclaw'
This commit is contained in:
354
src/db.test.ts
354
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,328 @@ 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 +2262,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',
|
||||
|
||||
172
src/db.ts
172
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,11 @@ export function getRegisteredGroup(
|
||||
const requestedAgentType = normalizeStoredAgentType(agentType);
|
||||
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
||||
if (stored) {
|
||||
const group = buildRegisteredGroupFromStoredSettings(
|
||||
return buildRegisteredGroupFromStoredSettings(
|
||||
db,
|
||||
stored,
|
||||
requestedAgentType,
|
||||
);
|
||||
if (group) return group;
|
||||
}
|
||||
return getLegacyRegisteredGroup(db, jid, agentType);
|
||||
}
|
||||
@@ -763,31 +771,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<AgentType>(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 +909,30 @@ 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 +968,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 +1028,63 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
|
||||
updateStoredRoomMetadata(db, chatJid, snapshot);
|
||||
}
|
||||
|
||||
function buildRoomRegistrationSnapshotFromStoredRoom(
|
||||
stored: StoredRoomSettings,
|
||||
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
|
||||
): 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<Pick<RoomRegistrationSnapshot, 'name'>>,
|
||||
):
|
||||
| {
|
||||
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 +1118,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;
|
||||
|
||||
@@ -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<AgentType>([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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(':');
|
||||
|
||||
Reference in New Issue
Block a user